From 34f660e1f5b41b4e14b5fc4cda826529f7f31e89 Mon Sep 17 00:00:00 2001 From: chtnnh <59027776+chtnnh@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:59:07 +0400 Subject: [PATCH 1/2] fix: make workflow blocker hints actionable Replace diagnostic and pseudo-shell recovery hints with commands that advance the gate, including quiz and grading scaffolds that preserve human approval boundaries. Cover every emitted pipeline recovery command and restore the forced-range blocker that was unreachable behind a contradictory scope guard. Know-Code-Verified: b33616afd663a9604ad07c67150c4adc3c025190a0289283b7d17297d411359c --- packages/cli/src/commands/check.ts | 4 +- packages/cli/src/commands/grade.ts | 37 +++++- packages/cli/src/e2e-workflow.test.ts | 31 +++++ packages/cli/src/index.ts | 11 +- packages/cli/src/pipeline-hints.test.ts | 158 ++++++++++++++++++++++++ packages/cli/src/pipeline.ts | 22 ++-- packages/cli/src/questions.ts | 28 ++++- 7 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 packages/cli/src/pipeline-hints.test.ts diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index f182eb8..4346602 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -76,7 +76,9 @@ export function runCheck( allowed: false, reason: "HEAD tree changed since pass — amended commits or extra work on tip?", - next: "know-code status", + // Status only diagnoses this condition. A new taught seal starts the + // required state-changing flow for the changed tip. + next: "know-code taught", }; } diff --git a/packages/cli/src/commands/grade.ts b/packages/cli/src/commands/grade.ts index 1abd5c1..68f361e 100644 --- a/packages/cli/src/commands/grade.ts +++ b/packages/cli/src/commands/grade.ts @@ -10,6 +10,7 @@ import { import { assertGradeProposalForHash, proposalDigest, + writeGradeProposal, type GradeProposal, } from "../grading.js"; import { resolveQuizContext } from "../hash.js"; @@ -75,7 +76,7 @@ function buildGradeReceipt( return receipt; } -export function cmdGradePropose(opts: { json?: boolean }): void { +export function cmdGradePropose(opts: { json?: boolean; write?: boolean }): void { const repoRoot = findGitRoot(); const config = readConfig(repoRoot); const ctx = resolveQuizContext(repoRoot, config); @@ -87,6 +88,10 @@ export function cmdGradePropose(opts: { json?: boolean }): void { console.error(err instanceof Error ? err.message : err); process.exit(1); } + const answersDigest = answers.answersDigest; + if (!answersDigest) { + throw new Error("know-code: answers.json missing answers digest"); + } let quiz: QuizSpec | null = null; const qPath = quizPath(repoRoot); @@ -96,7 +101,7 @@ export function cmdGradePropose(opts: { json?: boolean }): void { const context = { diffHash: ctx.diffHash, - answersDigest: answers.answersDigest, + answersDigest, level: resolveLevel(repoRoot, answers.level), scope: ctx.scope, passScore: PASS_SCORE, @@ -107,7 +112,7 @@ export function cmdGradePropose(opts: { json?: boolean }): void { proposalSchema: { version: 1, diffHash: ctx.diffHash, - answersDigest: answers.answersDigest, + answersDigest, proposedScore: 0.85, passed: true, perQuestion: (quiz?.questions ?? []).map((q) => ({ @@ -121,6 +126,29 @@ export function cmdGradePropose(opts: { json?: boolean }): void { }, }; + if (opts.write) { + writeGradeProposal(repoRoot, { + version: 1, + diffHash: ctx.diffHash, + answersDigest, + proposedScore: 0, + passed: false, + perQuestion: (quiz?.questions ?? []).map((q) => ({ + id: q.id, + score: 0, + feedback: "Replace with an evidence-based assessment.", + })), + rubricVersion: "1", + gradedBy: "agent-template", + gradedAt: new Date().toISOString(), + level: resolveLevel(repoRoot, answers.level), + }); + console.log( + "know-code: wrote a failing grade-proposal template; the agent must assess every answer before human review.", + ); + return; + } + if (opts.json) { console.log(JSON.stringify(context, null, 2)); return; @@ -283,9 +311,10 @@ export async function cmdGrade(opts: { review?: boolean; accept?: boolean; json?: boolean; + write?: boolean; }): Promise { if (opts.subcommand === "propose") { - cmdGradePropose({ json: opts.json }); + cmdGradePropose({ json: opts.json, write: opts.write }); return; } diff --git a/packages/cli/src/e2e-workflow.test.ts b/packages/cli/src/e2e-workflow.test.ts index 31d7f4c..a1f3d90 100644 --- a/packages/cli/src/e2e-workflow.test.ts +++ b/packages/cli/src/e2e-workflow.test.ts @@ -25,6 +25,7 @@ import { writeFile, commitAll, liteConfig, + setupOpenGate, writeCommitEditMsg, } from "./test-helpers.js"; import { messageWithTrailer } from "./trailers.js"; @@ -65,6 +66,36 @@ describe("e2e workflows", () => { } }); + it("requireTrailer denial points to commit, which supplies the grounded pending trailer", () => { + const { root, cleanup } = withTempRepo("kc-e2e-trailer-next-"); + try { + const { hash } = setupOpenGate(root, { requireTrailer: true }); + const denied = runCheck(root); + assert.equal(denied.allowed, false); + assert.equal(denied.next, 'know-code commit -m "…"'); + + writeCommitEditMsg(root, injectTrailer(["-m", "feat: change"], hash)[1]); + assert.equal(runCheck(root).allowed, true); + } finally { + cleanup(); + } + }); + + it("a pushed HEAD changed after pass points to the state-changing re-teach flow", () => { + const { root, cleanup } = withTempRepo("kc-e2e-head-next-"); + try { + setupOpenGate(root, { requireTrailer: false }); + writeFile(root, "after-pass.txt", "new tip\n"); + commitAll(root, "feat: after pass"); + + const denied = runCheck(root, { push: true }); + assert.equal(denied.allowed, false); + assert.equal(denied.next, "know-code taught"); + } finally { + cleanup(); + } + }); + it("range begin → commits → verify grounded tip trailer", () => { const { root, cleanup } = withTempRepo("kc-e2e-range-"); try { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 91ea212..c3d8fbe 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -30,7 +30,7 @@ import { CANONICAL_FLOW } from "./grading.js"; import { findGitRoot } from "./paths.js"; import { uninstallGitHooks, uninstallAgentHooks, cmdHooksInstall } from "./hooks.js"; import { cmdOverride } from "./override.js"; -import { cmdQuestions } from "./questions.js"; +import { cmdQuestions, cmdQuizInit } from "./questions.js"; import { cmdAttestInit } from "./seal.js"; function packageVersion(): string { @@ -58,7 +58,7 @@ Usage: know-code doctor [--json] [--strict] know-code range begin|status|seal|abort|continue [--from ] [--rewrite] [--keep-seal] [--yes] know-code questions [--json] [--template] [--from ] [--level …] - know-code quiz validate [--path .know-code/quiz.json] [--json] + know-code quiz init|validate [--path .know-code/quiz.json] [--json] know-code taught [--skip] [--hash ] [--passphrase ] know-code ask [--quiz .know-code/quiz.json] [--port 3847] [--timeout 1800] [--no-open] know-code grade propose [--json] @@ -277,6 +277,10 @@ function main(): void { }); break; case "quiz": + if (subcommand === "init") { + cmdQuizInit(); + break; + } if (subcommand === "validate") { cmdQuizValidate({ path: typeof flags.path === "string" ? flags.path : undefined, @@ -284,7 +288,7 @@ function main(): void { }); break; } - console.error("know-code quiz: use validate\n"); + console.error("know-code quiz: use init | validate\n"); process.exit(1); break; case "check": @@ -313,6 +317,7 @@ function main(): void { review: flags.review === true, accept: flags.accept === true, json: flags.json === true, + write: flags.write === true, }).catch(failAsync); return; case "pass": diff --git a/packages/cli/src/pipeline-hints.test.ts b/packages/cli/src/pipeline-hints.test.ts new file mode 100644 index 0000000..062be95 --- /dev/null +++ b/packages/cli/src/pipeline-hints.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import { writeAnswers, writeGrade, writeTaught } from "./attest.js"; +import { writeConfig } from "./config.js"; +import { computeDiffContext } from "./hash.js"; +import { evaluatePipeline } from "./pipeline.js"; +import { commitAll, liteConfig, setupOpenGate, withTempRepo, writeFile } from "./test-helpers.js"; + +function blockerCommand(repoRoot: string, step: string): string | undefined { + return evaluatePipeline(repoRoot).blockers.find((blocker) => blocker.step === step) + ?.command; +} + +function seedWorkflowArtifacts(repoRoot: string): { hash: string; cfg: ReturnType } { + const cfg = liteConfig({ requireGradeProposal: false }); + writeConfig(repoRoot, cfg); + const hash = computeDiffContext(repoRoot, cfg).diffHash; + writeTaught(repoRoot, { + version: 1, + diffHash: hash, + taughtAt: new Date().toISOString(), + skipped: false, + }); + writeFile( + repoRoot, + ".know-code/quiz.json", + JSON.stringify({ + diffHash: hash, + level: "lite", + title: "quiz", + questions: [ + { id: "q1", prompt: "What changed?" }, + { id: "q2", prompt: "Why?" }, + ], + }), + ); + writeAnswers(repoRoot, { + diffHash: hash, + answers: [ + { id: "q1", answer: "A" }, + { id: "q2", answer: "B" }, + ], + }); + writeGrade(repoRoot, { + version: 1, + diffHash: hash, + score: 1, + passed: true, + gradedAt: new Date().toISOString(), + answersDigest: "seeded", + }); + return { hash, cfg }; +} + +describe("pipeline recovery hints", () => { + it("uses a runnable scaffold command when quiz.json is missing", () => { + const { root, cleanup } = withTempRepo("kc-hint-quiz-"); + try { + writeFile(root, "a.txt", "base\n"); + commitAll(root, "base"); + mkdirSync(join(root, ".know-code"), { recursive: true }); + writeConfig(root, liteConfig()); + + assert.equal(blockerCommand(root, "quiz"), "know-code quiz init"); + } finally { + cleanup(); + } + }); + + it("uses a runnable proposal-draft command when a grade proposal is missing", () => { + const { root, cleanup } = withTempRepo("kc-hint-proposal-"); + try { + writeFile(root, "a.txt", "base\n"); + commitAll(root, "base"); + mkdirSync(join(root, ".know-code"), { recursive: true }); + writeConfig(root, liteConfig({ requireGradeProposal: true })); + + assert.equal( + blockerCommand(root, "grade-proposal"), + "know-code grade propose --write", + ); + } finally { + cleanup(); + } + }); + + it("maps every pipeline recovery state to a state-changing command", () => { + const { root, cleanup } = withTempRepo("kc-hint-matrix-"); + try { + writeFile(root, "a.txt", "base\n"); + commitAll(root, "base"); + mkdirSync(join(root, ".know-code"), { recursive: true }); + seedWorkflowArtifacts(root); + + const commands = evaluatePipeline(root).blockers + .map((blocker) => blocker.command) + .filter((command): command is string => !!command); + for (const command of commands) { + assert.doesNotMatch(command, /know-code status|&& write|^Agent:/); + } + assert.ok(commands.every((command) => command.startsWith("know-code ") || command === "git add -u")); + } finally { + cleanup(); + } + }); + + it("covers every emitted pipeline recovery command", () => { + const seen = new Set(); + const check = (setup: (root: string) => void, step: string, command: string) => { + const { root, cleanup } = withTempRepo("kc-hint-command-"); + try { + writeFile(root, "a.txt", "base\n"); + commitAll(root, "base"); + mkdirSync(join(root, ".know-code"), { recursive: true }); + setup(root); + assert.equal(blockerCommand(root, step), command); + seen.add(command); + } finally { + cleanup(); + } + }; + + check((root) => writeConfig(root, liteConfig({ requireAttest: true })), "attest", "know-code attest-init"); + check((root) => writeConfig(root, liteConfig({ rangeMode: "range" })), "range", "know-code range begin"); + check((root) => writeConfig(root, liteConfig()), "taught", "know-code taught"); + check((root) => writeConfig(root, liteConfig()), "quiz", "know-code quiz init"); + check((root) => writeConfig(root, liteConfig()), "answers", "know-code ask"); + check((root) => writeConfig(root, liteConfig({ requireGradeProposal: true })), "grade-proposal", "know-code grade propose --write"); + check((root) => writeConfig(root, liteConfig()), "grade", "know-code grade --review"); + check((root) => writeConfig(root, liteConfig()), "pass", "know-code pass"); + + check((root) => { + writeConfig(root, liteConfig()); + writeFile(root, ".know-code/taught.json", "{"); + }, "corrupt", "know-code reset"); + + check((root) => { + setupOpenGate(root, { requireTrailer: false }); + writeFile(root, "a.txt", "dirty\n"); + }, "pass", "git add -u"); + + assert.deepEqual([...seen].sort(), [ + "git add -u", + "know-code ask", + "know-code attest-init", + "know-code grade --review", + "know-code grade propose --write", + "know-code pass", + "know-code quiz init", + "know-code range begin", + "know-code reset", + "know-code taught", + ]); + }); +}); diff --git a/packages/cli/src/pipeline.ts b/packages/cli/src/pipeline.ts index 6dd4f8f..5de8d79 100644 --- a/packages/cli/src/pipeline.ts +++ b/packages/cli/src/pipeline.ts @@ -55,7 +55,7 @@ function pushCorrupt( export function evaluatePipeline(repoRoot: string): PipelineStatus { const config = readConfig(repoRoot); const state = resolveEffectiveQuizState(repoRoot, config); - const { ctx, effectiveHash: hash, commitDrift } = state; + const { effectiveHash: hash, commitDrift } = state; const blockers: PipelineBlocker[] = []; const meta = readAttestMeta(repoRoot); @@ -68,11 +68,7 @@ export function evaluatePipeline(repoRoot: string): PipelineStatus { } const session = readRangeSession(repoRoot); - if ( - config.rangeMode === "range" && - !session && - ctx.scope !== "range" - ) { + if (config.rangeMode === "range" && !session) { blockers.push({ step: "range", message: "Range mode requires active range session", @@ -104,7 +100,7 @@ export function evaluatePipeline(repoRoot: string): PipelineStatus { blockers.push({ step: "quiz", message: "quiz.json missing", - command: "know-code questions && write .know-code/quiz.json", + command: "know-code quiz init", }); } @@ -149,7 +145,7 @@ export function evaluatePipeline(repoRoot: string): PipelineStatus { message: proposal ? "grade-proposal.json stale or mismatched" : "Agent grading proposal missing", - command: "Agent: write .know-code/grade-proposal.json after ask", + command: "know-code grade propose --write", }); } } @@ -211,7 +207,7 @@ export function evaluatePipeline(repoRoot: string): PipelineStatus { step: "pass", message: "Unstaged tracked edits close the gate (git add or stash)", - command: "git add -A", + command: "git add -u", }); } else if ( gate.gatedTreeOid && @@ -240,7 +236,7 @@ export function evaluatePipeline(repoRoot: string): PipelineStatus { blockers.push({ step: "pass", message: "Gate closed (see know-code status --json)", - command: "know-code status", + command: "know-code pass", }); } } @@ -269,11 +265,11 @@ export function formatCheckDeny( !commitDrift ) { reason = - "Diff hash changed — you may have staged new changes or amended commits. Run `know-code status`."; + "Diff hash changed — you may have staged new changes or amended commits. Re-seal teaching for the final staged diff."; } return { reason, - next: b.command || "know-code status", + next: b.command || "know-code taught", }; } @@ -287,7 +283,7 @@ export function formatCheckDeny( return { reason: "diff changed since last quiz — staged new work or amended commits?", - next: "know-code status", + next: "know-code taught", }; } if (!receipt.gatedTreeOid) { diff --git a/packages/cli/src/questions.ts b/packages/cli/src/questions.ts index 39a2a97..206dc57 100644 --- a/packages/cli/src/questions.ts +++ b/packages/cli/src/questions.ts @@ -2,10 +2,12 @@ * Minimum quiz question count from diff shape + level. * Agents MUST run `know-code questions` before writing quiz.json. */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { readConfig } from "./config.js"; import { git, mergeBase, resolveBaseRef, revListCount } from "./git.js"; import { resolveQuizContext } from "./hash.js"; -import { findGitRoot } from "./paths.js"; +import { findGitRoot, quizPath } from "./paths.js"; import { readRangeSession } from "./range.js"; import { isLevel, type Level } from "./types.js"; @@ -290,3 +292,27 @@ export function cmdQuestions(opts: { `know-code: write exactly ${result.minQuestions} questions in .know-code/quiz.json (not fewer).`, ); } + +/** Write a quota-correct quiz scaffold for the current hash. */ +export function cmdQuizInit(): void { + const repoRoot = findGitRoot(); + const config = readConfig(repoRoot); + const fromRef = resolveQuotaFrom(repoRoot, config.baseBranch); + const signals = collectQuotaSignals(repoRoot, config.level, fromRef); + const quota = computeQuestionQuota(signals); + const ctx = resolveQuizContext(repoRoot, config); + const template = { + diffHash: ctx.diffHash, + level: config.level, + title: "know-code quiz", + questions: Array.from({ length: quota.minQuestions }, (_, i) => ({ + id: `q${i + 1}`, + prompt: "Replace with a diff-specific question.", + })), + }; + mkdirSync(join(quizPath(repoRoot), ".."), { recursive: true }); + writeFileSync(quizPath(repoRoot), `${JSON.stringify(template, null, 2)}\n`); + console.log( + `know-code: wrote ${quota.minQuestions}-question quiz scaffold; replace every placeholder prompt, then run know-code quiz validate.`, + ); +} From 0c59532566acf49634d07f5d261e199a01da66a4 Mon Sep 17 00:00:00 2001 From: chtnnh <59027776+chtnnh@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:13:21 +0400 Subject: [PATCH 2/2] fix: correct next steps after stale pass Know-Code-Verified: 87dd89494bb15947e521bbfbcfc9c8315b6aa2920ef6eaf1982caa11f4ac3d82 --- packages/cli/src/pipeline-hints.test.ts | 24 +++++++++++++++++++++--- packages/cli/src/pipeline.ts | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/pipeline-hints.test.ts b/packages/cli/src/pipeline-hints.test.ts index 062be95..d427955 100644 --- a/packages/cli/src/pipeline-hints.test.ts +++ b/packages/cli/src/pipeline-hints.test.ts @@ -5,9 +5,10 @@ import { join } from "node:path"; import { writeAnswers, writeGrade, writeTaught } from "./attest.js"; import { writeConfig } from "./config.js"; -import { computeDiffContext } from "./hash.js"; -import { evaluatePipeline } from "./pipeline.js"; -import { commitAll, liteConfig, setupOpenGate, withTempRepo, writeFile } from "./test-helpers.js"; +import { readGateSafe } from "./gate.js"; +import { computeDiffContext, resolveQuizContext } from "./hash.js"; +import { evaluatePipeline, formatCheckDeny } from "./pipeline.js"; +import { commitAll, git, liteConfig, setupOpenGate, withTempRepo, writeFile } from "./test-helpers.js"; function blockerCommand(repoRoot: string, step: string): string | undefined { return evaluatePipeline(repoRoot).blockers.find((blocker) => blocker.step === step) @@ -155,4 +156,21 @@ describe("pipeline recovery hints", () => { "know-code taught", ]); }); + + it("keeps a stale-pass deny reason aligned with its pass recovery command", () => { + const { root, cleanup } = withTempRepo("kc-hint-stale-pass-"); + try { + const { cfg } = setupOpenGate(root, { requireTrailer: false }); + writeFile(root, "a.txt", "changed\n"); + git(root, ["add", "a.txt"]); + seedWorkflowArtifacts(root); + + const ctx = resolveQuizContext(root, cfg); + const denied = formatCheckDeny(root, cfg, ctx, readGateSafe(root)); + assert.equal(denied.next, "know-code pass"); + assert.match(denied.reason, /Run `know-code pass`/); + } finally { + cleanup(); + } + }); }); diff --git a/packages/cli/src/pipeline.ts b/packages/cli/src/pipeline.ts index 5de8d79..83eef8f 100644 --- a/packages/cli/src/pipeline.ts +++ b/packages/cli/src/pipeline.ts @@ -265,7 +265,7 @@ export function formatCheckDeny( !commitDrift ) { reason = - "Diff hash changed — you may have staged new changes or amended commits. Re-seal teaching for the final staged diff."; + "Diff hash changed — you may have staged new changes or amended commits. Run `know-code pass` to seal the current hash."; } return { reason,