@hone-ai/cli 1.30.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hone-cli.js +231 -93
- package/lib/auto-verify.js +167 -0
- package/lib/bundle-context.js +182 -0
- package/lib/poll-decision.js +92 -0
- package/lib/run-report.js +76 -8
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -5604,10 +5604,13 @@ program
|
|
|
5604
5604
|
.option('--kill', 'Kill the latest workflow run for this story')
|
|
5605
5605
|
.option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
|
|
5606
5606
|
.option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0). Exit codes: 2 = paused at a human gate, 3 = parked for your editor (execution.mode: editor)')
|
|
5607
|
+
.option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
|
|
5607
5608
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
5608
5609
|
.option('--poll-interval <s>', 'Poll interval in seconds', '5')
|
|
5609
5610
|
.option('--story-file <path>', 'HC-GF-010: read the story text from a local file. Skips the GitHub-issue and Hone-roadmap lookups entirely, so a repo with neither can still run the pipeline.')
|
|
5610
5611
|
.option('--description <text>', 'HC-GF-010: the story text, inline. Same effect as --story-file.')
|
|
5612
|
+
.option('--acknowledge-abort', 'Approve a step the agent ABORTED (HC-GF-069). An abort means the agent found nothing it could review or build, so approving advances past a step that produced no usable result — read the reason first. Exists so a human can override, which is why the refusal is not simply a hard block.')
|
|
5613
|
+
.option('--no-follow', 'With --approve: post the approval and exit immediately instead of following the run to its next decision point (HC-GF-070). The default FOLLOWS, because a caller told to poll does not — which is why HC-GF-064 auto-verify never fired outside a TTY.')
|
|
5611
5614
|
.option('--include-paths <paths>', 'Comma-separated list of source file paths to bundle into the codebase context (HC-019n-followup-12). Augments auto-detected paths from the issue body.')
|
|
5612
5615
|
.option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
|
|
5613
5616
|
.option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
|
|
@@ -5638,6 +5641,132 @@ program
|
|
|
5638
5641
|
const storyId = isUuid ? null : storyIdOrRunId;
|
|
5639
5642
|
const runId = isUuid ? storyIdOrRunId : null;
|
|
5640
5643
|
|
|
5644
|
+
// HC-GF-070: ONE poll loop, reachable from BOTH entry points.
|
|
5645
|
+
//
|
|
5646
|
+
// It used to be reachable from exactly one — the initial `run-story <storyId>` — so
|
|
5647
|
+
// `--approve` posted, printed "Poll: ...", and returned. The caller was told to poll
|
|
5648
|
+
// and, exactly as with the verify-patch nag that motivated HC-GF-064 (5 of 22 runs),
|
|
5649
|
+
// nobody did. Every driver written against this re-implemented the loop and each had
|
|
5650
|
+
// a different bug: a paused gate treated as terminal; a no-op approval hot-looping
|
|
5651
|
+
// nine times; a gate advertised by a run whose driver had already exited. Not three
|
|
5652
|
+
// driver bugs — one missing behaviour, three times.
|
|
5653
|
+
//
|
|
5654
|
+
// Its DECISIONS live in cli/lib/poll-decision.js as a pure function, because calling
|
|
5655
|
+
// the same loop from a second place is only safe if what it decides is pinned.
|
|
5656
|
+
const pollUntilDecision = async (pollRunId) => {
|
|
5657
|
+
const { classifyPollState, POLL_OUTCOMES } = require('./lib/poll-decision');
|
|
5658
|
+
const interval = parseInt(opts.pollInterval, 10) * 1000;
|
|
5659
|
+
// HC-GF-064: at most ONE verification per attempt per process. Without this the
|
|
5660
|
+
// advisory path re-verifies on every poll tick, because advisory never advances
|
|
5661
|
+
// the run and the pause it re-observes is the same pause.
|
|
5662
|
+
const _verifiedAttempts = new Set();
|
|
5663
|
+
|
|
5664
|
+
for (;;) {
|
|
5665
|
+
await new Promise(r => setTimeout(r, interval));
|
|
5666
|
+
|
|
5667
|
+
let status;
|
|
5668
|
+
try {
|
|
5669
|
+
const { data: pollData } = await client.get(`/orchestrate/${pollRunId}`);
|
|
5670
|
+
status = pollData;
|
|
5671
|
+
} catch (e) {
|
|
5672
|
+
console.error(`Poll error: ${e.message}`);
|
|
5673
|
+
continue;
|
|
5674
|
+
}
|
|
5675
|
+
|
|
5676
|
+
const d = classifyPollState(status);
|
|
5677
|
+
|
|
5678
|
+
if (d.outcome === POLL_OUTCOMES.COMPLETED) {
|
|
5679
|
+
console.log(`✓ Workflow completed! Total tokens: ${(d.totalTokens || 0).toLocaleString()}`);
|
|
5680
|
+
process.exit(d.exitCode);
|
|
5681
|
+
}
|
|
5682
|
+
if (d.outcome === POLL_OUTCOMES.TERMINAL_FAILURE) {
|
|
5683
|
+
console.error(`✗ Workflow ${d.runStatus}: ${d.errorMessage}`);
|
|
5684
|
+
process.exit(d.exitCode);
|
|
5685
|
+
}
|
|
5686
|
+
if (d.outcome === POLL_OUTCOMES.EDITOR_PARKED) {
|
|
5687
|
+
console.log(editorParkedGuidance(pollRunId, d.stepKey, d.agent));
|
|
5688
|
+
process.exit(d.exitCode);
|
|
5689
|
+
}
|
|
5690
|
+
|
|
5691
|
+
if (d.outcome === POLL_OUTCOMES.HUMAN_GATE) {
|
|
5692
|
+
console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
|
|
5693
|
+
|
|
5694
|
+
// HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
|
|
5695
|
+
// existed since HC-019n-followup-34/35 and never fired, because nothing ever
|
|
5696
|
+
// ran the executing step. Consent is DERIVED: `ci.local_command` being
|
|
5697
|
+
// explicitly set is the adopter's own statement of how to test this repo (see
|
|
5698
|
+
// cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
|
|
5699
|
+
// Never throws — a failure here must leave the operator exactly where they
|
|
5700
|
+
// were, which is being asked to approve.
|
|
5701
|
+
try {
|
|
5702
|
+
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified } = require('./lib/auto-verify');
|
|
5703
|
+
const decision = decideAutoVerify({
|
|
5704
|
+
stepKey: d.stepKey,
|
|
5705
|
+
repoRoot: process.cwd(),
|
|
5706
|
+
noVerifyFlag: opts.verify === false,
|
|
5707
|
+
});
|
|
5708
|
+
if (decision.run && !alreadyVerified(d.attemptKey, _verifiedAttempts)) {
|
|
5709
|
+
_verifiedAttempts.add(d.attemptKey);
|
|
5710
|
+
console.log(announce(decision, pollRunId));
|
|
5711
|
+
const { spawnSync } = require('child_process');
|
|
5712
|
+
const r = spawnSync(process.execPath,
|
|
5713
|
+
[process.argv[1], 'verify-patch', pollRunId, '--report'],
|
|
5714
|
+
{ stdio: 'inherit', env: process.env });
|
|
5715
|
+
// A green verdict under `enforce` AUTO-APPROVES this gate, so the run may
|
|
5716
|
+
// already have moved on. Re-poll rather than prompting for an approval that
|
|
5717
|
+
// is no longer wanted — asking a human to approve something already approved
|
|
5718
|
+
// is how an operator learns to distrust the output.
|
|
5719
|
+
if (shouldRepoll({ gate: decision.gate, verdictOk: r.status === 0,
|
|
5720
|
+
attemptKey: d.attemptKey, verifiedAttempts: null })) {
|
|
5721
|
+
console.log(' → verified; enforce may have advanced the run. Re-checking...');
|
|
5722
|
+
continue;
|
|
5723
|
+
}
|
|
5724
|
+
console.log(r.status === 0
|
|
5725
|
+
? ` → verified and recorded (gate: ${decision.gate}). The gate still needs you.`
|
|
5726
|
+
: ' → verification did not pass; the gate still needs you.');
|
|
5727
|
+
}
|
|
5728
|
+
} catch (e) {
|
|
5729
|
+
console.log(` (auto-verify skipped: ${e.message})`);
|
|
5730
|
+
}
|
|
5731
|
+
|
|
5732
|
+
if (process.stdin.isTTY) {
|
|
5733
|
+
const readline = require('readline');
|
|
5734
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
5735
|
+
const answer = await new Promise(resolve => rl.question(' Approve? [y/n/kill] ', resolve));
|
|
5736
|
+
rl.close();
|
|
5737
|
+
|
|
5738
|
+
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
5739
|
+
try {
|
|
5740
|
+
await client.post(`/orchestrate/${pollRunId}/approve`, { stepKey: d.stepKey });
|
|
5741
|
+
console.log(' Approved — workflow resuming...');
|
|
5742
|
+
} catch (e) {
|
|
5743
|
+
console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
|
|
5744
|
+
}
|
|
5745
|
+
} else if (answer.toLowerCase() === 'kill') {
|
|
5746
|
+
try {
|
|
5747
|
+
await client.post(`/orchestrate/${pollRunId}/kill`, { reason: 'CLI kill' });
|
|
5748
|
+
console.log(' Workflow killed.');
|
|
5749
|
+
process.exit(0);
|
|
5750
|
+
} catch (e) {
|
|
5751
|
+
console.error(` Kill failed: ${e.message}`);
|
|
5752
|
+
}
|
|
5753
|
+
} else {
|
|
5754
|
+
console.log(` Skipped. Resume later: hone run-story ${pollRunId} --status`);
|
|
5755
|
+
process.exit(0);
|
|
5756
|
+
}
|
|
5757
|
+
} else {
|
|
5758
|
+
// Non-interactive (CI): exit 2 (paused, needs approval) — the contract
|
|
5759
|
+
// adopter pipelines already branch on.
|
|
5760
|
+
console.log(` Non-interactive mode. Approve with: hone run-story ${pollRunId} --approve ${d.stepKey}`);
|
|
5761
|
+
process.exit(d.exitCode);
|
|
5762
|
+
}
|
|
5763
|
+
continue;
|
|
5764
|
+
}
|
|
5765
|
+
|
|
5766
|
+
if (d.stepKey) process.stdout.write(` Running: ${d.stepKey} (${d.agent})...\r`);
|
|
5767
|
+
}
|
|
5768
|
+
};
|
|
5769
|
+
|
|
5641
5770
|
// Status mode
|
|
5642
5771
|
if (opts.status) {
|
|
5643
5772
|
const statusId = runId || storyIdOrRunId;
|
|
@@ -5705,13 +5834,33 @@ program
|
|
|
5705
5834
|
if (opts.approve) {
|
|
5706
5835
|
const approveId = runId || storyIdOrRunId;
|
|
5707
5836
|
try {
|
|
5708
|
-
|
|
5837
|
+
await client.post(`/orchestrate/${approveId}/approve`,
|
|
5838
|
+
{ stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true });
|
|
5709
5839
|
console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
|
|
5710
|
-
console.log(`Poll: hone run-story ${approveId} --status`);
|
|
5711
5840
|
} catch (e) {
|
|
5712
5841
|
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
5713
5842
|
process.exit(1);
|
|
5714
5843
|
}
|
|
5844
|
+
// HC-GF-070: FOLLOW the run to its next decision point instead of returning here.
|
|
5845
|
+
// Returning is what made every automated flow exit blind after each gate, and is
|
|
5846
|
+
// the direct reason HC-GF-064's auto-verify — which lives at the step_4 pause —
|
|
5847
|
+
// never fired outside an interactive terminal. Exit codes are unchanged: 0 done,
|
|
5848
|
+
// 1 failed, 2 next human gate, 3 editor park.
|
|
5849
|
+
if (opts.follow === false) {
|
|
5850
|
+
console.log(`Poll: hone run-story ${approveId} --status`);
|
|
5851
|
+
return;
|
|
5852
|
+
}
|
|
5853
|
+
// The initial invocation runs this inside the start-workflow try/catch; the
|
|
5854
|
+
// approve path has none, and an unhandled rejection here would surface as a bare
|
|
5855
|
+
// stack trace after a successful approval — the approval landed, so the exit code
|
|
5856
|
+
// must say what happened to the RUN, not look like the approve failed.
|
|
5857
|
+
try {
|
|
5858
|
+
await pollUntilDecision(approveId);
|
|
5859
|
+
} catch (e) {
|
|
5860
|
+
console.error(`Approved, but following the run failed: ${e.message}`);
|
|
5861
|
+
console.error(`Poll: hone run-story ${approveId} --status`);
|
|
5862
|
+
process.exit(1);
|
|
5863
|
+
}
|
|
5715
5864
|
return;
|
|
5716
5865
|
}
|
|
5717
5866
|
|
|
@@ -5915,21 +6064,24 @@ program
|
|
|
5915
6064
|
// bundler can WINDOW a too-big file around its edit site instead of
|
|
5916
6065
|
// head-truncating past it (the demo's line 787 sits at char 37K).
|
|
5917
6066
|
const candidateLines = new Map();
|
|
6067
|
+
// HC-GF-073: hoisted out of the bare-ref branch below, because the sibling tier
|
|
6068
|
+
// needs the same list on stories that contain no bare refs at all — which is most
|
|
6069
|
+
// of them.
|
|
6070
|
+
const { gitEnv } = require('./lib/git-env');
|
|
6071
|
+
let repoFiles = null;
|
|
6072
|
+
const listRepoFiles = () => {
|
|
6073
|
+
if (repoFiles) return repoFiles;
|
|
6074
|
+
try {
|
|
6075
|
+
repoFiles = execSync('git ls-files', {
|
|
6076
|
+
cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
6077
|
+
}).split('\n').filter(Boolean);
|
|
6078
|
+
} catch { repoFiles = []; }
|
|
6079
|
+
return repoFiles;
|
|
6080
|
+
};
|
|
5918
6081
|
if (issueBodyForFiles) {
|
|
5919
6082
|
const { extractBareRefs, resolveBareName, alreadyCovered } = require('./lib/bundle-paths');
|
|
5920
|
-
const { gitEnv } = require('./lib/git-env');
|
|
5921
6083
|
const bareRefs = extractBareRefs(issueBodyForFiles);
|
|
5922
6084
|
if (bareRefs.length > 0) {
|
|
5923
|
-
let repoFiles = null;
|
|
5924
|
-
const listRepoFiles = () => {
|
|
5925
|
-
if (repoFiles) return repoFiles;
|
|
5926
|
-
try {
|
|
5927
|
-
repoFiles = execSync('git ls-files', {
|
|
5928
|
-
cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
5929
|
-
}).split('\n').filter(Boolean);
|
|
5930
|
-
} catch { repoFiles = []; }
|
|
5931
|
-
return repoFiles;
|
|
5932
|
-
};
|
|
5933
6085
|
for (const { name, line } of bareRefs) {
|
|
5934
6086
|
if (alreadyCovered(name, candidates)) continue;
|
|
5935
6087
|
const r = resolveBareName(name, listRepoFiles());
|
|
@@ -5954,9 +6106,32 @@ program
|
|
|
5954
6106
|
const MAX_FILES = 30;
|
|
5955
6107
|
const MAX_TOTAL_CHARS = 150_000;
|
|
5956
6108
|
const MAX_PER_FILE_CHARS = 25_000;
|
|
6109
|
+
// HC-GF-073: `candidates` is only what the STORY named, and a story names the
|
|
6110
|
+
// files it will CREATE — so on a greenfield repo every one of them fails the
|
|
6111
|
+
// existsSync below and the bundle is EMPTY. Measured across FlowForge's 33 real
|
|
6112
|
+
// stories: 29 extracted zero existing files. The dogfood runs looked fine only
|
|
6113
|
+
// because an operator had typed --include-paths once; 12 of 12 runs then bundled
|
|
6114
|
+
// the same three files across five different stories.
|
|
6115
|
+
//
|
|
6116
|
+
// selectBundle() splits them into what exists, what is pending, and adds two
|
|
6117
|
+
// tiers the story can never name: repo-level signal files (whose ABSENCE is what
|
|
6118
|
+
// a security reviewer must be able to check — HC-GF-063) and existing neighbours
|
|
6119
|
+
// of the pending paths (the conventions a new module has to match). Order is
|
|
6120
|
+
// priority order, because the char cap below truncates from the end.
|
|
6121
|
+
const { selectBundle, auditLines } = require('./lib/bundle-context');
|
|
6122
|
+
const selection = selectBundle({
|
|
6123
|
+
named: [...candidates],
|
|
6124
|
+
repoFiles: listRepoFiles(),
|
|
6125
|
+
exists: (rel) => {
|
|
6126
|
+
if (rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return false;
|
|
6127
|
+
const abs = path.resolve(process.cwd(), rel);
|
|
6128
|
+
return abs.startsWith(process.cwd() + path.sep) && fs.existsSync(abs);
|
|
6129
|
+
},
|
|
6130
|
+
});
|
|
6131
|
+
const ordered = [...selection.present, ...selection.signal, ...selection.siblings];
|
|
5957
6132
|
const codebaseFiles = [];
|
|
5958
6133
|
let totalChars = 0;
|
|
5959
|
-
for (const candidate of
|
|
6134
|
+
for (const candidate of ordered) {
|
|
5960
6135
|
if (codebaseFiles.length >= MAX_FILES) break;
|
|
5961
6136
|
if (candidate.startsWith('/') || candidate.startsWith('~') || candidate.includes('..')) continue;
|
|
5962
6137
|
const abs = path.resolve(process.cwd(), candidate);
|
|
@@ -5989,6 +6164,11 @@ program
|
|
|
5989
6164
|
totalChars += content.length;
|
|
5990
6165
|
codebaseFiles.push({ path: candidate, content });
|
|
5991
6166
|
}
|
|
6167
|
+
// HC-GF-073: the old line printed a COUNT, and a count cannot distinguish three
|
|
6168
|
+
// files the story named from three it did not. That is why 12 consecutive runs
|
|
6169
|
+
// bundled an unrelated module and nothing said so — the "running blind" warning
|
|
6170
|
+
// below fires on an EMPTY bundle, and these were not empty.
|
|
6171
|
+
for (const line of auditLines(selection)) console.log(line);
|
|
5992
6172
|
if (codebaseFiles.length > 0) {
|
|
5993
6173
|
orchestrateConfig.codebaseFiles = codebaseFiles;
|
|
5994
6174
|
console.log(` → bundled ${codebaseFiles.length} codebase file(s) for step_1/step_4 context (${totalChars} chars)`);
|
|
@@ -6231,82 +6411,7 @@ program
|
|
|
6231
6411
|
console.log(`Poll: ${data.pollUrl}`);
|
|
6232
6412
|
console.log('');
|
|
6233
6413
|
|
|
6234
|
-
|
|
6235
|
-
const interval = parseInt(opts.pollInterval, 10) * 1000;
|
|
6236
|
-
while (true) {
|
|
6237
|
-
await new Promise(r => setTimeout(r, interval));
|
|
6238
|
-
|
|
6239
|
-
let status;
|
|
6240
|
-
try {
|
|
6241
|
-
const { data: pollData } = await client.get(`/orchestrate/${data.runId}`);
|
|
6242
|
-
status = pollData;
|
|
6243
|
-
} catch (e) {
|
|
6244
|
-
console.error(`Poll error: ${e.message}`);
|
|
6245
|
-
continue;
|
|
6246
|
-
}
|
|
6247
|
-
|
|
6248
|
-
if (status.status === 'completed') {
|
|
6249
|
-
console.log(`✓ Workflow completed! Total tokens: ${(status.totalTokens || 0).toLocaleString()}`);
|
|
6250
|
-
process.exit(0);
|
|
6251
|
-
}
|
|
6252
|
-
|
|
6253
|
-
if (status.status === 'failed' || status.status === 'killed') {
|
|
6254
|
-
console.error(`✗ Workflow ${status.status}: ${status.errorMessage || 'unknown'}`);
|
|
6255
|
-
process.exit(1);
|
|
6256
|
-
}
|
|
6257
|
-
|
|
6258
|
-
if (status.status === 'paused' && status.awaitingApproval) {
|
|
6259
|
-
const step = status.steps.find(s => s.stepKey === status.currentStep);
|
|
6260
|
-
// HC-COMM-010-followup-1: an editor-parked run is a gate the EDITOR
|
|
6261
|
-
// satisfies, not a human — never offer --approve here (B4 refuses it).
|
|
6262
|
-
if (isEditorParked(status)) {
|
|
6263
|
-
console.log(editorParkedGuidance(data.runId, status.currentStep, step?.agent));
|
|
6264
|
-
process.exit(EDITOR_PARKED_EXIT_CODE);
|
|
6265
|
-
}
|
|
6266
|
-
console.log(`⏳ Step ${status.currentStep} (${step?.agent || 'unknown'}) — awaiting approval`);
|
|
6267
|
-
|
|
6268
|
-
// Interactive prompt
|
|
6269
|
-
if (process.stdin.isTTY) {
|
|
6270
|
-
const readline = require('readline');
|
|
6271
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
6272
|
-
const answer = await new Promise(resolve => {
|
|
6273
|
-
rl.question(' Approve? [y/n/kill] ', resolve);
|
|
6274
|
-
});
|
|
6275
|
-
rl.close();
|
|
6276
|
-
|
|
6277
|
-
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
6278
|
-
try {
|
|
6279
|
-
await client.post(`/orchestrate/${data.runId}/approve`, { stepKey: status.currentStep });
|
|
6280
|
-
console.log(' Approved — workflow resuming...');
|
|
6281
|
-
} catch (e) {
|
|
6282
|
-
console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
|
|
6283
|
-
}
|
|
6284
|
-
} else if (answer.toLowerCase() === 'kill') {
|
|
6285
|
-
try {
|
|
6286
|
-
await client.post(`/orchestrate/${data.runId}/kill`, { reason: 'CLI kill' });
|
|
6287
|
-
console.log(' Workflow killed.');
|
|
6288
|
-
process.exit(0);
|
|
6289
|
-
} catch (e) {
|
|
6290
|
-
console.error(` Kill failed: ${e.message}`);
|
|
6291
|
-
}
|
|
6292
|
-
} else {
|
|
6293
|
-
console.log(` Skipped. Resume later: hone run-story ${data.runId} --status`);
|
|
6294
|
-
process.exit(0);
|
|
6295
|
-
}
|
|
6296
|
-
} else {
|
|
6297
|
-
// Non-interactive (CI): exit with code 2 (paused, needs approval)
|
|
6298
|
-
console.log(` Non-interactive mode. Approve with: hone run-story ${data.runId} --approve ${status.currentStep}`);
|
|
6299
|
-
process.exit(2);
|
|
6300
|
-
}
|
|
6301
|
-
continue;
|
|
6302
|
-
}
|
|
6303
|
-
|
|
6304
|
-
// Still running
|
|
6305
|
-
const currentStep = status.steps.find(s => s.status === 'running');
|
|
6306
|
-
if (currentStep) {
|
|
6307
|
-
process.stdout.write(` Running: ${currentStep.stepKey} (${currentStep.agent})...\r`);
|
|
6308
|
-
}
|
|
6309
|
-
}
|
|
6414
|
+
await pollUntilDecision(data.runId);
|
|
6310
6415
|
} catch (e) {
|
|
6311
6416
|
if (e.response?.status === 429) {
|
|
6312
6417
|
console.error(`Max concurrent workflows reached. ${e.response.data.error}`);
|
|
@@ -7344,11 +7449,15 @@ showCmd
|
|
|
7344
7449
|
.command('step <workflowId> <stepKey>')
|
|
7345
7450
|
.description('Print the actual LLM output of an orchestrator step (latest attempt by default)')
|
|
7346
7451
|
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7452
|
+
.option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
|
|
7347
7453
|
.action(async (workflowId, stepKey, opts) => {
|
|
7348
7454
|
const config = getConfig();
|
|
7349
7455
|
const client = api(config);
|
|
7350
7456
|
try {
|
|
7351
|
-
const
|
|
7457
|
+
const q = [];
|
|
7458
|
+
if (opts.attempt) q.push(`attempt=${opts.attempt}`);
|
|
7459
|
+
if (opts.prompt) q.push('prompt=1');
|
|
7460
|
+
const url = `/orchestrate/${workflowId}/step/${stepKey}${q.length ? `?${q.join('&')}` : ''}`;
|
|
7352
7461
|
const r = await client.get(url);
|
|
7353
7462
|
const s = r.data;
|
|
7354
7463
|
console.log(`── step output ──────────────────────────────────────────`);
|
|
@@ -7366,6 +7475,21 @@ showCmd
|
|
|
7366
7475
|
console.log(`── error_context (retry feedback) ──────────────────────`);
|
|
7367
7476
|
console.log(s.errorContext);
|
|
7368
7477
|
}
|
|
7478
|
+
// HC-GF-055: say whether the prompt was recorded, ALWAYS. The rule this repo keeps
|
|
7479
|
+
// relearning is that a stored field nobody surfaces is the same as no field at all —
|
|
7480
|
+
// `input_artifact` sat in the schema with zero writers for months and nobody noticed
|
|
7481
|
+
// because nothing ever mentioned it.
|
|
7482
|
+
console.log(``);
|
|
7483
|
+
if (s.promptChars == null) {
|
|
7484
|
+
console.log(`── prompt ─────────────────────────────────────────────`);
|
|
7485
|
+
console.log(` (not recorded for this attempt — pre-HC-GF-055 runs have none)`);
|
|
7486
|
+
} else if (s.prompt != null) {
|
|
7487
|
+
console.log(`── prompt SENT to the model (${s.promptChars} chars) ────────`);
|
|
7488
|
+
console.log(s.prompt);
|
|
7489
|
+
} else {
|
|
7490
|
+
console.log(`── prompt ─────────────────────────────────────────────`);
|
|
7491
|
+
console.log(` ${s.promptChars} chars recorded — re-run with --prompt to print it`);
|
|
7492
|
+
}
|
|
7369
7493
|
console.log(``);
|
|
7370
7494
|
console.log(`── output (the LLM's actual response) ──────────────────`);
|
|
7371
7495
|
console.log(s.output || '(no output recorded — step may not have completed)');
|
|
@@ -7727,7 +7851,16 @@ program
|
|
|
7727
7851
|
headSha,
|
|
7728
7852
|
gates: reportGates.length ? reportGates : undefined,
|
|
7729
7853
|
});
|
|
7730
|
-
if (opts.format !== 'json')
|
|
7854
|
+
if (opts.format !== 'json') {
|
|
7855
|
+
// HC-GF-070: name the gate that is actually configured. This said "(advisory)"
|
|
7856
|
+
// unconditionally, and printed it on a run where `enforce` was auto-approving
|
|
7857
|
+
// step_4 on the strength of that very verdict.
|
|
7858
|
+
const { verdictReportLine } = require('./lib/auto-verify');
|
|
7859
|
+
const { readVerificationGateConfig } = require('./lib/pipeline-config');
|
|
7860
|
+
let gate = 'advisory';
|
|
7861
|
+
try { gate = readVerificationGateConfig(process.cwd()) || 'advisory'; } catch { /* label only */ }
|
|
7862
|
+
console.log(verdictReportLine(reportVerdict, gate));
|
|
7863
|
+
}
|
|
7731
7864
|
} catch (e) {
|
|
7732
7865
|
if (opts.format !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
|
|
7733
7866
|
}
|
|
@@ -7752,7 +7885,12 @@ program
|
|
|
7752
7885
|
else console.log(renderReport(report));
|
|
7753
7886
|
// Exit non-zero when the operator has something to do, so `hone run-report`
|
|
7754
7887
|
// is usable as a gate in a script and not merely as prose.
|
|
7755
|
-
|
|
7888
|
+
//
|
|
7889
|
+
// HC-GF-065: count the GATING items, not every item. An advisory — currently
|
|
7890
|
+
// "verification never ran" — is shown in full but must not flip this exit code:
|
|
7891
|
+
// it fires on every unverified terminal run, and gating on it would turn a
|
|
7892
|
+
// passing adopter CI red the day they upgrade. HC-026c: report before you gate.
|
|
7893
|
+
process.exit((report.gatingCount ?? report.needsYou.length) ? 1 : 0);
|
|
7756
7894
|
} catch (e) {
|
|
7757
7895
|
console.error(`run-report failed: ${e.response?.data?.error || e.message}`);
|
|
7758
7896
|
process.exit(1);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* auto-verify.js — HC-GF-064: decide whether the CLI should run `verify-patch` itself
|
|
4
|
+
* when a run pauses at the step_4 gate.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS. The closed-loop gate has been shipped and integration-tested since
|
|
7
|
+
* HC-019n-followup-34/35, and had never fired: 22 of 22 runs `advisory`, 5 of 22 with any
|
|
8
|
+
* verdict recorded at all, and zero automatic invocations. The only prompt was a line
|
|
9
|
+
* `run-report` prints — "NO verification was reported for this run" — and 5-of-22 is what
|
|
10
|
+
* that nag is worth. A gate that never receives a verdict is indistinguishable from a gate
|
|
11
|
+
* that does not exist.
|
|
12
|
+
*
|
|
13
|
+
* The value is measured, not assumed. On run 77ed4f1b `verify-patch` found three ruff
|
|
14
|
+
* errors that BOTH the code-reviewer and the security agent had passed — neither could
|
|
15
|
+
* have caught them, because no reviewing step executes anything. And on run 6964b87e a
|
|
16
|
+
* green verdict auto-approved step_4 with no human approval, which is the first time
|
|
17
|
+
* enforce mode has been demonstrated end to end.
|
|
18
|
+
*
|
|
19
|
+
* CONSENT IS DERIVED, NOT REQUESTED. Running an adopter's test suite unasked is a real
|
|
20
|
+
* intrusion — it provisions dependencies, writes to disk, and costs wall clock. But
|
|
21
|
+
* `ci.local_command` and `ci.install_command` are ALREADY in their `.pipeline-config.yml`:
|
|
22
|
+
* an adopter who filled those in has declared, in their own words, how to build and test
|
|
23
|
+
* this repo locally. Running exactly those commands, in an isolated worktree, at the
|
|
24
|
+
* moment their pipeline pauses for their approval, is inside what they have sanctioned.
|
|
25
|
+
* An adopter who has not configured them gets today's behaviour, unchanged.
|
|
26
|
+
*
|
|
27
|
+
* So this defaults ON where consent already exists and OFF where it does not — neither a
|
|
28
|
+
* blanket opt-in (which recreates the 5-of-22 failure this work exists to fix) nor a
|
|
29
|
+
* blanket opt-out.
|
|
30
|
+
*
|
|
31
|
+
* THIS DOES NOT VIOLATE HC-026c's "ships advisory by default" discipline, though it looks
|
|
32
|
+
* like it might. That rule is about not letting a detector CHANGE FLOW before it has
|
|
33
|
+
* earned trust. This changes no flow: it produces a verdict. Whether the verdict gates
|
|
34
|
+
* anything remains `verification.gate`, which stays `advisory` by default and is untouched
|
|
35
|
+
* here. Running the tests is safe-by-default; ACTING on them stays opt-in.
|
|
36
|
+
*
|
|
37
|
+
* Design: docs/architecture/adr-verification-invocation.md
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const { readCIConfigDetails, readVerificationGateConfig } = require('./pipeline-config');
|
|
41
|
+
|
|
42
|
+
/** The only gate a `verify-patch` verdict can inform (gate_sources default). */
|
|
43
|
+
const VERIFIABLE_STEP = 'step_4';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @returns {{ run: boolean, reason: string, command: string|null, gate: string }}
|
|
47
|
+
*
|
|
48
|
+
* `reason` is always populated, including when `run` is true, because the caller ANNOUNCES
|
|
49
|
+
* it. An adopter discovering their test suite mid-run should be told why it is running and
|
|
50
|
+
* how to stop it — the surprise is the real cost of this feature, and an unexplained
|
|
51
|
+
* surprise is the version that gets it reverted.
|
|
52
|
+
*/
|
|
53
|
+
function decideAutoVerify({ stepKey, repoRoot, noVerifyFlag = false, env = process.env } = {}) {
|
|
54
|
+
const gate = safeGate(repoRoot);
|
|
55
|
+
const off = (reason) => ({ run: false, reason, command: null, gate });
|
|
56
|
+
|
|
57
|
+
if (noVerifyFlag) return off('--no-verify was passed');
|
|
58
|
+
if (String(env.HONE_NO_AUTO_VERIFY || '') === '1') return off('HONE_NO_AUTO_VERIFY=1');
|
|
59
|
+
|
|
60
|
+
// Only step_4. A verdict for any other step has no gate to inform, so running an
|
|
61
|
+
// adopter's suite there would spend their time for nothing.
|
|
62
|
+
if (stepKey !== VERIFIABLE_STEP) {
|
|
63
|
+
return off(`only ${VERIFIABLE_STEP} has a verification gate (paused at ${stepKey})`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const auto = readVerificationAuto(repoRoot);
|
|
67
|
+
if (auto === false) return off('verification.auto is false in .pipeline-config.yml');
|
|
68
|
+
|
|
69
|
+
const ci = safeCi(repoRoot);
|
|
70
|
+
if (!ci.local_command_explicit) {
|
|
71
|
+
// The consent gate. NOT an error and NOT a nag — an adopter who has not told us how
|
|
72
|
+
// to test their repo has not consented to us guessing, and `make ci` is a guess.
|
|
73
|
+
return off('ci.local_command is not set in .pipeline-config.yml — no consent to run tests');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { run: true, reason: `ci.local_command is configured (${ci.local_command})`, command: ci.local_command, gate };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `verification.auto` — true unless explicitly false. Unset means "use the consent rule". */
|
|
80
|
+
function readVerificationAuto(repoRoot) {
|
|
81
|
+
const block = readYamlBlock(repoRoot, 'verification');
|
|
82
|
+
if (!block || typeof block !== 'object') return null;
|
|
83
|
+
if (block.auto === false || String(block.auto).toLowerCase() === 'false') return false;
|
|
84
|
+
if (block.auto === true || String(block.auto).toLowerCase() === 'true') return true;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readYamlBlock(repoRoot, key) {
|
|
89
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
90
|
+
const fs = require('fs'); const path = require('path');
|
|
91
|
+
for (const p of [path.join(repoRoot, '.pipeline-config.yml'), path.join(repoRoot, '.github/.pipeline-config.yml')]) {
|
|
92
|
+
if (!fs.existsSync(p)) continue;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8'));
|
|
95
|
+
if (parsed && typeof parsed === 'object' && parsed[key] !== undefined) return parsed[key];
|
|
96
|
+
} catch { /* a malformed config must not break the run — fall through to null */ }
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function safeCi(repoRoot) {
|
|
102
|
+
try { return readCIConfigDetails(repoRoot); }
|
|
103
|
+
catch { return { local_command: null, local_command_explicit: false, install_command: null }; }
|
|
104
|
+
}
|
|
105
|
+
function safeGate(repoRoot) {
|
|
106
|
+
try { return readVerificationGateConfig(repoRoot) || 'advisory'; }
|
|
107
|
+
catch { return 'advisory'; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What the operator sees before their test suite starts. */
|
|
111
|
+
function announce(decision, runId) {
|
|
112
|
+
if (!decision.run) return null;
|
|
113
|
+
return [
|
|
114
|
+
` → verifying step_4 before you approve it: ${decision.command}`,
|
|
115
|
+
` (in an isolated worktree; gate mode: ${decision.gate}. Skip with --no-verify.)`,
|
|
116
|
+
].join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* After a verdict is posted, should the caller RE-POLL (the run may have moved) or fall
|
|
122
|
+
* through to the normal approve prompt?
|
|
123
|
+
*
|
|
124
|
+
* HC-GF-064-followup: the first version always re-polled on a green verdict, which is an
|
|
125
|
+
* UNBOUNDED LOOP under the default `advisory` mode — advisory only RECORDS the verdict, so
|
|
126
|
+
* the run stays paused, the caller re-polls, sees the same pause, and runs the adopter's
|
|
127
|
+
* test suite again, provisioning a worktree every time, once per poll interval, forever.
|
|
128
|
+
* It was invisible during development because the repo it was built against is set to
|
|
129
|
+
* `enforce`, where a green verdict really does advance the run. Every adopter on the
|
|
130
|
+
* DEFAULT config would have hit it.
|
|
131
|
+
*
|
|
132
|
+
* Two independent guards, because either alone is thin:
|
|
133
|
+
* - only `enforce` can advance a run, so only `enforce` justifies re-polling;
|
|
134
|
+
* - an attempt is verified AT MOST ONCE per process regardless of mode, so a
|
|
135
|
+
* misconfiguration or a future third mode cannot resurrect the loop.
|
|
136
|
+
*/
|
|
137
|
+
function shouldRepoll({ gate, verdictOk, attemptKey, verifiedAttempts }) {
|
|
138
|
+
if (!verdictOk) return false;
|
|
139
|
+
if (gate !== 'enforce') return false;
|
|
140
|
+
if (verifiedAttempts && verifiedAttempts.has(attemptKey)) return false;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Has this exact attempt already been verified by this process? */
|
|
145
|
+
function alreadyVerified(attemptKey, verifiedAttempts) {
|
|
146
|
+
return Boolean(verifiedAttempts && verifiedAttempts.has(attemptKey));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The line printed after a verdict is posted.
|
|
151
|
+
*
|
|
152
|
+
* HC-GF-070: this used to end in a hardcoded `(advisory)`. On the acceptance run for that
|
|
153
|
+
* story the gate was `enforce` and the verdict AUTO-APPROVED step_4 — the run advanced
|
|
154
|
+
* with no human involved — while the CLI told the operator the verdict was advisory. A
|
|
155
|
+
* constant cannot be wrong about the run it is describing, which is the same
|
|
156
|
+
* cannot-fail shape as a check that never fires, arriving in the output layer: it
|
|
157
|
+
* understates the one case where the verdict actually changed something.
|
|
158
|
+
*/
|
|
159
|
+
function verdictReportLine(verdict, gate) {
|
|
160
|
+
const mode = gate === 'enforce' ? 'enforce' : 'advisory';
|
|
161
|
+
const effect = mode === 'enforce'
|
|
162
|
+
? 'enforce — a passing verdict approves this gate'
|
|
163
|
+
: 'advisory — records only, the gate still needs a human';
|
|
164
|
+
return ` → reported verdict '${verdict}' to the server (${effect})`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { decideAutoVerify, announce, readVerificationAuto, shouldRepoll, alreadyVerified, verdictReportLine, VERIFIABLE_STEP };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* bundle-context.js — HC-GF-073: decide what source the pipeline actually gets to read.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. The bundler drops every path that fails `existsSync`, which on a
|
|
6
|
+
* greenfield story is all of them: a story names what it will CREATE. Measured across the
|
|
7
|
+
* 33 real FlowForge stories, 29 extracted ZERO existing files. The dogfood runs looked
|
|
8
|
+
* healthy only because an operator had typed `--include-paths` once — 12 of 12 runs then
|
|
9
|
+
* bundled the same three files across five different stories, none of which named them.
|
|
10
|
+
*
|
|
11
|
+
* Three recorded failures trace back here, and none of them looked like a bundling bug:
|
|
12
|
+
* HC-GF-063 a HIGH security finding on `.gitignore` "being unverified" — it was never
|
|
13
|
+
* bundled; `.gitignore:2` already contained `.env`.
|
|
14
|
+
* HC-GF-069 step_5 aborted: "two ACs are blocked on files that have never been bundled".
|
|
15
|
+
* HC-GF-072 the builder invented `FLOWFORGE_MODE` while `.env.example`, unbundled,
|
|
16
|
+
* already read `MODE=paper`.
|
|
17
|
+
*
|
|
18
|
+
* THREE TIERS, in priority order, because the CLI's char budget truncates from the end:
|
|
19
|
+
* present — paths the STORY named that exist. The best context there is.
|
|
20
|
+
* signal — small repo-level files no story ever names but every reviewer needs:
|
|
21
|
+
* ignore files, manifests, env TEMPLATES. Their ABSENCE is the finding.
|
|
22
|
+
* siblings — existing neighbours of the paths the story will create, taken from the
|
|
23
|
+
* nearest ancestor directory that has files. This is the greenfield answer:
|
|
24
|
+
* you cannot show the builder a file that does not exist, but you can show
|
|
25
|
+
* it how this package writes the ones that do.
|
|
26
|
+
*
|
|
27
|
+
* SECRETS ARE REFUSED AT EVERY TIER, including when the story names one outright. `named`
|
|
28
|
+
* is untrusted input — story prose, or an `--include-paths` flag. `.env.example` is a
|
|
29
|
+
* template and safe; `.env` holds live broker credentials, and `git ls-files` is no guard
|
|
30
|
+
* because a misconfigured repo can track one. A refusal is REPORTED, never silent.
|
|
31
|
+
*
|
|
32
|
+
* The reporting half is not decoration. Today's "running blind" warning fires on an EMPTY
|
|
33
|
+
* bundle, so a bundle of three irrelevant files is indistinguishable from a good one and
|
|
34
|
+
* the operator is told nothing — a check that cannot fail, at the input stage.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Repo-level files worth bundling regardless of what the story says. Deliberately
|
|
41
|
+
* cross-stack: a list that only knows `pyproject.toml` hands every Node adopter the old
|
|
42
|
+
* behaviour. Kept to files that are small and that answer "does this repo already handle
|
|
43
|
+
* X" — the question a reviewer cannot otherwise settle.
|
|
44
|
+
*/
|
|
45
|
+
const SIGNAL_FILES = Object.freeze([
|
|
46
|
+
'.gitignore', '.dockerignore', '.npmignore',
|
|
47
|
+
'.env.example', '.env.sample', '.env.template',
|
|
48
|
+
'package.json', 'pyproject.toml', 'setup.cfg', 'go.mod', 'Cargo.toml',
|
|
49
|
+
'Gemfile', 'pom.xml', 'build.gradle', 'composer.json',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A path that must never reach an agent prompt. Matched on the BASENAME so a nested
|
|
54
|
+
* `config/.env` is caught too. The `.example`/`.sample`/`.template` suffixes are the
|
|
55
|
+
* documented exception and are handled by testing them first.
|
|
56
|
+
*/
|
|
57
|
+
const SECRET_BASENAMES = [/^\.env($|\.)/i, /^secrets?\./i, /\.pem$/i, /\.p12$/i, /(^|[._-])id_(rsa|dsa|ecdsa|ed25519)$/i];
|
|
58
|
+
const SECRET_EXEMPT = /\.(example|sample|template|dist)$/i;
|
|
59
|
+
|
|
60
|
+
/** Is this path a credential file? `.env.example` is not; `.env.production` is. */
|
|
61
|
+
function isSecretPath(p) {
|
|
62
|
+
const base = path.posix.basename(String(p || '').replace(/\\/g, '/'));
|
|
63
|
+
if (SECRET_EXEMPT.test(base)) return false;
|
|
64
|
+
return SECRET_BASENAMES.some((re) => re.test(base));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Directory of a repo-relative path, '' at the root. */
|
|
68
|
+
const dirOf = (p) => {
|
|
69
|
+
const d = path.posix.dirname(String(p).replace(/\\/g, '/'));
|
|
70
|
+
return d === '.' ? '' : d;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Existing files that are DIRECT children of `dir` (not of its subdirectories — a
|
|
75
|
+
* recursive walk would drag a whole tree in and blow the char budget on the first story
|
|
76
|
+
* that touches a package root).
|
|
77
|
+
*/
|
|
78
|
+
function directChildren(dir, repoFiles) {
|
|
79
|
+
return repoFiles.filter((f) => dirOf(f) === dir);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Walk from a pending path's own directory toward the root, stopping at the FIRST
|
|
84
|
+
* directory that has files. `flowforge/config/settings.py` in a repo with no
|
|
85
|
+
* `flowforge/config/` yields `flowforge/`'s children; it does not climb to the root and
|
|
86
|
+
* return the entire repo.
|
|
87
|
+
*/
|
|
88
|
+
function nearestPopulatedAncestor(pendingPath, repoFiles) {
|
|
89
|
+
let dir = dirOf(pendingPath);
|
|
90
|
+
for (;;) {
|
|
91
|
+
const kids = directChildren(dir, repoFiles);
|
|
92
|
+
if (kids.length > 0) return { dir, kids };
|
|
93
|
+
if (dir === '') return { dir: '', kids: [] };
|
|
94
|
+
dir = dirOf(dir);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {object} o
|
|
100
|
+
* @param {string[]} o.named paths the story named (or --include-paths supplied)
|
|
101
|
+
* @param {string[]} o.repoFiles tracked files, `git ls-files`
|
|
102
|
+
* @param {(p:string)=>boolean} o.exists the CLI passes an fs check, so an untracked-but-
|
|
103
|
+
* present file is not miscounted as pending
|
|
104
|
+
* @param {number} [o.maxSiblings=6]
|
|
105
|
+
* @returns {{present:string[], pending:string[], signal:string[], siblings:string[], refused:string[]}}
|
|
106
|
+
*/
|
|
107
|
+
function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSiblings = 6 } = {}) {
|
|
108
|
+
const present = [], pending = [], refused = [];
|
|
109
|
+
for (const p of named) {
|
|
110
|
+
if (isSecretPath(p)) { refused.push(p); continue; }
|
|
111
|
+
(exists(p) ? present : pending).push(p);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const taken = new Set(present);
|
|
115
|
+
// No isSecretPath() filter here: SIGNAL_FILES is a frozen literal, so the guarantee
|
|
116
|
+
// belongs to the LIST and is asserted against it directly. A filter here could never
|
|
117
|
+
// fire, and a guard that cannot fire is indistinguishable from one that is missing.
|
|
118
|
+
const signal = SIGNAL_FILES.filter((f) => !taken.has(f) && exists(f));
|
|
119
|
+
signal.forEach((f) => taken.add(f));
|
|
120
|
+
|
|
121
|
+
// Prefer a sibling sharing the pending file's extension — the convention a new .py must
|
|
122
|
+
// match lives in another .py, not in the package's README.
|
|
123
|
+
const siblings = [];
|
|
124
|
+
for (const p of pending) {
|
|
125
|
+
if (siblings.length >= maxSiblings) break;
|
|
126
|
+
const ext = path.posix.extname(p);
|
|
127
|
+
const { kids } = nearestPopulatedAncestor(p, repoFiles);
|
|
128
|
+
const ranked = [...kids].sort((a, b) =>
|
|
129
|
+
(path.posix.extname(b) === ext) - (path.posix.extname(a) === ext));
|
|
130
|
+
for (const k of ranked) {
|
|
131
|
+
if (siblings.length >= maxSiblings) break;
|
|
132
|
+
if (taken.has(k) || isSecretPath(k)) continue;
|
|
133
|
+
taken.add(k); siblings.push(k);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { present, pending, signal, siblings, refused };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What the operator reads before the run starts.
|
|
142
|
+
*
|
|
143
|
+
* The load-bearing line is the ratio: `0 of 2 paths this story names`. It is what makes a
|
|
144
|
+
* bundle that matched nothing distinguishable from one that matched everything — the
|
|
145
|
+
* distinction the current empty-bundle warning cannot draw. The reason is always given
|
|
146
|
+
* alongside it, because a create-only story is NORMAL and a warning that reads as a defect
|
|
147
|
+
* on every greenfield story gets tuned out within a day, taking the real one with it.
|
|
148
|
+
*/
|
|
149
|
+
function auditLines(sel) {
|
|
150
|
+
const { present = [], pending = [], signal = [], siblings = [], refused = [] } = sel || {};
|
|
151
|
+
const named = present.length + pending.length;
|
|
152
|
+
const out = [];
|
|
153
|
+
|
|
154
|
+
if (named > 0) {
|
|
155
|
+
out.push(` → bundle: ${present.length} of ${named} path${named === 1 ? '' : 's'} this story names`);
|
|
156
|
+
if (pending.length > 0) {
|
|
157
|
+
out.push(` ${pending.length} do not yet exist (to be created): ${pending.join(', ')}`);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// The regression this fix nearly introduced, caught reviewing it before merge. A
|
|
161
|
+
// story yielding zero paths used to produce an EMPTY bundle, which tripped the
|
|
162
|
+
// caller's "running blind" warning. The signal tier means the bundle is never empty
|
|
163
|
+
// again — a .gitignore and a manifest are always there — so that warning would be
|
|
164
|
+
// silently suppressed on the one condition it exists for. Bundling two config files
|
|
165
|
+
// is not source context, and saying nothing about it would be this change committing
|
|
166
|
+
// the defect it was written to fix.
|
|
167
|
+
out.push(' ⚠ the story named no source paths — step_1 / step_4 will run WITHOUT source context');
|
|
168
|
+
}
|
|
169
|
+
if (siblings.length > 0) {
|
|
170
|
+
out.push(` + ${siblings.length} existing neighbour(s) for convention: ${siblings.join(', ')}`);
|
|
171
|
+
}
|
|
172
|
+
if (signal.length > 0) out.push(` + ${signal.length} repo-level file(s): ${signal.join(', ')}`);
|
|
173
|
+
if (refused.length > 0) {
|
|
174
|
+
out.push(` ⚠ refused ${refused.length} credential file(s), never bundled: ${refused.join(', ')}`);
|
|
175
|
+
}
|
|
176
|
+
if (present.length === 0 && siblings.length === 0 && signal.length === 0) {
|
|
177
|
+
out.push(' ⚠ nothing to bundle — the code steps will run WITHOUT source context');
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { selectBundle, auditLines, isSecretPath, SIGNAL_FILES };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* poll-decision.js — HC-GF-070: what the poll loop should DO about one status payload.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS ITS OWN MODULE. The loop had exactly one entry point — the initial
|
|
6
|
+
* `run-story <storyId>` — and `--approve` returned without polling, so every automated
|
|
7
|
+
* flow exited blind after each gate. Making `--approve` poll means calling the same loop
|
|
8
|
+
* from a second place, and the only way to do that safely is for its DECISIONS to be a
|
|
9
|
+
* pure function of the payload rather than control flow tangled through 130 lines of
|
|
10
|
+
* printing, prompting and process.exit.
|
|
11
|
+
*
|
|
12
|
+
* The ordering of the branches is the load-bearing part, and each ordering below is a
|
|
13
|
+
* bug a hand-written driver actually shipped in this session:
|
|
14
|
+
*
|
|
15
|
+
* terminal BEFORE gate a killed run carrying a stale `awaitingApproval` offered a
|
|
16
|
+
* gate to a driver whose run was already dead
|
|
17
|
+
* park BEFORE human gate an editor-parked step is satisfied by the EDITOR; the
|
|
18
|
+
* server refuses `--approve` there, so a driver that read it
|
|
19
|
+
* as a human gate would approve into a rejection forever
|
|
20
|
+
* awaitingApproval required a run can be `paused` while the server decides what is
|
|
21
|
+
* next; treating that as a gate approves a step nobody asked
|
|
22
|
+
* for, which is HC-GF-069 in miniature
|
|
23
|
+
*
|
|
24
|
+
* Everything unknown classifies as RUNNING. Inventing a terminal state for a status we
|
|
25
|
+
* have not seen would end a live run; polling one more tick costs an interval.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { isEditorParked } = require('./editor-park');
|
|
29
|
+
|
|
30
|
+
const POLL_OUTCOMES = Object.freeze({
|
|
31
|
+
COMPLETED: 'completed',
|
|
32
|
+
TERMINAL_FAILURE: 'terminal_failure',
|
|
33
|
+
EDITOR_PARKED: 'editor_parked',
|
|
34
|
+
HUMAN_GATE: 'human_gate',
|
|
35
|
+
RUNNING: 'running',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Exit codes are a CONTRACT with adopter CI: 2 = human gate, 3 = editor park. */
|
|
39
|
+
const EXIT = Object.freeze({
|
|
40
|
+
[POLL_OUTCOMES.COMPLETED]: 0,
|
|
41
|
+
[POLL_OUTCOMES.TERMINAL_FAILURE]: 1,
|
|
42
|
+
[POLL_OUTCOMES.HUMAN_GATE]: 2,
|
|
43
|
+
[POLL_OUTCOMES.EDITOR_PARKED]: 3,
|
|
44
|
+
[POLL_OUTCOMES.RUNNING]: null,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const decide = (outcome, extra = {}) => ({ outcome, exitCode: EXIT[outcome], ...extra });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object|null|undefined} status a `GET /orchestrate/:runId` payload, straight
|
|
51
|
+
* off the network and therefore not trusted to
|
|
52
|
+
* have any particular shape
|
|
53
|
+
* @returns {{outcome:string, exitCode:number|null, stepKey?:string, agent?:string,
|
|
54
|
+
* attemptKey?:string, totalTokens?:number, errorMessage?:string}}
|
|
55
|
+
*/
|
|
56
|
+
function classifyPollState(status) {
|
|
57
|
+
const s = (status && typeof status === 'object') ? status : {};
|
|
58
|
+
const steps = Array.isArray(s.steps) ? s.steps : [];
|
|
59
|
+
|
|
60
|
+
if (s.status === 'completed') {
|
|
61
|
+
return decide(POLL_OUTCOMES.COMPLETED, { totalTokens: s.totalTokens || 0 });
|
|
62
|
+
}
|
|
63
|
+
if (s.status === 'failed' || s.status === 'killed') {
|
|
64
|
+
return decide(POLL_OUTCOMES.TERMINAL_FAILURE, {
|
|
65
|
+
errorMessage: s.errorMessage || 'unknown', runStatus: s.status,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (s.status === 'paused' && s.awaitingApproval) {
|
|
70
|
+
const step = steps.find((x) => x && x.stepKey === s.currentStep);
|
|
71
|
+
if (isEditorParked(s)) {
|
|
72
|
+
return decide(POLL_OUTCOMES.EDITOR_PARKED, {
|
|
73
|
+
stepKey: s.currentStep, agent: step && step.agent,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// HC-GF-064 keys its at-most-once verification on the ATTEMPT: a retried step is a
|
|
77
|
+
// new artifact and deserves a new verdict, while re-verifying the SAME attempt on
|
|
78
|
+
// every tick is the unbounded advisory loop that story had to fix before merge.
|
|
79
|
+
const attempt = (step && step.attempt) || 1;
|
|
80
|
+
return decide(POLL_OUTCOMES.HUMAN_GATE, {
|
|
81
|
+
stepKey: s.currentStep, agent: step && step.agent,
|
|
82
|
+
attemptKey: `${s.currentStep}#${attempt}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const running = steps.find((x) => x && x.status === 'running');
|
|
87
|
+
return decide(POLL_OUTCOMES.RUNNING, {
|
|
88
|
+
stepKey: running && running.stepKey, agent: running && running.agent,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { classifyPollState, POLL_OUTCOMES, EXIT };
|
package/lib/run-report.js
CHANGED
|
@@ -48,10 +48,34 @@ function buildReport(run, extra = {}) {
|
|
|
48
48
|
// ── NEEDS YOU ──────────────────────────────────────────────────────────────
|
|
49
49
|
// 1. The run itself is not finished.
|
|
50
50
|
if (run.status === 'paused' && run.currentStep) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
// HC-GF-046: a paused run is not always a gate.
|
|
52
|
+
//
|
|
53
|
+
// Run 3dbde316 sat `paused` at step_4 with `MAX_RETRIES_EXCEEDED: step_4 retried 2
|
|
54
|
+
// times (max 2)`. That LOOKS like an available approval — paused, with a pending gate
|
|
55
|
+
// — but `checkGuardrails` refuses step_4 before the LLM call, so approving can never
|
|
56
|
+
// move the run. The line described the run's SHAPE and not its CAUSE, and so invited
|
|
57
|
+
// the one action the orchestrator had already decided to refuse. A driver looped on
|
|
58
|
+
// it; a human tried once and had to read the database.
|
|
59
|
+
//
|
|
60
|
+
// Matched on the specific messages the guardrail acts on, NOT on "any error_message".
|
|
61
|
+
// A resumable pause — a local rate-limit window, for instance — is not a refusal, and
|
|
62
|
+
// silencing a real gate on those would be the over-reach that makes the surface
|
|
63
|
+
// untrustworthy in the other direction.
|
|
64
|
+
const blocked = String(run.errorMessage || '')
|
|
65
|
+
.match(/^(MAX_RETRIES_EXCEEDED|MAX_STEPS_EXCEEDED|BUDGET_EXCEEDED|GARBAGE_OUTPUT)[^\n]*/);
|
|
66
|
+
if (blocked) {
|
|
67
|
+
needsYou.push({
|
|
68
|
+
what: `run CANNOT proceed at ${run.currentStep} — ${blocked[0].slice(0, 160)}. `
|
|
69
|
+
+ 'Approving will be refused by the guardrail, so this is a decision, not a gate',
|
|
70
|
+
how: `hone run-story ${run.runId} --kill (or raise guardrails.max_retries_per_step `
|
|
71
|
+
+ 'in .pipeline-config.yml and re-run the story)',
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
needsYou.push({
|
|
75
|
+
what: `run is paused at ${run.currentStep} — a gate is waiting on your judgment`,
|
|
76
|
+
how: `hone run-story ${run.runId} --approve ${run.currentStep}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
55
79
|
}
|
|
56
80
|
if (run.status === 'failed') {
|
|
57
81
|
needsYou.push({
|
|
@@ -59,6 +83,19 @@ function buildReport(run, extra = {}) {
|
|
|
59
83
|
how: `hone show step ${run.runId} ${run.currentStep || '<stepKey>'}`,
|
|
60
84
|
});
|
|
61
85
|
}
|
|
86
|
+
// HC-GF-065: a KILLED run needs you too.
|
|
87
|
+
//
|
|
88
|
+
// The predicate above is `status === 'failed'`, which `killed` does not match — so nine
|
|
89
|
+
// terminated runs in the corpus printed `NEEDS YOU (0)` and "nothing outstanding". A run
|
|
90
|
+
// somebody stopped mid-flight has work outstanding by definition, and the digest told
|
|
91
|
+
// the operator it did not. This is HC-GF-044's defect one field over, and its own lesson
|
|
92
|
+
// states the rule: a well-formed "nothing" is only as true as the predicate behind it.
|
|
93
|
+
if (run.status === 'killed') {
|
|
94
|
+
needsYou.push({
|
|
95
|
+
what: `run was KILLED before it finished${run.errorMessage ? `: ${String(run.errorMessage).slice(0, 160)}` : ''}`,
|
|
96
|
+
how: `hone run-story ${run.runId} --status (re-run the story, or read the last step)`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
62
99
|
|
|
63
100
|
// 1b. HC-GF-044: a STEP can be failed while the RUN says completed.
|
|
64
101
|
//
|
|
@@ -187,6 +224,32 @@ function buildReport(run, extra = {}) {
|
|
|
187
224
|
}
|
|
188
225
|
}
|
|
189
226
|
|
|
227
|
+
// HC-GF-065: NO verdict at all is itself an item, not a footnote under a zero.
|
|
228
|
+
//
|
|
229
|
+
// Measured over the corpus with this very function: 10 of 21 terminal runs printed
|
|
230
|
+
// "nothing outstanding", and the sentence is false in every one of them. Nothing was
|
|
231
|
+
// executed against the diff, so the report cannot know whether anything is outstanding
|
|
232
|
+
// — the two reviewing steps READ, they do not run anything, which is exactly why
|
|
233
|
+
// HC-GF-064's verdict exists.
|
|
234
|
+
//
|
|
235
|
+
// Scoped to terminal runs on purpose. A run still in flight has not reached the point
|
|
236
|
+
// where a verdict is expected, and flagging it would make the section noise on every
|
|
237
|
+
// poll — which is how the old nag reached 5-of-22 and got ignored.
|
|
238
|
+
if (['completed', 'failed', 'killed'].includes(run.status) && !lastVerification) {
|
|
239
|
+
needsYou.push({
|
|
240
|
+
what: 'verification never ran for this run — no test, lint or typecheck result was '
|
|
241
|
+
+ 'reported, so nothing here is known to build',
|
|
242
|
+
how: `hone verify-patch ${run.runId} step_4 --report`,
|
|
243
|
+
// HC-GF-065: ADVISORY. `hone run-report` exits 1 when needsYou is non-empty, and
|
|
244
|
+
// the comment at that exit says why — "so run-report is usable as a GATE in a
|
|
245
|
+
// script". This item fires on every unverified terminal run (15 of 21 in the
|
|
246
|
+
// corpus, most of them otherwise healthy), so gating on it would turn an adopter's
|
|
247
|
+
// passing CI red overnight. HC-026c is precisely this rule: a new signal REPORTS
|
|
248
|
+
// before it GATES. Visible in the report, absent from the exit code.
|
|
249
|
+
advisory: true,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
190
253
|
// ── WORTH KNOWING ──────────────────────────────────────────────────────────
|
|
191
254
|
// Retries: the step ran more than once. HC-GF-041(c): this used to append "(its
|
|
192
255
|
// first output was rejected)", which the report never checked and which is often
|
|
@@ -236,6 +299,9 @@ function buildReport(run, extra = {}) {
|
|
|
236
299
|
worthKnowing,
|
|
237
300
|
steps,
|
|
238
301
|
verifications,
|
|
302
|
+
// HC-GF-065: what the exit code should count. Advisory items are shown but do not
|
|
303
|
+
// gate, so a new signal cannot silently change the meaning of a script's exit status.
|
|
304
|
+
gatingCount: needsYou.filter(i => !i.advisory).length,
|
|
239
305
|
};
|
|
240
306
|
}
|
|
241
307
|
|
|
@@ -278,11 +344,13 @@ function renderReport(r) {
|
|
|
278
344
|
checked.push('verification');
|
|
279
345
|
for (const g of last.gates || []) checked.push(g.name);
|
|
280
346
|
}
|
|
347
|
+
// HC-GF-065: the empty state is only reachable when something was actually CHECKED.
|
|
348
|
+
// With no verdict there is nothing executed against the diff, so "nothing outstanding"
|
|
349
|
+
// asserts knowledge the report does not have — the missing verification is now an
|
|
350
|
+
// ITEM above rather than a footnote under a zero, and this branch is unreachable in
|
|
351
|
+
// that case. Kept as a genuine all-clear for a run that WAS verified.
|
|
281
352
|
L.push('NEEDS YOU (0)');
|
|
282
|
-
L.push(checked.
|
|
283
|
-
? ` nothing — ${checked.join(', ')} all reported and none is failing or unavailable`
|
|
284
|
-
: ' nothing outstanding, but NO verification was reported for this run — '
|
|
285
|
-
+ 'run `hone verify-patch <runId> step_4 --report` before trusting that');
|
|
353
|
+
L.push(` nothing — ${checked.join(', ')} all reported and none is failing or unavailable`);
|
|
286
354
|
}
|
|
287
355
|
L.push('');
|
|
288
356
|
|