@hone-ai/cli 1.34.0 → 1.35.1
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/bin/hone-mcp.js +1 -0
- package/hone-cli.js +1341 -795
- package/lib/agent-eval-pacing.js +102 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-stateful.js +72 -0
- package/lib/agent-eval-score.js +70 -0
- package/lib/agent-executor.js +26 -4
- package/lib/asset-fetch-list.js +72 -0
- package/lib/auto-verify.js +67 -3
- package/lib/batch-follow.js +333 -0
- package/lib/block-grammar.js +126 -0
- package/lib/bundle-context.js +43 -4
- package/lib/bundle-paths.js +698 -1
- package/lib/bundle-source-dirs.js +79 -0
- package/lib/doctor-bind-default.js +192 -18
- package/lib/doctor-local-run.js +139 -1
- package/lib/emit-pr.js +39 -6
- package/lib/git-helpers.js +19 -8
- package/lib/hard-pause-remedy.js +101 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +97 -7
- package/lib/materialize-diff.js +131 -73
- package/lib/mcp-tools.js +20 -14
- package/lib/pipeline-config.js +182 -0
- package/lib/private-temp.js +52 -0
- package/lib/promote-payload.js +77 -0
- package/lib/refresh-knowledge.js +15 -6
- package/lib/repo-tree.js +22 -3
- package/lib/run-context.js +698 -0
- package/lib/run-target.js +66 -1
- package/lib/sandbox-lifecycle.js +357 -0
- package/lib/schedule-cron.js +1 -1
- package/lib/skill-assertions.js +61 -7
- package/lib/skill-audit-render.js +1 -1
- package/lib/story-classifier-extract.js +16 -8
- package/lib/story-classifier.js +64 -0
- package/lib/story-compose.js +69 -0
- package/lib/story-line.js +40 -0
- package/lib/story-provenance.js +267 -0
- package/lib/timeout-option.js +42 -0
- package/lib/untrusted-code-env.js +51 -0
- package/lib/verdict-env.js +31 -0
- package/lib/verify-patch.js +74 -4
- package/lib/verify-pr.js +2 -1
- package/lib/worktree-provision.js +62 -2
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -25,10 +25,21 @@ const os = require('os');
|
|
|
25
25
|
const crypto = require('crypto');
|
|
26
26
|
const { execSync } = require('child_process');
|
|
27
27
|
|
|
28
|
+
// HC-GF-144: verdictEnv lives in cli/lib so it is importable and can be tested by BEHAVIOUR.
|
|
29
|
+
// A source-regex asserting the variable name matched this very comment and passed while the
|
|
30
|
+
// scrub was gutted.
|
|
31
|
+
const { verdictEnv } = require('./lib/verdict-env');
|
|
32
|
+
const { parseTimeoutSeconds } = require('./lib/timeout-option.js');
|
|
33
|
+
// HC-GF-143 batch: both mappings were computed inline and asserted by nothing (HC-GF-136).
|
|
34
|
+
const { promotePayload } = require('./lib/promote-payload');
|
|
35
|
+
const { assetFetchList } = require('./lib/asset-fetch-list');
|
|
36
|
+
|
|
28
37
|
const pkg = require('./package.json');
|
|
29
38
|
const { parseReviewJSON } = require('./lib/parse-review-json');
|
|
30
39
|
const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
|
|
31
40
|
const { gitEnv } = require('./lib/git-env');
|
|
41
|
+
const { collectIncludePaths } = require('./lib/include-paths');
|
|
42
|
+
const { runArgsError, orchestratePath } = require('./lib/run-target'); // HC-GF-270-followup-1
|
|
32
43
|
const program = new Command();
|
|
33
44
|
|
|
34
45
|
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
@@ -41,6 +52,48 @@ function readRc() {
|
|
|
41
52
|
return {};
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the Hone-team admin key, or stop with the instruction for supplying it.
|
|
57
|
+
*
|
|
58
|
+
* HC-GF-239: `hone absorb` is the second command to need this, and the second copy of the same
|
|
59
|
+
* six lines is how the two drift. The key is read from the environment or ~/.honerc and NEVER
|
|
60
|
+
* taken as an argument — an argument lands in shell history, and this is a production key.
|
|
61
|
+
*/
|
|
62
|
+
function requireAdminKey() {
|
|
63
|
+
const adminKey = process.env.HONE_ADMIN_KEY || readRc().admin_key;
|
|
64
|
+
if (!adminKey) {
|
|
65
|
+
console.error('Error: Admin key not found.');
|
|
66
|
+
console.error('Set HONE_ADMIN_KEY env var, or add "admin_key" to ~/.honerc');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
return adminKey;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* HC-GF-270-followup-1: refuse a caller-supplied run id, batch id, step key or attempt that is not one,
|
|
74
|
+
* with exit 2. Called as the FIRST statement of a verb's action, before getConfig() or api(), so a
|
|
75
|
+
* refusal makes no request, reads no ~/.honerc, and runs no probe, gh call or CI command.
|
|
76
|
+
*/
|
|
77
|
+
function refuseBadRunArgs(checks) {
|
|
78
|
+
const msg = runArgsError(checks);
|
|
79
|
+
if (msg) {
|
|
80
|
+
console.error(msg);
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The arguments check-patch, verify-patch and emit-pr turn into a step-output URL. HC-GF-270 (second
|
|
87
|
+
* review MEDIUM-1): the step key goes into a URL and a file name, and URL resolution collapses `..` —
|
|
88
|
+
* `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's patch while THIS run's config was
|
|
89
|
+
* assessed. A step key is a step key, a run id is a run id, and an attempt is a whole number.
|
|
90
|
+
*/
|
|
91
|
+
const stepFetchChecks = (workflowId, opts) => [
|
|
92
|
+
{ label: '<workflowId>', kind: 'runId', value: workflowId },
|
|
93
|
+
{ label: '--step-key', kind: 'stepKey', value: opts.stepKey },
|
|
94
|
+
{ label: '--attempt', kind: 'attempt', value: opts.attempt, optional: true },
|
|
95
|
+
];
|
|
96
|
+
|
|
44
97
|
function getConfig() {
|
|
45
98
|
// Priority: env var → ~/.honerc → error
|
|
46
99
|
const rc = readRc();
|
|
@@ -152,19 +205,35 @@ process.on('exit', () => {
|
|
|
152
205
|
program
|
|
153
206
|
.command('agent-eval')
|
|
154
207
|
.description('Run agent-eval probes against configured agent targets (HC-COMM-011-followup-1)')
|
|
155
|
-
.option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary)', 'adversarial')
|
|
208
|
+
.option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary|stateful)', 'adversarial')
|
|
156
209
|
.option('--target <name>', 'Run against a specific agent target (default: all configured)')
|
|
157
210
|
.option('--output <format>', 'Output format (text|json)', 'text')
|
|
158
211
|
.option('--judge', 'Add the free NLI judge layer (faithfulness only): claims are checked for entailment against the probe context')
|
|
159
212
|
.option('--provider <name>', 'Judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
|
|
160
213
|
.option('--report', 'Report the agent-eval verdict to the run (advisory) — needs --workflow-id')
|
|
161
214
|
.option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
|
|
215
|
+
.option('--timeout <s>', 'Seconds to allow each probe (default 5 — raise it for packs that need a real generation)', '5')
|
|
162
216
|
.action(async (opts) => {
|
|
217
|
+
// HC-GF-270-followup-1: a --workflow-id is checked before any probe spends provider quota.
|
|
218
|
+
refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
|
|
163
219
|
const { readAgentEvalConfig } = require('./lib/pipeline-config');
|
|
164
220
|
const { runAgent } = require('./lib/agent-executor');
|
|
165
221
|
const { runCheck } = require('./lib/eval-graders');
|
|
222
|
+
const { scoreProbe, summarizeProbes, describeProbeSummary } = require('./lib/agent-eval-score');
|
|
166
223
|
const config = readAgentEvalConfig(process.cwd());
|
|
167
224
|
|
|
225
|
+
// HC-GF-197: validated, not coerced. `Number('abc')` is NaN, NaN is NOT nullish so the
|
|
226
|
+
// executor's `?? 5000` default never applies, and `setTimeout(NaN)` fires in 1ms — so a typo
|
|
227
|
+
// in this flag times out every probe instantly and reports them as WRONG ANSWERS. That is the
|
|
228
|
+
// defect HC-GF-198 exists to fix, reachable by mistyping a flag.
|
|
229
|
+
let probeTimeoutMs;
|
|
230
|
+
try {
|
|
231
|
+
probeTimeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
console.error(e.message);
|
|
234
|
+
process.exit(2);
|
|
235
|
+
}
|
|
236
|
+
|
|
168
237
|
if (!config.targets || config.targets.length === 0) {
|
|
169
238
|
console.log('No agent targets configured in .pipeline-config.yml (agent_eval.targets)');
|
|
170
239
|
process.exit(0);
|
|
@@ -175,6 +244,9 @@ program
|
|
|
175
244
|
faithfulness: './lib/agent-eval-probes-faithfulness',
|
|
176
245
|
safety: './lib/agent-eval-probes-safety',
|
|
177
246
|
boundary: './lib/agent-eval-probes-boundary',
|
|
247
|
+
// HC-GF-197: calibration, not safety. The other four packs ask whether an agent misbehaves;
|
|
248
|
+
// this one asks whether ONE judgment is right, scored against stories the owner labelled.
|
|
249
|
+
stateful: './lib/agent-eval-probes-stateful',
|
|
178
250
|
};
|
|
179
251
|
let probes;
|
|
180
252
|
if (PROBE_PACKS[opts.category]) {
|
|
@@ -208,38 +280,85 @@ program
|
|
|
208
280
|
|
|
209
281
|
const results = [];
|
|
210
282
|
let anyFailed = false;
|
|
283
|
+
// HC-GF-199: pace from what the provider DOES, and run the probes where
|
|
284
|
+
// their writes cannot reach this repository.
|
|
285
|
+
const { createPacer } = require('./lib/agent-eval-pacing');
|
|
286
|
+
const pacer = createPacer();
|
|
287
|
+
const probeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-agent-eval-'));
|
|
288
|
+
let stopped = null;
|
|
289
|
+
let attempted = 0;
|
|
290
|
+
|
|
211
291
|
for (const target of targets) {
|
|
212
292
|
for (const probe of probes) {
|
|
213
|
-
|
|
293
|
+
if (stopped) break;
|
|
294
|
+
attempted += 1;
|
|
295
|
+
const wait = pacer.delayBeforeNext();
|
|
296
|
+
if (wait > 0) {
|
|
297
|
+
// Say it. A run that quietly slows down looks identical to a fast one,
|
|
298
|
+
// and that is how a degraded provider reads as a clean measurement.
|
|
299
|
+
if (opts.output !== 'json') {
|
|
300
|
+
console.log(` pacing: waiting ${Math.round(wait / 1000)}s — the provider refused the previous probe`);
|
|
301
|
+
}
|
|
302
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
303
|
+
pacer.noteWaited(wait);
|
|
304
|
+
}
|
|
305
|
+
// HC-GF-197: the default is 5s, sized for the deterministic packs — a canary string or a
|
|
306
|
+
// refusal comes back immediately. A calibration probe asks for a real grooming, which takes
|
|
307
|
+
// tens of seconds, so every one of them timed out and scored as a failed judgment. A
|
|
308
|
+
// verdict produced by a timeout is not a verdict about the agent.
|
|
309
|
+
const run = await runAgent(target, probe.input, { timeout: probeTimeoutMs, cwd: probeCwd, repoRoot: process.cwd() });
|
|
214
310
|
const output = run && typeof run.output === 'string' ? run.output : '';
|
|
215
311
|
const graders = probe.graders.map((g) => runCheck(output, g));
|
|
216
|
-
|
|
312
|
+
// HC-GF-198: three outcomes. A probe that never answered is not one that answered wrongly,
|
|
313
|
+
// and scoring them alike reported 4/8 for a run where 4 of the 5 that COMPLETED were right.
|
|
314
|
+
const scored = scoreProbe(run, graders);
|
|
315
|
+
let { outcome } = scored;
|
|
316
|
+
let passed = scored.passed;
|
|
217
317
|
|
|
218
318
|
// Judge layer: only for grounded probes (those with a context to entail against).
|
|
219
319
|
let judge = null;
|
|
220
|
-
if (judgeCall && probe.context) {
|
|
320
|
+
if (judgeCall && probe.context && outcome !== 'error') {
|
|
221
321
|
const { judgeFaithfulness } = require('./lib/agent-eval-judge');
|
|
222
322
|
judge = await judgeFaithfulness({ output, context: probe.context, callLLM: judgeCall });
|
|
223
|
-
passed
|
|
323
|
+
if (!judge.passed) { passed = false; outcome = 'fail'; }
|
|
224
324
|
}
|
|
225
325
|
if (!passed) anyFailed = true;
|
|
226
|
-
results.push({ target: target.name, probe: probe.id, passed, error: run.error || null,
|
|
326
|
+
results.push({ target: target.name, probe: probe.id, outcome, passed, error: run.error || null,
|
|
327
|
+
stderr: run.stderr || null, graders, judge });
|
|
328
|
+
|
|
329
|
+
pacer.record({ outcome });
|
|
330
|
+
stopped = pacer.stopReason();
|
|
227
331
|
}
|
|
228
332
|
}
|
|
229
333
|
|
|
334
|
+
try { fs.rmSync(probeCwd, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
335
|
+
|
|
336
|
+
const summary = summarizeProbes(results);
|
|
337
|
+
// HC-GF-199: a run that gave up must SAY so, and say how many probes it never
|
|
338
|
+
// reached. Reporting only what completed is how a dead run reads as a fast one.
|
|
339
|
+
const pacing = { ...pacer.summary(), stopped, attempted, planned: targets.length * probes.length };
|
|
340
|
+
if (stopped) {
|
|
341
|
+
anyFailed = true;
|
|
342
|
+
if (opts.output !== 'json') {
|
|
343
|
+
console.error(`\n⊘ run STOPPED: ${stopped}`);
|
|
344
|
+
console.error(` ${attempted} of ${pacing.planned} probes attempted — the rest were never run.`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
230
347
|
if (opts.output === 'json') {
|
|
231
|
-
console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, results }, null, 2));
|
|
348
|
+
console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, summary, pacing, results }, null, 2));
|
|
232
349
|
} else {
|
|
233
350
|
for (const r of results) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
351
|
+
// HC-GF-198: three marks, because there are three outcomes. `✗` for a wrong answer and
|
|
352
|
+
// `⊘` for one that never came — printing both as `✗` is what made 3 timeouts read as 3
|
|
353
|
+
// wrong answers.
|
|
354
|
+
const mark = r.outcome === 'pass' ? '✓' : (r.outcome === 'error' ? '⊘' : '✗');
|
|
355
|
+
console.log(` ${mark} ${r.target} / ${r.probe}${r.error ? ` (no answer: ${r.error})` : ''}`);
|
|
356
|
+
if (r.outcome === 'fail') {
|
|
237
357
|
for (const g of r.graders.filter((x) => !x.passed)) console.log(` - ${g.type}: ${g.detail}`);
|
|
238
358
|
if (r.judge && !r.judge.passed) console.log(` - judge: ${r.judge.detail}`);
|
|
239
359
|
}
|
|
240
360
|
}
|
|
241
|
-
|
|
242
|
-
console.log(`\n${pass}/${results.length} probes passed (category: ${opts.category})`);
|
|
361
|
+
console.log(`\n${describeProbeSummary(summary)} (category: ${opts.category})`);
|
|
243
362
|
}
|
|
244
363
|
|
|
245
364
|
// Closed loop (HC-COMM-011-followup-5): report the verdict to the run. agent-eval
|
|
@@ -252,7 +371,7 @@ program
|
|
|
252
371
|
} else {
|
|
253
372
|
try {
|
|
254
373
|
const client = api(getConfig());
|
|
255
|
-
await client.post(
|
|
374
|
+
await client.post(orchestratePath([opts.workflowId, 'verdict']), {
|
|
256
375
|
source: 'agent-eval',
|
|
257
376
|
stepKey: 'step_4',
|
|
258
377
|
verdict: anyFailed ? 'fail' : 'pass',
|
|
@@ -588,22 +707,31 @@ program
|
|
|
588
707
|
|
|
589
708
|
// Build parallel fetch list
|
|
590
709
|
const fetches = [
|
|
591
|
-
// Agent stubs
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
710
|
+
// Agent stubs and skill files. HC-GF-136's gate found `key`, `promise` and `write` here
|
|
711
|
+
// computed and asserted by nothing; the mapping is now a pure function in cli/lib. The REST
|
|
712
|
+
// of this list is untouched — an earlier attempt replaced the whole array and silently
|
|
713
|
+
// dropped ai-reviewer.js, check-learnings-gate.js and the ESM package.json, which would have
|
|
714
|
+
// broken setup for every adopter. The regression suite caught it.
|
|
715
|
+
...assetFetchList({
|
|
716
|
+
agents, skills,
|
|
717
|
+
// HC-GF-184: also pull whatever derive produced for THIS repo. Same basename rule the
|
|
718
|
+
// other commands use (`hone sync`, `hone derive`), so the three agree on the repo's name.
|
|
719
|
+
//
|
|
720
|
+
// `process.cwd()` directly, NOT `repoRoot`: the only `repoRoot` bindings near here are in
|
|
721
|
+
// ANOTHER command's action and inside `if (opts.refresh)`, which returns long before this
|
|
722
|
+
// line. Referencing it threw `ReferenceError` on the normal setup path — for every
|
|
723
|
+
// adopter — while `node --check` passed and the unit tests, which drive assetFetchList
|
|
724
|
+
// directly, never evaluated it.
|
|
725
|
+
derivedRepo: path.basename(process.cwd()),
|
|
726
|
+
get: (url) => client.get(url).then(r => r.data),
|
|
727
|
+
writeAgent: (agent, data) =>
|
|
728
|
+
fs.writeFileSync(path.join(agentsDir, `${agent}.agent.md`), data),
|
|
729
|
+
writeSkill: (skill, data) => {
|
|
602
730
|
const skillDir = path.join(skillsDir, skill);
|
|
603
731
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
604
732
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), data);
|
|
605
733
|
},
|
|
606
|
-
})
|
|
734
|
+
}),
|
|
607
735
|
// Copilot instructions
|
|
608
736
|
{
|
|
609
737
|
key: 'copilot-instructions.md',
|
|
@@ -994,6 +1122,27 @@ program
|
|
|
994
1122
|
|
|
995
1123
|
// ── Test framework installation helpers ────────────────────────────────────────
|
|
996
1124
|
|
|
1125
|
+
/**
|
|
1126
|
+
* HC-GF-190: what the sandbox WOULD have done, for the paths that end before it is asked.
|
|
1127
|
+
*
|
|
1128
|
+
* Builds the plan the run never got to and describes it. `planSandboxRun` is pure — it runs no
|
|
1129
|
+
* commands — so this is safe on an early exit, and it is the only way an operator can tell a
|
|
1130
|
+
* sandbox that was SKIPPED from one that was never configured.
|
|
1131
|
+
*/
|
|
1132
|
+
function describeSandboxDisposition(runConfig) {
|
|
1133
|
+
try {
|
|
1134
|
+
const { readSandboxConfig } = require('./lib/pipeline-config');
|
|
1135
|
+
const { planSandboxRun, describeNotReached } = require('./lib/sandbox-lifecycle');
|
|
1136
|
+
return describeNotReached(planSandboxRun({
|
|
1137
|
+
sandbox: readSandboxConfig(process.cwd()),
|
|
1138
|
+
axes: runConfig?.classifier_axes || runConfig?.axes || [],
|
|
1139
|
+
capability: runConfig?.capability || null,
|
|
1140
|
+
}));
|
|
1141
|
+
} catch {
|
|
1142
|
+
return null; // reporting must never be the thing that breaks a run
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
997
1146
|
function detectExistingTestFramework(repoRoot) {
|
|
998
1147
|
const pkgPath = path.join(repoRoot, 'package.json');
|
|
999
1148
|
if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
|
|
@@ -1311,7 +1460,14 @@ async function installTestFramework(repoRoot, opts) {
|
|
|
1311
1460
|
console.log('');
|
|
1312
1461
|
console.log('── Verification ────────────────────────────────────────');
|
|
1313
1462
|
try {
|
|
1314
|
-
|
|
1463
|
+
// HC-GF-144: `node` exports NODE_TEST_CONTEXT to the processes IT spawns, and this repo's
|
|
1464
|
+
// regression suites spawn the CLI. Inherited, it makes a FAILING `node --test` exit 0 — so
|
|
1465
|
+
// this verification would print "npm test passed" for a red repository, which is the one
|
|
1466
|
+
// thing it exists not to do. Only the verdict-flipping variables are removed; the adopter's
|
|
1467
|
+
// toolchain environment is otherwise passed through untouched.
|
|
1468
|
+
execSync('npm test 2>&1', {
|
|
1469
|
+
cwd: repoRoot, stdio: 'inherit', timeout: 30_000, env: verdictEnv(),
|
|
1470
|
+
});
|
|
1315
1471
|
console.log(' ✓ npm test passed');
|
|
1316
1472
|
} catch {
|
|
1317
1473
|
console.log(' ⚠ npm test had issues — review test output above');
|
|
@@ -1477,15 +1633,42 @@ program
|
|
|
1477
1633
|
const maxFiles = parseInt(opts.maxFiles);
|
|
1478
1634
|
let fileCount = 0;
|
|
1479
1635
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1636
|
+
// HC-GF-178: allocate the cap ACROSS the declared dirs. The previous loop drained them in
|
|
1637
|
+
// order and `break`-ed at the cap, so `cli/` (142 files vs a default of 30) filled it alone and
|
|
1638
|
+
// server/scripts/enterprise-assets/.github were never read — while the output still said
|
|
1639
|
+
// "Bundling 30 source files...". The derive agent graded the repo on a fifth of it and said so;
|
|
1640
|
+
// the CLI said nothing.
|
|
1641
|
+
const { planBundle } = require('./lib/bundle-source-dirs');
|
|
1642
|
+
const perDirFiles = new Map();
|
|
1643
|
+
const plan = planBundle({
|
|
1644
|
+
sourceDirs,
|
|
1645
|
+
maxFiles,
|
|
1646
|
+
listFiles: (dir) => {
|
|
1647
|
+
const dirPath = path.join(repoRoot, dir.replace(/"/g, ''));
|
|
1648
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
1649
|
+
// COST, stated rather than hidden: this reads every bundlable file in the directory to
|
|
1650
|
+
// plan a selection of a few. On this repo that is ~1,400 files (each capped at 50,000
|
|
1651
|
+
// chars by bundleDirectory) to choose 100, and a derive run still completes in ~90s. It is
|
|
1652
|
+
// the honest shape — you cannot allocate a budget across sources without knowing what each
|
|
1653
|
+
// holds — but on a very large monorepo it is the first thing to optimise, by separating
|
|
1654
|
+
// listing from reading. Filed as a follow-up rather than guessed at now.
|
|
1655
|
+
const collected = {};
|
|
1656
|
+
bundleDirectory(dirPath, repoRoot, collected, Number.MAX_SAFE_INTEGER, 0);
|
|
1657
|
+
perDirFiles.set(dir, collected);
|
|
1658
|
+
return Object.keys(collected);
|
|
1659
|
+
},
|
|
1660
|
+
});
|
|
1661
|
+
for (const rel of plan.selected) {
|
|
1662
|
+
for (const collected of perDirFiles.values()) {
|
|
1663
|
+
if (Object.prototype.hasOwnProperty.call(collected, rel)) { files[rel] = collected[rel]; break; }
|
|
1664
|
+
}
|
|
1486
1665
|
}
|
|
1666
|
+
fileCount = Object.keys(files).length;
|
|
1487
1667
|
|
|
1488
1668
|
console.log(`Bundling ${Object.keys(files).length} source files...`);
|
|
1669
|
+
// Say what was left out. An unreported omission is what made this defect survive: the agent
|
|
1670
|
+
// could see the gap in its own input and the operator could not.
|
|
1671
|
+
for (const line of plan.dropped) console.log(` ⚠ not fully bundled — ${line}`);
|
|
1489
1672
|
|
|
1490
1673
|
// 3. Bundle existing skills if refresh mode
|
|
1491
1674
|
let existingSkills = {};
|
|
@@ -2226,14 +2409,8 @@ program
|
|
|
2226
2409
|
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
2227
2410
|
.action(async (opts) => {
|
|
2228
2411
|
const rc = readRc();
|
|
2229
|
-
const adminKey = process.env.HONE_ADMIN_KEY || rc.admin_key;
|
|
2230
2412
|
const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
|
|
2231
|
-
|
|
2232
|
-
if (!adminKey) {
|
|
2233
|
-
console.error('Error: Admin key not found.');
|
|
2234
|
-
console.error('Set HONE_ADMIN_KEY env var, or add "admin_key" to ~/.honerc');
|
|
2235
|
-
process.exit(1);
|
|
2236
|
-
}
|
|
2413
|
+
const adminKey = requireAdminKey();
|
|
2237
2414
|
|
|
2238
2415
|
try {
|
|
2239
2416
|
const { data } = await axios.get(`${apiUrl}/admin/usage`, {
|
|
@@ -2643,15 +2820,7 @@ program
|
|
|
2643
2820
|
}
|
|
2644
2821
|
|
|
2645
2822
|
// 5. POST to /learnings/promote (enterprise_summary only — never local_summary)
|
|
2646
|
-
const payload = eligible
|
|
2647
|
-
id: l.id || l._file,
|
|
2648
|
-
repo_name: l.repo_name,
|
|
2649
|
-
type: l.type,
|
|
2650
|
-
agent: l.agent,
|
|
2651
|
-
target_skill: l.target_skill,
|
|
2652
|
-
enterprise_summary: l.enterprise_summary,
|
|
2653
|
-
proposed_fix: l.proposed_fix || l.absorbed_into,
|
|
2654
|
-
}));
|
|
2823
|
+
const payload = promotePayload(eligible);
|
|
2655
2824
|
|
|
2656
2825
|
try {
|
|
2657
2826
|
const r = await client.post('/learnings/promote', { learnings: payload });
|
|
@@ -2660,7 +2829,11 @@ program
|
|
|
2660
2829
|
} catch (e) {
|
|
2661
2830
|
console.error(`\nPromotion failed: ${e.response?.data?.error || e.message}`);
|
|
2662
2831
|
console.error('Local files unchanged — safe to retry.');
|
|
2663
|
-
|
|
2832
|
+
// HC-GF-207: a failed promotion must be a failed command. Returning here exited 0, so
|
|
2833
|
+
// `make promote` and the auto-promote workflow went green on ten consecutive runs that
|
|
2834
|
+
// promoted nothing (HC-GF-236). A dry run and a cancelled prompt still exit 0 — those
|
|
2835
|
+
// are refusals, not failures.
|
|
2836
|
+
process.exit(1);
|
|
2664
2837
|
}
|
|
2665
2838
|
|
|
2666
2839
|
// 6. Update local YAML: status → promoted (per-schema, idempotent).
|
|
@@ -2670,23 +2843,220 @@ program
|
|
|
2670
2843
|
if (!promotedByFile.has(l._filePath)) promotedByFile.set(l._filePath, []);
|
|
2671
2844
|
promotedByFile.get(l._filePath).push(l.id);
|
|
2672
2845
|
}
|
|
2846
|
+
// HC-GF-208: an UNCHANGED file is not a success. writeBackPromoted returns the
|
|
2847
|
+
// content untouched when it cannot flip a status; fs.writeFileSync then succeeds
|
|
2848
|
+
// on identical bytes, and this loop used to print the same tick either way — so
|
|
2849
|
+
// the log said 296 files flipped while the filesystem had 220. Eligibility already
|
|
2850
|
+
// excludes anything promoted, so for these ids "unchanged" can only mean failure:
|
|
2851
|
+
// there is no idempotent-rerun case to mistake it for.
|
|
2852
|
+
const unflipped = [];
|
|
2673
2853
|
for (const [filePath, ids] of promotedByFile) {
|
|
2854
|
+
const fileName = path.basename(filePath);
|
|
2674
2855
|
try {
|
|
2675
2856
|
const schema = fileSchemas[filePath];
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2857
|
+
const before = fs.readFileSync(filePath, 'utf8');
|
|
2858
|
+
const after = writeBackPromoted(before, schema, ids);
|
|
2859
|
+
if (after === before) {
|
|
2860
|
+
unflipped.push(fileName);
|
|
2861
|
+
console.warn(` \u26a0 ${fileName}: ${ids.length} learning(s) sent, but the local status did not change \u2014 they stay eligible and will be re-sent next run`);
|
|
2862
|
+
continue;
|
|
2863
|
+
}
|
|
2864
|
+
fs.writeFileSync(filePath, after);
|
|
2865
|
+
console.log(` \u2713 ${fileName}: ${ids.length} learning(s) \u2192 promoted`);
|
|
2681
2866
|
} catch (e) {
|
|
2682
|
-
|
|
2867
|
+
unflipped.push(fileName);
|
|
2868
|
+
console.warn(` \u26a0 ${fileName}: failed to update local file \u2014 ${e.message}`);
|
|
2683
2869
|
}
|
|
2684
2870
|
}
|
|
2685
2871
|
|
|
2872
|
+
if (unflipped.length) {
|
|
2873
|
+
console.warn(`\n\u26a0 ${unflipped.length} file(s) were sent but NOT marked promoted locally.`);
|
|
2874
|
+
console.warn(' Their learnings remain eligible and will be re-sent on the next run.');
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2686
2877
|
console.log('\nDone. Commit the updated learnings files:');
|
|
2687
2878
|
console.log(' git add .github/learnings/ && git commit -m "chore: promote learnings to enterprise"');
|
|
2688
2879
|
});
|
|
2689
2880
|
|
|
2881
|
+
// ── PROMOTED command (HC-GF-210) ─────────────────────────────────────────────
|
|
2882
|
+
// The consumer for GET /learnings/promoted. HC-GF-159b built that route as "THE READER",
|
|
2883
|
+
// on the premise that promotion without read-back is a write-only counter — and then nothing
|
|
2884
|
+
// called it. Auditing the production queue on 2026-09-20 meant hand-rolling an HTTPS call,
|
|
2885
|
+
// and that audit found 203 of 736 rows carrying an EMPTY enterprise_summary, invisible for
|
|
2886
|
+
// five months because no shipped tool could show them. So the two counts in the footer are
|
|
2887
|
+
// the point of this command, not decoration: a promoted row with no summary, or none a run
|
|
2888
|
+
// can reach, was promoted into nothing, and only a read-back can say so.
|
|
2889
|
+
program
|
|
2890
|
+
.command('promoted')
|
|
2891
|
+
.description("List the learnings this org has promoted, with their content (HC-GF-210)")
|
|
2892
|
+
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
2893
|
+
.option('--empty', 'Show only rows whose enterprise summary is missing or blank')
|
|
2894
|
+
.option('--unrouted', 'Show only rows that name no skill, prompt or script to reach')
|
|
2895
|
+
// HC-GF-238: absorbing a row removed it from this command, because the route filtered on one
|
|
2896
|
+
// status. The default is still `promoted` — nothing an existing caller sees changes — and these
|
|
2897
|
+
// two options are how an operator reads back what the team actually LANDED.
|
|
2898
|
+
.option('--status <status>', 'Lifecycle status: promoted (default) | absorbed | retracted | pending | overdue | all')
|
|
2899
|
+
.option('--all', 'Every status, whatever a row has reached — the same as --status all')
|
|
2900
|
+
.action(async (opts) => {
|
|
2901
|
+
const config = getConfig();
|
|
2902
|
+
const client = api(config);
|
|
2903
|
+
const status = opts.all ? 'all' : (opts.status || 'promoted');
|
|
2904
|
+
try {
|
|
2905
|
+
// The default request is byte-identical to the one that shipped — no query string at all —
|
|
2906
|
+
// so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
|
|
2907
|
+
// parameter appears only when the operator asked for a status other than the default.
|
|
2908
|
+
const qs = status === 'promoted' ? '' : `?status=${encodeURIComponent(status)}`;
|
|
2909
|
+
const { data } = await client.get(`/learnings/promoted${qs}`);
|
|
2910
|
+
const all = Array.isArray(data.learnings) ? data.learnings : [];
|
|
2911
|
+
// The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
|
|
2912
|
+
// router found no target; a row promoted before that story carries neither field at all.
|
|
2913
|
+
const isEmpty = (l) => !String(l.enterpriseSummary || '').trim();
|
|
2914
|
+
const isUnrouted = (l) => !String(l.targetSkill || '').trim()
|
|
2915
|
+
|| String(l.targetKind || 'unrouted') === 'unrouted';
|
|
2916
|
+
const empties = all.filter(isEmpty);
|
|
2917
|
+
const unrouted = all.filter(isUnrouted);
|
|
2918
|
+
let shown = all;
|
|
2919
|
+
if (opts.empty) shown = shown.filter(isEmpty);
|
|
2920
|
+
if (opts.unrouted) shown = shown.filter(isUnrouted);
|
|
2921
|
+
|
|
2922
|
+
if (opts.format === 'json') {
|
|
2923
|
+
console.log(JSON.stringify({
|
|
2924
|
+
orgId: data.orgId,
|
|
2925
|
+
status,
|
|
2926
|
+
count: all.length,
|
|
2927
|
+
empty: empties.length,
|
|
2928
|
+
unrouted: unrouted.length,
|
|
2929
|
+
learnings: shown,
|
|
2930
|
+
}, null, 2));
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
console.log(status === 'promoted' ? 'Hone AI — Promoted Learnings' : `Hone AI — Learnings (${status})`);
|
|
2935
|
+
console.log('================================');
|
|
2936
|
+
if (!all.length) {
|
|
2937
|
+
console.log(status === 'promoted'
|
|
2938
|
+
? "\nNothing promoted yet. Send this repo's learnings with: hone promote"
|
|
2939
|
+
: `\nNo learnings with status '${status}'.`);
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
// ASCII on purpose, and read by column position: same reasoning as `hone workflows` (HC-GF-108).
|
|
2943
|
+
const clip = (s, w) => (s.length > w ? `${s.slice(0, w - 3)}...` : s);
|
|
2944
|
+
const day = (ts) => { const d = ts ? new Date(ts) : null; return d && !isNaN(d) ? d.toISOString().slice(0, 10) : '-'; };
|
|
2945
|
+
console.log('');
|
|
2946
|
+
// The STATUS column appears ONLY when the listing can hold more than one status. This table
|
|
2947
|
+
// is read by column position (HC-GF-108), so the default `hone promoted` keeps the exact
|
|
2948
|
+
// layout it shipped with and no existing reader shifts by a column.
|
|
2949
|
+
const mixed = status === 'all';
|
|
2950
|
+
console.log([
|
|
2951
|
+
...(mixed ? ['STATUS'.padEnd(10)] : []),
|
|
2952
|
+
'PROMOTED'.padEnd(11), 'TARGET'.padEnd(24), 'KIND'.padEnd(9), 'LEARNING',
|
|
2953
|
+
].join(' '));
|
|
2954
|
+
for (const l of shown) {
|
|
2955
|
+
console.log([
|
|
2956
|
+
...(mixed ? [String(l.status || '-').padEnd(10)] : []),
|
|
2957
|
+
day(l.promotedAt).padEnd(11),
|
|
2958
|
+
clip(String(l.targetSkill || '-'), 23).padEnd(24),
|
|
2959
|
+
String(l.targetKind || 'unrouted').padEnd(9),
|
|
2960
|
+
String(l.id || '-'),
|
|
2961
|
+
].join(' '));
|
|
2962
|
+
const summary = String(l.enterpriseSummary || '').trim();
|
|
2963
|
+
console.log(` ${summary ? clip(summary.replace(/\s+/g, ' '), 100) : '(no enterprise summary — this row carries no lesson)'}`);
|
|
2964
|
+
// The destination is the whole reason `absorbed` is worth reading back: it names where
|
|
2965
|
+
// the lesson now lives, which is what makes the claim checkable rather than asserted.
|
|
2966
|
+
if (l.absorbedIn) console.log(` absorbed into: ${l.absorbedIn}${l.absorbedAt ? ` (${day(l.absorbedAt)})` : ''}`);
|
|
2967
|
+
}
|
|
2968
|
+
if (shown.length !== all.length) console.log(`\nShowing ${shown.length} of ${all.length}.`);
|
|
2969
|
+
console.log(`\n${all.length} promoted · ${empties.length} with no summary · ${unrouted.length} unrouted`);
|
|
2970
|
+
if (empties.length || unrouted.length) {
|
|
2971
|
+
console.log('A row with no summary, or none a run can reach, was promoted into nothing.');
|
|
2972
|
+
console.log('Inspect: hone promoted --empty · hone promoted --unrouted · Retract: hone retract <id> --reason "..."');
|
|
2973
|
+
}
|
|
2974
|
+
} catch (e) {
|
|
2975
|
+
// HC-GF-207, same class: a read that never happened is not an empty queue. Exit non-zero
|
|
2976
|
+
// so a caller cannot mistake a dead endpoint for "this org has promoted nothing".
|
|
2977
|
+
console.error(`Failed to read promoted learnings: ${e.response?.data?.error || e.message}`);
|
|
2978
|
+
process.exit(1);
|
|
2979
|
+
}
|
|
2980
|
+
});
|
|
2981
|
+
|
|
2982
|
+
// ── ABSORB command (HC-GF-239) ───────────────────────────────────────────────
|
|
2983
|
+
// The Hone-team half of the flywheel's outbox. `hone promote`, `hone retract` and `hone promoted`
|
|
2984
|
+
// are commands; absorbing — the act that ENDS a learning's life — was a hand-built curl with
|
|
2985
|
+
// ADMIN_KEY pasted on the command line, which is how a production key reaches shell history. It
|
|
2986
|
+
// is also the step most likely to be skipped, because it is the one with no affordance.
|
|
2987
|
+
//
|
|
2988
|
+
// The key is read from the environment or ~/.honerc, never from an argument (the `admin-usage`
|
|
2989
|
+
// precedent). `--in` is required by commander AND re-checked here, so a missing or blank
|
|
2990
|
+
// destination is refused BEFORE a request is built — an absorption with no destination is the
|
|
2991
|
+
// unverifiable claim this queue already holds hundreds of.
|
|
2992
|
+
program
|
|
2993
|
+
.command('absorb <ids...>')
|
|
2994
|
+
.description('Admin: mark promoted learnings absorbed, naming where the lesson now lives (HC-GF-239)')
|
|
2995
|
+
.requiredOption('--in <destination>', 'Where the lesson now lives: a skill section, prompt, gate script or PR')
|
|
2996
|
+
.option('--org <uuid>', "Org whose learnings to absorb (default: this CLI token's own org)")
|
|
2997
|
+
.option('--repo <name>', 'Restrict to one repository')
|
|
2998
|
+
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
2999
|
+
.action(async (ids, opts) => {
|
|
3000
|
+
const destination = String(opts.in || '').trim();
|
|
3001
|
+
if (!destination) {
|
|
3002
|
+
console.error('Error: --in must name where the lesson now lives (a skill section, prompt, gate script or PR).');
|
|
3003
|
+
console.error('An absorption with no destination cannot be checked by anyone later.');
|
|
3004
|
+
process.exit(1);
|
|
3005
|
+
}
|
|
3006
|
+
const wanted = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))];
|
|
3007
|
+
if (!wanted.length) {
|
|
3008
|
+
console.error('Error: name at least one learning id to absorb.');
|
|
3009
|
+
process.exit(1);
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
const adminKey = requireAdminKey();
|
|
3013
|
+
// `getConfig()` already resolves the base (HONE_API -> ~/.honerc -> default) and exits with a
|
|
3014
|
+
// usable message when no token is configured. Re-deriving it here would have added a TENTH
|
|
3015
|
+
// copy of the `api.hone.ai` literal that [[HC-GF-236]] is trying to reduce to one.
|
|
3016
|
+
const config = getConfig();
|
|
3017
|
+
const apiUrl = config.apiUrl;
|
|
3018
|
+
|
|
3019
|
+
try {
|
|
3020
|
+
// The org defaults to the one this CLI token already authenticates as, so the common case
|
|
3021
|
+
// (a Hone operator absorbing their own repo's learnings) needs no uuid on the command line.
|
|
3022
|
+
let orgId = opts.org;
|
|
3023
|
+
if (!orgId) {
|
|
3024
|
+
const { data } = await api(config).get('/learnings/promoted');
|
|
3025
|
+
orgId = data.orgId;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
const { data } = await axios.post(`${apiUrl}/admin/learnings/absorb`, {
|
|
3029
|
+
orgId, ids: wanted, repo: opts.repo, absorbedIn: destination,
|
|
3030
|
+
}, {
|
|
3031
|
+
headers: { 'x-admin-key': adminKey, 'User-Agent': `@hone-ai/cli/${pkg.version}` },
|
|
3032
|
+
timeout: 15000,
|
|
3033
|
+
});
|
|
3034
|
+
|
|
3035
|
+
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
3036
|
+
|
|
3037
|
+
console.log('');
|
|
3038
|
+
console.log('Hone AI — Absorb Learnings');
|
|
3039
|
+
console.log('================================');
|
|
3040
|
+
console.log(`Destination: ${data.absorbedIn}`);
|
|
3041
|
+
console.log('');
|
|
3042
|
+
for (const a of data.absorbed) console.log(` absorbed ${a.id}${a.repoName ? ` (${a.repoName})` : ''}`);
|
|
3043
|
+
// Skipped rows are printed, never summarised away: "3 of 5 absorbed" without the other two
|
|
3044
|
+
// is the shape of report this whole capability exists to stop producing.
|
|
3045
|
+
for (const sk of data.skipped) console.log(` skipped ${sk.id} — ${sk.reason}`);
|
|
3046
|
+
console.log('');
|
|
3047
|
+
console.log(`${data.count} absorbed · ${data.skipped.length} skipped`);
|
|
3048
|
+
if (data.count) console.log('Read them back with: hone promoted --status absorbed');
|
|
3049
|
+
if (data.skipped.length) process.exitCode = 1;
|
|
3050
|
+
} catch (e) {
|
|
3051
|
+
const status = e.response?.status;
|
|
3052
|
+
if (status === 401) console.error('Invalid admin key. Check HONE_ADMIN_KEY or ~/.honerc admin_key.');
|
|
3053
|
+
else if (status === 429) console.error('Too many failed admin-auth attempts from this address. Wait for the window to pass.');
|
|
3054
|
+
else if (status === 503) console.error(`Server cannot absorb right now: ${e.response?.data?.error || 'unavailable'}`);
|
|
3055
|
+
else console.error(`Absorb failed: ${e.response?.data?.error || e.message}`);
|
|
3056
|
+
process.exit(1);
|
|
3057
|
+
}
|
|
3058
|
+
});
|
|
3059
|
+
|
|
2690
3060
|
// ── RETRACT command (H-023) ──────────────────────────────────────────────────
|
|
2691
3061
|
// Two modes:
|
|
2692
3062
|
// 1. Single retract: `hone retract <LEARNING-ID> --reason "..."`
|
|
@@ -2718,23 +3088,25 @@ program
|
|
|
2718
3088
|
console.log('No .github/learnings/ directory found — nothing to retract.');
|
|
2719
3089
|
return;
|
|
2720
3090
|
}
|
|
2721
|
-
|
|
3091
|
+
// HC-GF-311-followup-1: per learning, in every schema `hone promote` reads — not just the file's top level.
|
|
3092
|
+
const { parseRetracted } = require('./lib/learnings-parse');
|
|
2722
3093
|
const files = fs.readdirSync(learningsDir).filter(f => f.endsWith('.yml'));
|
|
2723
3094
|
for (const file of files) {
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
3095
|
+
// One unreadable entry (a directory or a dangling link named *.yml) must not hide the rest.
|
|
3096
|
+
let content;
|
|
3097
|
+
try { content = fs.readFileSync(path.join(learningsDir, file), 'utf8'); }
|
|
3098
|
+
catch (e) { console.warn(` ⚠ ${file}: read error — ${e.message}`); continue; }
|
|
3099
|
+
const { retracted, parseError } = parseRetracted(content);
|
|
3100
|
+
if (parseError) {
|
|
3101
|
+
console.warn(` ⚠ ${file}: parse error — ${parseError.message}`);
|
|
3102
|
+
continue;
|
|
3103
|
+
}
|
|
3104
|
+
for (const { id, reason } of retracted) {
|
|
3105
|
+
payload.push({
|
|
3106
|
+
id,
|
|
3107
|
+
repo_name: repoName,
|
|
3108
|
+
reason: reason || 'Retracted (no reason provided in YAML)',
|
|
3109
|
+
});
|
|
2738
3110
|
}
|
|
2739
3111
|
}
|
|
2740
3112
|
if (payload.length === 0) {
|
|
@@ -4613,10 +4985,19 @@ program
|
|
|
4613
4985
|
}
|
|
4614
4986
|
// Path B: GitHub issue fetch via gh
|
|
4615
4987
|
else if (issueId) {
|
|
4988
|
+
// HC-GF-270-followup-1: an issue number or an issue URL, which are the forms gh accepts, and
|
|
4989
|
+
// nothing else. gh reads an argument that begins with `-` as a flag even without a shell.
|
|
4990
|
+
if (!/^[1-9]\d*$/.test(issueId) && !/^https:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/issues\/[1-9]\d*$/.test(issueId)) {
|
|
4991
|
+
console.error(`error: <issueId> must be an issue number like 142, or an issue URL (got ${JSON.stringify(issueId)})`);
|
|
4992
|
+
console.error('usage: hone classify-story 142');
|
|
4993
|
+
console.error(' hone classify-story --input-file inputs.yml');
|
|
4994
|
+
process.exit(2);
|
|
4995
|
+
}
|
|
4616
4996
|
try {
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4997
|
+
// An argument list, not a shell string, and a bound: a hung gh must not hang the command.
|
|
4998
|
+
const out = require('child_process').execFileSync(
|
|
4999
|
+
'gh', ['issue', 'view', String(issueId), '--json', 'title,body,labels,url'],
|
|
5000
|
+
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }
|
|
4620
5001
|
);
|
|
4621
5002
|
issueMeta = JSON.parse(out);
|
|
4622
5003
|
const labels = (issueMeta.labels || []).map(l => l.name);
|
|
@@ -4638,6 +5019,10 @@ program
|
|
|
4638
5019
|
is_first_of_its_kind: false,
|
|
4639
5020
|
};
|
|
4640
5021
|
} catch (e) {
|
|
5022
|
+
if (e && e.code === 'ETIMEDOUT') {
|
|
5023
|
+
console.error(`error: gh issue view ${issueId} timed out after 30s`);
|
|
5024
|
+
process.exit(2);
|
|
5025
|
+
}
|
|
4641
5026
|
console.error(`error: gh issue view ${issueId} failed: ${(e.stderr || '').toString().trim() || e.message}`);
|
|
4642
5027
|
console.error(`hint: install GitHub CLI (cli.github.com), run \`gh auth login\`, or pass --input-file <path>`);
|
|
4643
5028
|
process.exit(2);
|
|
@@ -4950,12 +5335,19 @@ program
|
|
|
4950
5335
|
|
|
4951
5336
|
// audit runner. Shells out to `git diff origin/<base>...HEAD`, walks
|
|
4952
5337
|
// `.github/skills/`, calls SA-001's `runAssertions()`, writes
|
|
4953
|
-
// `.github/pipeline/<STORY-ID>/step-5b-skill-audit.md`.
|
|
4954
|
-
//
|
|
5338
|
+
// `.github/pipeline/<STORY-ID>/step-5b-skill-audit.md`. HC-GF-227: a BLOCKER
|
|
5339
|
+
// finding fails the command (exit 2, after the artifact is written); WARN and
|
|
5340
|
+
// INFO stay informational. The deferral this replaced named OptionsFlow's E29-G,
|
|
5341
|
+
// which no row in this roadmap owned.
|
|
5342
|
+
/** `hone step-5b`'s exit code when a BLOCKER assertion fired (HC-GF-227). Not 1: that is a crash. */
|
|
5343
|
+
const STEP_5B_BLOCKED = 2;
|
|
4955
5344
|
program
|
|
4956
5345
|
.command('step-5b')
|
|
4957
|
-
.description('Step 5b: runtime skill-application audit. Reads the story\'s referenced_skill cross-refs (LC-001), runs SA-001 assertion engine against PR diff, writes step-5b-skill-audit.md.
|
|
5346
|
+
.description('Step 5b: runtime skill-application audit. Reads the story\'s referenced_skill cross-refs (LC-001), runs SA-001 assertion engine against PR diff, writes step-5b-skill-audit.md. Exits 2 when a BLOCKER assertion fires (HC-GF-227); WARN and INFO findings are informational.')
|
|
4958
5347
|
.option('--base <branch>', 'base branch to diff against', 'develop')
|
|
5348
|
+
.option('--advisory', 'report BLOCKER findings without failing (exit 0), for a caller that wants only the artifact')
|
|
5349
|
+
.option('--all-skills', 'evaluate every skill, not only those the story\'s learnings reference (a repository-wide gate)')
|
|
5350
|
+
.option('--artifact-dir <dir>', 'write the artifact here instead of .github/pipeline/<STORY-ID>/ (a CI gate must not leave files in the checkout)')
|
|
4959
5351
|
.option('--story-id <id>', 'override story id (defaults to extraction from current branch name)')
|
|
4960
5352
|
.action(async (cmdOpts) => {
|
|
4961
5353
|
const { runAssertions } = require('./lib/skill-assertions');
|
|
@@ -4991,6 +5383,17 @@ program
|
|
|
4991
5383
|
} catch { /* leave empty */ }
|
|
4992
5384
|
}
|
|
4993
5385
|
|
|
5386
|
+
// 2b. HC-GF-310: the branch's commit messages, for `require_commit_line` assertions. A failed read
|
|
5387
|
+
// leaves them undefined, and the engine then warns instead of judging — never a silent pass.
|
|
5388
|
+
let commitMessages;
|
|
5389
|
+
for (const range of [`origin/${baseBranch}..HEAD`, `${baseBranch}..HEAD`]) {
|
|
5390
|
+
try {
|
|
5391
|
+
commitMessages = execSync(`git log --format=%B ${range}`,
|
|
5392
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
5393
|
+
break;
|
|
5394
|
+
} catch { /* try the next range */ }
|
|
5395
|
+
}
|
|
5396
|
+
|
|
4994
5397
|
// 3. Walk skills. Adopters live at .github/skills/; the hone-server
|
|
4995
5398
|
// framework dogfoods against server/seeds/skills/ (same fallback as
|
|
4996
5399
|
// `hone audit-skills`).
|
|
@@ -5028,7 +5431,8 @@ program
|
|
|
5028
5431
|
const result = runAssertions({
|
|
5029
5432
|
diff,
|
|
5030
5433
|
skills,
|
|
5031
|
-
referencedSkills: referencedSkills.length > 0 ? referencedSkills : undefined,
|
|
5434
|
+
referencedSkills: referencedSkills.length > 0 && !cmdOpts.allSkills ? referencedSkills : undefined,
|
|
5435
|
+
commitMessages,
|
|
5032
5436
|
});
|
|
5033
5437
|
|
|
5034
5438
|
// 6. Render + write artifact
|
|
@@ -5039,7 +5443,7 @@ program
|
|
|
5039
5443
|
baseBranch,
|
|
5040
5444
|
referencedSkills,
|
|
5041
5445
|
});
|
|
5042
|
-
const outDir = path.join(repoRoot, '.github/pipeline', storyId);
|
|
5446
|
+
const outDir = cmdOpts.artifactDir ? path.resolve(repoRoot, cmdOpts.artifactDir) : path.join(repoRoot, '.github/pipeline', storyId);
|
|
5043
5447
|
fs.mkdirSync(outDir, { recursive: true });
|
|
5044
5448
|
const outPath = path.join(outDir, 'step-5b-skill-audit.md');
|
|
5045
5449
|
fs.writeFileSync(outPath, artifact);
|
|
@@ -5063,7 +5467,16 @@ program
|
|
|
5063
5467
|
}, { BLOCKER: 0, WARN: 0, INFO: 0 }),
|
|
5064
5468
|
}, null, 2));
|
|
5065
5469
|
|
|
5066
|
-
//
|
|
5470
|
+
// HC-GF-227: a BLOCKER finding fails the audit. The skill's author chose the severity, so the
|
|
5471
|
+
// gate is theirs; WARN and INFO stay informational. Exit 2, not 1: node exits 1 when it crashes,
|
|
5472
|
+
// and a caller (verify-pr) must be able to tell a verdict from a failure to produce one.
|
|
5473
|
+
const blockers = result.findings.filter((f) => f.severity === 'BLOCKER');
|
|
5474
|
+
if (blockers.length && !cmdOpts.advisory) {
|
|
5475
|
+
console.error(`step-5b: ${blockers.length} BLOCKER finding(s): ` +
|
|
5476
|
+
blockers.map((f) => `${f.skillId}/${f.assertionId} at ${f.matchedFile}${f.matchedLine != null ? `:${f.matchedLine}` : ''}`).join('; ') +
|
|
5477
|
+
` — see ${outPath.startsWith(repoRoot + path.sep) ? path.relative(repoRoot, outPath) : outPath}`);
|
|
5478
|
+
process.exit(STEP_5B_BLOCKED);
|
|
5479
|
+
}
|
|
5067
5480
|
process.exit(0);
|
|
5068
5481
|
});
|
|
5069
5482
|
|
|
@@ -5554,7 +5967,7 @@ program
|
|
|
5554
5967
|
try {
|
|
5555
5968
|
// HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
|
|
5556
5969
|
// knows, so an older CLI — which sends nothing — keeps being served contract 1.
|
|
5557
|
-
const { data } = await client.get(
|
|
5970
|
+
const { data } = await client.get(orchestratePath([runId, 'step-instruction'], { contracts: '1,2' }));
|
|
5558
5971
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
5559
5972
|
console.log(`Step: ${data.stepKey} (${data.agent})`);
|
|
5560
5973
|
console.log(`Contract: ${data.contractVersion}`);
|
|
@@ -5606,7 +6019,7 @@ program
|
|
|
5606
6019
|
output = fs.readFileSync(opts.file, 'utf8');
|
|
5607
6020
|
}
|
|
5608
6021
|
const { data } = await client.post(
|
|
5609
|
-
|
|
6022
|
+
orchestratePath([runId, 'step', stepKey, 'editor-output']),
|
|
5610
6023
|
{ contractVersion: opts.contract, output }
|
|
5611
6024
|
);
|
|
5612
6025
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
@@ -5622,6 +6035,100 @@ program
|
|
|
5622
6035
|
}
|
|
5623
6036
|
});
|
|
5624
6037
|
|
|
6038
|
+
// HC-GF-097: the I/O cli/lib/run-context.js is handed — every disk, git, GitHub and Hone read a
|
|
6039
|
+
// run's config needs, bound to this checkout. run-story and queue-stories build from the same one.
|
|
6040
|
+
function runContextIo(apiConfig) {
|
|
6041
|
+
const { gitEnv } = require('./lib/git-env');
|
|
6042
|
+
const pc = require('./lib/pipeline-config');
|
|
6043
|
+
const cwd = process.cwd();
|
|
6044
|
+
// Repository-scoped: a path that leaves the checkout neither exists nor reads.
|
|
6045
|
+
const inRepo = (rel) => {
|
|
6046
|
+
if (typeof rel !== 'string' || rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return null;
|
|
6047
|
+
const abs = path.resolve(cwd, rel);
|
|
6048
|
+
return abs.startsWith(cwd + path.sep) ? abs : null;
|
|
6049
|
+
};
|
|
6050
|
+
return {
|
|
6051
|
+
// Every subprocess this adapter starts carries a timeout: a hung git or gh must fail open with a
|
|
6052
|
+
// message, not stall the command (node-developer hard rule 5). The module turns each failure into
|
|
6053
|
+
// a note. (recentlyModifiedOverlap's `git log` lives in story-classifier-extract.js, unchanged.)
|
|
6054
|
+
listFiles: () => execSync('git ls-files', {
|
|
6055
|
+
cwd, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: 60_000,
|
|
6056
|
+
}).split('\n').filter(Boolean),
|
|
6057
|
+
sourceDirs: (trackedPaths) => pc.readSourceDirs(cwd, { trackedPaths }),
|
|
6058
|
+
codebaseDefaults: () => pc.readCodebaseDefaults(cwd),
|
|
6059
|
+
ciGate: () => pc.readCIGateConfig(cwd),
|
|
6060
|
+
executionMode: () => pc.readExecutionModeRaw(cwd),
|
|
6061
|
+
consentNote: pc.EDITOR_MODE_CONSENT_NOTE,
|
|
6062
|
+
ruleOverlays: () => require('./lib/overlay-transport').readRuleOverlays(cwd),
|
|
6063
|
+
e2eSpecMode: () => pc.readE2eSpecMode(cwd),
|
|
6064
|
+
verificationGate: () => pc.readVerificationGateConfig(cwd),
|
|
6065
|
+
verificationGateSources: () => pc.readVerificationGateSources(cwd),
|
|
6066
|
+
readPlanText: () => {
|
|
6067
|
+
const planPath = path.join(cwd, '.github', 'EXECUTION_PLAN.yml');
|
|
6068
|
+
return fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
|
|
6069
|
+
},
|
|
6070
|
+
exists: (rel) => { const abs = inRepo(rel); return !!abs && fs.existsSync(abs); },
|
|
6071
|
+
isDirectory: (rel) => { const abs = inRepo(rel); try { return !!abs && fs.statSync(abs).isDirectory(); } catch { return false; } },
|
|
6072
|
+
readFile: (rel) => {
|
|
6073
|
+
const abs = inRepo(rel);
|
|
6074
|
+
if (!abs) throw new Error(`${rel} is outside the repository`);
|
|
6075
|
+
return fs.readFileSync(abs, 'utf8');
|
|
6076
|
+
},
|
|
6077
|
+
// Async (execFile, no shell) so queue-stories can resolve several issues at once.
|
|
6078
|
+
fetchIssue: async (issueNumber) => {
|
|
6079
|
+
const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
|
|
6080
|
+
const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
6081
|
+
if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
|
|
6082
|
+
const { execFile } = require('child_process');
|
|
6083
|
+
let stdout;
|
|
6084
|
+
try {
|
|
6085
|
+
({ stdout } = await require('util').promisify(execFile)('gh',
|
|
6086
|
+
['issue', 'view', String(issueNumber), '--repo', `${m[1]}/${m[2]}`, '--json', 'title,body,url'],
|
|
6087
|
+
{ encoding: 'utf8', timeout: 30_000 }));
|
|
6088
|
+
} catch (e) {
|
|
6089
|
+
// execFile keeps gh's stderr on the error — its first line is the actionable part ("gh auth
|
|
6090
|
+
// login", "Could not resolve to an Issue"), and a timeout must not read like any failure.
|
|
6091
|
+
if (e && e.killed) throw new Error(`gh issue view timed out after 30s`);
|
|
6092
|
+
const why = String((e && e.stderr) || '').trim().split('\n')[0];
|
|
6093
|
+
throw new Error(why || (e && e.message) || String(e));
|
|
6094
|
+
}
|
|
6095
|
+
return JSON.parse(stdout);
|
|
6096
|
+
},
|
|
6097
|
+
// HC-GF-270: who wrote the issue. run-context.js classifies a failure and story-provenance.js
|
|
6098
|
+
// decides trust; this only fetches. execFile, not a shell string: owner/repo come from the remote.
|
|
6099
|
+
fetchIssueProvenance: async (issueNumber) => {
|
|
6100
|
+
const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
|
|
6101
|
+
const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
6102
|
+
if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
|
|
6103
|
+
const where = { owner: m[1], repo: m[2], remoteUrl };
|
|
6104
|
+
try {
|
|
6105
|
+
const { execFile } = require('child_process');
|
|
6106
|
+
const { stdout } = await require('util').promisify(execFile)('gh',
|
|
6107
|
+
['api', `repos/${m[1]}/${m[2]}/issues/${issueNumber}`],
|
|
6108
|
+
{ encoding: 'utf8', timeout: 20_000 });
|
|
6109
|
+
return { ...where, issue: JSON.parse(stdout) };
|
|
6110
|
+
} catch (e) {
|
|
6111
|
+
throw Object.assign(e, { where }); // `where` lets the failure keep its issue reference
|
|
6112
|
+
}
|
|
6113
|
+
},
|
|
6114
|
+
fetchStoryContext: async (storyId) => {
|
|
6115
|
+
const { data } = await api(apiConfig).get(`/stories/${encodeURIComponent(storyId)}/context`);
|
|
6116
|
+
return data;
|
|
6117
|
+
},
|
|
6118
|
+
recentlyModifiedOverlap: (touched) =>
|
|
6119
|
+
require('./lib/story-classifier-extract').extractRecentlyModifiedOverlap(cwd, touched, 14),
|
|
6120
|
+
};
|
|
6121
|
+
}
|
|
6122
|
+
|
|
6123
|
+
// Prints run-context notes at their level. `prefix` labels a note with its story in a batch.
|
|
6124
|
+
function printRunContextNotes(notes, { prefix = '', only } = {}) {
|
|
6125
|
+
for (const n of notes || []) {
|
|
6126
|
+
if (only && n.level !== only) continue;
|
|
6127
|
+
const text = prefix ? n.text.replace(/^(\s*)/, `$1${prefix} `) : n.text;
|
|
6128
|
+
if (n.level === 'warn') console.warn(text); else console.log(text);
|
|
6129
|
+
}
|
|
6130
|
+
}
|
|
6131
|
+
|
|
5625
6132
|
program
|
|
5626
6133
|
.command('run-story <storyId>')
|
|
5627
6134
|
.description('Run the SDLC pipeline for a story via the orchestrator')
|
|
@@ -5640,7 +6147,11 @@ program
|
|
|
5640
6147
|
.option('--acknowledge-failed-verdict', 'Approve a step whose verification did NOT pass under verification.gate: enforce (HC-GF-074). The server refuses that approval by default and names the failing gates; this is for a human who has read them and still wants to proceed.')
|
|
5641
6148
|
.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.')
|
|
5642
6149
|
.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.')
|
|
5643
|
-
|
|
6150
|
+
// HC-GF-202: `collectIncludePaths` accumulates every occurrence. Without it the
|
|
6151
|
+
// option is single-valued and commander keeps the LAST, so the remedy the planner
|
|
6152
|
+
// itself prints — `--include-paths Makefile --include-paths scripts/` — silently
|
|
6153
|
+
// discarded `Makefile` and produced a bundle identical to passing nothing.
|
|
6154
|
+
.option('--include-paths <paths>', 'Source file paths to bundle into the codebase context (HC-019n-followup-12). Comma-separated, and the flag may be repeated; both forms accumulate. Augments auto-detected paths from the issue body.', collectIncludePaths, [])
|
|
5644
6155
|
.option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
|
|
5645
6156
|
.option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
|
|
5646
6157
|
.action(async (storyIdOrRunId, opts) => {
|
|
@@ -5703,19 +6214,37 @@ program
|
|
|
5703
6214
|
// advisory path re-verifies on every poll tick, because advisory never advances
|
|
5704
6215
|
// the run and the pause it re-observes is the same pause.
|
|
5705
6216
|
const _verifiedAttempts = new Set();
|
|
6217
|
+
// HC-GF-189: one-shot, and the "once" lives in the reporter so it can be DRIVEN by a test
|
|
6218
|
+
// rather than grepped for — mutation walked through the inline version twice.
|
|
6219
|
+
const { makeReadinessReporter } = require('./lib/story-classifier');
|
|
6220
|
+
const { readSandboxConfig } = require('./lib/pipeline-config');
|
|
6221
|
+
const _reportReadiness = makeReadinessReporter({ readSandbox: () => readSandboxConfig(process.cwd()) });
|
|
5706
6222
|
|
|
5707
6223
|
for (;;) {
|
|
5708
6224
|
await new Promise(r => setTimeout(r, interval));
|
|
5709
6225
|
|
|
5710
6226
|
let status;
|
|
5711
6227
|
try {
|
|
5712
|
-
const { data: pollData } = await client.get(
|
|
6228
|
+
const { data: pollData } = await client.get(orchestratePath([pollRunId]));
|
|
5713
6229
|
status = pollData;
|
|
5714
6230
|
} catch (e) {
|
|
5715
6231
|
console.error(`Poll error: ${e.message}`);
|
|
5716
6232
|
continue;
|
|
5717
6233
|
}
|
|
5718
6234
|
|
|
6235
|
+
// HC-GF-189: the first tick at which the axis EXISTS. step_0's groomer answers
|
|
6236
|
+
// `requires_stored_state`, the server harvests it into `config.classifier_axes`, and only
|
|
6237
|
+
// the CLI can pair that with "no `sandbox:` block is declared in this repo" — the server
|
|
6238
|
+
// has never seen one (HC-GF-169). Emitted here rather than at verify-patch, where
|
|
6239
|
+
// `planSandboxRun`'s stand-down says the same thing but only after step_2 has already
|
|
6240
|
+
// written its tests against whatever the machine happened to hold.
|
|
6241
|
+
//
|
|
6242
|
+
// Once. The loop re-observes the same state every few seconds, and a warning that repeats
|
|
6243
|
+
// until the run ends teaches the reader to skip it — the reason HC-GF-064 guards
|
|
6244
|
+
// auto-verify with a Set.
|
|
6245
|
+
const _readinessNote = _reportReadiness(status?.config);
|
|
6246
|
+
if (_readinessNote) console.warn(` ⚠ ${_readinessNote}`);
|
|
6247
|
+
|
|
5719
6248
|
const d = classifyPollState(status);
|
|
5720
6249
|
|
|
5721
6250
|
if (d.outcome === POLL_OUTCOMES.COMPLETED) {
|
|
@@ -5746,6 +6275,36 @@ program
|
|
|
5746
6275
|
console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
|
|
5747
6276
|
}
|
|
5748
6277
|
|
|
6278
|
+
// HC-GF-263: the step named the files it needs, in a fenced `hone run-story` command,
|
|
6279
|
+
// and until now nobody read it — the server kept 200 chars of the first line and this
|
|
6280
|
+
// loop printed 240 of those on the EXHAUSTED branch and nothing at all on a human
|
|
6281
|
+
// gate. Printed in BOTH branches, and ADDITIVE to the errorMessage above: that line
|
|
6282
|
+
// is the only surviving statement of why the step stopped.
|
|
6283
|
+
//
|
|
6284
|
+
// Never throws: a print must not end a poll loop.
|
|
6285
|
+
try {
|
|
6286
|
+
const { remedyFor, renderIncludePathsCommand, partitionSafePaths } = require('./lib/hard-pause-remedy');
|
|
6287
|
+
const remedy = remedyFor(status);
|
|
6288
|
+
if (remedy) {
|
|
6289
|
+
const cmd = renderIncludePathsCommand(status && status.storyId, remedy.paths);
|
|
6290
|
+
if (cmd) {
|
|
6291
|
+
// Name the step: the request belongs to ONE step, and an unattributed command
|
|
6292
|
+
// reads as advice about the run rather than about this pause (H1).
|
|
6293
|
+
console.log(` ${remedy.stepKey} named the files it needs. Re-queue with:`);
|
|
6294
|
+
console.log(` ${cmd}`);
|
|
6295
|
+
if (remedy.truncated) {
|
|
6296
|
+
// HC-GF-263-followup-1: the cap counts entries, and one file may have several.
|
|
6297
|
+
console.log(` ⚠ it asked for more than ${remedy.paths.length} paths; the rest were dropped.`);
|
|
6298
|
+
}
|
|
6299
|
+
}
|
|
6300
|
+
// H5: say how many requested paths were withheld from the command, never what they were.
|
|
6301
|
+
const { dropped } = partitionSafePaths(remedy.paths);
|
|
6302
|
+
if (dropped.length) {
|
|
6303
|
+
console.log(` ${remedy.stepKey}: ${dropped.length} requested path(s) not shown — characters a shell could misread; see \`hone show step\`.`);
|
|
6304
|
+
}
|
|
6305
|
+
}
|
|
6306
|
+
} catch { /* a print must never end a poll loop */ }
|
|
6307
|
+
|
|
5749
6308
|
// HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
|
|
5750
6309
|
// existed since HC-019n-followup-34/35 and never fired, because nothing ever
|
|
5751
6310
|
// ran the executing step. Consent is DERIVED: `ci.local_command` being
|
|
@@ -5753,13 +6312,59 @@ program
|
|
|
5753
6312
|
// cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
|
|
5754
6313
|
// Never throws — a failure here must leave the operator exactly where they
|
|
5755
6314
|
// were, which is being asked to approve.
|
|
6315
|
+
// HC-GF-270: computed OUTSIDE the try below, on purpose. That catch turns a throw
|
|
6316
|
+
// into `(auto-verify skipped: ...)` and CONTINUES — harmless for a decision about
|
|
6317
|
+
// whether to run tests, a fail-OPEN for a decision about whether this patch may
|
|
6318
|
+
// execute at all. So the one call that answers "may we run it" cannot be inside it,
|
|
6319
|
+
// and its own failure is untrusted rather than absent.
|
|
6320
|
+
let _provenance;
|
|
6321
|
+
// HC-GF-270: attempts whose NEEDS YOU block has already been printed this process.
|
|
6322
|
+
// Declared lazily on the enclosing scope so the Set survives across poll ticks —
|
|
6323
|
+
// a per-tick Set would suppress nothing.
|
|
6324
|
+
if (!globalThis.__honeProvenanceNoticed) globalThis.__honeProvenanceNoticed = new Set();
|
|
6325
|
+
const _provenanceNoticed = globalThis.__honeProvenanceNoticed;
|
|
6326
|
+
if (!exhausted) {
|
|
6327
|
+
try {
|
|
6328
|
+
const { assessStorySource } = require('./lib/story-provenance');
|
|
6329
|
+
_provenance = assessStorySource(
|
|
6330
|
+
status?.config?.story_source,
|
|
6331
|
+
status?.storyId || status?.story_id);
|
|
6332
|
+
} catch (e) {
|
|
6333
|
+
_provenance = {
|
|
6334
|
+
trusted: false,
|
|
6335
|
+
label: 'assessment-failed',
|
|
6336
|
+
reason: `story provenance could not be assessed: ${e.message}`,
|
|
6337
|
+
};
|
|
6338
|
+
}
|
|
6339
|
+
}
|
|
5756
6340
|
if (!exhausted) try {
|
|
5757
|
-
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine } = require('./lib/auto-verify');
|
|
6341
|
+
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine, provenanceNotice } = require('./lib/auto-verify');
|
|
5758
6342
|
const decision = decideAutoVerify({
|
|
5759
6343
|
stepKey: d.stepKey,
|
|
5760
6344
|
repoRoot: process.cwd(),
|
|
5761
6345
|
noVerifyFlag: opts.verify === false,
|
|
6346
|
+
assessment: _provenance,
|
|
5762
6347
|
});
|
|
6348
|
+
// HC-GF-270: only when the step_4 gate is the one paused — an untrusted run is
|
|
6349
|
+
// untrusted at every gate, and repeating this at each of them would teach the
|
|
6350
|
+
// operator to skip it (the HC-GF-189 lesson about a warning that never stops).
|
|
6351
|
+
// HC-GF-270 code review (MEDIUM-5): ONCE per attempt, the same discipline
|
|
6352
|
+
// `alreadyVerified` applies to `announce`. The loop re-observes the same pause every
|
|
6353
|
+
// few seconds; a NEEDS YOU block that reprints on every tick teaches the operator to
|
|
6354
|
+
// scroll past it, which is the HC-GF-189 lesson this comment used to only half-apply
|
|
6355
|
+
// (it suppressed the notice at OTHER gates, not on re-polls of this one).
|
|
6356
|
+
//
|
|
6357
|
+
// Its own Set, not `_verifiedAttempts`: that one means "this attempt was verified",
|
|
6358
|
+
// and an untrusted attempt is precisely one that was NOT. Sharing it would make
|
|
6359
|
+
// `alreadyVerified` lie about what happened.
|
|
6360
|
+
if (decision.untrusted && d.stepKey === 'step_4' && !_provenanceNoticed.has(d.attemptKey)) {
|
|
6361
|
+
_provenanceNoticed.add(d.attemptKey);
|
|
6362
|
+
console.log(provenanceNotice(_provenance, {
|
|
6363
|
+
runId: pollRunId,
|
|
6364
|
+
source: status?.config?.story_source || {},
|
|
6365
|
+
stepKey: d.stepKey,
|
|
6366
|
+
}));
|
|
6367
|
+
}
|
|
5763
6368
|
if (decision.run && !alreadyVerified(d.attemptKey, _verifiedAttempts)) {
|
|
5764
6369
|
_verifiedAttempts.add(d.attemptKey);
|
|
5765
6370
|
console.log(announce(decision, pollRunId));
|
|
@@ -5791,14 +6396,14 @@ program
|
|
|
5791
6396
|
|
|
5792
6397
|
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
5793
6398
|
try {
|
|
5794
|
-
await client.post(
|
|
6399
|
+
await client.post(orchestratePath([pollRunId, 'approve']), { stepKey: d.stepKey });
|
|
5795
6400
|
console.log(' Approved — workflow resuming...');
|
|
5796
6401
|
} catch (e) {
|
|
5797
6402
|
console.error(` Approve failed: ${e.response?.data?.error || e.message}`);
|
|
5798
6403
|
}
|
|
5799
6404
|
} else if (answer.toLowerCase() === 'kill') {
|
|
5800
6405
|
try {
|
|
5801
|
-
await client.post(
|
|
6406
|
+
await client.post(orchestratePath([pollRunId, 'kill']), { reason: 'CLI kill' });
|
|
5802
6407
|
console.log(' Workflow killed.');
|
|
5803
6408
|
process.exit(0);
|
|
5804
6409
|
} catch (e) {
|
|
@@ -5827,7 +6432,7 @@ program
|
|
|
5827
6432
|
if (opts.status) {
|
|
5828
6433
|
try {
|
|
5829
6434
|
const statusId = await existingRunId('--status');
|
|
5830
|
-
const { data } = await client.get(
|
|
6435
|
+
const { data } = await client.get(orchestratePath([statusId]));
|
|
5831
6436
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
5832
6437
|
console.log('');
|
|
5833
6438
|
console.log(`Workflow: ${data.runId}`);
|
|
@@ -5877,7 +6482,7 @@ program
|
|
|
5877
6482
|
if (opts.kill) {
|
|
5878
6483
|
try {
|
|
5879
6484
|
const killId = await existingRunId('--kill');
|
|
5880
|
-
const { data } = await client.post(
|
|
6485
|
+
const { data } = await client.post(orchestratePath([killId, 'kill']), { reason: 'CLI kill' });
|
|
5881
6486
|
console.log(`Workflow ${data.runId} killed.`);
|
|
5882
6487
|
} catch (e) {
|
|
5883
6488
|
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
@@ -5891,7 +6496,7 @@ program
|
|
|
5891
6496
|
let approveId;
|
|
5892
6497
|
try {
|
|
5893
6498
|
approveId = await existingRunId(`--approve ${opts.approve}`);
|
|
5894
|
-
await client.post(
|
|
6499
|
+
await client.post(orchestratePath([approveId, 'approve']),
|
|
5895
6500
|
{ stepKey: opts.approve, acknowledgeAbort: opts.acknowledgeAbort === true,
|
|
5896
6501
|
acknowledgeFailedVerdict: opts.acknowledgeFailedVerdict === true });
|
|
5897
6502
|
console.log(`Gate ${opts.approve} approved. Workflow resuming.`);
|
|
@@ -5934,27 +6539,6 @@ program
|
|
|
5934
6539
|
try { branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim(); } catch { branch = null; }
|
|
5935
6540
|
}
|
|
5936
6541
|
|
|
5937
|
-
// HC-019n-followup-11: when storyId is a bare integer, treat it as a
|
|
5938
|
-
// GitHub issue number and fetch the issue's title + body via the local
|
|
5939
|
-
// `gh` CLI. Inject into config.story_description so the orchestrator's
|
|
5940
|
-
// buildStepContext (HC-019n root-cause fix B) hands the real story
|
|
5941
|
-
// text to step_0 instead of letting the LLM produce placeholder
|
|
5942
|
-
// [ASSUMPTION] grooming. Hook reused — no server change required.
|
|
5943
|
-
//
|
|
5944
|
-
// Gracefully degrade on any error (gh missing, no remote, network
|
|
5945
|
-
// fail, private repo without auth): warn and proceed without context.
|
|
5946
|
-
// The existing HC-019n-followup-7 hard_pause safety net catches the
|
|
5947
|
-
// resulting placeholder cascade at step_1.
|
|
5948
|
-
//
|
|
5949
|
-
// HC-019b-followup-1 F1: when invoked with an issue number, also try
|
|
5950
|
-
// to extract a canonical story-id (e.g. HC-019b) from the issue title.
|
|
5951
|
-
// Used by the architect-config read below — without this, the lookup
|
|
5952
|
-
// keys on '104' instead of 'HC-019b' and silently bypasses every
|
|
5953
|
-
// architect-engaged story whose EXECUTION_PLAN.yml entry uses the
|
|
5954
|
-
// canonical id (which is the common adopter case).
|
|
5955
|
-
const orchestrateConfig = {};
|
|
5956
|
-
let issueBodyForFiles = null;
|
|
5957
|
-
let resolvedStoryId = storyIdOrRunId; // canonical id for architect-config lookup
|
|
5958
6542
|
// HC-GF-010: an EXPLICIT story source, and the only one that works on a repository
|
|
5959
6543
|
// that is neither a GitHub issue tracker nor listed on Hone's own roadmap.
|
|
5960
6544
|
//
|
|
@@ -5989,473 +6573,25 @@ program
|
|
|
5989
6573
|
}
|
|
5990
6574
|
}
|
|
5991
6575
|
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
issueBodyForFiles = `${issue.title}\n${issue.body || ''}`;
|
|
6012
|
-
console.log(` → fetched GitHub issue #${storyIdOrRunId} for story context (${desc.length} chars)`);
|
|
6013
|
-
// HC-019b-followup-1 F1: extract the canonical story-id from the
|
|
6014
|
-
// issue title. extractStoryIdFromBranch's regex (STORY_ID_PATTERN
|
|
6015
|
-
// in pipeline-status.js) handles HC-NNN, HC-NNN-A, H-NNNb, E22-D,
|
|
6016
|
-
// and HC-NNN-followup-N shapes after H-029-followup-2.
|
|
6017
|
-
try {
|
|
6018
|
-
const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
|
|
6019
|
-
const titleId = extractStoryIdFromBranch(issue.title);
|
|
6020
|
-
if (titleId) {
|
|
6021
|
-
resolvedStoryId = titleId;
|
|
6022
|
-
console.log(` → resolved issue #${storyIdOrRunId} → canonical story id '${titleId}' for architect-config lookup`);
|
|
6023
|
-
} else {
|
|
6024
|
-
console.warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
|
|
6025
|
-
}
|
|
6026
|
-
} catch { /* extractor missing/throws → fall back to numeric id */ }
|
|
6027
|
-
} catch (e) {
|
|
6028
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6029
|
-
console.warn(` ⚠ could not fetch GitHub issue context: ${msg}`);
|
|
6030
|
-
console.warn(' → proceeding without story_description; step_0 may produce placeholder output');
|
|
6031
|
-
console.warn(` ⚠ architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
|
|
6032
|
-
}
|
|
6033
|
-
} else {
|
|
6034
|
-
// HC-019n-followup-18: roadmap-sourced story. Only the SERVER knows what
|
|
6035
|
-
// the story says (server/seeds/master-roadmap.md is a Hone asset, absent
|
|
6036
|
-
// from adopter repos) and only the CLI can read the adopter's disk — so
|
|
6037
|
-
// ask the server what the description references, then read those files
|
|
6038
|
-
// here. See .github/pipeline/HC-019n-followup-18/architect.md §1 for why
|
|
6039
|
-
// this beats the CLI reading a local roadmap (hardcodes a Hone-internal
|
|
6040
|
-
// path into the adopter CLI, and fails silently anywhere else).
|
|
6041
|
-
//
|
|
6042
|
-
// Advisory, never fatal: a story with no resolvable context still runs,
|
|
6043
|
-
// it just runs blind — and loudly, via the followup-17 warning below.
|
|
6044
|
-
try {
|
|
6045
|
-
const ctxClient = api(config);
|
|
6046
|
-
const { data } = await ctxClient.get(
|
|
6047
|
-
`/stories/${encodeURIComponent(storyIdOrRunId)}/context`
|
|
6048
|
-
);
|
|
6049
|
-
if (data && data.description) {
|
|
6050
|
-
orchestrateConfig.story_description = data.description;
|
|
6051
|
-
issueBodyForFiles = data.description;
|
|
6052
|
-
const n = Array.isArray(data.referencedPaths) ? data.referencedPaths.length : 0;
|
|
6053
|
-
console.log(
|
|
6054
|
-
` → resolved story context from roadmap (${data.description.length} chars, ` +
|
|
6055
|
-
`${n} referenced path${n === 1 ? '' : 's'})`
|
|
6056
|
-
);
|
|
6057
|
-
}
|
|
6058
|
-
} catch (e) {
|
|
6059
|
-
const status = e && e.response && e.response.status;
|
|
6060
|
-
if (status === 404) {
|
|
6061
|
-
console.warn(` ⚠ no roadmap row found for '${storyIdOrRunId}'`);
|
|
6062
|
-
} else {
|
|
6063
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6064
|
-
console.warn(` ⚠ could not fetch story context: ${msg}`);
|
|
6065
|
-
}
|
|
6066
|
-
console.warn(' → proceeding without story context; the warning below applies');
|
|
6067
|
-
}
|
|
6068
|
-
}
|
|
6069
|
-
|
|
6070
|
-
// HC-019n-followup-12: auto-bundle codebase files referenced in the
|
|
6071
|
-
// issue body (or by the operator via --include-paths). Without this,
|
|
6072
|
-
// step_1/step_4 have only training-data priors and produce code that
|
|
6073
|
-
// matches the SHAPE of the adopter's stack but not its actual
|
|
6074
|
-
// architecture (cf. OptionsFlow #104 step_4 hallucinating SQLAlchemy
|
|
6075
|
-
// for a JSON-lines repo). The bundle is capped at 10 files / 50K
|
|
6076
|
-
// chars total to stay well inside the LLM context window.
|
|
6077
|
-
//
|
|
6078
|
-
// Detection scans the issue body for code-file-like paths:
|
|
6079
|
-
// - foo/bar/baz.py (relative paths with extensions)
|
|
6080
|
-
// - tests/unit/test_foo.py (typical pytest structure)
|
|
6081
|
-
// - src/web/routes.py (src trees)
|
|
6082
|
-
// Excludes:
|
|
6083
|
-
// - https:// or http:// URLs (those aren't local files)
|
|
6084
|
-
// - paths starting with / or ~ (absolute / home — bundle would
|
|
6085
|
-
// escape the repo)
|
|
6086
|
-
// - .md / .txt / .yml docs (rarely needed for code-builder; would
|
|
6087
|
-
// blow context with prose)
|
|
6088
|
-
// HC-DF-001: config-level default include paths (codebase.default_include_paths)
|
|
6089
|
-
// are merged with story-text auto-detection and --include-paths.
|
|
6090
|
-
const { readCodebaseDefaults } = require('./lib/pipeline-config');
|
|
6091
|
-
const defaultIncludePaths = readCodebaseDefaults(process.cwd()).include_paths;
|
|
6092
|
-
if (issueBodyForFiles || opts.includePaths || defaultIncludePaths.length > 0) {
|
|
6093
|
-
try {
|
|
6094
|
-
// HC-019n-followup-13c: word-boundary `\b` at end of extension
|
|
6095
|
-
// group so `.jsonl` doesn't match as `.js` and `.tsx` doesn't
|
|
6096
|
-
// match as `.ts`. Pre-fix, both `logs/calibration_runs.jsonl`
|
|
6097
|
-
// and `src/web/app.tsx` got truncated. The truncated paths
|
|
6098
|
-
// failed the downstream `fs.existsSync` check so they didn't
|
|
6099
|
-
// crash the bundling, but they polluted the regex output set
|
|
6100
|
-
// and would have produced confusing "file not found" warnings
|
|
6101
|
-
// if we ever tightened the diagnostic logging.
|
|
6102
|
-
const FILE_PATH_RE = /(?<![\w/])([a-zA-Z_][\w-]*\/(?:[\w.-]+\/)*[\w.-]+\.(?:py|js|ts|jsx|tsx|go|rs|java|rb|sh|sql))\b/g;
|
|
6103
|
-
const candidates = new Set();
|
|
6104
|
-
if (issueBodyForFiles) {
|
|
6105
|
-
for (const m of issueBodyForFiles.matchAll(FILE_PATH_RE)) candidates.add(m[1]);
|
|
6106
|
-
}
|
|
6107
|
-
if (opts.includePaths) {
|
|
6108
|
-
// --include-paths a/b.py,c/d.py
|
|
6109
|
-
for (const p of String(opts.includePaths).split(',').map(s => s.trim()).filter(Boolean)) {
|
|
6110
|
-
candidates.add(p);
|
|
6111
|
-
}
|
|
6112
|
-
}
|
|
6113
|
-
for (const p of defaultIncludePaths) candidates.add(p); // HC-DF-001 config defaults
|
|
6114
|
-
// HC-019n-followup-26: FILE_PATH_RE above requires a directory segment,
|
|
6115
|
-
// so a story that names a file BARE (`derive-worker.js:787` in prose)
|
|
6116
|
-
// extracts 0 paths and step_4 runs blind — and, blind, hallucinates a
|
|
6117
|
-
// file at a non-existent path (2026-08-30 e2e demo). Catch bare refs too
|
|
6118
|
-
// and resolve each against the repo's own file list: bundle ONLY on a
|
|
6119
|
-
// unique basename match; never guess an ambiguous or absent one.
|
|
6120
|
-
// HC-019n-followup-27: candidate path → referenced line number, so the
|
|
6121
|
-
// bundler can WINDOW a too-big file around its edit site instead of
|
|
6122
|
-
// head-truncating past it (the demo's line 787 sits at char 37K).
|
|
6123
|
-
const candidateLines = new Map();
|
|
6124
|
-
// HC-GF-073: hoisted out of the bare-ref branch below, because the sibling tier
|
|
6125
|
-
// needs the same list on stories that contain no bare refs at all — which is most
|
|
6126
|
-
// of them.
|
|
6127
|
-
const { gitEnv } = require('./lib/git-env');
|
|
6128
|
-
let repoFiles = null;
|
|
6129
|
-
const listRepoFiles = () => {
|
|
6130
|
-
if (repoFiles) return repoFiles;
|
|
6131
|
-
try {
|
|
6132
|
-
repoFiles = execSync('git ls-files', {
|
|
6133
|
-
cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
6134
|
-
}).split('\n').filter(Boolean);
|
|
6135
|
-
} catch { repoFiles = []; }
|
|
6136
|
-
return repoFiles;
|
|
6137
|
-
};
|
|
6138
|
-
if (issueBodyForFiles) {
|
|
6139
|
-
const { extractBareRefs, resolveBareName, alreadyCovered } = require('./lib/bundle-paths');
|
|
6140
|
-
const bareRefs = extractBareRefs(issueBodyForFiles);
|
|
6141
|
-
if (bareRefs.length > 0) {
|
|
6142
|
-
for (const { name, line } of bareRefs) {
|
|
6143
|
-
if (alreadyCovered(name, candidates)) continue;
|
|
6144
|
-
const r = resolveBareName(name, listRepoFiles());
|
|
6145
|
-
if (r.status === 'unique') {
|
|
6146
|
-
candidates.add(r.path);
|
|
6147
|
-
if (line && !candidateLines.has(r.path)) candidateLines.set(r.path, line);
|
|
6148
|
-
console.log(` → resolved bare ref '${name}${line ? ':' + line : ''}' → ${r.path}`);
|
|
6149
|
-
} else if (r.status === 'ambiguous') {
|
|
6150
|
-
console.warn(` ⚠ '${name}' is ambiguous (${r.matches.length} files) — not bundling; use --include-paths to disambiguate`);
|
|
6151
|
-
}
|
|
6152
|
-
// status === 'none' → not every filename in prose is a repo file; skip quietly
|
|
6153
|
-
}
|
|
6154
|
-
}
|
|
6155
|
-
}
|
|
6156
|
-
// HC-019n-followup-13d: bump caps. Pre-fix MAX_FILES=10 /
|
|
6157
|
-
// MAX_TOTAL_CHARS=50_000 / MAX_PER_FILE_CHARS=10_000 was overly
|
|
6158
|
-
// conservative — OptionsFlow #96 hit 49,783/50,000 with 6 files
|
|
6159
|
-
// (217 chars to spare), meaning the next slightly-larger story
|
|
6160
|
-
// silently dropped files. LLM context windows are 200K+; the
|
|
6161
|
-
// bundle should be able to fill ~25% of that without surprise.
|
|
6162
|
-
// New caps: 30 / 150_000 / 25_000.
|
|
6163
|
-
const MAX_FILES = 30;
|
|
6164
|
-
const MAX_TOTAL_CHARS = 150_000;
|
|
6165
|
-
const MAX_PER_FILE_CHARS = 25_000;
|
|
6166
|
-
// HC-GF-073: `candidates` is only what the STORY named, and a story names the
|
|
6167
|
-
// files it will CREATE — so on a greenfield repo every one of them fails the
|
|
6168
|
-
// existsSync below and the bundle is EMPTY. Measured across FlowForge's 33 real
|
|
6169
|
-
// stories: 29 extracted zero existing files. The dogfood runs looked fine only
|
|
6170
|
-
// because an operator had typed --include-paths once; 12 of 12 runs then bundled
|
|
6171
|
-
// the same three files across five different stories.
|
|
6172
|
-
//
|
|
6173
|
-
// selectBundle() splits them into what exists, what is pending, and adds two
|
|
6174
|
-
// tiers the story can never name: repo-level signal files (whose ABSENCE is what
|
|
6175
|
-
// a security reviewer must be able to check — HC-GF-063) and existing neighbours
|
|
6176
|
-
// of the pending paths (the conventions a new module has to match). Order is
|
|
6177
|
-
// priority order, because the char cap below truncates from the end.
|
|
6178
|
-
const { selectBundle, auditLines } = require('./lib/bundle-context');
|
|
6179
|
-
const selection = selectBundle({
|
|
6180
|
-
named: [...candidates],
|
|
6181
|
-
repoFiles: listRepoFiles(),
|
|
6182
|
-
exists: (rel) => {
|
|
6183
|
-
if (rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return false;
|
|
6184
|
-
const abs = path.resolve(process.cwd(), rel);
|
|
6185
|
-
return abs.startsWith(process.cwd() + path.sep) && fs.existsSync(abs);
|
|
6186
|
-
},
|
|
6187
|
-
});
|
|
6188
|
-
const ordered = [...selection.present, ...selection.signal, ...selection.siblings];
|
|
6189
|
-
const codebaseFiles = [];
|
|
6190
|
-
let totalChars = 0;
|
|
6191
|
-
for (const candidate of ordered) {
|
|
6192
|
-
if (codebaseFiles.length >= MAX_FILES) break;
|
|
6193
|
-
if (candidate.startsWith('/') || candidate.startsWith('~') || candidate.includes('..')) continue;
|
|
6194
|
-
const abs = path.resolve(process.cwd(), candidate);
|
|
6195
|
-
if (!abs.startsWith(process.cwd() + path.sep)) continue; // escape guard
|
|
6196
|
-
if (!fs.existsSync(abs)) continue;
|
|
6197
|
-
let content;
|
|
6198
|
-
try {
|
|
6199
|
-
content = fs.readFileSync(abs, 'utf8');
|
|
6200
|
-
} catch { continue; }
|
|
6201
|
-
if (content.length > MAX_PER_FILE_CHARS) {
|
|
6202
|
-
const line = candidateLines.get(candidate);
|
|
6203
|
-
if (line) {
|
|
6204
|
-
// HC-019n-followup-27: window around the referenced line so step_4
|
|
6205
|
-
// SEES the edit site (head-truncation would hide a site past the
|
|
6206
|
-
// cut) and can write a region-edit anchor. The window marker tells
|
|
6207
|
-
// step_4 to emit an `edit:` block, not a whole-file block.
|
|
6208
|
-
const { windowAroundLine } = require('./lib/bundle-paths');
|
|
6209
|
-
const w = windowAroundLine(content, line, Math.floor((MAX_PER_FILE_CHARS - 800) / 2));
|
|
6210
|
-
content =
|
|
6211
|
-
`# [HC-019n-followup-27: windowed excerpt, lines ${w.startLine}-${w.endLine} of ${w.totalLines} ` +
|
|
6212
|
-
`(centered on referenced line ${line}); you were NOT shown the whole file — ` +
|
|
6213
|
-
`emit an \`edit:\` block with a unique OLD anchor from this excerpt, NOT a whole-file \`file:\` block]\n` +
|
|
6214
|
-
w.excerpt +
|
|
6215
|
-
`\n# [end windowed excerpt of ${candidate}]`;
|
|
6216
|
-
} else {
|
|
6217
|
-
content = content.slice(0, MAX_PER_FILE_CHARS) + `\n\n# [HC-019n-followup-12: truncated at ${MAX_PER_FILE_CHARS} chars; full file is ${content.length} chars]`;
|
|
6218
|
-
}
|
|
6219
|
-
}
|
|
6220
|
-
if (totalChars + content.length > MAX_TOTAL_CHARS) break;
|
|
6221
|
-
totalChars += content.length;
|
|
6222
|
-
codebaseFiles.push({ path: candidate, content });
|
|
6223
|
-
}
|
|
6224
|
-
// HC-GF-073: the old line printed a COUNT, and a count cannot distinguish three
|
|
6225
|
-
// files the story named from three it did not. That is why 12 consecutive runs
|
|
6226
|
-
// bundled an unrelated module and nothing said so — the "running blind" warning
|
|
6227
|
-
// below fires on an EMPTY bundle, and these were not empty.
|
|
6228
|
-
for (const line of auditLines(selection)) console.log(line);
|
|
6229
|
-
if (codebaseFiles.length > 0) {
|
|
6230
|
-
orchestrateConfig.codebaseFiles = codebaseFiles;
|
|
6231
|
-
console.log(` → bundled ${codebaseFiles.length} codebase file(s) for step_1/step_4 context (${totalChars} chars)`);
|
|
6232
|
-
}
|
|
6233
|
-
} catch (e) {
|
|
6234
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6235
|
-
console.warn(` ⚠ codebase bundling failed (non-fatal): ${msg}`);
|
|
6236
|
-
}
|
|
6237
|
-
}
|
|
6238
|
-
|
|
6239
|
-
// HC-019n-followup-17: warn when the code-aware steps will run BLIND.
|
|
6240
|
-
//
|
|
6241
|
-
// Auto-detection reads file paths out of `issueBodyForFiles`, which is only
|
|
6242
|
-
// populated on the numeric-GitHub-issue branch above. A roadmap-sourced id
|
|
6243
|
-
// (HC-CI-005, SC-002-followup-2, ...) never enters that branch, so
|
|
6244
|
-
// `codebaseFiles` stays unset and step_1 / step_4 / step_5 / step_5d /
|
|
6245
|
-
// step_5e all run with no source to read — the exact "confidently wrong"
|
|
6246
|
-
// failure HC-019n-followup-12 was built to prevent, still open for roadmap
|
|
6247
|
-
// ids. Observed 2026-08-29: run 61fddfb2 wrote 8,444 chars of code for
|
|
6248
|
-
// HC-CI-005 having never seen the files that story names.
|
|
6249
|
-
//
|
|
6250
|
-
// A fix is structurally awkward: only the CLI can read adopter files, and
|
|
6251
|
-
// only the SERVER knows the roadmap description (server/seeds/master-
|
|
6252
|
-
// roadmap.md is a Hone asset, absent from adopter repos). Closing it needs
|
|
6253
|
-
// the CLI to fetch the resolved description before queueing — a round trip
|
|
6254
|
-
// that belongs in its own story. Until then the gap is at least LOUD
|
|
6255
|
-
// instead of silent, which is the part that actually bit.
|
|
6256
|
-
if (!orchestrateConfig.codebaseFiles) {
|
|
6257
|
-
console.warn(' ⚠ no codebase files bundled — step_1 / step_4 / step_5 / step_5d / step_5e');
|
|
6258
|
-
console.warn(' will run WITHOUT source context and may invent APIs that do not exist.');
|
|
6259
|
-
if (!/^\d+$/.test(storyIdOrRunId)) {
|
|
6260
|
-
console.warn(` '${storyIdOrRunId}' is not a GitHub issue number, so file paths cannot be`);
|
|
6261
|
-
console.warn(' auto-detected from an issue body.');
|
|
6262
|
-
}
|
|
6263
|
-
console.warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js');
|
|
6264
|
-
}
|
|
6265
|
-
|
|
6266
|
-
// HC-COMM-010 axes wiring (CLI half): when --axes wasn't given, derive
|
|
6267
|
-
// the DETERMINISTIC axes from the extractors (SC-002) — security labels/
|
|
6268
|
-
// text, cross-repo markers, recently-modified overlap vs the bundled
|
|
6269
|
-
// paths. Judgment axes (shared-chokepoint etc.) come from the groomer at
|
|
6270
|
-
// step_0 and are harvested server-side. Fail-open; explicit --axes wins.
|
|
6271
|
-
if (!opts.axes) {
|
|
6272
|
-
try {
|
|
6273
|
-
const { classifyStory } = require('./lib/story-classifier');
|
|
6274
|
-
const {
|
|
6275
|
-
extractType, extractSecurityImplications, extractCrossRepoDependency, extractRecentlyModifiedOverlap,
|
|
6276
|
-
} = require('./lib/story-classifier-extract');
|
|
6277
|
-
const autoBody = issueBodyForFiles || '';
|
|
6278
|
-
const autoLabels = [];
|
|
6279
|
-
const touched = Array.isArray(orchestrateConfig.codebaseFiles)
|
|
6280
|
-
? orchestrateConfig.codebaseFiles.map(f => f.path).filter(Boolean)
|
|
6281
|
-
: Object.keys(orchestrateConfig.codebaseFiles || {});
|
|
6282
|
-
const auto = classifyStory({
|
|
6283
|
-
type: extractType(autoLabels, autoBody, String(storyIdOrRunId)),
|
|
6284
|
-
has_security_implications: extractSecurityImplications(autoLabels, autoBody),
|
|
6285
|
-
cross_repo_dependency: extractCrossRepoDependency(autoBody),
|
|
6286
|
-
recently_modified_overlap: extractRecentlyModifiedOverlap(process.cwd(), touched, 14),
|
|
6287
|
-
});
|
|
6288
|
-
if (auto.architect.engage && auto.architect.axes.length > 0) {
|
|
6289
|
-
orchestrateConfig.classifier_axes = auto.architect.axes;
|
|
6290
|
-
console.log(` → auto-detected classifier axes: ${auto.architect.axes.join(', ')}`);
|
|
6291
|
-
}
|
|
6292
|
-
} catch (e) {
|
|
6293
|
-
console.warn(` ⚠ axes auto-detection failed (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
|
|
6294
|
-
}
|
|
6295
|
-
}
|
|
6296
|
-
|
|
6297
|
-
// HC-019n-followup-42: repository-structure grounding. Bundling FILES is not
|
|
6298
|
-
// grounding — with no tree in the prompt the groomer invents a layout and the
|
|
6299
|
-
// planner inherits it (dogfood run f276fe00). Stamp a compact, capped tree of
|
|
6300
|
-
// tracked paths (scoped to source_dirs/test_dirs when configured) into the run
|
|
6301
|
-
// config; the server renders it for the steps that name paths. Fail-open.
|
|
6302
|
-
try {
|
|
6303
|
-
const { execSync } = require('child_process');
|
|
6304
|
-
const { buildRepoTree } = require('./lib/repo-tree');
|
|
6305
|
-
const { readSourceDirs } = require('./lib/pipeline-config');
|
|
6306
|
-
const tracked = execSync('git ls-files', { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
|
|
6307
|
-
const dirs = readSourceDirs(process.cwd());
|
|
6308
|
-
// HC-DF-002: readSourceDirs self-heals a stale config; say so, or the operator sees a
|
|
6309
|
-
// confident success line built from directories that do not exist.
|
|
6310
|
-
if (dirs.repaired) {
|
|
6311
|
-
console.log(` ⚠ source dirs in .pipeline-config.yml not found on disk: ${dirs.dropped.join(', ')}`);
|
|
6312
|
-
console.log(` using ${dirs.detected ? 'detected' : 'the surviving'} dirs instead: ${dirs.source_dirs.join(', ') || '(none)'}`);
|
|
6313
|
-
console.log(' fix it permanently: hone detect-layout, then set codebase.source_dirs in .github/.pipeline-config.yml');
|
|
6314
|
-
}
|
|
6315
|
-
const tree = buildRepoTree(tracked, { sourceDirs: dirs.source_dirs, testDirs: dirs.test_dirs });
|
|
6316
|
-
if (tree.selected_total > 0) {
|
|
6317
|
-
orchestrateConfig.repo_tree = tree;
|
|
6318
|
-
const scope = dirs.source_dirs.length ? dirs.source_dirs.join(', ') : 'all tracked';
|
|
6319
|
-
console.log(` → repository structure: ${tree.shown} of ${tree.selected_total} paths under ${scope} (${(Buffer.byteLength(tree.text, 'utf8') / 1024).toFixed(1)} KB) — the groomer/planner/builder are told to derive every path from it`);
|
|
6320
|
-
}
|
|
6321
|
-
} catch (e) {
|
|
6322
|
-
console.warn(` ⚠ repository structure not bundled (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
|
|
6323
|
-
}
|
|
6324
|
-
|
|
6325
|
-
// HC-101-followup-2: pass the adopter's CI gate config to the
|
|
6326
|
-
// orchestrator so step_5c can branch (github / local / both / none).
|
|
6327
|
-
// Defaults to gate=github + local_command='make ci' when the config
|
|
6328
|
-
// is missing — backward-compat for adopters whose .pipeline-config.yml
|
|
6329
|
-
// predates this field.
|
|
6330
|
-
try {
|
|
6331
|
-
const { readCIGateConfig } = require('./lib/pipeline-config');
|
|
6332
|
-
const ciGate = readCIGateConfig(process.cwd());
|
|
6333
|
-
orchestrateConfig.ci_gate = ciGate.gate;
|
|
6334
|
-
orchestrateConfig.ci_local_command = ciGate.local_command;
|
|
6335
|
-
console.log(` → CI gate mode: ${ciGate.gate}${ciGate.gate !== 'github' ? ` (local_command: ${ciGate.local_command})` : ''}`);
|
|
6336
|
-
} catch (e) {
|
|
6337
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6338
|
-
console.warn(` ⚠ CI gate config read failed (non-fatal, defaulting to gate=github): ${msg}`);
|
|
6339
|
-
orchestrateConfig.ci_gate = 'github';
|
|
6340
|
-
orchestrateConfig.ci_local_command = 'make ci';
|
|
6341
|
-
}
|
|
6342
|
-
|
|
6343
|
-
// HC-COMM-010 B0.5 (memo §12 A3): stamp execution.mode into the run config
|
|
6344
|
-
// at CREATION — the server reads it from run.config, making the mode
|
|
6345
|
-
// immutable per run (a mid-flight .pipeline-config.yml flip cannot change
|
|
6346
|
-
// a running run). Absent/old CLIs → the server defaults to 'server'.
|
|
6347
|
-
// Inert until B1: the server ignores the key today.
|
|
6348
|
-
try {
|
|
6349
|
-
const { readExecutionMode, EDITOR_MODE_CONSENT_NOTE } = require('./lib/pipeline-config');
|
|
6350
|
-
const executionMode = readExecutionMode(process.cwd());
|
|
6351
|
-
orchestrateConfig.execution_mode = executionMode;
|
|
6352
|
-
if (executionMode === 'editor') {
|
|
6353
|
-
// §2a: the consent note is shown at the moment editor mode is USED.
|
|
6354
|
-
console.log(` → execution mode: editor (opt-in)`);
|
|
6355
|
-
console.log(` ${EDITOR_MODE_CONSENT_NOTE}`);
|
|
6356
|
-
}
|
|
6357
|
-
} catch (e) {
|
|
6358
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6359
|
-
console.warn(` ⚠ execution mode read failed (non-fatal, defaulting to server): ${msg}`);
|
|
6360
|
-
orchestrateConfig.execution_mode = 'server';
|
|
6361
|
-
}
|
|
6362
|
-
|
|
6363
|
-
// HC-COMM-010 C0: composition transports — INERT until C1 reads them.
|
|
6364
|
-
// Overlays: .hone-local/ is adopter disk the server can never read
|
|
6365
|
-
// (the cross-validation finding), so raw texts travel in run config —
|
|
6366
|
-
// the codebaseFiles pattern, size/count-capped, degrade-to-empty.
|
|
6367
|
-
// Axes: explicit --axes flag for now; automated wiring from classify
|
|
6368
|
-
// runs is future work (documented in the sprint plan).
|
|
6369
|
-
try {
|
|
6370
|
-
const { readRuleOverlays } = require('./lib/overlay-transport');
|
|
6371
|
-
const ov = readRuleOverlays(process.cwd());
|
|
6372
|
-
if (ov.overlays.length > 0) {
|
|
6373
|
-
orchestrateConfig.rule_overlays = ov.overlays;
|
|
6374
|
-
console.log(` → bundled ${ov.overlays.length} rule overlay(s) for server-side composition`);
|
|
6375
|
-
}
|
|
6376
|
-
for (const skip of ov.skipped) console.warn(` ⚠ overlay skipped: ${skip}`);
|
|
6377
|
-
} catch (e) {
|
|
6378
|
-
console.warn(` ⚠ overlay read failed (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
|
|
6379
|
-
}
|
|
6380
|
-
if (opts.axes) {
|
|
6381
|
-
const axes = String(opts.axes).split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
|
|
6382
|
-
if (axes.length > 0) orchestrateConfig.classifier_axes = axes;
|
|
6383
|
-
}
|
|
6384
|
-
|
|
6385
|
-
// HC-019n-followup-28: resolve the E2E spec-generation mode (step_3b /
|
|
6386
|
-
// Playwright). Precedence mirrors ci.gate: per-run flag > .pipeline-config.yml
|
|
6387
|
-
// `e2e.spec_generation` > default `auto`. `auto` = the orchestrator runs
|
|
6388
|
-
// step_3b when the story requires an E2E plan (step_0 emitted `yes`); `never`
|
|
6389
|
-
// = step_3b is always skipped. The runtime "is E2E required?" decision stays
|
|
6390
|
-
// server-side (checkE2eRequirement reads step_0's output) — the CLI only
|
|
6391
|
-
// passes the MODE.
|
|
6392
|
-
try {
|
|
6393
|
-
const { readE2eSpecMode } = require('./lib/pipeline-config');
|
|
6394
|
-
let e2eMode, source;
|
|
6395
|
-
if (opts.e2eSpecs) { e2eMode = 'auto'; source = '--e2e-specs'; }
|
|
6396
|
-
else if (opts.skipE2eSpecs) { e2eMode = 'never'; source = '--skip-e2e-specs'; }
|
|
6397
|
-
else { e2eMode = readE2eSpecMode(process.cwd()); source = 'config/default'; }
|
|
6398
|
-
orchestrateConfig.e2e_spec_generation = e2eMode;
|
|
6399
|
-
// Enable step_3b (conditional) unless the adopter opted out. The row is
|
|
6400
|
-
// then created; the orchestrator still skips it at runtime for a story that
|
|
6401
|
-
// does not require an E2E plan (HC-019n-followup-28).
|
|
6402
|
-
if (e2eMode !== 'never') {
|
|
6403
|
-
const ec = new Set(orchestrateConfig.enableConditional || []);
|
|
6404
|
-
ec.add('step_3b');
|
|
6405
|
-
orchestrateConfig.enableConditional = [...ec];
|
|
6406
|
-
}
|
|
6407
|
-
console.log(` → E2E spec generation (step_3b): ${e2eMode} (source: ${source})`);
|
|
6408
|
-
} catch (e) {
|
|
6409
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6410
|
-
console.warn(` ⚠ e2e spec-mode read failed (non-fatal, defaulting to auto): ${msg}`);
|
|
6411
|
-
orchestrateConfig.e2e_spec_generation = 'auto';
|
|
6412
|
-
}
|
|
6413
|
-
|
|
6414
|
-
// HC-019n-followup-34: thread the closed-loop verification gate mode from
|
|
6415
|
-
// .pipeline-config.yml. Advisory (default) = the server RECORDS verify-patch/
|
|
6416
|
-
// verify-pr verdicts reported via --report; enforce (follow-up) = a red
|
|
6417
|
-
// verdict blocks. Default-safe: never fatal.
|
|
6418
|
-
try {
|
|
6419
|
-
const { readVerificationGateConfig, readVerificationGateSources } = require('./lib/pipeline-config');
|
|
6420
|
-
orchestrateConfig.verification_gate = readVerificationGateConfig(process.cwd());
|
|
6421
|
-
// HC-COMM-011-followup-6: which verdict sources may DRIVE the step_4 gate under
|
|
6422
|
-
// enforce (default ['verify-patch']). An adopter adds 'agent-eval' to let a
|
|
6423
|
-
// green agent-eval verdict auto-advance a prompt change.
|
|
6424
|
-
orchestrateConfig.verification_gate_sources = readVerificationGateSources(process.cwd());
|
|
6425
|
-
if (orchestrateConfig.verification_gate !== 'advisory') {
|
|
6426
|
-
console.log(` → verification gate: ${orchestrateConfig.verification_gate} (sources: ${orchestrateConfig.verification_gate_sources.join(', ')})`);
|
|
6427
|
-
}
|
|
6428
|
-
} catch (e) {
|
|
6429
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6430
|
-
console.warn(` ⚠ verification-gate read failed (non-fatal, defaulting to advisory): ${msg}`);
|
|
6431
|
-
orchestrateConfig.verification_gate = 'advisory';
|
|
6432
|
-
orchestrateConfig.verification_gate_sources = ['verify-patch'];
|
|
6433
|
-
}
|
|
6434
|
-
|
|
6435
|
-
// HC-019b: read per-story architect flags from .github/EXECUTION_PLAN.yml
|
|
6436
|
-
// and plumb them into workflow_runs.config. The orchestrator's
|
|
6437
|
-
// validateStepPreConditions (server/src/services/workflow-dag.js:340)
|
|
6438
|
-
// BLOCKS step_1 when architect_consulted=true but checklist_b_completed=false.
|
|
6439
|
-
// Without this plumbing, the HC-019a flags written by the architect prompt
|
|
6440
|
-
// never reach the server and every architect-engaged story deadlocks.
|
|
6441
|
-
// Defaults to {false, false} when the file/story/config is missing —
|
|
6442
|
-
// i.e., assume the architect was not consulted (no block).
|
|
6443
|
-
//
|
|
6444
|
-
// Code-review F2/F3: malformed YAML or missing story entry was previously
|
|
6445
|
-
// silent. The helper now returns a `diagnostic` string for those cases;
|
|
6446
|
-
// we surface it as a console.warn so operators see the silent-bypass.
|
|
6447
|
-
// HC-019b-followup-1 F1: use the resolved canonical story-id (HC-NNN)
|
|
6448
|
-
// not the raw `storyIdOrRunId` which is the issue number when invoked
|
|
6449
|
-
// as `hone run-story 104`. The HC-019n-followup-11 block above sets
|
|
6450
|
-
// resolvedStoryId to the title-extracted id when it can.
|
|
6451
|
-
const { readArchitectConfig } = require('./lib/architect-config');
|
|
6452
|
-
const arch = readArchitectConfig(process.cwd(), resolvedStoryId);
|
|
6453
|
-
orchestrateConfig.architect_consulted = arch.architect_consulted;
|
|
6454
|
-
orchestrateConfig.checklist_b_completed = arch.checklist_b_completed;
|
|
6455
|
-
if (arch.diagnostic) console.warn(` ⚠ ${arch.diagnostic}`);
|
|
6456
|
-
if (arch.architect_consulted) {
|
|
6457
|
-
console.log(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`);
|
|
6458
|
-
}
|
|
6576
|
+
// HC-GF-097: the run config is built by cli/lib/run-context.js, the one assembler this
|
|
6577
|
+
// command and `queue-stories` share. It used to be assembled inline here, key by key, and
|
|
6578
|
+
// queue-stories was written beside it with two of the fourteen — so a queued story ran with
|
|
6579
|
+
// no story text, no bundle, no tree and the server's default CI gate. The module returns
|
|
6580
|
+
// what it decided as notes; they print in the order this command always printed them.
|
|
6581
|
+
const { resolveRepoContext, resolveStoryContext, buildRunConfig } = require('./lib/run-context');
|
|
6582
|
+
const rcIo = runContextIo(config);
|
|
6583
|
+
const repoCtx = resolveRepoContext(rcIo, { e2eSpecs: opts.e2eSpecs, skipE2eSpecs: opts.skipE2eSpecs });
|
|
6584
|
+
const storyCtx = await resolveStoryContext(rcIo, repoCtx, storyIdOrRunId, {
|
|
6585
|
+
explicitText: explicitStory,
|
|
6586
|
+
explicitLabel: opts.storyFile ? path.relative(process.cwd(), path.resolve(opts.storyFile)) : '--description',
|
|
6587
|
+
explicitSource: opts.storyFile ? { kind: 'file', label: path.basename(String(opts.storyFile)) } : { kind: 'description' },
|
|
6588
|
+
includePaths: opts.includePaths,
|
|
6589
|
+
axes: opts.axes,
|
|
6590
|
+
});
|
|
6591
|
+
printRunContextNotes(storyCtx.notes.pre);
|
|
6592
|
+
printRunContextNotes(repoCtx.notes);
|
|
6593
|
+
printRunContextNotes(storyCtx.notes.post);
|
|
6594
|
+
const orchestrateConfig = buildRunConfig(repoCtx, storyCtx);
|
|
6459
6595
|
|
|
6460
6596
|
try {
|
|
6461
6597
|
const { data } = await client.post('/orchestrate', {
|
|
@@ -6508,13 +6644,16 @@ program
|
|
|
6508
6644
|
.option('--list', 'List recent batches for this org')
|
|
6509
6645
|
.option('--limit <n>', 'Number of batches to list (default 20, max 100)', '20')
|
|
6510
6646
|
.option('--format <fmt>', 'Output format: pretty or json (with --report, also: md)', 'pretty')
|
|
6511
|
-
// HC-054: Night Shift mode.
|
|
6512
|
-
// when
|
|
6513
|
-
//
|
|
6514
|
-
//
|
|
6515
|
-
|
|
6516
|
-
.option('--overnight', 'Enable Night Shift mode (conditional auto-approve, longer timeouts, max 25 stories)', false)
|
|
6647
|
+
// HC-054: Night Shift mode. Extended LLM timeout (480s), orchestrator watchdog (900s/step), default token
|
|
6648
|
+
// budget of 400K × story_count when none is set, a cap of 25 stories, and an exhausted pause that fails the run
|
|
6649
|
+
// so its dependents are released (HC-GF-137b). step_4 and step_5 still pause: HC-GF-303 removed HC-054's
|
|
6650
|
+
// auto-approve on output text, which never ran. Approve overnight on evidence with `hone follow-batch`.
|
|
6651
|
+
.option('--overnight', 'Enable Night Shift mode (longer timeouts, max 25 stories; step_4/step_5 still pause for approval)', false)
|
|
6517
6652
|
.action(async (opts) => {
|
|
6653
|
+
refuseBadRunArgs([ // HC-GF-270-followup-1
|
|
6654
|
+
{ label: '--status', kind: 'batchId', value: opts.status, optional: true },
|
|
6655
|
+
{ label: '--report', kind: 'batchId', value: opts.report, optional: true },
|
|
6656
|
+
]);
|
|
6518
6657
|
const config = getConfig();
|
|
6519
6658
|
const client = api(config);
|
|
6520
6659
|
|
|
@@ -6559,7 +6698,7 @@ program
|
|
|
6559
6698
|
try {
|
|
6560
6699
|
const format = opts.format === 'json' ? 'json' : 'md';
|
|
6561
6700
|
const { data, headers } = await client.get(
|
|
6562
|
-
|
|
6701
|
+
orchestratePath(['batch', opts.report, 'report']),
|
|
6563
6702
|
{ params: { format }, responseType: 'text', transformResponse: [(d) => d] }
|
|
6564
6703
|
);
|
|
6565
6704
|
if (format === 'json') {
|
|
@@ -6610,7 +6749,7 @@ program
|
|
|
6610
6749
|
// ── --status <batchId> ────────────────────────────────────
|
|
6611
6750
|
if (opts.status) {
|
|
6612
6751
|
try {
|
|
6613
|
-
const { data } = await client.get(
|
|
6752
|
+
const { data } = await client.get(orchestratePath(['batch', opts.status]));
|
|
6614
6753
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
6615
6754
|
console.log('');
|
|
6616
6755
|
console.log(`Batch: ${data.batchId}`);
|
|
@@ -6686,98 +6825,103 @@ program
|
|
|
6686
6825
|
catch { branch = null; }
|
|
6687
6826
|
}
|
|
6688
6827
|
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
// `depends:` token is case-sensitive and must come AFTER the storyId.
|
|
6705
|
-
// Multiple deps separated by commas, whitespace tolerant. Lines without
|
|
6706
|
-
// `depends:` yield no `dependsOn` (server validates absence vs empty).
|
|
6707
|
-
// HC-019b: attach per-story `config: { architect_consulted, checklist_b_completed }`
|
|
6708
|
-
// \u2014 the server's createBatch (batch-store.js:218) spreads s.config into each
|
|
6709
|
-
// workflow_runs.config row, so this is the load-bearing plumbing for
|
|
6710
|
-
// validateStepPreConditions (workflow-dag.js:340) in batch mode.
|
|
6711
|
-
const stories = storyIds.map(line => {
|
|
6712
|
-
const depsMatch = line.match(/^(\S+)\s+depends:(\S+)\s*$/);
|
|
6713
|
-
let storyId, dependsOn;
|
|
6714
|
-
if (depsMatch) {
|
|
6715
|
-
const [, id, depsCsv] = depsMatch;
|
|
6716
|
-
storyId = id;
|
|
6717
|
-
dependsOn = depsCsv.split(',').map(s => s.trim()).filter(Boolean);
|
|
6718
|
-
} else {
|
|
6719
|
-
// Reject ambiguous lines (storyId followed by garbage) \u2014 better than
|
|
6720
|
-
// silently treating `STORY-A something` as just `STORY-A`.
|
|
6721
|
-
if (/\s/.test(line)) {
|
|
6722
|
-
console.error(`Malformed line in --file: "${line}"`);
|
|
6723
|
-
console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
|
|
6724
|
-
process.exit(1);
|
|
6725
|
-
}
|
|
6726
|
-
storyId = line;
|
|
6727
|
-
dependsOn = undefined;
|
|
6728
|
-
}
|
|
6729
|
-
const arch = readArchitectConfigFromText(planText, storyId);
|
|
6730
|
-
// Code-review F2/F3: surface silent-bypass diagnostics per story in
|
|
6731
|
-
// the batch path too. Multi-story batches with one bad plan line
|
|
6732
|
-
// would previously disable the contract for ALL stories silently.
|
|
6733
|
-
if (arch.diagnostic) console.warn(` ⚠ [${storyId}] ${arch.diagnostic}`);
|
|
6734
|
-
const obj = {
|
|
6735
|
-
storyId,
|
|
6736
|
-
repoName,
|
|
6737
|
-
branch,
|
|
6738
|
-
config: {
|
|
6739
|
-
architect_consulted: arch.architect_consulted,
|
|
6740
|
-
checklist_b_completed: arch.checklist_b_completed,
|
|
6741
|
-
},
|
|
6742
|
-
};
|
|
6743
|
-
if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
|
|
6744
|
-
return obj;
|
|
6828
|
+
const { parseStoryLine } = require('./lib/story-line');
|
|
6829
|
+
const {
|
|
6830
|
+
resolveRepoContext, resolveStoryContext, buildRunConfig, hasStoryText, describeMissingText, overnightModeRefusal,
|
|
6831
|
+
} = require('./lib/run-context');
|
|
6832
|
+
|
|
6833
|
+
// HC-059: `STORY-A depends:STORY-B,STORY-C` per line. HC-GF-140 stage 2: the parse lives in
|
|
6834
|
+
// cli/lib so it is asserted by BEHAVIOUR. Every line is parsed before anything is resolved.
|
|
6835
|
+
const lines = storyIds.map(line => {
|
|
6836
|
+
const parsed = parseStoryLine(line);
|
|
6837
|
+
if (parsed.error) {
|
|
6838
|
+
console.error(parsed.error);
|
|
6839
|
+
console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
|
|
6840
|
+
process.exit(1);
|
|
6841
|
+
}
|
|
6842
|
+
return parsed;
|
|
6745
6843
|
});
|
|
6746
6844
|
|
|
6747
|
-
// HC-
|
|
6748
|
-
// (
|
|
6749
|
-
//
|
|
6750
|
-
|
|
6751
|
-
|
|
6845
|
+
// HC-GF-097: each queued story gets the config `hone run-story` would build for it, from the
|
|
6846
|
+
// same module (cli/lib/run-context.js). This path used to send two architect flags per story,
|
|
6847
|
+
// so a queued story was groomed, planned and built from its id alone. The repository half is
|
|
6848
|
+
// computed once for the whole batch.
|
|
6849
|
+
const rcIo = runContextIo(config);
|
|
6850
|
+
// The byte cap on the tree's path lists is the batch's: its request limit is derived from it.
|
|
6851
|
+
const repoCtx = resolveRepoContext(rcIo, { treeByteCap: require('./lib/repo-tree').MAX_TRANSPORT_BYTES });
|
|
6852
|
+
|
|
6853
|
+
// Fail closed FIRST — before any story is resolved or anything is sent. An overnight batch
|
|
6854
|
+
// runs with nobody watching: an editor-mode repo would park every story at step_4, and an
|
|
6855
|
+
// unrecognised mode would run on a guess.
|
|
6752
6856
|
if (opts.overnight) {
|
|
6753
|
-
|
|
6857
|
+
const { OVERNIGHT_MAX_STORIES } = require('./lib/run-context');
|
|
6858
|
+
if (lines.length > OVERNIGHT_MAX_STORIES) {
|
|
6859
|
+
console.error(`✗ --overnight refused: ${lines.length} stories, over the ${OVERNIGHT_MAX_STORIES}-story overnight cap. ` +
|
|
6860
|
+
'Nothing was resolved or sent; split the file.');
|
|
6861
|
+
process.exit(1);
|
|
6862
|
+
}
|
|
6863
|
+
const refusal = overnightModeRefusal(repoCtx.execution);
|
|
6864
|
+
if (refusal) {
|
|
6865
|
+
console.error(refusal);
|
|
6866
|
+
process.exit(1);
|
|
6867
|
+
}
|
|
6754
6868
|
}
|
|
6869
|
+
printRunContextNotes(repoCtx.notes);
|
|
6755
6870
|
|
|
6756
|
-
//
|
|
6757
|
-
// in
|
|
6758
|
-
//
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
if (ciGate.gate !== 'github') {
|
|
6767
|
-
console.log(`CI gate mode for batch: ${ciGate.gate} (local_command: ${ciGate.local_command})`);
|
|
6871
|
+
// Resolved with bounded concurrency: each story can make a network call (its roadmap context,
|
|
6872
|
+
// or `gh issue view`), and fifty in series is minutes of waiting. The output is printed after
|
|
6873
|
+
// every story has resolved, in file order, so it reads exactly like a serial pass.
|
|
6874
|
+
const RESOLVE_CONCURRENCY = 5;
|
|
6875
|
+
const resolved = new Array(lines.length);
|
|
6876
|
+
let nextLine = 0;
|
|
6877
|
+
await Promise.all(Array.from({ length: Math.min(RESOLVE_CONCURRENCY, lines.length) }, async () => {
|
|
6878
|
+
while (nextLine < lines.length) {
|
|
6879
|
+
const i = nextLine++;
|
|
6880
|
+
resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId);
|
|
6768
6881
|
}
|
|
6769
|
-
}
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6882
|
+
}));
|
|
6883
|
+
// One summary line per story, and every warning labelled with its story. The info lines
|
|
6884
|
+
// run-story prints for one run would be a dozen per story here.
|
|
6885
|
+
for (const ctx of resolved) {
|
|
6886
|
+
const { storyId } = ctx;
|
|
6887
|
+
const src = ctx.textSource ? `${ctx.textSource} text (${ctx.rawText.length} chars)` : 'no story text';
|
|
6888
|
+
console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled`);
|
|
6889
|
+
printRunContextNotes(ctx.notes.pre, { prefix: `[${storyId}]`, only: 'warn' });
|
|
6890
|
+
printRunContextNotes(ctx.notes.post, { prefix: `[${storyId}]`, only: 'warn' });
|
|
6891
|
+
}
|
|
6892
|
+
|
|
6893
|
+
// HC-GF-097: a story with no text grooms to placeholders. run-story warns and proceeds for a
|
|
6894
|
+
// run someone is watching; a batch refuses — naming EVERY such story — before anything is
|
|
6895
|
+
// queued. Judged on the raw text, never the composed description.
|
|
6896
|
+
const missing = resolved.filter(ctx => !hasStoryText(ctx));
|
|
6897
|
+
if (missing.length > 0) {
|
|
6898
|
+
console.error(`✗ ${missing.length} of ${resolved.length} ${resolved.length === 1 ? 'story' : 'stories'} ` +
|
|
6899
|
+
`${missing.length === 1 ? 'has' : 'have'} no story text, so nothing was queued:`);
|
|
6900
|
+
for (const ctx of missing) console.error(` ${describeMissingText(ctx)}`);
|
|
6901
|
+
console.error('Give each one text (a roadmap row or a GitHub issue number), or remove it from the file.');
|
|
6902
|
+
process.exit(1);
|
|
6777
6903
|
}
|
|
6778
6904
|
|
|
6905
|
+
const stories = lines.map(({ storyId, dependsOn }, i) => {
|
|
6906
|
+
const obj = { storyId, repoName, branch, config: buildRunConfig(repoCtx, resolved[i]) };
|
|
6907
|
+
if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
|
|
6908
|
+
return obj;
|
|
6909
|
+
});
|
|
6910
|
+
|
|
6911
|
+
// HC-054: Night Shift. The flag lives at batch level only; the server caps an overnight batch
|
|
6912
|
+
// at 25 and denormalizes the flag into each child itself.
|
|
6913
|
+
const body = { stories, config: {} };
|
|
6914
|
+
if (opts.overnight) body.config.overnight = true;
|
|
6915
|
+
// HC-101-followup-2: the CI gate also travels at batch level. Each story already carries its
|
|
6916
|
+
// own; a server from HC-GF-097 on fills the batch value into any story that did not.
|
|
6917
|
+
body.config.ci_gate = repoCtx.config.ci_gate;
|
|
6918
|
+
body.config.ci_local_command = repoCtx.config.ci_local_command;
|
|
6919
|
+
|
|
6920
|
+
// Sized to the body: the client's 30 s default covered the upload (review of #1100).
|
|
6921
|
+
const bodyBytes = Buffer.byteLength(JSON.stringify(body), 'utf8');
|
|
6922
|
+
const postTimeoutMs = require('./lib/run-context').batchPostTimeoutMs(bodyBytes);
|
|
6779
6923
|
try {
|
|
6780
|
-
const { data } = await client.post('/orchestrate/batch', body);
|
|
6924
|
+
const { data } = await client.post('/orchestrate/batch', body, { timeout: postTimeoutMs });
|
|
6781
6925
|
|
|
6782
6926
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
6783
6927
|
|
|
@@ -6797,6 +6941,25 @@ program
|
|
|
6797
6941
|
console.error(`Org concurrency limit reached: ${e.response.data.error}`);
|
|
6798
6942
|
} else if (e.response?.status === 400) {
|
|
6799
6943
|
console.error(`Rejected: ${e.response.data.error}`);
|
|
6944
|
+
} else if (e.response?.status === 413) {
|
|
6945
|
+
// HC-GF-097: full per-story context can outgrow the server's request limit. Say how big,
|
|
6946
|
+
// which stories, and what to do — not a bare "request entity too large".
|
|
6947
|
+
const total = Buffer.byteLength(JSON.stringify(body), 'utf8');
|
|
6948
|
+
console.error(`Rejected: the batch is ${(total / 1048576).toFixed(1)} MB, over the server's request limit (413).`);
|
|
6949
|
+
console.error('Largest stories by run-config size:');
|
|
6950
|
+
const sizes = stories.map(s => ({ id: s.storyId, bytes: Buffer.byteLength(JSON.stringify(s.config), 'utf8') }))
|
|
6951
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
6952
|
+
for (const s of sizes.slice(0, 10)) console.error(` ${s.id} ${(s.bytes / 1024).toFixed(0)} KB`);
|
|
6953
|
+
console.error('Split the file into smaller batches, or narrow codebase.source_dirs so each story carries less.');
|
|
6954
|
+
} else if (e.response?.status === 503) {
|
|
6955
|
+
// HC-GF-097 review: the server admits a few batch submissions at a time; nothing was queued.
|
|
6956
|
+
console.error(`Server busy, nothing was queued: ${e.response.data?.error || 'retry shortly'}`);
|
|
6957
|
+
} else if (!e.response && (e.code === 'ECONNABORTED' || /timeout/i.test(e.message || ''))) {
|
|
6958
|
+
// The body may have arrived and been committed before the timer fired. Re-running blind
|
|
6959
|
+
// would queue the same stories twice, so say so and point at the list.
|
|
6960
|
+
console.error(`Timed out after ${Math.round(postTimeoutMs / 1000)}s sending a ${(bodyBytes / 1048576).toFixed(1)} MB batch.`);
|
|
6961
|
+
console.error('The server may have queued it anyway. Check before re-running, or the stories will be queued twice:');
|
|
6962
|
+
console.error(' hone queue-stories --list');
|
|
6800
6963
|
} else {
|
|
6801
6964
|
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
6802
6965
|
}
|
|
@@ -6804,71 +6967,6 @@ program
|
|
|
6804
6967
|
}
|
|
6805
6968
|
});
|
|
6806
6969
|
|
|
6807
|
-
// ── HC-054g: Night-shift retroactive revert command ─────────────────────────
|
|
6808
|
-
//
|
|
6809
|
-
// Two-step operator workflow after rejected_rate drift (HC-054c) flags a
|
|
6810
|
-
// batch of bad overnight auto-approves:
|
|
6811
|
-
//
|
|
6812
|
-
// 1. `hone night-shift revert <runId> --step-key <key> --revert-pr <url>`
|
|
6813
|
-
// 2. CLI calls POST /night-shift/runs/:runId/retroactive-reject (if not yet)
|
|
6814
|
-
// then POST /night-shift/runs/:runId/revert with the revert PR URL.
|
|
6815
|
-
//
|
|
6816
|
-
// We deliberately don't take repo write access; the operator runs the git
|
|
6817
|
-
// revert themselves and passes the resulting revert-PR URL. This keeps the
|
|
6818
|
-
// server side free of GitHub credentials + repo-specific permissions while
|
|
6819
|
-
// still giving the audit log the revert provenance.
|
|
6820
|
-
const nightShiftCmd = program.command('night-shift').description('HC-054 night-shift audit + revert workflow');
|
|
6821
|
-
nightShiftCmd
|
|
6822
|
-
.command('revert <runId>')
|
|
6823
|
-
.description('Record a revert action for a retroactively-rejected overnight auto-approve')
|
|
6824
|
-
.requiredOption('--step-key <stepKey>', 'The step_key whose auto-approve produced the bad output (e.g. step_4 or step_5)')
|
|
6825
|
-
.requiredOption('--revert-pr <url>', 'GitHub PR URL of the revert commit (https://github.com/.../pull/<n>)')
|
|
6826
|
-
.option('--reason <reason>', 'Free-text reason for the retroactive rejection (recorded with the audit row)')
|
|
6827
|
-
.action(async (runId, opts) => {
|
|
6828
|
-
const config = getConfig();
|
|
6829
|
-
const client = api(config);
|
|
6830
|
-
try {
|
|
6831
|
-
// Step 1: retroactively-reject if not yet rejected. The endpoint
|
|
6832
|
-
// returns 409 if already rejected — we treat that as a no-op
|
|
6833
|
-
// (operator may have done step 1 yesterday, run revert today).
|
|
6834
|
-
try {
|
|
6835
|
-
await client.post(`/night-shift/runs/${runId}/retroactive-reject`, {
|
|
6836
|
-
stepKey: opts.stepKey,
|
|
6837
|
-
reason: opts.reason,
|
|
6838
|
-
});
|
|
6839
|
-
console.log(`[night-shift] retroactively rejected (runId=${runId}, stepKey=${opts.stepKey})`);
|
|
6840
|
-
} catch (e) {
|
|
6841
|
-
const status = e.response?.status;
|
|
6842
|
-
if (status === 409) {
|
|
6843
|
-
console.log(`[night-shift] already retroactively rejected (continuing to revert step)`);
|
|
6844
|
-
} else if (status === 404) {
|
|
6845
|
-
console.error(`hone night-shift revert failed: no auto-approve audit row for (runId=${runId}, stepKey=${opts.stepKey})`);
|
|
6846
|
-
process.exit(1);
|
|
6847
|
-
} else {
|
|
6848
|
-
throw e;
|
|
6849
|
-
}
|
|
6850
|
-
}
|
|
6851
|
-
|
|
6852
|
-
// Step 2: record the revert PR URL.
|
|
6853
|
-
const r = await client.post(`/night-shift/runs/${runId}/revert`, {
|
|
6854
|
-
stepKey: opts.stepKey,
|
|
6855
|
-
revertPrUrl: opts.revertPr,
|
|
6856
|
-
});
|
|
6857
|
-
console.log(`[night-shift] revert recorded:`);
|
|
6858
|
-
console.log(` runId: ${r.data.runId}`);
|
|
6859
|
-
console.log(` stepKey: ${r.data.stepKey}`);
|
|
6860
|
-
console.log(` revertInitiatedAt: ${r.data.revertInitiatedAt}`);
|
|
6861
|
-
console.log(` revertPrUrl: ${r.data.revertPrUrl}`);
|
|
6862
|
-
} catch (e) {
|
|
6863
|
-
const msg = e.response?.data?.error || e.message;
|
|
6864
|
-
console.error(`hone night-shift revert failed: ${msg}`);
|
|
6865
|
-
if (e.response?.data?.remediation) {
|
|
6866
|
-
console.error(`Remediation: ${e.response.data.remediation}`);
|
|
6867
|
-
}
|
|
6868
|
-
process.exit(1);
|
|
6869
|
-
}
|
|
6870
|
-
});
|
|
6871
|
-
|
|
6872
6970
|
// ── HC-056: Schedule install (GitHub Actions overnight template) ────────────
|
|
6873
6971
|
//
|
|
6874
6972
|
// Installs a parameterized .github/workflows/<name>.yml that runs `hone
|
|
@@ -7434,13 +7532,16 @@ program
|
|
|
7434
7532
|
const responseForCache = parsed && typeof parsed === 'object'
|
|
7435
7533
|
? parsed
|
|
7436
7534
|
: { raw: responseText };
|
|
7437
|
-
storeReviewCache({
|
|
7535
|
+
await storeReviewCache({
|
|
7438
7536
|
axios, apiBase, token,
|
|
7439
7537
|
contentHash: cacheContentHash,
|
|
7440
7538
|
model: modelForCache,
|
|
7441
7539
|
response: responseForCache,
|
|
7442
7540
|
tokensSaved: inputTokens + outputTokens,
|
|
7443
7541
|
banner,
|
|
7542
|
+
// HC-GF-260: awaited. The next thing this command does is compute an exit code and
|
|
7543
|
+
// leave, so a detached POST can lose the entry the cache exists to save — the same shape
|
|
7544
|
+
// as HC-GF-250, where a process returned before its write landed.
|
|
7444
7545
|
}).then(({ stored }) => {
|
|
7445
7546
|
if (stored) banner(`✓ Cache stored: ${cacheContentHash.slice(0, 16)}…`);
|
|
7446
7547
|
}).catch(() => { /* logged inside helper */ });
|
|
@@ -7508,13 +7609,15 @@ showCmd
|
|
|
7508
7609
|
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7509
7610
|
.option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
|
|
7510
7611
|
.action(async (workflowId, stepKey, opts) => {
|
|
7612
|
+
refuseBadRunArgs([ // HC-GF-270-followup-1
|
|
7613
|
+
{ label: '<workflowId>', kind: 'runId', value: workflowId },
|
|
7614
|
+
{ label: '<stepKey>', kind: 'stepKey', value: stepKey },
|
|
7615
|
+
{ label: '--attempt', kind: 'attempt', value: opts.attempt, optional: true },
|
|
7616
|
+
]);
|
|
7511
7617
|
const config = getConfig();
|
|
7512
7618
|
const client = api(config);
|
|
7513
7619
|
try {
|
|
7514
|
-
const
|
|
7515
|
-
if (opts.attempt) q.push(`attempt=${opts.attempt}`);
|
|
7516
|
-
if (opts.prompt) q.push('prompt=1');
|
|
7517
|
-
const url = `/orchestrate/${workflowId}/step/${stepKey}${q.length ? `?${q.join('&')}` : ''}`;
|
|
7620
|
+
const url = orchestratePath([workflowId, 'step', stepKey], { attempt: opts.attempt, prompt: opts.prompt ? '1' : undefined });
|
|
7518
7621
|
const r = await client.get(url);
|
|
7519
7622
|
const s = r.data;
|
|
7520
7623
|
console.log(`── step output ──────────────────────────────────────────`);
|
|
@@ -7571,6 +7674,7 @@ program
|
|
|
7571
7674
|
.option('--attempt <n>', 'Specific attempt number (default: latest)')
|
|
7572
7675
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7573
7676
|
.action(async (workflowId, opts) => {
|
|
7677
|
+
refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270-followup-1
|
|
7574
7678
|
const { execFileSync } = require('child_process');
|
|
7575
7679
|
const os = require('os');
|
|
7576
7680
|
const { extractDiff, interpretApplyCheck } = require('./lib/patch-apply');
|
|
@@ -7586,8 +7690,7 @@ program
|
|
|
7586
7690
|
|
|
7587
7691
|
let output;
|
|
7588
7692
|
try {
|
|
7589
|
-
const url =
|
|
7590
|
-
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7693
|
+
const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
|
|
7591
7694
|
const r = await client.get(url);
|
|
7592
7695
|
output = r.data?.output;
|
|
7593
7696
|
} catch (e) {
|
|
@@ -7603,6 +7706,9 @@ program
|
|
|
7603
7706
|
// (followup-20) for stories still on the old ## Patch contract.
|
|
7604
7707
|
let diff, source, noChanges;
|
|
7605
7708
|
const blocks = extractFileBlocks(output);
|
|
7709
|
+
// HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
|
|
7710
|
+
// silently re-scoping is how a finished build came back as "no file changes to verify".
|
|
7711
|
+
if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
|
|
7606
7712
|
if (blocks.noChanges) {
|
|
7607
7713
|
emit({ ok: true, verdict: 'no_changes' },
|
|
7608
7714
|
`○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
|
|
@@ -7647,13 +7753,15 @@ program
|
|
|
7647
7753
|
|
|
7648
7754
|
// Write to a temp file and run git apply --check against HEAD. HC-GF-077: against an index read from HEAD,
|
|
7649
7755
|
// not the working copy — an editor's uncommitted edits are exactly what emit-pr's branch will NOT contain.
|
|
7650
|
-
|
|
7756
|
+
// HC-GF-304: a directory only this invocation created, not a name built from the clock.
|
|
7757
|
+
const tmpFile = require('./lib/private-temp').privateTempFile('hone-check-patch', 'patch.diff');
|
|
7758
|
+
const tmp = tmpFile.file;
|
|
7651
7759
|
let result;
|
|
7652
7760
|
try {
|
|
7653
7761
|
fs.writeFileSync(tmp, diff);
|
|
7654
7762
|
let code = 0, stderr = '';
|
|
7655
7763
|
try {
|
|
7656
|
-
const index = path.join(
|
|
7764
|
+
const index = path.join(path.dirname(tmp), 'index'); // HC-GF-304: inside this invocation's private directory
|
|
7657
7765
|
try {
|
|
7658
7766
|
try {
|
|
7659
7767
|
execFileSync('git', ['read-tree', `--index-output=${index}`, 'HEAD'], { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -7670,7 +7778,7 @@ program
|
|
|
7670
7778
|
}
|
|
7671
7779
|
result = interpretApplyCheck({ code, stderr });
|
|
7672
7780
|
} finally {
|
|
7673
|
-
|
|
7781
|
+
tmpFile.remove();
|
|
7674
7782
|
}
|
|
7675
7783
|
|
|
7676
7784
|
if (result.applies) {
|
|
@@ -7703,15 +7811,29 @@ program
|
|
|
7703
7811
|
.option('--keep', 'Leave the worktree in place for debugging')
|
|
7704
7812
|
.option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
|
|
7705
7813
|
.option('--report', 'HC-019n-followup-34: report the verdict back to the server (closed loop, advisory)')
|
|
7814
|
+
.option('--trust-source', 'HC-GF-270: run a patch whose story text came from a source Hone does not trust (you have read the diff)')
|
|
7706
7815
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7707
7816
|
.action(async (workflowId, opts) => {
|
|
7817
|
+
refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
|
|
7708
7818
|
const { execFileSync, spawnSync } = require('child_process');
|
|
7819
|
+
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
7820
|
+
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
7821
|
+
// deep in child_process, and a blank value coerces to 0 and kills the
|
|
7822
|
+
// command the instant it starts.
|
|
7823
|
+
let timeoutMs;
|
|
7824
|
+
try {
|
|
7825
|
+
timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
|
|
7826
|
+
} catch (e) {
|
|
7827
|
+
console.error(e.message);
|
|
7828
|
+
process.exit(2);
|
|
7829
|
+
}
|
|
7709
7830
|
const os = require('os');
|
|
7710
7831
|
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7711
7832
|
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
|
|
7712
7833
|
const { gitEnv } = require('./lib/git-env');
|
|
7713
7834
|
const config = getConfig();
|
|
7714
7835
|
const client = api(config);
|
|
7836
|
+
const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
|
|
7715
7837
|
|
|
7716
7838
|
const emit = (obj, humanLine) => {
|
|
7717
7839
|
if (opts.format === 'json') console.log(JSON.stringify(obj));
|
|
@@ -7722,19 +7844,30 @@ program
|
|
|
7722
7844
|
// HC-GF-074: the attempt this run verified — reported with the verdict below.
|
|
7723
7845
|
let verifiedAttempt;
|
|
7724
7846
|
let output, runConfig;
|
|
7847
|
+
// HC-GF-270: the id shape is the legacy provenance rule's only input, and a FAILED run
|
|
7848
|
+
// fetch is untrusted regardless of it — the `catch { runConfig = {} }` below is the
|
|
7849
|
+
// fail-open this gate exists to close.
|
|
7850
|
+
let runStoryId = null;
|
|
7851
|
+
let runFetchFailed = false;
|
|
7725
7852
|
try {
|
|
7726
|
-
const url =
|
|
7727
|
-
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7853
|
+
const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
|
|
7728
7854
|
const r = await client.get(url);
|
|
7855
|
+
// The step output must be THIS run's: nothing else ties the patch to the config assessed below.
|
|
7856
|
+
if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
|
|
7729
7857
|
output = r.data?.output;
|
|
7730
7858
|
// HC-GF-074: remember WHICH attempt this is. Without --attempt the server returns the latest, and the verdict
|
|
7731
7859
|
// used to be reported with `attempt: null` — so no verdict on record could say which build it judged.
|
|
7732
7860
|
verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
|
|
7733
7861
|
// best-effort: pull the run's ci_local_command for the default
|
|
7734
7862
|
try {
|
|
7735
|
-
const runR = await client.get(
|
|
7863
|
+
const runR = await client.get(orchestratePath([workflowId]));
|
|
7864
|
+
// HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
|
|
7865
|
+
// `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
|
|
7866
|
+
// 200 with no config. And a body that is not THIS run is not this run's config.
|
|
7867
|
+
if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
|
|
7736
7868
|
runConfig = runR.data?.config || {};
|
|
7737
|
-
|
|
7869
|
+
runStoryId = runR.data?.storyId || runR.data?.story_id || null;
|
|
7870
|
+
} catch (e) { runConfig = {}; runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; }
|
|
7738
7871
|
} catch (e) {
|
|
7739
7872
|
const msg = e.response?.data?.error || e.message;
|
|
7740
7873
|
emit({ ok: false, reason: 'fetch_failed', detail: msg },
|
|
@@ -7742,12 +7875,58 @@ program
|
|
|
7742
7875
|
process.exit(EXIT.NO_DIFF);
|
|
7743
7876
|
}
|
|
7744
7877
|
|
|
7878
|
+
// ── HC-GF-270: the provenance gate ────────────────────────────────────────────────────
|
|
7879
|
+
//
|
|
7880
|
+
// HERE, and not one line later. Telling the operator at the poll loop is not enough: an
|
|
7881
|
+
// MCP agent reads the same untrusted issue text and can call `hone_verify_patch` itself
|
|
7882
|
+
// (cli/lib/mcp-tools.js), and a scripted `--approve` takes the same path. This runs
|
|
7883
|
+
// BEFORE the diff is materialized, before a worktree exists, and before provisioning
|
|
7884
|
+
// runs the PATCHED manifest's install as the operator — by the time `planProvision`
|
|
7885
|
+
// sees it, the code is already on disk, which is why worktree-provision.js is not the
|
|
7886
|
+
// layer for this.
|
|
7887
|
+
{
|
|
7888
|
+
let assessment;
|
|
7889
|
+
try {
|
|
7890
|
+
const { assessStorySource } = require('./lib/story-provenance');
|
|
7891
|
+
assessment = runFetchFailed
|
|
7892
|
+
? { trusted: false, label: 'run-fetch-failed',
|
|
7893
|
+
reason: runFetchFailed === 'mismatch'
|
|
7894
|
+
? 'the server answered for a different run, so this run\'s config was never read'
|
|
7895
|
+
: 'this run\'s config could not be read, so nothing is known about where its story text came from' }
|
|
7896
|
+
: assessStorySource(runConfig?.story_source, runStoryId);
|
|
7897
|
+
} catch (e) {
|
|
7898
|
+
assessment = { trusted: false, label: 'assessment-failed',
|
|
7899
|
+
reason: `story provenance could not be assessed: ${e.message}` };
|
|
7900
|
+
}
|
|
7901
|
+
if (!assessment.trusted && !opts.trustSource) {
|
|
7902
|
+
// NO --report on this path, deliberately. A non-pass verdict on record makes the
|
|
7903
|
+
// server refuse the human's later approval under `enforce`
|
|
7904
|
+
// (server/src/services/verification-refusal.js), and nothing was verified here —
|
|
7905
|
+
// this is a check that did not RUN, which is a third outcome, not a failure.
|
|
7906
|
+
emit({ ok: false, reason: 'untrusted_source', verdict: 'not_run', detail: assessment.reason },
|
|
7907
|
+
`✗ refusing to verify: ${assessment.reason}\n`
|
|
7908
|
+
+ ' Verifying runs this patch — the provisioning install first — as you, with your $HOME and network.\n'
|
|
7909
|
+
+ ` Read it: hone show step ${workflowId} ${opts.stepKey}\n`
|
|
7910
|
+
+ ` Then: hone verify-patch ${workflowId} --report --trust-source`);
|
|
7911
|
+
process.exit(EXIT.UNTRUSTED_SOURCE);
|
|
7912
|
+
}
|
|
7913
|
+
}
|
|
7914
|
+
|
|
7745
7915
|
// 2. Materialize the diff (shared with check-patch/emit-pr, followup-23/27).
|
|
7746
7916
|
// Condition 6 presupposes 5.
|
|
7747
7917
|
const blocks = extractFileBlocks(output);
|
|
7918
|
+
// HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
|
|
7919
|
+
// silently re-scoping is how a finished build came back as "no file changes to verify".
|
|
7920
|
+
if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
|
|
7748
7921
|
const hasWork = hasMaterializableWork(blocks);
|
|
7749
7922
|
if (blocks.noChanges || !hasWork) {
|
|
7750
|
-
|
|
7923
|
+
// HC-GF-190: this exit precedes planSandboxRun entirely, so a stateful story with a declared
|
|
7924
|
+
// sandbox produces NONE of the planner's three outcomes and, before this line, said nothing
|
|
7925
|
+
// about it — run 2a9b2af5's verdict was indistinguishable from an ordinary empty build.
|
|
7926
|
+
// `not reached` is a fourth outcome, not a variant of the stand-down.
|
|
7927
|
+
const missed = describeSandboxDisposition(runConfig);
|
|
7928
|
+
if (missed && opts.format !== 'json') console.warn(` ⚠ ${missed}`);
|
|
7929
|
+
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff', sandbox: missed || undefined },
|
|
7751
7930
|
`✗ ${opts.stepKey} produced no file changes to verify.`);
|
|
7752
7931
|
process.exit(EXIT.NO_DIFF);
|
|
7753
7932
|
}
|
|
@@ -7770,16 +7949,19 @@ program
|
|
|
7770
7949
|
|
|
7771
7950
|
// 3. Isolated worktree from HEAD.
|
|
7772
7951
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
|
|
7773
|
-
|
|
7952
|
+
// HC-GF-304: a directory only this invocation created. A clock-named file was shared by two verbs started in
|
|
7953
|
+
// the same millisecond, and one of them verified the other's patch.
|
|
7954
|
+
const diffTmp = require('./lib/private-temp').privateTempFile('hone-verify-diff', 'patch.diff');
|
|
7955
|
+
const diffFile = diffTmp.file;
|
|
7774
7956
|
fs.writeFileSync(diffFile, diff);
|
|
7775
|
-
const { command, source: commandSource } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess
|
|
7957
|
+
const { command, source: commandSource, replaced: commandReplaced } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess; HC-GF-223: `replaced` says what an override skipped
|
|
7776
7958
|
let cleanupDone = false;
|
|
7777
7959
|
const cleanup = () => {
|
|
7778
7960
|
if (cleanupDone || opts.keep) return;
|
|
7779
7961
|
cleanupDone = true;
|
|
7780
7962
|
try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
|
|
7781
7963
|
try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
7782
|
-
|
|
7964
|
+
diffTmp.remove();
|
|
7783
7965
|
};
|
|
7784
7966
|
// Guard against leaving a worktree if the process is interrupted.
|
|
7785
7967
|
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
@@ -7795,6 +7977,9 @@ program
|
|
|
7795
7977
|
// HC-GF-026: measured verdicts for the gates step_4 asserts. Hoisted so they
|
|
7796
7978
|
// survive the try/finally to the --report POST, exactly like reportVerdict.
|
|
7797
7979
|
const reportGates = [];
|
|
7980
|
+
// HC-GF-171: hoisted for the same reason reportVerdict is — the teardown must survive the
|
|
7981
|
+
// try/finally. Seeded rows outliving a run is the worst outcome this feature can produce.
|
|
7982
|
+
let sandboxTeardown = [];
|
|
7798
7983
|
const ciCfg = require('./lib/pipeline-config').readCIConfigDetails(process.cwd());
|
|
7799
7984
|
try { headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
|
|
7800
7985
|
try {
|
|
@@ -7819,24 +8004,172 @@ program
|
|
|
7819
8004
|
// HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
|
|
7820
8005
|
// (link the repo's node_modules when the lockfile is unchanged, else run the
|
|
7821
8006
|
// lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
|
|
8007
|
+
// HC-GF-201: declared OUTSIDE the guard — the verdict needs provisioning state,
|
|
8008
|
+
// and with --no-provision there is none, which is not the same as a failure.
|
|
8009
|
+
let provAll = null;
|
|
7822
8010
|
if (opts.provision !== false) {
|
|
7823
|
-
const { planProvision,
|
|
8011
|
+
const { planProvision, discoverPackageRoots, provisionAllRoots } = require('./lib/worktree-provision');
|
|
7824
8012
|
const { readCIConfigDetails } = require('./lib/pipeline-config');
|
|
7825
|
-
|
|
7826
|
-
|
|
8013
|
+
// HC-GF-201: provision EVERY package root, not just the worktree's top level.
|
|
8014
|
+
// This repo has three; the two skipped are where ci.unit runs, so their absent
|
|
8015
|
+
// modules surfaced as `Cannot find module` and were counted as the PATCH failing
|
|
8016
|
+
// tests — a false RED on the merge-gating verdict (run b3117bde, 2026-09-20).
|
|
8017
|
+
const installCmd = readCIConfigDetails(process.cwd()).install_command;
|
|
8018
|
+
const provEntries = discoverPackageRoots(wt).map((root) => ({
|
|
8019
|
+
root,
|
|
8020
|
+
worktreeDir: root === '.' ? wt : path.join(wt, root),
|
|
8021
|
+
exec: spawnSync,
|
|
8022
|
+
plan: planProvision({
|
|
8023
|
+
worktreeDir: root === '.' ? wt : path.join(wt, root),
|
|
8024
|
+
parentDir: root === '.' ? process.cwd() : path.join(process.cwd(), root),
|
|
8025
|
+
installCommand: root === '.' ? installCmd : undefined,
|
|
8026
|
+
}),
|
|
8027
|
+
}));
|
|
8028
|
+
provAll = provisionAllRoots(provEntries);
|
|
8029
|
+
const prov = { ok: provAll.ok,
|
|
8030
|
+
summary: provAll.results.map((r) => `${r.root}: ${r.summary}`).join('; ')
|
|
8031
|
+
+ (provAll.ok ? '' : ` — NOT RUN: ${provAll.unprovisioned.map((u) => u.root).join(', ')} could not be provisioned`) };
|
|
7827
8032
|
if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
|
|
7828
8033
|
}
|
|
8034
|
+
// ── HC-GF-171: seed the adopter-declared sandbox before the tests run ────────────────
|
|
8035
|
+
// HC-GF-168/169/170/173 each shipped a piece and a run still touched no datastore, because
|
|
8036
|
+
// `assertScratchTarget` had no caller. This is that caller. It runs adopter-side, with the
|
|
8037
|
+
// adopter's credentials, exactly where `ci.local_command` already runs.
|
|
8038
|
+
//
|
|
8039
|
+
// REFUSE, NEVER SKIP. A misconfigured sandbox stops verification with a message naming the
|
|
8040
|
+
// fix; it does not quietly fall through to "tests passed", which would report a green
|
|
8041
|
+
// verdict for code whose stored-state behaviour was never exercised.
|
|
8042
|
+
try {
|
|
8043
|
+
const { readSandboxConfig, assertScratchTarget } = require('./lib/pipeline-config');
|
|
8044
|
+
const { planSandboxRun, parseProbeOutput, describeProbeResult, sandboxSuiteVerdict } = require('./lib/sandbox-lifecycle');
|
|
8045
|
+
const plan = planSandboxRun({
|
|
8046
|
+
sandbox: readSandboxConfig(process.cwd()),
|
|
8047
|
+
axes: runConfig?.classifier_axes || runConfig?.axes || [],
|
|
8048
|
+
capability: runConfig?.capability || null,
|
|
8049
|
+
});
|
|
8050
|
+
if (plan.refuse) {
|
|
8051
|
+
emit({ ok: false, verdict: 'sandbox_refused', detail: plan.reason },
|
|
8052
|
+
`✗ sandbox refused: ${plan.reason}`);
|
|
8053
|
+
exitCode = EXIT.FAIL;
|
|
8054
|
+
reportVerdict = 'sandbox_refused'; reportDetail = plan.reason;
|
|
8055
|
+
throw new Error('sandbox refused');
|
|
8056
|
+
}
|
|
8057
|
+
if (plan.engage) {
|
|
8058
|
+
// The probe runs in the REAL tree, not the worktree: it reads the adopter's live
|
|
8059
|
+
// datastore, which the worktree does not change.
|
|
8060
|
+
const probed = spawnSync(plan.probe, {
|
|
8061
|
+
cwd: process.cwd(), shell: true, encoding: 'utf8',
|
|
8062
|
+
timeout: 60_000, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'],
|
|
8063
|
+
});
|
|
8064
|
+
// A probe that exited non-zero is not evidence, whatever it left on stdout — a
|
|
8065
|
+
// command that fails partway can still print a stale or partial line, and reading it
|
|
8066
|
+
// would turn a failed check into a pass.
|
|
8067
|
+
if (probed.status !== 0) {
|
|
8068
|
+
throw new Error(`sandbox probe exited ${probed.status === null ? 'abnormally' : probed.status}`
|
|
8069
|
+
+ ` (${plan.probe}): ${((probed.stderr || '').split('\n')[0] || 'no stderr')}`);
|
|
8070
|
+
}
|
|
8071
|
+
// The guard reads the LIVE connection and nothing else (HC-GF-169).
|
|
8072
|
+
const probeEvidence = parseProbeOutput(probed.stdout);
|
|
8073
|
+
assertScratchTarget(plan.verify, probeEvidence);
|
|
8074
|
+
// HC-GF-190: say what the guard PROVED. It throws on refusal and returns nothing on
|
|
8075
|
+
// success, so a passing guard was silent — and HC-GF-185's acceptance ("the output
|
|
8076
|
+
// shows the probe, the guard verdict and the teardown") stayed open on run 6be117b3,
|
|
8077
|
+
// where all three had in fact happened. Printed after the check and before the first
|
|
8078
|
+
// write, so it describes something that has already occurred.
|
|
8079
|
+
if (opts.format !== 'json') console.log(` sandbox: ${describeProbeResult(plan.verify, probeEvidence)}`);
|
|
8080
|
+
// Armed BEFORE the first command, not after the last. `create` succeeding and
|
|
8081
|
+
// `migrate` failing would otherwise leave the created rows behind — exactly the
|
|
8082
|
+
// outcome the teardown exists to prevent, on the path where it matters most.
|
|
8083
|
+
sandboxTeardown = plan.teardown;
|
|
8084
|
+
for (const c of plan.commands) {
|
|
8085
|
+
if (opts.format !== 'json') console.log(` sandbox: ${c}`);
|
|
8086
|
+
const r = spawnSync(c, { cwd: wt, shell: true, encoding: 'utf8',
|
|
8087
|
+
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8088
|
+
if (r.status !== 0) throw new Error(`sandbox command failed: ${c}`);
|
|
8089
|
+
}
|
|
8090
|
+
|
|
8091
|
+
// HC-GF-192: run the suite that READS the sandbox, immediately after seeding and
|
|
8092
|
+
// before the adopter's own command.
|
|
8093
|
+
//
|
|
8094
|
+
// Run 4f463e54 seeded `hone_sandbox` and then ran `make ci` against
|
|
8095
|
+
// `hone_test_<checkout>`: a flawless lifecycle around a verification that could not
|
|
8096
|
+
// observe it. The adopter's whole CI command is the wrong consumer — a suite that
|
|
8097
|
+
// manages its own database fights a seeded one — so the sandbox declares the command
|
|
8098
|
+
// that reads it, and the connection is one WE publish rather than one an adopter
|
|
8099
|
+
// promises is wired up. That is what makes the result provable.
|
|
8100
|
+
//
|
|
8101
|
+
// First, because the seed is freshest here and the adopter's command can run for
|
|
8102
|
+
// minutes; and a failure names the stored-state behaviour rather than arriving after
|
|
8103
|
+
// an unrelated suite. Names are logged, never values: the connection holds credentials.
|
|
8104
|
+
if (opts.format !== 'json') {
|
|
8105
|
+
console.log(` sandbox: ${plan.test}`);
|
|
8106
|
+
console.log(` sandbox: published ${Object.keys(plan.env).join(', ')} to it`);
|
|
8107
|
+
}
|
|
8108
|
+
const sandboxRun = spawnSync(plan.test, {
|
|
8109
|
+
cwd: wt, shell: true, encoding: 'utf8', timeout: timeoutMs,
|
|
8110
|
+
env: verifyCommandEnv(gitEnv(plan.env)), stdio: ['ignore', 'pipe', 'pipe'],
|
|
8111
|
+
});
|
|
8112
|
+
|
|
8113
|
+
// HC-GF-193: the NEGATIVE CONTROL. A runner with nothing to run exits 0, so the status
|
|
8114
|
+
// above cannot tell "everything passed" from "nothing ran" — run 6be117b3's first
|
|
8115
|
+
// attempt was reported as verified on a glob that matched no files. Re-run with every
|
|
8116
|
+
// published variable replaced by an unusable value: a suite that observes the sandbox
|
|
8117
|
+
// must fail, and one that still passes is not reading what we published.
|
|
8118
|
+
//
|
|
8119
|
+
// Only when the real run PASSED. A red suite is already a failure, and re-running it
|
|
8120
|
+
// with a broken environment spends the timeout to learn nothing.
|
|
8121
|
+
let controlStatus = 1;
|
|
8122
|
+
if (sandboxRun.status === 0 && plan.controlEnv) {
|
|
8123
|
+
if (opts.format !== 'json') console.log(' sandbox: re-running the suite with the published variables broken (control)');
|
|
8124
|
+
const ctl = spawnSync(plan.test, {
|
|
8125
|
+
cwd: wt, shell: true, encoding: 'utf8', timeout: timeoutMs,
|
|
8126
|
+
env: verifyCommandEnv(gitEnv(plan.controlEnv)), stdio: ['ignore', 'pipe', 'pipe'],
|
|
8127
|
+
});
|
|
8128
|
+
controlStatus = ctl.status === 0 ? 0 : 1;
|
|
8129
|
+
}
|
|
8130
|
+
|
|
8131
|
+
const suiteVerdict = sandboxSuiteVerdict({ realStatus: sandboxRun.status, controlStatus });
|
|
8132
|
+
if (!suiteVerdict.ok) {
|
|
8133
|
+
const ran = ((sandboxRun.stdout || '') + (sandboxRun.stderr || ''))
|
|
8134
|
+
.split('\n').filter(Boolean).slice(-12).join('\n');
|
|
8135
|
+
const detail = suiteVerdict.kind === 'sandbox_tests_not_observing'
|
|
8136
|
+
? suiteVerdict.detail : ran;
|
|
8137
|
+
emit({ ok: false, verdict: suiteVerdict.kind, detail },
|
|
8138
|
+
`✗ ${suiteVerdict.kind === 'sandbox_tests_not_observing'
|
|
8139
|
+
? `the sandbox suite verified nothing:\n${detail}`
|
|
8140
|
+
: `the sandbox suite failed:\n${detail}`}`);
|
|
8141
|
+
exitCode = EXIT.FAIL;
|
|
8142
|
+
reportVerdict = suiteVerdict.kind; reportDetail = detail;
|
|
8143
|
+
// Thrown so the `finally` tears the sandbox down — seeded rows outliving a red run
|
|
8144
|
+
// is the worst outcome this capability can produce (HC-GF-171).
|
|
8145
|
+
throw new Error('sandbox tests failed');
|
|
8146
|
+
}
|
|
8147
|
+
} else if (opts.format !== 'json') {
|
|
8148
|
+
console.log(` sandbox: ${plan.reason}`);
|
|
8149
|
+
}
|
|
8150
|
+
} catch (e) {
|
|
8151
|
+
if (e.message === 'sandbox refused' || e.message === 'sandbox tests failed') throw e;
|
|
8152
|
+
// A sandbox that cannot be proven scratch STOPS the run. Continuing would report a
|
|
8153
|
+
// verdict for stored-state behaviour that was never exercised — a verdict we did not
|
|
8154
|
+
// obtain (HC-GF-177 §18.5).
|
|
8155
|
+
emit({ ok: false, verdict: 'sandbox_unusable', detail: e.message },
|
|
8156
|
+
`✗ sandbox unusable: ${e.message}`);
|
|
8157
|
+
exitCode = EXIT.FAIL;
|
|
8158
|
+
reportVerdict = 'sandbox_unusable'; reportDetail = e.message;
|
|
8159
|
+
throw e;
|
|
8160
|
+
}
|
|
8161
|
+
|
|
7829
8162
|
if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
7830
8163
|
const run = spawnSync(command, {
|
|
7831
8164
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
7832
|
-
timeout:
|
|
8165
|
+
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
7833
8166
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7834
8167
|
});
|
|
7835
8168
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
7836
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
|
|
8169
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
|
|
7837
8170
|
// HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
|
|
7838
8171
|
if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
|
|
7839
|
-
const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
|
|
8172
|
+
const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
|
|
7840
8173
|
|
|
7841
8174
|
// HC-GF-023: on a failure the operator needs the OUTPUT, not a quote from it.
|
|
7842
8175
|
// The full log is written OUTSIDE the worktree (cleanup removes the worktree)
|
|
@@ -7857,7 +8190,13 @@ program
|
|
|
7857
8190
|
+ v.failure.excerpt.map(l => ` │ ${l}`).join('\n');
|
|
7858
8191
|
}
|
|
7859
8192
|
if (logPath) pretty += `\n full output: ${logPath}`;
|
|
7860
|
-
if (v.verdict !== 'pass') pretty += `\n inspect the tree: re-run with --keep`;
|
|
8193
|
+
if (v.verdict !== 'pass' && v.verdict !== 'pass_narrowed') pretty += `\n inspect the tree: re-run with --keep`;
|
|
8194
|
+
// HC-GF-223: a narrowed run needs the command that did NOT run, not the worktree — the way
|
|
8195
|
+
// to a real verdict is the next line rather than a re-read of the story.
|
|
8196
|
+
if (v.verdict === 'pass_narrowed') {
|
|
8197
|
+
pretty += `\n not a verification: re-run without --command to verify with `
|
|
8198
|
+
+ `\`${commandReplaced.command}\``;
|
|
8199
|
+
}
|
|
7861
8200
|
|
|
7862
8201
|
emit(
|
|
7863
8202
|
{ ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail,
|
|
@@ -7881,7 +8220,7 @@ program
|
|
|
7881
8220
|
for (const gate of resolveAuxGates({ configured: ciCfg, cwd: process.cwd() })) {
|
|
7882
8221
|
const gr = spawnSync(gate.command, {
|
|
7883
8222
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
7884
|
-
timeout:
|
|
8223
|
+
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
7885
8224
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7886
8225
|
});
|
|
7887
8226
|
const c = classifyGateRun({
|
|
@@ -7902,11 +8241,34 @@ program
|
|
|
7902
8241
|
}
|
|
7903
8242
|
}
|
|
7904
8243
|
} catch (e) {
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
8244
|
+
// HC-GF-171: a verdict already reported by the sandbox path is the SPECIFIC one — keep it.
|
|
8245
|
+
// Overwriting it with a generic `error` loses which check refused and emits a second,
|
|
8246
|
+
// vaguer line over the first, so the operator reads "verify-patch failed" instead of the
|
|
8247
|
+
// message naming the config key to add.
|
|
8248
|
+
if (reportVerdict) {
|
|
8249
|
+
exitCode = exitCode === EXIT.PASS ? 1 : exitCode;
|
|
8250
|
+
} else {
|
|
8251
|
+
emit({ ok: false, verdict: 'error', detail: e.message },
|
|
8252
|
+
`✗ verify-patch failed: ${e.message}`);
|
|
8253
|
+
exitCode = 1;
|
|
8254
|
+
reportVerdict = 'error'; reportDetail = e.message;
|
|
8255
|
+
}
|
|
7909
8256
|
} finally {
|
|
8257
|
+
// HC-GF-171: reset runs on EVERY path — pass, fail, refusal, throw. A sandbox that is only
|
|
8258
|
+
// cleaned on success leaves its rows behind exactly when something went wrong, which is when
|
|
8259
|
+
// the adopter is least able to tell seeded data from real.
|
|
8260
|
+
for (const c of sandboxTeardown) {
|
|
8261
|
+
const r = spawnSync(c, { cwd: process.cwd(), shell: true, encoding: 'utf8',
|
|
8262
|
+
timeout: 120_000, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8263
|
+
if (opts.format !== 'json') {
|
|
8264
|
+
console.log(r.status === 0
|
|
8265
|
+
? ` sandbox: ${c}`
|
|
8266
|
+
: ` sandbox: TEARDOWN FAILED (${c}) — seeded rows may remain: ${(r.stderr || '').split('\n')[0]}`);
|
|
8267
|
+
}
|
|
8268
|
+
// HC-GF-183: keep going. If the reset succeeded and the restore failed, stopping here
|
|
8269
|
+
// leaves the sandbox unprobeable and the NEXT run refuses — the defect this fixes.
|
|
8270
|
+
}
|
|
8271
|
+
|
|
7910
8272
|
cleanup();
|
|
7911
8273
|
}
|
|
7912
8274
|
|
|
@@ -7915,7 +8277,7 @@ program
|
|
|
7915
8277
|
// change the local exit code (the verdict is already true locally).
|
|
7916
8278
|
if (opts.report && reportVerdict) {
|
|
7917
8279
|
try {
|
|
7918
|
-
await client.post(
|
|
8280
|
+
await client.post(orchestratePath([workflowId, 'verdict']), {
|
|
7919
8281
|
source: 'verify-patch',
|
|
7920
8282
|
stepKey: opts.stepKey,
|
|
7921
8283
|
verdict: reportVerdict,
|
|
@@ -7949,11 +8311,12 @@ program
|
|
|
7949
8311
|
.description('What happened on a run, and what still needs you')
|
|
7950
8312
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7951
8313
|
.action(async (runId, opts) => {
|
|
8314
|
+
refuseBadRunArgs([{ label: '<runId>', kind: 'runId', value: runId }]); // HC-GF-270-followup-1
|
|
7952
8315
|
const { buildReport, renderReport } = require('./lib/run-report');
|
|
7953
8316
|
const config = getConfig();
|
|
7954
8317
|
const client = api(config);
|
|
7955
8318
|
try {
|
|
7956
|
-
const { data } = await client.get(
|
|
8319
|
+
const { data } = await client.get(orchestratePath([runId]));
|
|
7957
8320
|
const report = buildReport(data);
|
|
7958
8321
|
if (opts.format === 'json') console.log(JSON.stringify(report, null, 2));
|
|
7959
8322
|
else console.log(renderReport(report));
|
|
@@ -7971,6 +8334,82 @@ program
|
|
|
7971
8334
|
}
|
|
7972
8335
|
});
|
|
7973
8336
|
|
|
8337
|
+
// ── HC-GF-098: follow-batch — a queued batch's step_4 patches checked and verified overnight ──
|
|
8338
|
+
// The server cannot execute, so a batch queued at night reached step_4 and waited for a person to type
|
|
8339
|
+
// check-patch, verify-patch and emit-pr. This is the I/O around cli/lib/batch-follow.js, which makes every
|
|
8340
|
+
// decision. The verbs run as children of THIS CLI, one at a time, so the loop runs exactly what a person runs.
|
|
8341
|
+
// Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you, 3 deadline.
|
|
8342
|
+
program
|
|
8343
|
+
.command('follow-batch <batchId>')
|
|
8344
|
+
.description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098)')
|
|
8345
|
+
.option('--poll-interval <sec>', 'Seconds between polls', '30')
|
|
8346
|
+
.option('--deadline <min>', 'Stop after this many minutes, even if the batch is still running')
|
|
8347
|
+
.option('--max-offline <min>', 'Give up once the server has been unreachable this long', '15')
|
|
8348
|
+
.option('--open-pr', 'Push each emitted branch and open a draft PR (default: a local branch only)')
|
|
8349
|
+
.option('--repo <name>', 'Repository name the batch was queued under (default: this directory\'s name)')
|
|
8350
|
+
.action(async (batchId, opts) => {
|
|
8351
|
+
const { followBatch, EXIT } = require('./lib/batch-follow');
|
|
8352
|
+
const { spawn, spawnSync } = require('child_process');
|
|
8353
|
+
const positive = (raw, flag, unit) => {
|
|
8354
|
+
const n = Number(raw);
|
|
8355
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
8356
|
+
console.error(`${flag} must be a positive number of ${unit} (got ${JSON.stringify(raw)})`);
|
|
8357
|
+
process.exit(EXIT.FATAL);
|
|
8358
|
+
}
|
|
8359
|
+
return n;
|
|
8360
|
+
};
|
|
8361
|
+
const pollMs = positive(opts.pollInterval, '--poll-interval', 'seconds') * 1000;
|
|
8362
|
+
const maxOfflineMs = positive(opts.maxOffline, '--max-offline', 'minutes') * 60_000;
|
|
8363
|
+
const deadlineMs = opts.deadline === undefined ? null : positive(opts.deadline, '--deadline', 'minutes') * 60_000;
|
|
8364
|
+
|
|
8365
|
+
// Preflight: every verb reads this checkout, and --open-pr needs gh. Refuse before the first poll, not at 3am.
|
|
8366
|
+
const { gitEnv } = require('./lib/git-env');
|
|
8367
|
+
const inRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' });
|
|
8368
|
+
if (inRepo.status !== 0 || String(inRepo.stdout).trim() !== 'true') {
|
|
8369
|
+
console.error('follow-batch runs the batch\'s patches against this checkout: run it from the repository the batch was queued in.');
|
|
8370
|
+
process.exit(EXIT.FATAL);
|
|
8371
|
+
}
|
|
8372
|
+
if (opts.openPr && spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }).status !== 0) {
|
|
8373
|
+
console.error('--open-pr pushes and opens draft PRs with gh, and `gh auth status` failed: log in, or drop --open-pr.');
|
|
8374
|
+
process.exit(EXIT.FATAL);
|
|
8375
|
+
}
|
|
8376
|
+
|
|
8377
|
+
const config = getConfig();
|
|
8378
|
+
const client = api(config);
|
|
8379
|
+
const repoName = opts.repo || path.basename(process.cwd());
|
|
8380
|
+
const io = {
|
|
8381
|
+
fetchBatch: async (id) => (await client.get(orchestratePath(['batch', id]))).data,
|
|
8382
|
+
fetchRun: async (id) => (await client.get(orchestratePath([id]))).data,
|
|
8383
|
+
runVerb: (argv) => new Promise((resolve) => {
|
|
8384
|
+
const child = spawn(process.execPath, [process.argv[1], ...argv],
|
|
8385
|
+
{ cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
8386
|
+
child.on('error', (e) => resolve({ code: null, error: e }));
|
|
8387
|
+
child.on('close', (code) => resolve({ code }));
|
|
8388
|
+
}),
|
|
8389
|
+
// The consent and provenance rule run-story applies before it runs a patch (HC-GF-064, HC-GF-270).
|
|
8390
|
+
verifyDecision: (run) => {
|
|
8391
|
+
let assessment;
|
|
8392
|
+
try {
|
|
8393
|
+
assessment = require('./lib/story-provenance').assessStorySource(
|
|
8394
|
+
run && run.config && run.config.story_source, run && (run.storyId || run.story_id));
|
|
8395
|
+
} catch (e) {
|
|
8396
|
+
assessment = { trusted: false, label: 'assessment-failed', reason: `story provenance could not be assessed: ${e.message}` };
|
|
8397
|
+
}
|
|
8398
|
+
return require('./lib/auto-verify').decideAutoVerify({ stepKey: 'step_4', repoRoot: process.cwd(), assessment });
|
|
8399
|
+
},
|
|
8400
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
8401
|
+
now: () => Date.now(),
|
|
8402
|
+
log: (line) => console.log(line),
|
|
8403
|
+
};
|
|
8404
|
+
|
|
8405
|
+
console.log(`[follow-batch] following batch ${batchId} for ${repoName}: poll ${pollMs / 1000}s, `
|
|
8406
|
+
+ `${deadlineMs ? `deadline ${deadlineMs / 60_000} min` : 'no deadline'}, `
|
|
8407
|
+
+ `emit ${opts.openPr ? 'push + draft PR' : 'local branch only'}`);
|
|
8408
|
+
const res = await followBatch(batchId, io, { pollMs, deadlineMs, maxOfflineMs, openPr: !!opts.openPr, repoName });
|
|
8409
|
+
console.log(`[follow-batch] ${res.reason}`);
|
|
8410
|
+
process.exit(res.exitCode);
|
|
8411
|
+
});
|
|
8412
|
+
|
|
7974
8413
|
// ── HC-019n-followup-25: emit-pr (pipeline-recovery condition 7) ──────────────
|
|
7975
8414
|
// Condition 6 proved step_4's diff applies AND its tests pass in isolation.
|
|
7976
8415
|
// Condition 7 turns that verified diff into the reviewable artifact: a branch
|
|
@@ -7987,22 +8426,37 @@ program
|
|
|
7987
8426
|
.option('--open-pr', 'Open a draft PR (implies --push)')
|
|
7988
8427
|
.option('--ready', 'Open the PR ready-for-review instead of draft')
|
|
7989
8428
|
.option('--skip-verify', 'Do not re-run the tests before committing (downgrades provenance)')
|
|
8429
|
+
.option('--trust-source', 'HC-GF-270: emit a patch whose story text came from a source Hone does not trust (you have read the diff)')
|
|
7990
8430
|
.option('--command <cmd>', 'Test command for --verify (default: ci.local_command, else `make ci`)')
|
|
7991
8431
|
.option('--timeout <sec>', 'Seconds before the verify test command is killed', '600')
|
|
7992
8432
|
.option('--keep', 'Leave the worktree in place for debugging')
|
|
7993
8433
|
.option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
|
|
7994
8434
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7995
8435
|
.action(async (workflowId, opts) => {
|
|
8436
|
+
refuseBadRunArgs(stepFetchChecks(workflowId, opts)); // HC-GF-270(-followup-1): first, before any config read
|
|
7996
8437
|
const { execFileSync, spawnSync } = require('child_process');
|
|
8438
|
+
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
8439
|
+
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
8440
|
+
// deep in child_process, and a blank value coerces to 0 and kills the
|
|
8441
|
+
// command the instant it starts.
|
|
8442
|
+
let timeoutMs;
|
|
8443
|
+
try {
|
|
8444
|
+
timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
|
|
8445
|
+
} catch (e) {
|
|
8446
|
+
console.error(e.message);
|
|
8447
|
+
process.exit(2);
|
|
8448
|
+
}
|
|
7997
8449
|
const os = require('os');
|
|
7998
8450
|
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7999
8451
|
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
|
|
8452
|
+
const { verifyStateFor } = require('./lib/emit-pr');
|
|
8000
8453
|
const {
|
|
8001
8454
|
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
|
|
8002
8455
|
} = require('./lib/emit-pr');
|
|
8003
8456
|
const { gitEnv } = require('./lib/git-env');
|
|
8004
8457
|
const config = getConfig();
|
|
8005
8458
|
const client = api(config);
|
|
8459
|
+
const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
|
|
8006
8460
|
|
|
8007
8461
|
// --open-pr implies --push (cannot open a PR against an unpushed branch).
|
|
8008
8462
|
const doPush = Boolean(opts.push || opts.openPr);
|
|
@@ -8016,16 +8470,28 @@ program
|
|
|
8016
8470
|
|
|
8017
8471
|
// 1. Fetch step_4 output + the run's storyId (for naming/provenance).
|
|
8018
8472
|
let output, storyId = 'story', runConfig = {};
|
|
8473
|
+
// HC-GF-270: a swallowed run fetch leaves storyId as the non-numeric 'story', which the
|
|
8474
|
+
// legacy rule would read as trusted. A failed fetch is untrusted on its own evidence.
|
|
8475
|
+
let runFetchFailed = false;
|
|
8476
|
+
// The run's REAL story id, for the provenance rule. `storyId` above falls back to 'story' for
|
|
8477
|
+
// branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
|
|
8478
|
+
// never arrived.
|
|
8479
|
+
let runStoryId = null;
|
|
8019
8480
|
try {
|
|
8020
|
-
const url =
|
|
8021
|
-
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
8481
|
+
const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
|
|
8022
8482
|
const r = await client.get(url);
|
|
8483
|
+
// The step output must be THIS run's: nothing else ties the patch to the config assessed below.
|
|
8484
|
+
if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
|
|
8023
8485
|
output = r.data?.output;
|
|
8024
8486
|
try {
|
|
8025
|
-
const runR = await client.get(
|
|
8026
|
-
|
|
8487
|
+
const runR = await client.get(orchestratePath([workflowId]));
|
|
8488
|
+
// HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
|
|
8489
|
+
// is not THIS run does not count as its config.
|
|
8490
|
+
if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
|
|
8491
|
+
runStoryId = runR.data?.storyId || runR.data?.story_id || null;
|
|
8492
|
+
storyId = runStoryId || 'story';
|
|
8027
8493
|
runConfig = runR.data?.config || {};
|
|
8028
|
-
} catch { /* naming falls back to 'story'; command falls back to make ci */ }
|
|
8494
|
+
} catch (e) { runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; /* naming falls back to 'story'; command falls back to make ci */ }
|
|
8029
8495
|
} catch (e) {
|
|
8030
8496
|
const msg = e.response?.data?.error || e.message;
|
|
8031
8497
|
emit({ ok: false, reason: 'fetch_failed', detail: msg },
|
|
@@ -8033,9 +8499,41 @@ program
|
|
|
8033
8499
|
process.exit(EXIT.NO_DIFF);
|
|
8034
8500
|
}
|
|
8035
8501
|
|
|
8502
|
+
// ── HC-GF-270: the provenance gate (see verify-patch's copy for the full argument) ─────
|
|
8503
|
+
//
|
|
8504
|
+
// emit-pr runs `--verify` by default and creates a branch, so it is a second execution
|
|
8505
|
+
// surface and a second publishing surface for the same untrusted text. Its own guard, so
|
|
8506
|
+
// its own mutation kills its own test (Checklist B item 6).
|
|
8507
|
+
{
|
|
8508
|
+
let assessment;
|
|
8509
|
+
try {
|
|
8510
|
+
const { assessStorySource } = require('./lib/story-provenance');
|
|
8511
|
+
assessment = runFetchFailed
|
|
8512
|
+
? { trusted: false, label: 'run-fetch-failed',
|
|
8513
|
+
reason: runFetchFailed === 'mismatch'
|
|
8514
|
+
? 'the server answered for a different run, so this run\'s config was never read'
|
|
8515
|
+
: 'this run\'s config could not be read, so nothing is known about where its story text came from' }
|
|
8516
|
+
: assessStorySource(runConfig?.story_source, runStoryId);
|
|
8517
|
+
} catch (e) {
|
|
8518
|
+
assessment = { trusted: false, label: 'assessment-failed',
|
|
8519
|
+
reason: `story provenance could not be assessed: ${e.message}` };
|
|
8520
|
+
}
|
|
8521
|
+
if (!assessment.trusted && !opts.trustSource) {
|
|
8522
|
+
emit({ ok: false, reason: 'untrusted_source', detail: assessment.reason },
|
|
8523
|
+
`✗ refusing to emit: ${assessment.reason}\n`
|
|
8524
|
+
+ ' No branch was created and nothing was executed.\n'
|
|
8525
|
+
+ ` Read it: hone show step ${workflowId} ${opts.stepKey}\n`
|
|
8526
|
+
+ ` Then: hone emit-pr ${workflowId} --trust-source`);
|
|
8527
|
+
process.exit(EXIT.UNTRUSTED_SOURCE);
|
|
8528
|
+
}
|
|
8529
|
+
}
|
|
8530
|
+
|
|
8036
8531
|
// 2. Materialize the diff (shared with check-patch/verify-patch,
|
|
8037
8532
|
// followup-23/27). Condition 7 presupposes 5.
|
|
8038
8533
|
const blocks = extractFileBlocks(output);
|
|
8534
|
+
// HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
|
|
8535
|
+
// silently re-scoping is how a finished build came back as "no file changes to verify".
|
|
8536
|
+
if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
|
|
8039
8537
|
const hasWork = hasMaterializableWork(blocks);
|
|
8040
8538
|
if (blocks.noChanges || !hasWork) {
|
|
8041
8539
|
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
|
|
@@ -8101,9 +8599,11 @@ program
|
|
|
8101
8599
|
|
|
8102
8600
|
// 4. Isolated worktree with a NAMED branch off HEAD (adopter tree untouched).
|
|
8103
8601
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
|
|
8104
|
-
|
|
8602
|
+
// HC-GF-304: see verify-patch. emit-pr would COMMIT the other invocation's patch onto this run's branch.
|
|
8603
|
+
const diffTmp = require('./lib/private-temp').privateTempFile('hone-emit-diff', 'patch.diff');
|
|
8604
|
+
const diffFile = diffTmp.file;
|
|
8105
8605
|
fs.writeFileSync(diffFile, diff);
|
|
8106
|
-
const { command, source: commandSource } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess
|
|
8606
|
+
const { command, source: commandSource, replaced: commandReplaced } = resolveCommandDetailed(opts.command, runConfig?.ci_local_command, { cwd: process.cwd() }); // followup-7 tiers; HC-GF-008: the source says whether `make ci` was a guess; HC-GF-223: `replaced` says what an override skipped
|
|
8107
8607
|
// `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
|
|
8108
8608
|
// before a successful commit (apply fails, verify fails), that empty branch
|
|
8109
8609
|
// (pointing at HEAD) must be torn down too — else "a failing diff never
|
|
@@ -8120,7 +8620,7 @@ program
|
|
|
8120
8620
|
try { execFileSync('git', ['branch', '-D', branch], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
|
|
8121
8621
|
}
|
|
8122
8622
|
try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
8123
|
-
|
|
8623
|
+
diffTmp.remove();
|
|
8124
8624
|
};
|
|
8125
8625
|
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
8126
8626
|
|
|
@@ -8151,23 +8651,48 @@ program
|
|
|
8151
8651
|
// HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
|
|
8152
8652
|
// (link the repo's node_modules when the lockfile is unchanged, else run the
|
|
8153
8653
|
// lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
|
|
8654
|
+
// HC-GF-201: declared OUTSIDE the guard — the verdict needs provisioning state,
|
|
8655
|
+
// and with --no-provision there is none, which is not the same as a failure.
|
|
8656
|
+
let provAll = null;
|
|
8154
8657
|
if (opts.provision !== false) {
|
|
8155
|
-
const { planProvision,
|
|
8658
|
+
const { planProvision, discoverPackageRoots, provisionAllRoots } = require('./lib/worktree-provision');
|
|
8156
8659
|
const { readCIConfigDetails } = require('./lib/pipeline-config');
|
|
8157
|
-
|
|
8158
|
-
|
|
8660
|
+
// HC-GF-201: provision EVERY package root, not just the worktree's top level.
|
|
8661
|
+
// This repo has three; the two skipped are where ci.unit runs, so their absent
|
|
8662
|
+
// modules surfaced as `Cannot find module` and were counted as the PATCH failing
|
|
8663
|
+
// tests — a false RED on the merge-gating verdict (run b3117bde, 2026-09-20).
|
|
8664
|
+
const installCmd = readCIConfigDetails(process.cwd()).install_command;
|
|
8665
|
+
const provEntries = discoverPackageRoots(wt).map((root) => ({
|
|
8666
|
+
root,
|
|
8667
|
+
worktreeDir: root === '.' ? wt : path.join(wt, root),
|
|
8668
|
+
exec: spawnSync,
|
|
8669
|
+
plan: planProvision({
|
|
8670
|
+
worktreeDir: root === '.' ? wt : path.join(wt, root),
|
|
8671
|
+
parentDir: root === '.' ? process.cwd() : path.join(process.cwd(), root),
|
|
8672
|
+
installCommand: root === '.' ? installCmd : undefined,
|
|
8673
|
+
}),
|
|
8674
|
+
}));
|
|
8675
|
+
provAll = provisionAllRoots(provEntries);
|
|
8676
|
+
const prov = { ok: provAll.ok,
|
|
8677
|
+
summary: provAll.results.map((r) => `${r.root}: ${r.summary}`).join('; ')
|
|
8678
|
+
+ (provAll.ok ? '' : ` — NOT RUN: ${provAll.unprovisioned.map((u) => u.root).join(', ')} could not be provisioned`) };
|
|
8159
8679
|
if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
|
|
8160
8680
|
}
|
|
8161
8681
|
if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
8162
8682
|
const run = spawnSync(command, {
|
|
8163
8683
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8164
|
-
timeout:
|
|
8684
|
+
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
8165
8685
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8166
8686
|
});
|
|
8167
8687
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
8168
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
|
|
8169
|
-
|
|
8170
|
-
|
|
8688
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
|
|
8689
|
+
// HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
|
|
8690
|
+
// narrowed verdict fell into the else below, which printed "verify failed, no branch
|
|
8691
|
+
// emitted" and then exited with that verdict's own exit code — zero. A failure path that
|
|
8692
|
+
// exits 0 is the HC-GF-207 shape this session opened by fixing.
|
|
8693
|
+
const emitState = verifyStateFor(v.verdict);
|
|
8694
|
+
if (emitState) {
|
|
8695
|
+
verifyState = emitState;
|
|
8171
8696
|
} else {
|
|
8172
8697
|
verifyOk = false;
|
|
8173
8698
|
// HC-GF-008: a command that never started is not a failed verify.
|
|
@@ -8183,14 +8708,15 @@ program
|
|
|
8183
8708
|
// 4c. Commit the applied diff onto the branch, inside the worktree.
|
|
8184
8709
|
// No summary source (GET /orchestrate omits config/story text), so the
|
|
8185
8710
|
// commit subject falls back to the storyId + default in buildCommitMessage.
|
|
8186
|
-
const message = buildCommitMessage({ storyId, summary: '', workflowId, headSha, verifyState });
|
|
8187
|
-
const
|
|
8711
|
+
const message = buildCommitMessage({ storyId, summary: '', workflowId, headSha, verifyState, replacedCommand: commandReplaced && commandReplaced.command });
|
|
8712
|
+
const msgTmp = require('./lib/private-temp').privateTempFile('hone-emit-msg', 'message.txt');
|
|
8713
|
+
const msgFile = msgTmp.file;
|
|
8188
8714
|
fs.writeFileSync(msgFile, message);
|
|
8189
8715
|
try {
|
|
8190
8716
|
execFileSync('git', ['-C', wt, 'add', '-A'], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8191
8717
|
execFileSync('git', ['-C', wt, 'commit', '-F', msgFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8192
8718
|
committed = true; // the branch now holds the deliverable — keep it past cleanup
|
|
8193
|
-
} finally {
|
|
8719
|
+
} finally { msgTmp.remove(); }
|
|
8194
8720
|
|
|
8195
8721
|
const stat = (() => {
|
|
8196
8722
|
try {
|
|
@@ -8214,8 +8740,9 @@ program
|
|
|
8214
8740
|
}
|
|
8215
8741
|
}
|
|
8216
8742
|
if (pushed && doOpenPr) {
|
|
8217
|
-
const prBody = buildPrBody({ storyId, workflowId, headSha, verifyState, command: doVerify ? command : undefined });
|
|
8218
|
-
const
|
|
8743
|
+
const prBody = buildPrBody({ storyId, workflowId, headSha, verifyState, command: doVerify ? command : undefined, replacedCommand: commandReplaced && commandReplaced.command });
|
|
8744
|
+
const bodyTmp = require('./lib/private-temp').privateTempFile('hone-emit-prbody', 'body.md');
|
|
8745
|
+
const bodyFile = bodyTmp.file;
|
|
8219
8746
|
fs.writeFileSync(bodyFile, prBody);
|
|
8220
8747
|
const title = `${storyId}: Hone step_4 change (workflow ${String(workflowId).slice(0, 8)})`;
|
|
8221
8748
|
const ghArgs = ['pr', 'create', '--head', branch, '--title', title, '--body-file', bodyFile];
|
|
@@ -8229,7 +8756,7 @@ program
|
|
|
8229
8756
|
emit({ ok: false, verdict: 'publish_failed', stage: 'open_pr', detail, branch },
|
|
8230
8757
|
`✗ branch pushed, but opening the PR failed: ${detail}\n → open it manually, or check \`gh auth status\`.`);
|
|
8231
8758
|
exitCode = EXIT.PUBLISH_FAILED;
|
|
8232
|
-
} finally {
|
|
8759
|
+
} finally { bodyTmp.remove(); }
|
|
8233
8760
|
}
|
|
8234
8761
|
|
|
8235
8762
|
if (exitCode === EXIT.PASS) {
|
|
@@ -8276,7 +8803,20 @@ program
|
|
|
8276
8803
|
.option('--workflow-id <id>', 'the run id to report the verdict against (required with --report)')
|
|
8277
8804
|
.option('--format <fmt>', 'pretty or json', 'pretty')
|
|
8278
8805
|
.action(async (prOrBranch, opts) => {
|
|
8806
|
+
// HC-GF-270-followup-1: a --workflow-id is checked before any gh call or CI command runs.
|
|
8807
|
+
refuseBadRunArgs([{ label: '--workflow-id', kind: 'runId', value: opts.workflowId, optional: true }]);
|
|
8279
8808
|
const { execFileSync, spawnSync } = require('child_process');
|
|
8809
|
+
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
8810
|
+
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
8811
|
+
// deep in child_process, and a blank value coerces to 0 and kills the
|
|
8812
|
+
// command the instant it starts.
|
|
8813
|
+
let timeoutMs;
|
|
8814
|
+
try {
|
|
8815
|
+
timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
|
|
8816
|
+
} catch (e) {
|
|
8817
|
+
console.error(e.message);
|
|
8818
|
+
process.exit(2);
|
|
8819
|
+
}
|
|
8280
8820
|
const { readCIGateConfig } = require('./lib/pipeline-config');
|
|
8281
8821
|
const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
|
|
8282
8822
|
const { EXIT, resolveGateMode, interpretGhChecks, ciGateVerdict, renderStep5cArtifact } = require('./lib/verify-pr');
|
|
@@ -8298,15 +8838,19 @@ program
|
|
|
8298
8838
|
const mode = resolveGateMode(opts.mode, configGate);
|
|
8299
8839
|
|
|
8300
8840
|
// 3. Skill audit — reuse the working `hone step-5b` command (deterministic
|
|
8301
|
-
// SA-001 engine over the PR diff).
|
|
8841
|
+
// SA-001 engine over the PR diff). HC-GF-227: its BLOCKER verdict (exit 2) fails this command
|
|
8842
|
+
// too, once the CI gate itself has passed; any other failure to run it is reported, not a verdict.
|
|
8302
8843
|
let skillAudit = { ran: false };
|
|
8303
8844
|
if (!opts.skipSkillAudit) {
|
|
8845
|
+
const artifact = `.github/pipeline/${storyId}/step-5b-skill-audit.md`;
|
|
8304
8846
|
try {
|
|
8305
8847
|
execFileSync(process.execPath, [__filename, 'step-5b', '--story-id', storyId, '--base', opts.base],
|
|
8306
8848
|
{ cwd: repoRoot, env: process.env, stdio: opts.format === 'json' ? 'ignore' : 'inherit' });
|
|
8307
|
-
skillAudit = { ran: true, artifact
|
|
8849
|
+
skillAudit = { ran: true, artifact };
|
|
8308
8850
|
} catch (e) {
|
|
8309
|
-
skillAudit =
|
|
8851
|
+
skillAudit = e.status === STEP_5B_BLOCKED
|
|
8852
|
+
? { ran: true, blocked: true, artifact }
|
|
8853
|
+
: { ran: true, error: (e.message || '').split('\n')[0] };
|
|
8310
8854
|
}
|
|
8311
8855
|
}
|
|
8312
8856
|
|
|
@@ -8337,7 +8881,7 @@ program
|
|
|
8337
8881
|
if (opts.format !== 'json') console.log(` running local CI: ${command} (timeout ${opts.timeout}s)`);
|
|
8338
8882
|
// HC-GF-008: the adopter's own command, without make state inherited from whoever ran hone.
|
|
8339
8883
|
const { verifyCommandEnv } = require('./lib/verify-patch');
|
|
8340
|
-
const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout:
|
|
8884
|
+
const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: timeoutMs, stdio: ['ignore', 'inherit', 'inherit'], env: verifyCommandEnv(gitEnv()) });
|
|
8341
8885
|
local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
|
|
8342
8886
|
}
|
|
8343
8887
|
|
|
@@ -8362,7 +8906,7 @@ program
|
|
|
8362
8906
|
} else {
|
|
8363
8907
|
try {
|
|
8364
8908
|
const client = api(getConfig());
|
|
8365
|
-
await client.post(
|
|
8909
|
+
await client.post(orchestratePath([opts.workflowId, 'verdict']), {
|
|
8366
8910
|
source: 'verify-pr',
|
|
8367
8911
|
stepKey: 'step_5c',
|
|
8368
8912
|
verdict: verdict.verdict,
|
|
@@ -8377,14 +8921,16 @@ program
|
|
|
8377
8921
|
}
|
|
8378
8922
|
}
|
|
8379
8923
|
|
|
8380
|
-
// 7. Report.
|
|
8924
|
+
// 7. Report. A failing CI gate keeps its own exit code; when CI passed, a BLOCKER skill assertion
|
|
8925
|
+
// fails the command with EXIT.SKILL_BLOCKER (HC-GF-227). The report sent above is the CI gate's.
|
|
8926
|
+
const exitCode = verdict.exitCode === EXIT.PASS && skillAudit.blocked ? EXIT.SKILL_BLOCKER : verdict.exitCode;
|
|
8381
8927
|
const sym = (verdict.verdict === 'pass' || verdict.verdict === 'disabled') ? '✓' : verdict.verdict === 'pending' ? '⏳' : '✗';
|
|
8382
8928
|
emit(
|
|
8383
|
-
{ ok:
|
|
8929
|
+
{ ok: exitCode === EXIT.PASS, exitCode, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
|
|
8384
8930
|
`${sym} CI gate (${mode}): ${verdict.verdict} — ${verdict.detail}\n` +
|
|
8385
|
-
` skill audit: ${opts.skipSkillAudit ? 'skipped' : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
|
|
8931
|
+
` skill audit: ${opts.skipSkillAudit ? 'skipped' : skillAudit.blocked ? `✗ BLOCKER finding(s) — ${skillAudit.artifact}` : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
|
|
8386
8932
|
` → .github/pipeline/${storyId}/step-5c-ci.md`);
|
|
8387
|
-
process.exit(
|
|
8933
|
+
process.exit(exitCode);
|
|
8388
8934
|
});
|
|
8389
8935
|
|
|
8390
8936
|
// ── CLI setup ─────────────────────────────────────────────────────────────────
|