@hone-ai/cli 1.33.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hone-mcp.js +1 -0
- package/hone-cli.js +1289 -759
- 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/headroom.js +63 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +51 -6
- package/lib/materialize-diff.js +134 -64
- package/lib/mcp-tools.js +36 -11
- package/lib/pipeline-config.js +182 -0
- package/lib/private-temp.js +34 -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-report.js +36 -0
- 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/tree-artifact.js +166 -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,20 @@ 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');
|
|
32
42
|
const program = new Command();
|
|
33
43
|
|
|
34
44
|
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
@@ -41,6 +51,23 @@ function readRc() {
|
|
|
41
51
|
return {};
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the Hone-team admin key, or stop with the instruction for supplying it.
|
|
56
|
+
*
|
|
57
|
+
* HC-GF-239: `hone absorb` is the second command to need this, and the second copy of the same
|
|
58
|
+
* six lines is how the two drift. The key is read from the environment or ~/.honerc and NEVER
|
|
59
|
+
* taken as an argument — an argument lands in shell history, and this is a production key.
|
|
60
|
+
*/
|
|
61
|
+
function requireAdminKey() {
|
|
62
|
+
const adminKey = process.env.HONE_ADMIN_KEY || readRc().admin_key;
|
|
63
|
+
if (!adminKey) {
|
|
64
|
+
console.error('Error: Admin key not found.');
|
|
65
|
+
console.error('Set HONE_ADMIN_KEY env var, or add "admin_key" to ~/.honerc');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
return adminKey;
|
|
69
|
+
}
|
|
70
|
+
|
|
44
71
|
function getConfig() {
|
|
45
72
|
// Priority: env var → ~/.honerc → error
|
|
46
73
|
const rc = readRc();
|
|
@@ -152,19 +179,33 @@ process.on('exit', () => {
|
|
|
152
179
|
program
|
|
153
180
|
.command('agent-eval')
|
|
154
181
|
.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')
|
|
182
|
+
.option('--category <category>', 'Probe category (adversarial|faithfulness|safety|boundary|stateful)', 'adversarial')
|
|
156
183
|
.option('--target <name>', 'Run against a specific agent target (default: all configured)')
|
|
157
184
|
.option('--output <format>', 'Output format (text|json)', 'text')
|
|
158
185
|
.option('--judge', 'Add the free NLI judge layer (faithfulness only): claims are checked for entailment against the probe context')
|
|
159
186
|
.option('--provider <name>', 'Judge provider: gh-models (free, default) | claude (paid Sonnet)', 'gh-models')
|
|
160
187
|
.option('--report', 'Report the agent-eval verdict to the run (advisory) — needs --workflow-id')
|
|
161
188
|
.option('--workflow-id <id>', 'The run id to report the verdict against (for --report)')
|
|
189
|
+
.option('--timeout <s>', 'Seconds to allow each probe (default 5 — raise it for packs that need a real generation)', '5')
|
|
162
190
|
.action(async (opts) => {
|
|
163
191
|
const { readAgentEvalConfig } = require('./lib/pipeline-config');
|
|
164
192
|
const { runAgent } = require('./lib/agent-executor');
|
|
165
193
|
const { runCheck } = require('./lib/eval-graders');
|
|
194
|
+
const { scoreProbe, summarizeProbes, describeProbeSummary } = require('./lib/agent-eval-score');
|
|
166
195
|
const config = readAgentEvalConfig(process.cwd());
|
|
167
196
|
|
|
197
|
+
// HC-GF-197: validated, not coerced. `Number('abc')` is NaN, NaN is NOT nullish so the
|
|
198
|
+
// executor's `?? 5000` default never applies, and `setTimeout(NaN)` fires in 1ms — so a typo
|
|
199
|
+
// in this flag times out every probe instantly and reports them as WRONG ANSWERS. That is the
|
|
200
|
+
// defect HC-GF-198 exists to fix, reachable by mistyping a flag.
|
|
201
|
+
let probeTimeoutMs;
|
|
202
|
+
try {
|
|
203
|
+
probeTimeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error(e.message);
|
|
206
|
+
process.exit(2);
|
|
207
|
+
}
|
|
208
|
+
|
|
168
209
|
if (!config.targets || config.targets.length === 0) {
|
|
169
210
|
console.log('No agent targets configured in .pipeline-config.yml (agent_eval.targets)');
|
|
170
211
|
process.exit(0);
|
|
@@ -175,6 +216,9 @@ program
|
|
|
175
216
|
faithfulness: './lib/agent-eval-probes-faithfulness',
|
|
176
217
|
safety: './lib/agent-eval-probes-safety',
|
|
177
218
|
boundary: './lib/agent-eval-probes-boundary',
|
|
219
|
+
// HC-GF-197: calibration, not safety. The other four packs ask whether an agent misbehaves;
|
|
220
|
+
// this one asks whether ONE judgment is right, scored against stories the owner labelled.
|
|
221
|
+
stateful: './lib/agent-eval-probes-stateful',
|
|
178
222
|
};
|
|
179
223
|
let probes;
|
|
180
224
|
if (PROBE_PACKS[opts.category]) {
|
|
@@ -208,38 +252,85 @@ program
|
|
|
208
252
|
|
|
209
253
|
const results = [];
|
|
210
254
|
let anyFailed = false;
|
|
255
|
+
// HC-GF-199: pace from what the provider DOES, and run the probes where
|
|
256
|
+
// their writes cannot reach this repository.
|
|
257
|
+
const { createPacer } = require('./lib/agent-eval-pacing');
|
|
258
|
+
const pacer = createPacer();
|
|
259
|
+
const probeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-agent-eval-'));
|
|
260
|
+
let stopped = null;
|
|
261
|
+
let attempted = 0;
|
|
262
|
+
|
|
211
263
|
for (const target of targets) {
|
|
212
264
|
for (const probe of probes) {
|
|
213
|
-
|
|
265
|
+
if (stopped) break;
|
|
266
|
+
attempted += 1;
|
|
267
|
+
const wait = pacer.delayBeforeNext();
|
|
268
|
+
if (wait > 0) {
|
|
269
|
+
// Say it. A run that quietly slows down looks identical to a fast one,
|
|
270
|
+
// and that is how a degraded provider reads as a clean measurement.
|
|
271
|
+
if (opts.output !== 'json') {
|
|
272
|
+
console.log(` pacing: waiting ${Math.round(wait / 1000)}s — the provider refused the previous probe`);
|
|
273
|
+
}
|
|
274
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
275
|
+
pacer.noteWaited(wait);
|
|
276
|
+
}
|
|
277
|
+
// HC-GF-197: the default is 5s, sized for the deterministic packs — a canary string or a
|
|
278
|
+
// refusal comes back immediately. A calibration probe asks for a real grooming, which takes
|
|
279
|
+
// tens of seconds, so every one of them timed out and scored as a failed judgment. A
|
|
280
|
+
// verdict produced by a timeout is not a verdict about the agent.
|
|
281
|
+
const run = await runAgent(target, probe.input, { timeout: probeTimeoutMs, cwd: probeCwd, repoRoot: process.cwd() });
|
|
214
282
|
const output = run && typeof run.output === 'string' ? run.output : '';
|
|
215
283
|
const graders = probe.graders.map((g) => runCheck(output, g));
|
|
216
|
-
|
|
284
|
+
// HC-GF-198: three outcomes. A probe that never answered is not one that answered wrongly,
|
|
285
|
+
// and scoring them alike reported 4/8 for a run where 4 of the 5 that COMPLETED were right.
|
|
286
|
+
const scored = scoreProbe(run, graders);
|
|
287
|
+
let { outcome } = scored;
|
|
288
|
+
let passed = scored.passed;
|
|
217
289
|
|
|
218
290
|
// Judge layer: only for grounded probes (those with a context to entail against).
|
|
219
291
|
let judge = null;
|
|
220
|
-
if (judgeCall && probe.context) {
|
|
292
|
+
if (judgeCall && probe.context && outcome !== 'error') {
|
|
221
293
|
const { judgeFaithfulness } = require('./lib/agent-eval-judge');
|
|
222
294
|
judge = await judgeFaithfulness({ output, context: probe.context, callLLM: judgeCall });
|
|
223
|
-
passed
|
|
295
|
+
if (!judge.passed) { passed = false; outcome = 'fail'; }
|
|
224
296
|
}
|
|
225
297
|
if (!passed) anyFailed = true;
|
|
226
|
-
results.push({ target: target.name, probe: probe.id, passed, error: run.error || null,
|
|
298
|
+
results.push({ target: target.name, probe: probe.id, outcome, passed, error: run.error || null,
|
|
299
|
+
stderr: run.stderr || null, graders, judge });
|
|
300
|
+
|
|
301
|
+
pacer.record({ outcome });
|
|
302
|
+
stopped = pacer.stopReason();
|
|
227
303
|
}
|
|
228
304
|
}
|
|
229
305
|
|
|
306
|
+
try { fs.rmSync(probeCwd, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
307
|
+
|
|
308
|
+
const summary = summarizeProbes(results);
|
|
309
|
+
// HC-GF-199: a run that gave up must SAY so, and say how many probes it never
|
|
310
|
+
// reached. Reporting only what completed is how a dead run reads as a fast one.
|
|
311
|
+
const pacing = { ...pacer.summary(), stopped, attempted, planned: targets.length * probes.length };
|
|
312
|
+
if (stopped) {
|
|
313
|
+
anyFailed = true;
|
|
314
|
+
if (opts.output !== 'json') {
|
|
315
|
+
console.error(`\n⊘ run STOPPED: ${stopped}`);
|
|
316
|
+
console.error(` ${attempted} of ${pacing.planned} probes attempted — the rest were never run.`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
230
319
|
if (opts.output === 'json') {
|
|
231
|
-
console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, results }, null, 2));
|
|
320
|
+
console.log(JSON.stringify({ category: opts.category, passed: !anyFailed, summary, pacing, results }, null, 2));
|
|
232
321
|
} else {
|
|
233
322
|
for (const r of results) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
323
|
+
// HC-GF-198: three marks, because there are three outcomes. `✗` for a wrong answer and
|
|
324
|
+
// `⊘` for one that never came — printing both as `✗` is what made 3 timeouts read as 3
|
|
325
|
+
// wrong answers.
|
|
326
|
+
const mark = r.outcome === 'pass' ? '✓' : (r.outcome === 'error' ? '⊘' : '✗');
|
|
327
|
+
console.log(` ${mark} ${r.target} / ${r.probe}${r.error ? ` (no answer: ${r.error})` : ''}`);
|
|
328
|
+
if (r.outcome === 'fail') {
|
|
237
329
|
for (const g of r.graders.filter((x) => !x.passed)) console.log(` - ${g.type}: ${g.detail}`);
|
|
238
330
|
if (r.judge && !r.judge.passed) console.log(` - judge: ${r.judge.detail}`);
|
|
239
331
|
}
|
|
240
332
|
}
|
|
241
|
-
|
|
242
|
-
console.log(`\n${pass}/${results.length} probes passed (category: ${opts.category})`);
|
|
333
|
+
console.log(`\n${describeProbeSummary(summary)} (category: ${opts.category})`);
|
|
243
334
|
}
|
|
244
335
|
|
|
245
336
|
// Closed loop (HC-COMM-011-followup-5): report the verdict to the run. agent-eval
|
|
@@ -588,22 +679,31 @@ program
|
|
|
588
679
|
|
|
589
680
|
// Build parallel fetch list
|
|
590
681
|
const fetches = [
|
|
591
|
-
// Agent stubs
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
682
|
+
// Agent stubs and skill files. HC-GF-136's gate found `key`, `promise` and `write` here
|
|
683
|
+
// computed and asserted by nothing; the mapping is now a pure function in cli/lib. The REST
|
|
684
|
+
// of this list is untouched — an earlier attempt replaced the whole array and silently
|
|
685
|
+
// dropped ai-reviewer.js, check-learnings-gate.js and the ESM package.json, which would have
|
|
686
|
+
// broken setup for every adopter. The regression suite caught it.
|
|
687
|
+
...assetFetchList({
|
|
688
|
+
agents, skills,
|
|
689
|
+
// HC-GF-184: also pull whatever derive produced for THIS repo. Same basename rule the
|
|
690
|
+
// other commands use (`hone sync`, `hone derive`), so the three agree on the repo's name.
|
|
691
|
+
//
|
|
692
|
+
// `process.cwd()` directly, NOT `repoRoot`: the only `repoRoot` bindings near here are in
|
|
693
|
+
// ANOTHER command's action and inside `if (opts.refresh)`, which returns long before this
|
|
694
|
+
// line. Referencing it threw `ReferenceError` on the normal setup path — for every
|
|
695
|
+
// adopter — while `node --check` passed and the unit tests, which drive assetFetchList
|
|
696
|
+
// directly, never evaluated it.
|
|
697
|
+
derivedRepo: path.basename(process.cwd()),
|
|
698
|
+
get: (url) => client.get(url).then(r => r.data),
|
|
699
|
+
writeAgent: (agent, data) =>
|
|
700
|
+
fs.writeFileSync(path.join(agentsDir, `${agent}.agent.md`), data),
|
|
701
|
+
writeSkill: (skill, data) => {
|
|
602
702
|
const skillDir = path.join(skillsDir, skill);
|
|
603
703
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
604
704
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), data);
|
|
605
705
|
},
|
|
606
|
-
})
|
|
706
|
+
}),
|
|
607
707
|
// Copilot instructions
|
|
608
708
|
{
|
|
609
709
|
key: 'copilot-instructions.md',
|
|
@@ -994,6 +1094,27 @@ program
|
|
|
994
1094
|
|
|
995
1095
|
// ── Test framework installation helpers ────────────────────────────────────────
|
|
996
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* HC-GF-190: what the sandbox WOULD have done, for the paths that end before it is asked.
|
|
1099
|
+
*
|
|
1100
|
+
* Builds the plan the run never got to and describes it. `planSandboxRun` is pure — it runs no
|
|
1101
|
+
* commands — so this is safe on an early exit, and it is the only way an operator can tell a
|
|
1102
|
+
* sandbox that was SKIPPED from one that was never configured.
|
|
1103
|
+
*/
|
|
1104
|
+
function describeSandboxDisposition(runConfig) {
|
|
1105
|
+
try {
|
|
1106
|
+
const { readSandboxConfig } = require('./lib/pipeline-config');
|
|
1107
|
+
const { planSandboxRun, describeNotReached } = require('./lib/sandbox-lifecycle');
|
|
1108
|
+
return describeNotReached(planSandboxRun({
|
|
1109
|
+
sandbox: readSandboxConfig(process.cwd()),
|
|
1110
|
+
axes: runConfig?.classifier_axes || runConfig?.axes || [],
|
|
1111
|
+
capability: runConfig?.capability || null,
|
|
1112
|
+
}));
|
|
1113
|
+
} catch {
|
|
1114
|
+
return null; // reporting must never be the thing that breaks a run
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
997
1118
|
function detectExistingTestFramework(repoRoot) {
|
|
998
1119
|
const pkgPath = path.join(repoRoot, 'package.json');
|
|
999
1120
|
if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
|
|
@@ -1311,7 +1432,14 @@ async function installTestFramework(repoRoot, opts) {
|
|
|
1311
1432
|
console.log('');
|
|
1312
1433
|
console.log('── Verification ────────────────────────────────────────');
|
|
1313
1434
|
try {
|
|
1314
|
-
|
|
1435
|
+
// HC-GF-144: `node` exports NODE_TEST_CONTEXT to the processes IT spawns, and this repo's
|
|
1436
|
+
// regression suites spawn the CLI. Inherited, it makes a FAILING `node --test` exit 0 — so
|
|
1437
|
+
// this verification would print "npm test passed" for a red repository, which is the one
|
|
1438
|
+
// thing it exists not to do. Only the verdict-flipping variables are removed; the adopter's
|
|
1439
|
+
// toolchain environment is otherwise passed through untouched.
|
|
1440
|
+
execSync('npm test 2>&1', {
|
|
1441
|
+
cwd: repoRoot, stdio: 'inherit', timeout: 30_000, env: verdictEnv(),
|
|
1442
|
+
});
|
|
1315
1443
|
console.log(' ✓ npm test passed');
|
|
1316
1444
|
} catch {
|
|
1317
1445
|
console.log(' ⚠ npm test had issues — review test output above');
|
|
@@ -1477,15 +1605,42 @@ program
|
|
|
1477
1605
|
const maxFiles = parseInt(opts.maxFiles);
|
|
1478
1606
|
let fileCount = 0;
|
|
1479
1607
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1608
|
+
// HC-GF-178: allocate the cap ACROSS the declared dirs. The previous loop drained them in
|
|
1609
|
+
// order and `break`-ed at the cap, so `cli/` (142 files vs a default of 30) filled it alone and
|
|
1610
|
+
// server/scripts/enterprise-assets/.github were never read — while the output still said
|
|
1611
|
+
// "Bundling 30 source files...". The derive agent graded the repo on a fifth of it and said so;
|
|
1612
|
+
// the CLI said nothing.
|
|
1613
|
+
const { planBundle } = require('./lib/bundle-source-dirs');
|
|
1614
|
+
const perDirFiles = new Map();
|
|
1615
|
+
const plan = planBundle({
|
|
1616
|
+
sourceDirs,
|
|
1617
|
+
maxFiles,
|
|
1618
|
+
listFiles: (dir) => {
|
|
1619
|
+
const dirPath = path.join(repoRoot, dir.replace(/"/g, ''));
|
|
1620
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
1621
|
+
// COST, stated rather than hidden: this reads every bundlable file in the directory to
|
|
1622
|
+
// plan a selection of a few. On this repo that is ~1,400 files (each capped at 50,000
|
|
1623
|
+
// chars by bundleDirectory) to choose 100, and a derive run still completes in ~90s. It is
|
|
1624
|
+
// the honest shape — you cannot allocate a budget across sources without knowing what each
|
|
1625
|
+
// holds — but on a very large monorepo it is the first thing to optimise, by separating
|
|
1626
|
+
// listing from reading. Filed as a follow-up rather than guessed at now.
|
|
1627
|
+
const collected = {};
|
|
1628
|
+
bundleDirectory(dirPath, repoRoot, collected, Number.MAX_SAFE_INTEGER, 0);
|
|
1629
|
+
perDirFiles.set(dir, collected);
|
|
1630
|
+
return Object.keys(collected);
|
|
1631
|
+
},
|
|
1632
|
+
});
|
|
1633
|
+
for (const rel of plan.selected) {
|
|
1634
|
+
for (const collected of perDirFiles.values()) {
|
|
1635
|
+
if (Object.prototype.hasOwnProperty.call(collected, rel)) { files[rel] = collected[rel]; break; }
|
|
1636
|
+
}
|
|
1486
1637
|
}
|
|
1638
|
+
fileCount = Object.keys(files).length;
|
|
1487
1639
|
|
|
1488
1640
|
console.log(`Bundling ${Object.keys(files).length} source files...`);
|
|
1641
|
+
// Say what was left out. An unreported omission is what made this defect survive: the agent
|
|
1642
|
+
// could see the gap in its own input and the operator could not.
|
|
1643
|
+
for (const line of plan.dropped) console.log(` ⚠ not fully bundled — ${line}`);
|
|
1489
1644
|
|
|
1490
1645
|
// 3. Bundle existing skills if refresh mode
|
|
1491
1646
|
let existingSkills = {};
|
|
@@ -2226,14 +2381,8 @@ program
|
|
|
2226
2381
|
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
2227
2382
|
.action(async (opts) => {
|
|
2228
2383
|
const rc = readRc();
|
|
2229
|
-
const adminKey = process.env.HONE_ADMIN_KEY || rc.admin_key;
|
|
2230
2384
|
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
|
-
}
|
|
2385
|
+
const adminKey = requireAdminKey();
|
|
2237
2386
|
|
|
2238
2387
|
try {
|
|
2239
2388
|
const { data } = await axios.get(`${apiUrl}/admin/usage`, {
|
|
@@ -2643,15 +2792,7 @@ program
|
|
|
2643
2792
|
}
|
|
2644
2793
|
|
|
2645
2794
|
// 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
|
-
}));
|
|
2795
|
+
const payload = promotePayload(eligible);
|
|
2655
2796
|
|
|
2656
2797
|
try {
|
|
2657
2798
|
const r = await client.post('/learnings/promote', { learnings: payload });
|
|
@@ -2660,7 +2801,11 @@ program
|
|
|
2660
2801
|
} catch (e) {
|
|
2661
2802
|
console.error(`\nPromotion failed: ${e.response?.data?.error || e.message}`);
|
|
2662
2803
|
console.error('Local files unchanged — safe to retry.');
|
|
2663
|
-
|
|
2804
|
+
// HC-GF-207: a failed promotion must be a failed command. Returning here exited 0, so
|
|
2805
|
+
// `make promote` and the auto-promote workflow went green on ten consecutive runs that
|
|
2806
|
+
// promoted nothing (HC-GF-236). A dry run and a cancelled prompt still exit 0 — those
|
|
2807
|
+
// are refusals, not failures.
|
|
2808
|
+
process.exit(1);
|
|
2664
2809
|
}
|
|
2665
2810
|
|
|
2666
2811
|
// 6. Update local YAML: status → promoted (per-schema, idempotent).
|
|
@@ -2670,23 +2815,220 @@ program
|
|
|
2670
2815
|
if (!promotedByFile.has(l._filePath)) promotedByFile.set(l._filePath, []);
|
|
2671
2816
|
promotedByFile.get(l._filePath).push(l.id);
|
|
2672
2817
|
}
|
|
2818
|
+
// HC-GF-208: an UNCHANGED file is not a success. writeBackPromoted returns the
|
|
2819
|
+
// content untouched when it cannot flip a status; fs.writeFileSync then succeeds
|
|
2820
|
+
// on identical bytes, and this loop used to print the same tick either way — so
|
|
2821
|
+
// the log said 296 files flipped while the filesystem had 220. Eligibility already
|
|
2822
|
+
// excludes anything promoted, so for these ids "unchanged" can only mean failure:
|
|
2823
|
+
// there is no idempotent-rerun case to mistake it for.
|
|
2824
|
+
const unflipped = [];
|
|
2673
2825
|
for (const [filePath, ids] of promotedByFile) {
|
|
2826
|
+
const fileName = path.basename(filePath);
|
|
2674
2827
|
try {
|
|
2675
2828
|
const schema = fileSchemas[filePath];
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2829
|
+
const before = fs.readFileSync(filePath, 'utf8');
|
|
2830
|
+
const after = writeBackPromoted(before, schema, ids);
|
|
2831
|
+
if (after === before) {
|
|
2832
|
+
unflipped.push(fileName);
|
|
2833
|
+
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`);
|
|
2834
|
+
continue;
|
|
2835
|
+
}
|
|
2836
|
+
fs.writeFileSync(filePath, after);
|
|
2837
|
+
console.log(` \u2713 ${fileName}: ${ids.length} learning(s) \u2192 promoted`);
|
|
2681
2838
|
} catch (e) {
|
|
2682
|
-
|
|
2839
|
+
unflipped.push(fileName);
|
|
2840
|
+
console.warn(` \u26a0 ${fileName}: failed to update local file \u2014 ${e.message}`);
|
|
2683
2841
|
}
|
|
2684
2842
|
}
|
|
2685
2843
|
|
|
2844
|
+
if (unflipped.length) {
|
|
2845
|
+
console.warn(`\n\u26a0 ${unflipped.length} file(s) were sent but NOT marked promoted locally.`);
|
|
2846
|
+
console.warn(' Their learnings remain eligible and will be re-sent on the next run.');
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2686
2849
|
console.log('\nDone. Commit the updated learnings files:');
|
|
2687
2850
|
console.log(' git add .github/learnings/ && git commit -m "chore: promote learnings to enterprise"');
|
|
2688
2851
|
});
|
|
2689
2852
|
|
|
2853
|
+
// ── PROMOTED command (HC-GF-210) ─────────────────────────────────────────────
|
|
2854
|
+
// The consumer for GET /learnings/promoted. HC-GF-159b built that route as "THE READER",
|
|
2855
|
+
// on the premise that promotion without read-back is a write-only counter — and then nothing
|
|
2856
|
+
// called it. Auditing the production queue on 2026-09-20 meant hand-rolling an HTTPS call,
|
|
2857
|
+
// and that audit found 203 of 736 rows carrying an EMPTY enterprise_summary, invisible for
|
|
2858
|
+
// five months because no shipped tool could show them. So the two counts in the footer are
|
|
2859
|
+
// the point of this command, not decoration: a promoted row with no summary, or none a run
|
|
2860
|
+
// can reach, was promoted into nothing, and only a read-back can say so.
|
|
2861
|
+
program
|
|
2862
|
+
.command('promoted')
|
|
2863
|
+
.description("List the learnings this org has promoted, with their content (HC-GF-210)")
|
|
2864
|
+
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
2865
|
+
.option('--empty', 'Show only rows whose enterprise summary is missing or blank')
|
|
2866
|
+
.option('--unrouted', 'Show only rows that name no skill, prompt or script to reach')
|
|
2867
|
+
// HC-GF-238: absorbing a row removed it from this command, because the route filtered on one
|
|
2868
|
+
// status. The default is still `promoted` — nothing an existing caller sees changes — and these
|
|
2869
|
+
// two options are how an operator reads back what the team actually LANDED.
|
|
2870
|
+
.option('--status <status>', 'Lifecycle status: promoted (default) | absorbed | retracted | pending | overdue | all')
|
|
2871
|
+
.option('--all', 'Every status, whatever a row has reached — the same as --status all')
|
|
2872
|
+
.action(async (opts) => {
|
|
2873
|
+
const config = getConfig();
|
|
2874
|
+
const client = api(config);
|
|
2875
|
+
const status = opts.all ? 'all' : (opts.status || 'promoted');
|
|
2876
|
+
try {
|
|
2877
|
+
// The default request is byte-identical to the one that shipped — no query string at all —
|
|
2878
|
+
// so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
|
|
2879
|
+
// parameter appears only when the operator asked for a status other than the default.
|
|
2880
|
+
const qs = status === 'promoted' ? '' : `?status=${encodeURIComponent(status)}`;
|
|
2881
|
+
const { data } = await client.get(`/learnings/promoted${qs}`);
|
|
2882
|
+
const all = Array.isArray(data.learnings) ? data.learnings : [];
|
|
2883
|
+
// The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
|
|
2884
|
+
// router found no target; a row promoted before that story carries neither field at all.
|
|
2885
|
+
const isEmpty = (l) => !String(l.enterpriseSummary || '').trim();
|
|
2886
|
+
const isUnrouted = (l) => !String(l.targetSkill || '').trim()
|
|
2887
|
+
|| String(l.targetKind || 'unrouted') === 'unrouted';
|
|
2888
|
+
const empties = all.filter(isEmpty);
|
|
2889
|
+
const unrouted = all.filter(isUnrouted);
|
|
2890
|
+
let shown = all;
|
|
2891
|
+
if (opts.empty) shown = shown.filter(isEmpty);
|
|
2892
|
+
if (opts.unrouted) shown = shown.filter(isUnrouted);
|
|
2893
|
+
|
|
2894
|
+
if (opts.format === 'json') {
|
|
2895
|
+
console.log(JSON.stringify({
|
|
2896
|
+
orgId: data.orgId,
|
|
2897
|
+
status,
|
|
2898
|
+
count: all.length,
|
|
2899
|
+
empty: empties.length,
|
|
2900
|
+
unrouted: unrouted.length,
|
|
2901
|
+
learnings: shown,
|
|
2902
|
+
}, null, 2));
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
console.log(status === 'promoted' ? 'Hone AI — Promoted Learnings' : `Hone AI — Learnings (${status})`);
|
|
2907
|
+
console.log('================================');
|
|
2908
|
+
if (!all.length) {
|
|
2909
|
+
console.log(status === 'promoted'
|
|
2910
|
+
? "\nNothing promoted yet. Send this repo's learnings with: hone promote"
|
|
2911
|
+
: `\nNo learnings with status '${status}'.`);
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
// ASCII on purpose, and read by column position: same reasoning as `hone workflows` (HC-GF-108).
|
|
2915
|
+
const clip = (s, w) => (s.length > w ? `${s.slice(0, w - 3)}...` : s);
|
|
2916
|
+
const day = (ts) => { const d = ts ? new Date(ts) : null; return d && !isNaN(d) ? d.toISOString().slice(0, 10) : '-'; };
|
|
2917
|
+
console.log('');
|
|
2918
|
+
// The STATUS column appears ONLY when the listing can hold more than one status. This table
|
|
2919
|
+
// is read by column position (HC-GF-108), so the default `hone promoted` keeps the exact
|
|
2920
|
+
// layout it shipped with and no existing reader shifts by a column.
|
|
2921
|
+
const mixed = status === 'all';
|
|
2922
|
+
console.log([
|
|
2923
|
+
...(mixed ? ['STATUS'.padEnd(10)] : []),
|
|
2924
|
+
'PROMOTED'.padEnd(11), 'TARGET'.padEnd(24), 'KIND'.padEnd(9), 'LEARNING',
|
|
2925
|
+
].join(' '));
|
|
2926
|
+
for (const l of shown) {
|
|
2927
|
+
console.log([
|
|
2928
|
+
...(mixed ? [String(l.status || '-').padEnd(10)] : []),
|
|
2929
|
+
day(l.promotedAt).padEnd(11),
|
|
2930
|
+
clip(String(l.targetSkill || '-'), 23).padEnd(24),
|
|
2931
|
+
String(l.targetKind || 'unrouted').padEnd(9),
|
|
2932
|
+
String(l.id || '-'),
|
|
2933
|
+
].join(' '));
|
|
2934
|
+
const summary = String(l.enterpriseSummary || '').trim();
|
|
2935
|
+
console.log(` ${summary ? clip(summary.replace(/\s+/g, ' '), 100) : '(no enterprise summary — this row carries no lesson)'}`);
|
|
2936
|
+
// The destination is the whole reason `absorbed` is worth reading back: it names where
|
|
2937
|
+
// the lesson now lives, which is what makes the claim checkable rather than asserted.
|
|
2938
|
+
if (l.absorbedIn) console.log(` absorbed into: ${l.absorbedIn}${l.absorbedAt ? ` (${day(l.absorbedAt)})` : ''}`);
|
|
2939
|
+
}
|
|
2940
|
+
if (shown.length !== all.length) console.log(`\nShowing ${shown.length} of ${all.length}.`);
|
|
2941
|
+
console.log(`\n${all.length} promoted · ${empties.length} with no summary · ${unrouted.length} unrouted`);
|
|
2942
|
+
if (empties.length || unrouted.length) {
|
|
2943
|
+
console.log('A row with no summary, or none a run can reach, was promoted into nothing.');
|
|
2944
|
+
console.log('Inspect: hone promoted --empty · hone promoted --unrouted · Retract: hone retract <id> --reason "..."');
|
|
2945
|
+
}
|
|
2946
|
+
} catch (e) {
|
|
2947
|
+
// HC-GF-207, same class: a read that never happened is not an empty queue. Exit non-zero
|
|
2948
|
+
// so a caller cannot mistake a dead endpoint for "this org has promoted nothing".
|
|
2949
|
+
console.error(`Failed to read promoted learnings: ${e.response?.data?.error || e.message}`);
|
|
2950
|
+
process.exit(1);
|
|
2951
|
+
}
|
|
2952
|
+
});
|
|
2953
|
+
|
|
2954
|
+
// ── ABSORB command (HC-GF-239) ───────────────────────────────────────────────
|
|
2955
|
+
// The Hone-team half of the flywheel's outbox. `hone promote`, `hone retract` and `hone promoted`
|
|
2956
|
+
// are commands; absorbing — the act that ENDS a learning's life — was a hand-built curl with
|
|
2957
|
+
// ADMIN_KEY pasted on the command line, which is how a production key reaches shell history. It
|
|
2958
|
+
// is also the step most likely to be skipped, because it is the one with no affordance.
|
|
2959
|
+
//
|
|
2960
|
+
// The key is read from the environment or ~/.honerc, never from an argument (the `admin-usage`
|
|
2961
|
+
// precedent). `--in` is required by commander AND re-checked here, so a missing or blank
|
|
2962
|
+
// destination is refused BEFORE a request is built — an absorption with no destination is the
|
|
2963
|
+
// unverifiable claim this queue already holds hundreds of.
|
|
2964
|
+
program
|
|
2965
|
+
.command('absorb <ids...>')
|
|
2966
|
+
.description('Admin: mark promoted learnings absorbed, naming where the lesson now lives (HC-GF-239)')
|
|
2967
|
+
.requiredOption('--in <destination>', 'Where the lesson now lives: a skill section, prompt, gate script or PR')
|
|
2968
|
+
.option('--org <uuid>', "Org whose learnings to absorb (default: this CLI token's own org)")
|
|
2969
|
+
.option('--repo <name>', 'Restrict to one repository')
|
|
2970
|
+
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
2971
|
+
.action(async (ids, opts) => {
|
|
2972
|
+
const destination = String(opts.in || '').trim();
|
|
2973
|
+
if (!destination) {
|
|
2974
|
+
console.error('Error: --in must name where the lesson now lives (a skill section, prompt, gate script or PR).');
|
|
2975
|
+
console.error('An absorption with no destination cannot be checked by anyone later.');
|
|
2976
|
+
process.exit(1);
|
|
2977
|
+
}
|
|
2978
|
+
const wanted = [...new Set(ids.map((x) => String(x || '').trim()).filter(Boolean))];
|
|
2979
|
+
if (!wanted.length) {
|
|
2980
|
+
console.error('Error: name at least one learning id to absorb.');
|
|
2981
|
+
process.exit(1);
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
const adminKey = requireAdminKey();
|
|
2985
|
+
// `getConfig()` already resolves the base (HONE_API -> ~/.honerc -> default) and exits with a
|
|
2986
|
+
// usable message when no token is configured. Re-deriving it here would have added a TENTH
|
|
2987
|
+
// copy of the `api.hone.ai` literal that [[HC-GF-236]] is trying to reduce to one.
|
|
2988
|
+
const config = getConfig();
|
|
2989
|
+
const apiUrl = config.apiUrl;
|
|
2990
|
+
|
|
2991
|
+
try {
|
|
2992
|
+
// The org defaults to the one this CLI token already authenticates as, so the common case
|
|
2993
|
+
// (a Hone operator absorbing their own repo's learnings) needs no uuid on the command line.
|
|
2994
|
+
let orgId = opts.org;
|
|
2995
|
+
if (!orgId) {
|
|
2996
|
+
const { data } = await api(config).get('/learnings/promoted');
|
|
2997
|
+
orgId = data.orgId;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
const { data } = await axios.post(`${apiUrl}/admin/learnings/absorb`, {
|
|
3001
|
+
orgId, ids: wanted, repo: opts.repo, absorbedIn: destination,
|
|
3002
|
+
}, {
|
|
3003
|
+
headers: { 'x-admin-key': adminKey, 'User-Agent': `@hone-ai/cli/${pkg.version}` },
|
|
3004
|
+
timeout: 15000,
|
|
3005
|
+
});
|
|
3006
|
+
|
|
3007
|
+
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
3008
|
+
|
|
3009
|
+
console.log('');
|
|
3010
|
+
console.log('Hone AI — Absorb Learnings');
|
|
3011
|
+
console.log('================================');
|
|
3012
|
+
console.log(`Destination: ${data.absorbedIn}`);
|
|
3013
|
+
console.log('');
|
|
3014
|
+
for (const a of data.absorbed) console.log(` absorbed ${a.id}${a.repoName ? ` (${a.repoName})` : ''}`);
|
|
3015
|
+
// Skipped rows are printed, never summarised away: "3 of 5 absorbed" without the other two
|
|
3016
|
+
// is the shape of report this whole capability exists to stop producing.
|
|
3017
|
+
for (const sk of data.skipped) console.log(` skipped ${sk.id} — ${sk.reason}`);
|
|
3018
|
+
console.log('');
|
|
3019
|
+
console.log(`${data.count} absorbed · ${data.skipped.length} skipped`);
|
|
3020
|
+
if (data.count) console.log('Read them back with: hone promoted --status absorbed');
|
|
3021
|
+
if (data.skipped.length) process.exitCode = 1;
|
|
3022
|
+
} catch (e) {
|
|
3023
|
+
const status = e.response?.status;
|
|
3024
|
+
if (status === 401) console.error('Invalid admin key. Check HONE_ADMIN_KEY or ~/.honerc admin_key.');
|
|
3025
|
+
else if (status === 429) console.error('Too many failed admin-auth attempts from this address. Wait for the window to pass.');
|
|
3026
|
+
else if (status === 503) console.error(`Server cannot absorb right now: ${e.response?.data?.error || 'unavailable'}`);
|
|
3027
|
+
else console.error(`Absorb failed: ${e.response?.data?.error || e.message}`);
|
|
3028
|
+
process.exit(1);
|
|
3029
|
+
}
|
|
3030
|
+
});
|
|
3031
|
+
|
|
2690
3032
|
// ── RETRACT command (H-023) ──────────────────────────────────────────────────
|
|
2691
3033
|
// Two modes:
|
|
2692
3034
|
// 1. Single retract: `hone retract <LEARNING-ID> --reason "..."`
|
|
@@ -4950,12 +5292,19 @@ program
|
|
|
4950
5292
|
|
|
4951
5293
|
// audit runner. Shells out to `git diff origin/<base>...HEAD`, walks
|
|
4952
5294
|
// `.github/skills/`, calls SA-001's `runAssertions()`, writes
|
|
4953
|
-
// `.github/pipeline/<STORY-ID>/step-5b-skill-audit.md`.
|
|
4954
|
-
//
|
|
5295
|
+
// `.github/pipeline/<STORY-ID>/step-5b-skill-audit.md`. HC-GF-227: a BLOCKER
|
|
5296
|
+
// finding fails the command (exit 2, after the artifact is written); WARN and
|
|
5297
|
+
// INFO stay informational. The deferral this replaced named OptionsFlow's E29-G,
|
|
5298
|
+
// which no row in this roadmap owned.
|
|
5299
|
+
/** `hone step-5b`'s exit code when a BLOCKER assertion fired (HC-GF-227). Not 1: that is a crash. */
|
|
5300
|
+
const STEP_5B_BLOCKED = 2;
|
|
4955
5301
|
program
|
|
4956
5302
|
.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.
|
|
5303
|
+
.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
5304
|
.option('--base <branch>', 'base branch to diff against', 'develop')
|
|
5305
|
+
.option('--advisory', 'report BLOCKER findings without failing (exit 0), for a caller that wants only the artifact')
|
|
5306
|
+
.option('--all-skills', 'evaluate every skill, not only those the story\'s learnings reference (a repository-wide gate)')
|
|
5307
|
+
.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
5308
|
.option('--story-id <id>', 'override story id (defaults to extraction from current branch name)')
|
|
4960
5309
|
.action(async (cmdOpts) => {
|
|
4961
5310
|
const { runAssertions } = require('./lib/skill-assertions');
|
|
@@ -4991,6 +5340,17 @@ program
|
|
|
4991
5340
|
} catch { /* leave empty */ }
|
|
4992
5341
|
}
|
|
4993
5342
|
|
|
5343
|
+
// 2b. HC-GF-310: the branch's commit messages, for `require_commit_line` assertions. A failed read
|
|
5344
|
+
// leaves them undefined, and the engine then warns instead of judging — never a silent pass.
|
|
5345
|
+
let commitMessages;
|
|
5346
|
+
for (const range of [`origin/${baseBranch}..HEAD`, `${baseBranch}..HEAD`]) {
|
|
5347
|
+
try {
|
|
5348
|
+
commitMessages = execSync(`git log --format=%B ${range}`,
|
|
5349
|
+
{ cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
|
|
5350
|
+
break;
|
|
5351
|
+
} catch { /* try the next range */ }
|
|
5352
|
+
}
|
|
5353
|
+
|
|
4994
5354
|
// 3. Walk skills. Adopters live at .github/skills/; the hone-server
|
|
4995
5355
|
// framework dogfoods against server/seeds/skills/ (same fallback as
|
|
4996
5356
|
// `hone audit-skills`).
|
|
@@ -5028,7 +5388,8 @@ program
|
|
|
5028
5388
|
const result = runAssertions({
|
|
5029
5389
|
diff,
|
|
5030
5390
|
skills,
|
|
5031
|
-
referencedSkills: referencedSkills.length > 0 ? referencedSkills : undefined,
|
|
5391
|
+
referencedSkills: referencedSkills.length > 0 && !cmdOpts.allSkills ? referencedSkills : undefined,
|
|
5392
|
+
commitMessages,
|
|
5032
5393
|
});
|
|
5033
5394
|
|
|
5034
5395
|
// 6. Render + write artifact
|
|
@@ -5039,7 +5400,7 @@ program
|
|
|
5039
5400
|
baseBranch,
|
|
5040
5401
|
referencedSkills,
|
|
5041
5402
|
});
|
|
5042
|
-
const outDir = path.join(repoRoot, '.github/pipeline', storyId);
|
|
5403
|
+
const outDir = cmdOpts.artifactDir ? path.resolve(repoRoot, cmdOpts.artifactDir) : path.join(repoRoot, '.github/pipeline', storyId);
|
|
5043
5404
|
fs.mkdirSync(outDir, { recursive: true });
|
|
5044
5405
|
const outPath = path.join(outDir, 'step-5b-skill-audit.md');
|
|
5045
5406
|
fs.writeFileSync(outPath, artifact);
|
|
@@ -5063,7 +5424,16 @@ program
|
|
|
5063
5424
|
}, { BLOCKER: 0, WARN: 0, INFO: 0 }),
|
|
5064
5425
|
}, null, 2));
|
|
5065
5426
|
|
|
5066
|
-
//
|
|
5427
|
+
// HC-GF-227: a BLOCKER finding fails the audit. The skill's author chose the severity, so the
|
|
5428
|
+
// gate is theirs; WARN and INFO stay informational. Exit 2, not 1: node exits 1 when it crashes,
|
|
5429
|
+
// and a caller (verify-pr) must be able to tell a verdict from a failure to produce one.
|
|
5430
|
+
const blockers = result.findings.filter((f) => f.severity === 'BLOCKER');
|
|
5431
|
+
if (blockers.length && !cmdOpts.advisory) {
|
|
5432
|
+
console.error(`step-5b: ${blockers.length} BLOCKER finding(s): ` +
|
|
5433
|
+
blockers.map((f) => `${f.skillId}/${f.assertionId} at ${f.matchedFile}${f.matchedLine != null ? `:${f.matchedLine}` : ''}`).join('; ') +
|
|
5434
|
+
` — see ${outPath.startsWith(repoRoot + path.sep) ? path.relative(repoRoot, outPath) : outPath}`);
|
|
5435
|
+
process.exit(STEP_5B_BLOCKED);
|
|
5436
|
+
}
|
|
5067
5437
|
process.exit(0);
|
|
5068
5438
|
});
|
|
5069
5439
|
|
|
@@ -5552,7 +5922,9 @@ program
|
|
|
5552
5922
|
const config = getConfig();
|
|
5553
5923
|
const client = api(config);
|
|
5554
5924
|
try {
|
|
5555
|
-
|
|
5925
|
+
// HC-GF-092: advertise what this CLI can honour. The server serves the highest it also
|
|
5926
|
+
// knows, so an older CLI — which sends nothing — keeps being served contract 1.
|
|
5927
|
+
const { data } = await client.get(`/orchestrate/${encodeURIComponent(runId)}/step-instruction?contracts=1,2`);
|
|
5556
5928
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
5557
5929
|
console.log(`Step: ${data.stepKey} (${data.agent})`);
|
|
5558
5930
|
console.log(`Contract: ${data.contractVersion}`);
|
|
@@ -5573,15 +5945,36 @@ program
|
|
|
5573
5945
|
.command('submit-step <runId> <stepKey>')
|
|
5574
5946
|
.description('Submit editor-executed output for an awaiting_editor step (HC-COMM-010 B3)')
|
|
5575
5947
|
.option('--file <path>', 'Read the step output from this file')
|
|
5948
|
+
.option('--from-tree', 'HC-GF-091: render the artifact from the working tree against HEAD instead of reading one file — the agent writes files with its own tools, so no single model response has to hold the whole build')
|
|
5949
|
+
.option('--test-report <text>', 'With --from-tree: the test-run report to carry verbatim. Never invented when absent.')
|
|
5576
5950
|
.option('--contract <version>', 'Instruction contract version (from step-instruction)', '1')
|
|
5577
5951
|
.option('--format <fmt>', 'Output format: pretty | json', 'pretty')
|
|
5578
5952
|
.action(async (runId, stepKey, opts) => {
|
|
5579
5953
|
const config = getConfig();
|
|
5580
5954
|
const client = api(config);
|
|
5581
5955
|
try {
|
|
5582
|
-
if (
|
|
5956
|
+
if (opts.file && opts.fromTree) { console.error('Use --file OR --from-tree, not both: they are two different claims about where the build is.'); process.exit(1); }
|
|
5957
|
+
// HC-GF-092: under contract 2 the instruction told the agent to WRITE the files with its own
|
|
5958
|
+
// tools. A hand-assembled --file artifact means the instruction and the submission disagree
|
|
5959
|
+
// about where the build is, and the server cannot see that — only this side can.
|
|
5960
|
+
if (String(opts.contract) === '2' && !opts.fromTree) {
|
|
5961
|
+
console.error('Contract 2 is the from-tree contract: the instruction told the agent to write the files with its own tools. Re-run with --from-tree, or fetch a contract 1 instruction if you really did assemble the output by hand.');
|
|
5962
|
+
process.exit(1);
|
|
5963
|
+
}
|
|
5964
|
+
if (!opts.file && !opts.fromTree) { console.error('Provide the output via --file <path> (the text your editor produced), or --from-tree to render it from the working tree.'); process.exit(1); }
|
|
5583
5965
|
const fs = require('fs');
|
|
5584
|
-
|
|
5966
|
+
let output;
|
|
5967
|
+
if (opts.fromTree) {
|
|
5968
|
+
const { renderFromTree } = require('./lib/tree-artifact');
|
|
5969
|
+
const rendered = renderFromTree({ repoRoot: process.cwd(), testReport: opts.testReport });
|
|
5970
|
+
output = rendered.text;
|
|
5971
|
+
// Say what was left out BEFORE the submission, not after: a file the tree has and the
|
|
5972
|
+
// artifact does not is a decision the operator has to be able to see.
|
|
5973
|
+
for (const s2 of rendered.skipped) console.error(`not included: ${s2.path} — ${s2.reason}`);
|
|
5974
|
+
console.error(`from-tree: ${rendered.files.length} file(s), ${rendered.deletes.length} deletion(s), ${rendered.skipped.length} skipped`);
|
|
5975
|
+
} else {
|
|
5976
|
+
output = fs.readFileSync(opts.file, 'utf8');
|
|
5977
|
+
}
|
|
5585
5978
|
const { data } = await client.post(
|
|
5586
5979
|
`/orchestrate/${encodeURIComponent(runId)}/step/${encodeURIComponent(stepKey)}/editor-output`,
|
|
5587
5980
|
{ contractVersion: opts.contract, output }
|
|
@@ -5599,6 +5992,100 @@ program
|
|
|
5599
5992
|
}
|
|
5600
5993
|
});
|
|
5601
5994
|
|
|
5995
|
+
// HC-GF-097: the I/O cli/lib/run-context.js is handed — every disk, git, GitHub and Hone read a
|
|
5996
|
+
// run's config needs, bound to this checkout. run-story and queue-stories build from the same one.
|
|
5997
|
+
function runContextIo(apiConfig) {
|
|
5998
|
+
const { gitEnv } = require('./lib/git-env');
|
|
5999
|
+
const pc = require('./lib/pipeline-config');
|
|
6000
|
+
const cwd = process.cwd();
|
|
6001
|
+
// Repository-scoped: a path that leaves the checkout neither exists nor reads.
|
|
6002
|
+
const inRepo = (rel) => {
|
|
6003
|
+
if (typeof rel !== 'string' || rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return null;
|
|
6004
|
+
const abs = path.resolve(cwd, rel);
|
|
6005
|
+
return abs.startsWith(cwd + path.sep) ? abs : null;
|
|
6006
|
+
};
|
|
6007
|
+
return {
|
|
6008
|
+
// Every subprocess this adapter starts carries a timeout: a hung git or gh must fail open with a
|
|
6009
|
+
// message, not stall the command (node-developer hard rule 5). The module turns each failure into
|
|
6010
|
+
// a note. (recentlyModifiedOverlap's `git log` lives in story-classifier-extract.js, unchanged.)
|
|
6011
|
+
listFiles: () => execSync('git ls-files', {
|
|
6012
|
+
cwd, env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: 60_000,
|
|
6013
|
+
}).split('\n').filter(Boolean),
|
|
6014
|
+
sourceDirs: (trackedPaths) => pc.readSourceDirs(cwd, { trackedPaths }),
|
|
6015
|
+
codebaseDefaults: () => pc.readCodebaseDefaults(cwd),
|
|
6016
|
+
ciGate: () => pc.readCIGateConfig(cwd),
|
|
6017
|
+
executionMode: () => pc.readExecutionModeRaw(cwd),
|
|
6018
|
+
consentNote: pc.EDITOR_MODE_CONSENT_NOTE,
|
|
6019
|
+
ruleOverlays: () => require('./lib/overlay-transport').readRuleOverlays(cwd),
|
|
6020
|
+
e2eSpecMode: () => pc.readE2eSpecMode(cwd),
|
|
6021
|
+
verificationGate: () => pc.readVerificationGateConfig(cwd),
|
|
6022
|
+
verificationGateSources: () => pc.readVerificationGateSources(cwd),
|
|
6023
|
+
readPlanText: () => {
|
|
6024
|
+
const planPath = path.join(cwd, '.github', 'EXECUTION_PLAN.yml');
|
|
6025
|
+
return fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
|
|
6026
|
+
},
|
|
6027
|
+
exists: (rel) => { const abs = inRepo(rel); return !!abs && fs.existsSync(abs); },
|
|
6028
|
+
isDirectory: (rel) => { const abs = inRepo(rel); try { return !!abs && fs.statSync(abs).isDirectory(); } catch { return false; } },
|
|
6029
|
+
readFile: (rel) => {
|
|
6030
|
+
const abs = inRepo(rel);
|
|
6031
|
+
if (!abs) throw new Error(`${rel} is outside the repository`);
|
|
6032
|
+
return fs.readFileSync(abs, 'utf8');
|
|
6033
|
+
},
|
|
6034
|
+
// Async (execFile, no shell) so queue-stories can resolve several issues at once.
|
|
6035
|
+
fetchIssue: async (issueNumber) => {
|
|
6036
|
+
const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
|
|
6037
|
+
const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
6038
|
+
if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
|
|
6039
|
+
const { execFile } = require('child_process');
|
|
6040
|
+
let stdout;
|
|
6041
|
+
try {
|
|
6042
|
+
({ stdout } = await require('util').promisify(execFile)('gh',
|
|
6043
|
+
['issue', 'view', String(issueNumber), '--repo', `${m[1]}/${m[2]}`, '--json', 'title,body,url'],
|
|
6044
|
+
{ encoding: 'utf8', timeout: 30_000 }));
|
|
6045
|
+
} catch (e) {
|
|
6046
|
+
// execFile keeps gh's stderr on the error — its first line is the actionable part ("gh auth
|
|
6047
|
+
// login", "Could not resolve to an Issue"), and a timeout must not read like any failure.
|
|
6048
|
+
if (e && e.killed) throw new Error(`gh issue view timed out after 30s`);
|
|
6049
|
+
const why = String((e && e.stderr) || '').trim().split('\n')[0];
|
|
6050
|
+
throw new Error(why || (e && e.message) || String(e));
|
|
6051
|
+
}
|
|
6052
|
+
return JSON.parse(stdout);
|
|
6053
|
+
},
|
|
6054
|
+
// HC-GF-270: who wrote the issue. run-context.js classifies a failure and story-provenance.js
|
|
6055
|
+
// decides trust; this only fetches. execFile, not a shell string: owner/repo come from the remote.
|
|
6056
|
+
fetchIssueProvenance: async (issueNumber) => {
|
|
6057
|
+
const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf8', timeout: 10_000 }).trim();
|
|
6058
|
+
const m = remoteUrl.match(/[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
6059
|
+
if (!m) throw new Error(`could not parse owner/repo from remote: ${remoteUrl}`);
|
|
6060
|
+
const where = { owner: m[1], repo: m[2], remoteUrl };
|
|
6061
|
+
try {
|
|
6062
|
+
const { execFile } = require('child_process');
|
|
6063
|
+
const { stdout } = await require('util').promisify(execFile)('gh',
|
|
6064
|
+
['api', `repos/${m[1]}/${m[2]}/issues/${issueNumber}`],
|
|
6065
|
+
{ encoding: 'utf8', timeout: 20_000 });
|
|
6066
|
+
return { ...where, issue: JSON.parse(stdout) };
|
|
6067
|
+
} catch (e) {
|
|
6068
|
+
throw Object.assign(e, { where }); // `where` lets the failure keep its issue reference
|
|
6069
|
+
}
|
|
6070
|
+
},
|
|
6071
|
+
fetchStoryContext: async (storyId) => {
|
|
6072
|
+
const { data } = await api(apiConfig).get(`/stories/${encodeURIComponent(storyId)}/context`);
|
|
6073
|
+
return data;
|
|
6074
|
+
},
|
|
6075
|
+
recentlyModifiedOverlap: (touched) =>
|
|
6076
|
+
require('./lib/story-classifier-extract').extractRecentlyModifiedOverlap(cwd, touched, 14),
|
|
6077
|
+
};
|
|
6078
|
+
}
|
|
6079
|
+
|
|
6080
|
+
// Prints run-context notes at their level. `prefix` labels a note with its story in a batch.
|
|
6081
|
+
function printRunContextNotes(notes, { prefix = '', only } = {}) {
|
|
6082
|
+
for (const n of notes || []) {
|
|
6083
|
+
if (only && n.level !== only) continue;
|
|
6084
|
+
const text = prefix ? n.text.replace(/^(\s*)/, `$1${prefix} `) : n.text;
|
|
6085
|
+
if (n.level === 'warn') console.warn(text); else console.log(text);
|
|
6086
|
+
}
|
|
6087
|
+
}
|
|
6088
|
+
|
|
5602
6089
|
program
|
|
5603
6090
|
.command('run-story <storyId>')
|
|
5604
6091
|
.description('Run the SDLC pipeline for a story via the orchestrator')
|
|
@@ -5617,7 +6104,11 @@ program
|
|
|
5617
6104
|
.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.')
|
|
5618
6105
|
.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.')
|
|
5619
6106
|
.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.')
|
|
5620
|
-
|
|
6107
|
+
// HC-GF-202: `collectIncludePaths` accumulates every occurrence. Without it the
|
|
6108
|
+
// option is single-valued and commander keeps the LAST, so the remedy the planner
|
|
6109
|
+
// itself prints — `--include-paths Makefile --include-paths scripts/` — silently
|
|
6110
|
+
// discarded `Makefile` and produced a bundle identical to passing nothing.
|
|
6111
|
+
.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, [])
|
|
5621
6112
|
.option('--e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) ON for this run (overrides e2e.spec_generation config)')
|
|
5622
6113
|
.option('--skip-e2e-specs', 'HC-019n-followup-28: force step_3b (Playwright spec generation) OFF for this run (overrides e2e.spec_generation config)')
|
|
5623
6114
|
.action(async (storyIdOrRunId, opts) => {
|
|
@@ -5680,6 +6171,11 @@ program
|
|
|
5680
6171
|
// advisory path re-verifies on every poll tick, because advisory never advances
|
|
5681
6172
|
// the run and the pause it re-observes is the same pause.
|
|
5682
6173
|
const _verifiedAttempts = new Set();
|
|
6174
|
+
// HC-GF-189: one-shot, and the "once" lives in the reporter so it can be DRIVEN by a test
|
|
6175
|
+
// rather than grepped for — mutation walked through the inline version twice.
|
|
6176
|
+
const { makeReadinessReporter } = require('./lib/story-classifier');
|
|
6177
|
+
const { readSandboxConfig } = require('./lib/pipeline-config');
|
|
6178
|
+
const _reportReadiness = makeReadinessReporter({ readSandbox: () => readSandboxConfig(process.cwd()) });
|
|
5683
6179
|
|
|
5684
6180
|
for (;;) {
|
|
5685
6181
|
await new Promise(r => setTimeout(r, interval));
|
|
@@ -5693,6 +6189,19 @@ program
|
|
|
5693
6189
|
continue;
|
|
5694
6190
|
}
|
|
5695
6191
|
|
|
6192
|
+
// HC-GF-189: the first tick at which the axis EXISTS. step_0's groomer answers
|
|
6193
|
+
// `requires_stored_state`, the server harvests it into `config.classifier_axes`, and only
|
|
6194
|
+
// the CLI can pair that with "no `sandbox:` block is declared in this repo" — the server
|
|
6195
|
+
// has never seen one (HC-GF-169). Emitted here rather than at verify-patch, where
|
|
6196
|
+
// `planSandboxRun`'s stand-down says the same thing but only after step_2 has already
|
|
6197
|
+
// written its tests against whatever the machine happened to hold.
|
|
6198
|
+
//
|
|
6199
|
+
// Once. The loop re-observes the same state every few seconds, and a warning that repeats
|
|
6200
|
+
// until the run ends teaches the reader to skip it — the reason HC-GF-064 guards
|
|
6201
|
+
// auto-verify with a Set.
|
|
6202
|
+
const _readinessNote = _reportReadiness(status?.config);
|
|
6203
|
+
if (_readinessNote) console.warn(` ⚠ ${_readinessNote}`);
|
|
6204
|
+
|
|
5696
6205
|
const d = classifyPollState(status);
|
|
5697
6206
|
|
|
5698
6207
|
if (d.outcome === POLL_OUTCOMES.COMPLETED) {
|
|
@@ -5723,6 +6232,35 @@ program
|
|
|
5723
6232
|
console.log(`⏳ Step ${d.stepKey} (${d.agent || 'unknown'}) — awaiting approval`);
|
|
5724
6233
|
}
|
|
5725
6234
|
|
|
6235
|
+
// HC-GF-263: the step named the files it needs, in a fenced `hone run-story` command,
|
|
6236
|
+
// and until now nobody read it — the server kept 200 chars of the first line and this
|
|
6237
|
+
// loop printed 240 of those on the EXHAUSTED branch and nothing at all on a human
|
|
6238
|
+
// gate. Printed in BOTH branches, and ADDITIVE to the errorMessage above: that line
|
|
6239
|
+
// is the only surviving statement of why the step stopped.
|
|
6240
|
+
//
|
|
6241
|
+
// Never throws: a print must not end a poll loop.
|
|
6242
|
+
try {
|
|
6243
|
+
const { remedyFor, renderIncludePathsCommand, partitionSafePaths } = require('./lib/hard-pause-remedy');
|
|
6244
|
+
const remedy = remedyFor(status);
|
|
6245
|
+
if (remedy) {
|
|
6246
|
+
const cmd = renderIncludePathsCommand(status && status.storyId, remedy.paths);
|
|
6247
|
+
if (cmd) {
|
|
6248
|
+
// Name the step: the request belongs to ONE step, and an unattributed command
|
|
6249
|
+
// reads as advice about the run rather than about this pause (H1).
|
|
6250
|
+
console.log(` ${remedy.stepKey} named the files it needs. Re-queue with:`);
|
|
6251
|
+
console.log(` ${cmd}`);
|
|
6252
|
+
if (remedy.truncated) {
|
|
6253
|
+
console.log(` ⚠ it asked for more than ${remedy.paths.length} files; the rest were dropped.`);
|
|
6254
|
+
}
|
|
6255
|
+
}
|
|
6256
|
+
// H5: say how many requested paths were withheld from the command, never what they were.
|
|
6257
|
+
const { dropped } = partitionSafePaths(remedy.paths);
|
|
6258
|
+
if (dropped.length) {
|
|
6259
|
+
console.log(` ${remedy.stepKey}: ${dropped.length} requested path(s) not shown — characters a shell could misread; see \`hone show step\`.`);
|
|
6260
|
+
}
|
|
6261
|
+
}
|
|
6262
|
+
} catch { /* a print must never end a poll loop */ }
|
|
6263
|
+
|
|
5726
6264
|
// HC-GF-064: verify BEFORE asking anyone to approve. The gate this feeds had
|
|
5727
6265
|
// existed since HC-019n-followup-34/35 and never fired, because nothing ever
|
|
5728
6266
|
// ran the executing step. Consent is DERIVED: `ci.local_command` being
|
|
@@ -5730,13 +6268,59 @@ program
|
|
|
5730
6268
|
// cli/lib/auto-verify.js and docs/architecture/adr-verification-invocation.md).
|
|
5731
6269
|
// Never throws — a failure here must leave the operator exactly where they
|
|
5732
6270
|
// were, which is being asked to approve.
|
|
6271
|
+
// HC-GF-270: computed OUTSIDE the try below, on purpose. That catch turns a throw
|
|
6272
|
+
// into `(auto-verify skipped: ...)` and CONTINUES — harmless for a decision about
|
|
6273
|
+
// whether to run tests, a fail-OPEN for a decision about whether this patch may
|
|
6274
|
+
// execute at all. So the one call that answers "may we run it" cannot be inside it,
|
|
6275
|
+
// and its own failure is untrusted rather than absent.
|
|
6276
|
+
let _provenance;
|
|
6277
|
+
// HC-GF-270: attempts whose NEEDS YOU block has already been printed this process.
|
|
6278
|
+
// Declared lazily on the enclosing scope so the Set survives across poll ticks —
|
|
6279
|
+
// a per-tick Set would suppress nothing.
|
|
6280
|
+
if (!globalThis.__honeProvenanceNoticed) globalThis.__honeProvenanceNoticed = new Set();
|
|
6281
|
+
const _provenanceNoticed = globalThis.__honeProvenanceNoticed;
|
|
6282
|
+
if (!exhausted) {
|
|
6283
|
+
try {
|
|
6284
|
+
const { assessStorySource } = require('./lib/story-provenance');
|
|
6285
|
+
_provenance = assessStorySource(
|
|
6286
|
+
status?.config?.story_source,
|
|
6287
|
+
status?.storyId || status?.story_id);
|
|
6288
|
+
} catch (e) {
|
|
6289
|
+
_provenance = {
|
|
6290
|
+
trusted: false,
|
|
6291
|
+
label: 'assessment-failed',
|
|
6292
|
+
reason: `story provenance could not be assessed: ${e.message}`,
|
|
6293
|
+
};
|
|
6294
|
+
}
|
|
6295
|
+
}
|
|
5733
6296
|
if (!exhausted) try {
|
|
5734
|
-
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine } = require('./lib/auto-verify');
|
|
6297
|
+
const { decideAutoVerify, announce, shouldRepoll, alreadyVerified, verifyOutcomeLine, provenanceNotice } = require('./lib/auto-verify');
|
|
5735
6298
|
const decision = decideAutoVerify({
|
|
5736
6299
|
stepKey: d.stepKey,
|
|
5737
6300
|
repoRoot: process.cwd(),
|
|
5738
6301
|
noVerifyFlag: opts.verify === false,
|
|
6302
|
+
assessment: _provenance,
|
|
5739
6303
|
});
|
|
6304
|
+
// HC-GF-270: only when the step_4 gate is the one paused — an untrusted run is
|
|
6305
|
+
// untrusted at every gate, and repeating this at each of them would teach the
|
|
6306
|
+
// operator to skip it (the HC-GF-189 lesson about a warning that never stops).
|
|
6307
|
+
// HC-GF-270 code review (MEDIUM-5): ONCE per attempt, the same discipline
|
|
6308
|
+
// `alreadyVerified` applies to `announce`. The loop re-observes the same pause every
|
|
6309
|
+
// few seconds; a NEEDS YOU block that reprints on every tick teaches the operator to
|
|
6310
|
+
// scroll past it, which is the HC-GF-189 lesson this comment used to only half-apply
|
|
6311
|
+
// (it suppressed the notice at OTHER gates, not on re-polls of this one).
|
|
6312
|
+
//
|
|
6313
|
+
// Its own Set, not `_verifiedAttempts`: that one means "this attempt was verified",
|
|
6314
|
+
// and an untrusted attempt is precisely one that was NOT. Sharing it would make
|
|
6315
|
+
// `alreadyVerified` lie about what happened.
|
|
6316
|
+
if (decision.untrusted && d.stepKey === 'step_4' && !_provenanceNoticed.has(d.attemptKey)) {
|
|
6317
|
+
_provenanceNoticed.add(d.attemptKey);
|
|
6318
|
+
console.log(provenanceNotice(_provenance, {
|
|
6319
|
+
runId: pollRunId,
|
|
6320
|
+
source: status?.config?.story_source || {},
|
|
6321
|
+
stepKey: d.stepKey,
|
|
6322
|
+
}));
|
|
6323
|
+
}
|
|
5740
6324
|
if (decision.run && !alreadyVerified(d.attemptKey, _verifiedAttempts)) {
|
|
5741
6325
|
_verifiedAttempts.add(d.attemptKey);
|
|
5742
6326
|
console.log(announce(decision, pollRunId));
|
|
@@ -5911,27 +6495,6 @@ program
|
|
|
5911
6495
|
try { branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim(); } catch { branch = null; }
|
|
5912
6496
|
}
|
|
5913
6497
|
|
|
5914
|
-
// HC-019n-followup-11: when storyId is a bare integer, treat it as a
|
|
5915
|
-
// GitHub issue number and fetch the issue's title + body via the local
|
|
5916
|
-
// `gh` CLI. Inject into config.story_description so the orchestrator's
|
|
5917
|
-
// buildStepContext (HC-019n root-cause fix B) hands the real story
|
|
5918
|
-
// text to step_0 instead of letting the LLM produce placeholder
|
|
5919
|
-
// [ASSUMPTION] grooming. Hook reused — no server change required.
|
|
5920
|
-
//
|
|
5921
|
-
// Gracefully degrade on any error (gh missing, no remote, network
|
|
5922
|
-
// fail, private repo without auth): warn and proceed without context.
|
|
5923
|
-
// The existing HC-019n-followup-7 hard_pause safety net catches the
|
|
5924
|
-
// resulting placeholder cascade at step_1.
|
|
5925
|
-
//
|
|
5926
|
-
// HC-019b-followup-1 F1: when invoked with an issue number, also try
|
|
5927
|
-
// to extract a canonical story-id (e.g. HC-019b) from the issue title.
|
|
5928
|
-
// Used by the architect-config read below — without this, the lookup
|
|
5929
|
-
// keys on '104' instead of 'HC-019b' and silently bypasses every
|
|
5930
|
-
// architect-engaged story whose EXECUTION_PLAN.yml entry uses the
|
|
5931
|
-
// canonical id (which is the common adopter case).
|
|
5932
|
-
const orchestrateConfig = {};
|
|
5933
|
-
let issueBodyForFiles = null;
|
|
5934
|
-
let resolvedStoryId = storyIdOrRunId; // canonical id for architect-config lookup
|
|
5935
6498
|
// HC-GF-010: an EXPLICIT story source, and the only one that works on a repository
|
|
5936
6499
|
// that is neither a GitHub issue tracker nor listed on Hone's own roadmap.
|
|
5937
6500
|
//
|
|
@@ -5966,473 +6529,25 @@ program
|
|
|
5966
6529
|
}
|
|
5967
6530
|
}
|
|
5968
6531
|
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
issueBodyForFiles = `${issue.title}\n${issue.body || ''}`;
|
|
5989
|
-
console.log(` → fetched GitHub issue #${storyIdOrRunId} for story context (${desc.length} chars)`);
|
|
5990
|
-
// HC-019b-followup-1 F1: extract the canonical story-id from the
|
|
5991
|
-
// issue title. extractStoryIdFromBranch's regex (STORY_ID_PATTERN
|
|
5992
|
-
// in pipeline-status.js) handles HC-NNN, HC-NNN-A, H-NNNb, E22-D,
|
|
5993
|
-
// and HC-NNN-followup-N shapes after H-029-followup-2.
|
|
5994
|
-
try {
|
|
5995
|
-
const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
|
|
5996
|
-
const titleId = extractStoryIdFromBranch(issue.title);
|
|
5997
|
-
if (titleId) {
|
|
5998
|
-
resolvedStoryId = titleId;
|
|
5999
|
-
console.log(` → resolved issue #${storyIdOrRunId} → canonical story id '${titleId}' for architect-config lookup`);
|
|
6000
|
-
} else {
|
|
6001
|
-
console.warn(` ⚠ could not extract canonical story id from issue title '${issue.title}' — architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
|
|
6002
|
-
}
|
|
6003
|
-
} catch { /* extractor missing/throws → fall back to numeric id */ }
|
|
6004
|
-
} catch (e) {
|
|
6005
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6006
|
-
console.warn(` ⚠ could not fetch GitHub issue context: ${msg}`);
|
|
6007
|
-
console.warn(' → proceeding without story_description; step_0 may produce placeholder output');
|
|
6008
|
-
console.warn(` ⚠ architect-config lookup will use '${storyIdOrRunId}' (likely silent miss)`);
|
|
6009
|
-
}
|
|
6010
|
-
} else {
|
|
6011
|
-
// HC-019n-followup-18: roadmap-sourced story. Only the SERVER knows what
|
|
6012
|
-
// the story says (server/seeds/master-roadmap.md is a Hone asset, absent
|
|
6013
|
-
// from adopter repos) and only the CLI can read the adopter's disk — so
|
|
6014
|
-
// ask the server what the description references, then read those files
|
|
6015
|
-
// here. See .github/pipeline/HC-019n-followup-18/architect.md §1 for why
|
|
6016
|
-
// this beats the CLI reading a local roadmap (hardcodes a Hone-internal
|
|
6017
|
-
// path into the adopter CLI, and fails silently anywhere else).
|
|
6018
|
-
//
|
|
6019
|
-
// Advisory, never fatal: a story with no resolvable context still runs,
|
|
6020
|
-
// it just runs blind — and loudly, via the followup-17 warning below.
|
|
6021
|
-
try {
|
|
6022
|
-
const ctxClient = api(config);
|
|
6023
|
-
const { data } = await ctxClient.get(
|
|
6024
|
-
`/stories/${encodeURIComponent(storyIdOrRunId)}/context`
|
|
6025
|
-
);
|
|
6026
|
-
if (data && data.description) {
|
|
6027
|
-
orchestrateConfig.story_description = data.description;
|
|
6028
|
-
issueBodyForFiles = data.description;
|
|
6029
|
-
const n = Array.isArray(data.referencedPaths) ? data.referencedPaths.length : 0;
|
|
6030
|
-
console.log(
|
|
6031
|
-
` → resolved story context from roadmap (${data.description.length} chars, ` +
|
|
6032
|
-
`${n} referenced path${n === 1 ? '' : 's'})`
|
|
6033
|
-
);
|
|
6034
|
-
}
|
|
6035
|
-
} catch (e) {
|
|
6036
|
-
const status = e && e.response && e.response.status;
|
|
6037
|
-
if (status === 404) {
|
|
6038
|
-
console.warn(` ⚠ no roadmap row found for '${storyIdOrRunId}'`);
|
|
6039
|
-
} else {
|
|
6040
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6041
|
-
console.warn(` ⚠ could not fetch story context: ${msg}`);
|
|
6042
|
-
}
|
|
6043
|
-
console.warn(' → proceeding without story context; the warning below applies');
|
|
6044
|
-
}
|
|
6045
|
-
}
|
|
6046
|
-
|
|
6047
|
-
// HC-019n-followup-12: auto-bundle codebase files referenced in the
|
|
6048
|
-
// issue body (or by the operator via --include-paths). Without this,
|
|
6049
|
-
// step_1/step_4 have only training-data priors and produce code that
|
|
6050
|
-
// matches the SHAPE of the adopter's stack but not its actual
|
|
6051
|
-
// architecture (cf. OptionsFlow #104 step_4 hallucinating SQLAlchemy
|
|
6052
|
-
// for a JSON-lines repo). The bundle is capped at 10 files / 50K
|
|
6053
|
-
// chars total to stay well inside the LLM context window.
|
|
6054
|
-
//
|
|
6055
|
-
// Detection scans the issue body for code-file-like paths:
|
|
6056
|
-
// - foo/bar/baz.py (relative paths with extensions)
|
|
6057
|
-
// - tests/unit/test_foo.py (typical pytest structure)
|
|
6058
|
-
// - src/web/routes.py (src trees)
|
|
6059
|
-
// Excludes:
|
|
6060
|
-
// - https:// or http:// URLs (those aren't local files)
|
|
6061
|
-
// - paths starting with / or ~ (absolute / home — bundle would
|
|
6062
|
-
// escape the repo)
|
|
6063
|
-
// - .md / .txt / .yml docs (rarely needed for code-builder; would
|
|
6064
|
-
// blow context with prose)
|
|
6065
|
-
// HC-DF-001: config-level default include paths (codebase.default_include_paths)
|
|
6066
|
-
// are merged with story-text auto-detection and --include-paths.
|
|
6067
|
-
const { readCodebaseDefaults } = require('./lib/pipeline-config');
|
|
6068
|
-
const defaultIncludePaths = readCodebaseDefaults(process.cwd()).include_paths;
|
|
6069
|
-
if (issueBodyForFiles || opts.includePaths || defaultIncludePaths.length > 0) {
|
|
6070
|
-
try {
|
|
6071
|
-
// HC-019n-followup-13c: word-boundary `\b` at end of extension
|
|
6072
|
-
// group so `.jsonl` doesn't match as `.js` and `.tsx` doesn't
|
|
6073
|
-
// match as `.ts`. Pre-fix, both `logs/calibration_runs.jsonl`
|
|
6074
|
-
// and `src/web/app.tsx` got truncated. The truncated paths
|
|
6075
|
-
// failed the downstream `fs.existsSync` check so they didn't
|
|
6076
|
-
// crash the bundling, but they polluted the regex output set
|
|
6077
|
-
// and would have produced confusing "file not found" warnings
|
|
6078
|
-
// if we ever tightened the diagnostic logging.
|
|
6079
|
-
const FILE_PATH_RE = /(?<![\w/])([a-zA-Z_][\w-]*\/(?:[\w.-]+\/)*[\w.-]+\.(?:py|js|ts|jsx|tsx|go|rs|java|rb|sh|sql))\b/g;
|
|
6080
|
-
const candidates = new Set();
|
|
6081
|
-
if (issueBodyForFiles) {
|
|
6082
|
-
for (const m of issueBodyForFiles.matchAll(FILE_PATH_RE)) candidates.add(m[1]);
|
|
6083
|
-
}
|
|
6084
|
-
if (opts.includePaths) {
|
|
6085
|
-
// --include-paths a/b.py,c/d.py
|
|
6086
|
-
for (const p of String(opts.includePaths).split(',').map(s => s.trim()).filter(Boolean)) {
|
|
6087
|
-
candidates.add(p);
|
|
6088
|
-
}
|
|
6089
|
-
}
|
|
6090
|
-
for (const p of defaultIncludePaths) candidates.add(p); // HC-DF-001 config defaults
|
|
6091
|
-
// HC-019n-followup-26: FILE_PATH_RE above requires a directory segment,
|
|
6092
|
-
// so a story that names a file BARE (`derive-worker.js:787` in prose)
|
|
6093
|
-
// extracts 0 paths and step_4 runs blind — and, blind, hallucinates a
|
|
6094
|
-
// file at a non-existent path (2026-08-30 e2e demo). Catch bare refs too
|
|
6095
|
-
// and resolve each against the repo's own file list: bundle ONLY on a
|
|
6096
|
-
// unique basename match; never guess an ambiguous or absent one.
|
|
6097
|
-
// HC-019n-followup-27: candidate path → referenced line number, so the
|
|
6098
|
-
// bundler can WINDOW a too-big file around its edit site instead of
|
|
6099
|
-
// head-truncating past it (the demo's line 787 sits at char 37K).
|
|
6100
|
-
const candidateLines = new Map();
|
|
6101
|
-
// HC-GF-073: hoisted out of the bare-ref branch below, because the sibling tier
|
|
6102
|
-
// needs the same list on stories that contain no bare refs at all — which is most
|
|
6103
|
-
// of them.
|
|
6104
|
-
const { gitEnv } = require('./lib/git-env');
|
|
6105
|
-
let repoFiles = null;
|
|
6106
|
-
const listRepoFiles = () => {
|
|
6107
|
-
if (repoFiles) return repoFiles;
|
|
6108
|
-
try {
|
|
6109
|
-
repoFiles = execSync('git ls-files', {
|
|
6110
|
-
cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
6111
|
-
}).split('\n').filter(Boolean);
|
|
6112
|
-
} catch { repoFiles = []; }
|
|
6113
|
-
return repoFiles;
|
|
6114
|
-
};
|
|
6115
|
-
if (issueBodyForFiles) {
|
|
6116
|
-
const { extractBareRefs, resolveBareName, alreadyCovered } = require('./lib/bundle-paths');
|
|
6117
|
-
const bareRefs = extractBareRefs(issueBodyForFiles);
|
|
6118
|
-
if (bareRefs.length > 0) {
|
|
6119
|
-
for (const { name, line } of bareRefs) {
|
|
6120
|
-
if (alreadyCovered(name, candidates)) continue;
|
|
6121
|
-
const r = resolveBareName(name, listRepoFiles());
|
|
6122
|
-
if (r.status === 'unique') {
|
|
6123
|
-
candidates.add(r.path);
|
|
6124
|
-
if (line && !candidateLines.has(r.path)) candidateLines.set(r.path, line);
|
|
6125
|
-
console.log(` → resolved bare ref '${name}${line ? ':' + line : ''}' → ${r.path}`);
|
|
6126
|
-
} else if (r.status === 'ambiguous') {
|
|
6127
|
-
console.warn(` ⚠ '${name}' is ambiguous (${r.matches.length} files) — not bundling; use --include-paths to disambiguate`);
|
|
6128
|
-
}
|
|
6129
|
-
// status === 'none' → not every filename in prose is a repo file; skip quietly
|
|
6130
|
-
}
|
|
6131
|
-
}
|
|
6132
|
-
}
|
|
6133
|
-
// HC-019n-followup-13d: bump caps. Pre-fix MAX_FILES=10 /
|
|
6134
|
-
// MAX_TOTAL_CHARS=50_000 / MAX_PER_FILE_CHARS=10_000 was overly
|
|
6135
|
-
// conservative — OptionsFlow #96 hit 49,783/50,000 with 6 files
|
|
6136
|
-
// (217 chars to spare), meaning the next slightly-larger story
|
|
6137
|
-
// silently dropped files. LLM context windows are 200K+; the
|
|
6138
|
-
// bundle should be able to fill ~25% of that without surprise.
|
|
6139
|
-
// New caps: 30 / 150_000 / 25_000.
|
|
6140
|
-
const MAX_FILES = 30;
|
|
6141
|
-
const MAX_TOTAL_CHARS = 150_000;
|
|
6142
|
-
const MAX_PER_FILE_CHARS = 25_000;
|
|
6143
|
-
// HC-GF-073: `candidates` is only what the STORY named, and a story names the
|
|
6144
|
-
// files it will CREATE — so on a greenfield repo every one of them fails the
|
|
6145
|
-
// existsSync below and the bundle is EMPTY. Measured across FlowForge's 33 real
|
|
6146
|
-
// stories: 29 extracted zero existing files. The dogfood runs looked fine only
|
|
6147
|
-
// because an operator had typed --include-paths once; 12 of 12 runs then bundled
|
|
6148
|
-
// the same three files across five different stories.
|
|
6149
|
-
//
|
|
6150
|
-
// selectBundle() splits them into what exists, what is pending, and adds two
|
|
6151
|
-
// tiers the story can never name: repo-level signal files (whose ABSENCE is what
|
|
6152
|
-
// a security reviewer must be able to check — HC-GF-063) and existing neighbours
|
|
6153
|
-
// of the pending paths (the conventions a new module has to match). Order is
|
|
6154
|
-
// priority order, because the char cap below truncates from the end.
|
|
6155
|
-
const { selectBundle, auditLines } = require('./lib/bundle-context');
|
|
6156
|
-
const selection = selectBundle({
|
|
6157
|
-
named: [...candidates],
|
|
6158
|
-
repoFiles: listRepoFiles(),
|
|
6159
|
-
exists: (rel) => {
|
|
6160
|
-
if (rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return false;
|
|
6161
|
-
const abs = path.resolve(process.cwd(), rel);
|
|
6162
|
-
return abs.startsWith(process.cwd() + path.sep) && fs.existsSync(abs);
|
|
6163
|
-
},
|
|
6164
|
-
});
|
|
6165
|
-
const ordered = [...selection.present, ...selection.signal, ...selection.siblings];
|
|
6166
|
-
const codebaseFiles = [];
|
|
6167
|
-
let totalChars = 0;
|
|
6168
|
-
for (const candidate of ordered) {
|
|
6169
|
-
if (codebaseFiles.length >= MAX_FILES) break;
|
|
6170
|
-
if (candidate.startsWith('/') || candidate.startsWith('~') || candidate.includes('..')) continue;
|
|
6171
|
-
const abs = path.resolve(process.cwd(), candidate);
|
|
6172
|
-
if (!abs.startsWith(process.cwd() + path.sep)) continue; // escape guard
|
|
6173
|
-
if (!fs.existsSync(abs)) continue;
|
|
6174
|
-
let content;
|
|
6175
|
-
try {
|
|
6176
|
-
content = fs.readFileSync(abs, 'utf8');
|
|
6177
|
-
} catch { continue; }
|
|
6178
|
-
if (content.length > MAX_PER_FILE_CHARS) {
|
|
6179
|
-
const line = candidateLines.get(candidate);
|
|
6180
|
-
if (line) {
|
|
6181
|
-
// HC-019n-followup-27: window around the referenced line so step_4
|
|
6182
|
-
// SEES the edit site (head-truncation would hide a site past the
|
|
6183
|
-
// cut) and can write a region-edit anchor. The window marker tells
|
|
6184
|
-
// step_4 to emit an `edit:` block, not a whole-file block.
|
|
6185
|
-
const { windowAroundLine } = require('./lib/bundle-paths');
|
|
6186
|
-
const w = windowAroundLine(content, line, Math.floor((MAX_PER_FILE_CHARS - 800) / 2));
|
|
6187
|
-
content =
|
|
6188
|
-
`# [HC-019n-followup-27: windowed excerpt, lines ${w.startLine}-${w.endLine} of ${w.totalLines} ` +
|
|
6189
|
-
`(centered on referenced line ${line}); you were NOT shown the whole file — ` +
|
|
6190
|
-
`emit an \`edit:\` block with a unique OLD anchor from this excerpt, NOT a whole-file \`file:\` block]\n` +
|
|
6191
|
-
w.excerpt +
|
|
6192
|
-
`\n# [end windowed excerpt of ${candidate}]`;
|
|
6193
|
-
} else {
|
|
6194
|
-
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]`;
|
|
6195
|
-
}
|
|
6196
|
-
}
|
|
6197
|
-
if (totalChars + content.length > MAX_TOTAL_CHARS) break;
|
|
6198
|
-
totalChars += content.length;
|
|
6199
|
-
codebaseFiles.push({ path: candidate, content });
|
|
6200
|
-
}
|
|
6201
|
-
// HC-GF-073: the old line printed a COUNT, and a count cannot distinguish three
|
|
6202
|
-
// files the story named from three it did not. That is why 12 consecutive runs
|
|
6203
|
-
// bundled an unrelated module and nothing said so — the "running blind" warning
|
|
6204
|
-
// below fires on an EMPTY bundle, and these were not empty.
|
|
6205
|
-
for (const line of auditLines(selection)) console.log(line);
|
|
6206
|
-
if (codebaseFiles.length > 0) {
|
|
6207
|
-
orchestrateConfig.codebaseFiles = codebaseFiles;
|
|
6208
|
-
console.log(` → bundled ${codebaseFiles.length} codebase file(s) for step_1/step_4 context (${totalChars} chars)`);
|
|
6209
|
-
}
|
|
6210
|
-
} catch (e) {
|
|
6211
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6212
|
-
console.warn(` ⚠ codebase bundling failed (non-fatal): ${msg}`);
|
|
6213
|
-
}
|
|
6214
|
-
}
|
|
6215
|
-
|
|
6216
|
-
// HC-019n-followup-17: warn when the code-aware steps will run BLIND.
|
|
6217
|
-
//
|
|
6218
|
-
// Auto-detection reads file paths out of `issueBodyForFiles`, which is only
|
|
6219
|
-
// populated on the numeric-GitHub-issue branch above. A roadmap-sourced id
|
|
6220
|
-
// (HC-CI-005, SC-002-followup-2, ...) never enters that branch, so
|
|
6221
|
-
// `codebaseFiles` stays unset and step_1 / step_4 / step_5 / step_5d /
|
|
6222
|
-
// step_5e all run with no source to read — the exact "confidently wrong"
|
|
6223
|
-
// failure HC-019n-followup-12 was built to prevent, still open for roadmap
|
|
6224
|
-
// ids. Observed 2026-08-29: run 61fddfb2 wrote 8,444 chars of code for
|
|
6225
|
-
// HC-CI-005 having never seen the files that story names.
|
|
6226
|
-
//
|
|
6227
|
-
// A fix is structurally awkward: only the CLI can read adopter files, and
|
|
6228
|
-
// only the SERVER knows the roadmap description (server/seeds/master-
|
|
6229
|
-
// roadmap.md is a Hone asset, absent from adopter repos). Closing it needs
|
|
6230
|
-
// the CLI to fetch the resolved description before queueing — a round trip
|
|
6231
|
-
// that belongs in its own story. Until then the gap is at least LOUD
|
|
6232
|
-
// instead of silent, which is the part that actually bit.
|
|
6233
|
-
if (!orchestrateConfig.codebaseFiles) {
|
|
6234
|
-
console.warn(' ⚠ no codebase files bundled — step_1 / step_4 / step_5 / step_5d / step_5e');
|
|
6235
|
-
console.warn(' will run WITHOUT source context and may invent APIs that do not exist.');
|
|
6236
|
-
if (!/^\d+$/.test(storyIdOrRunId)) {
|
|
6237
|
-
console.warn(` '${storyIdOrRunId}' is not a GitHub issue number, so file paths cannot be`);
|
|
6238
|
-
console.warn(' auto-detected from an issue body.');
|
|
6239
|
-
}
|
|
6240
|
-
console.warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js');
|
|
6241
|
-
}
|
|
6242
|
-
|
|
6243
|
-
// HC-COMM-010 axes wiring (CLI half): when --axes wasn't given, derive
|
|
6244
|
-
// the DETERMINISTIC axes from the extractors (SC-002) — security labels/
|
|
6245
|
-
// text, cross-repo markers, recently-modified overlap vs the bundled
|
|
6246
|
-
// paths. Judgment axes (shared-chokepoint etc.) come from the groomer at
|
|
6247
|
-
// step_0 and are harvested server-side. Fail-open; explicit --axes wins.
|
|
6248
|
-
if (!opts.axes) {
|
|
6249
|
-
try {
|
|
6250
|
-
const { classifyStory } = require('./lib/story-classifier');
|
|
6251
|
-
const {
|
|
6252
|
-
extractType, extractSecurityImplications, extractCrossRepoDependency, extractRecentlyModifiedOverlap,
|
|
6253
|
-
} = require('./lib/story-classifier-extract');
|
|
6254
|
-
const autoBody = issueBodyForFiles || '';
|
|
6255
|
-
const autoLabels = [];
|
|
6256
|
-
const touched = Array.isArray(orchestrateConfig.codebaseFiles)
|
|
6257
|
-
? orchestrateConfig.codebaseFiles.map(f => f.path).filter(Boolean)
|
|
6258
|
-
: Object.keys(orchestrateConfig.codebaseFiles || {});
|
|
6259
|
-
const auto = classifyStory({
|
|
6260
|
-
type: extractType(autoLabels, autoBody, String(storyIdOrRunId)),
|
|
6261
|
-
has_security_implications: extractSecurityImplications(autoLabels, autoBody),
|
|
6262
|
-
cross_repo_dependency: extractCrossRepoDependency(autoBody),
|
|
6263
|
-
recently_modified_overlap: extractRecentlyModifiedOverlap(process.cwd(), touched, 14),
|
|
6264
|
-
});
|
|
6265
|
-
if (auto.architect.engage && auto.architect.axes.length > 0) {
|
|
6266
|
-
orchestrateConfig.classifier_axes = auto.architect.axes;
|
|
6267
|
-
console.log(` → auto-detected classifier axes: ${auto.architect.axes.join(', ')}`);
|
|
6268
|
-
}
|
|
6269
|
-
} catch (e) {
|
|
6270
|
-
console.warn(` ⚠ axes auto-detection failed (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
|
|
6271
|
-
}
|
|
6272
|
-
}
|
|
6273
|
-
|
|
6274
|
-
// HC-019n-followup-42: repository-structure grounding. Bundling FILES is not
|
|
6275
|
-
// grounding — with no tree in the prompt the groomer invents a layout and the
|
|
6276
|
-
// planner inherits it (dogfood run f276fe00). Stamp a compact, capped tree of
|
|
6277
|
-
// tracked paths (scoped to source_dirs/test_dirs when configured) into the run
|
|
6278
|
-
// config; the server renders it for the steps that name paths. Fail-open.
|
|
6279
|
-
try {
|
|
6280
|
-
const { execSync } = require('child_process');
|
|
6281
|
-
const { buildRepoTree } = require('./lib/repo-tree');
|
|
6282
|
-
const { readSourceDirs } = require('./lib/pipeline-config');
|
|
6283
|
-
const tracked = execSync('git ls-files', { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
|
|
6284
|
-
const dirs = readSourceDirs(process.cwd());
|
|
6285
|
-
// HC-DF-002: readSourceDirs self-heals a stale config; say so, or the operator sees a
|
|
6286
|
-
// confident success line built from directories that do not exist.
|
|
6287
|
-
if (dirs.repaired) {
|
|
6288
|
-
console.log(` ⚠ source dirs in .pipeline-config.yml not found on disk: ${dirs.dropped.join(', ')}`);
|
|
6289
|
-
console.log(` using ${dirs.detected ? 'detected' : 'the surviving'} dirs instead: ${dirs.source_dirs.join(', ') || '(none)'}`);
|
|
6290
|
-
console.log(' fix it permanently: hone detect-layout, then set codebase.source_dirs in .github/.pipeline-config.yml');
|
|
6291
|
-
}
|
|
6292
|
-
const tree = buildRepoTree(tracked, { sourceDirs: dirs.source_dirs, testDirs: dirs.test_dirs });
|
|
6293
|
-
if (tree.selected_total > 0) {
|
|
6294
|
-
orchestrateConfig.repo_tree = tree;
|
|
6295
|
-
const scope = dirs.source_dirs.length ? dirs.source_dirs.join(', ') : 'all tracked';
|
|
6296
|
-
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`);
|
|
6297
|
-
}
|
|
6298
|
-
} catch (e) {
|
|
6299
|
-
console.warn(` ⚠ repository structure not bundled (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
|
|
6300
|
-
}
|
|
6301
|
-
|
|
6302
|
-
// HC-101-followup-2: pass the adopter's CI gate config to the
|
|
6303
|
-
// orchestrator so step_5c can branch (github / local / both / none).
|
|
6304
|
-
// Defaults to gate=github + local_command='make ci' when the config
|
|
6305
|
-
// is missing — backward-compat for adopters whose .pipeline-config.yml
|
|
6306
|
-
// predates this field.
|
|
6307
|
-
try {
|
|
6308
|
-
const { readCIGateConfig } = require('./lib/pipeline-config');
|
|
6309
|
-
const ciGate = readCIGateConfig(process.cwd());
|
|
6310
|
-
orchestrateConfig.ci_gate = ciGate.gate;
|
|
6311
|
-
orchestrateConfig.ci_local_command = ciGate.local_command;
|
|
6312
|
-
console.log(` → CI gate mode: ${ciGate.gate}${ciGate.gate !== 'github' ? ` (local_command: ${ciGate.local_command})` : ''}`);
|
|
6313
|
-
} catch (e) {
|
|
6314
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6315
|
-
console.warn(` ⚠ CI gate config read failed (non-fatal, defaulting to gate=github): ${msg}`);
|
|
6316
|
-
orchestrateConfig.ci_gate = 'github';
|
|
6317
|
-
orchestrateConfig.ci_local_command = 'make ci';
|
|
6318
|
-
}
|
|
6319
|
-
|
|
6320
|
-
// HC-COMM-010 B0.5 (memo §12 A3): stamp execution.mode into the run config
|
|
6321
|
-
// at CREATION — the server reads it from run.config, making the mode
|
|
6322
|
-
// immutable per run (a mid-flight .pipeline-config.yml flip cannot change
|
|
6323
|
-
// a running run). Absent/old CLIs → the server defaults to 'server'.
|
|
6324
|
-
// Inert until B1: the server ignores the key today.
|
|
6325
|
-
try {
|
|
6326
|
-
const { readExecutionMode, EDITOR_MODE_CONSENT_NOTE } = require('./lib/pipeline-config');
|
|
6327
|
-
const executionMode = readExecutionMode(process.cwd());
|
|
6328
|
-
orchestrateConfig.execution_mode = executionMode;
|
|
6329
|
-
if (executionMode === 'editor') {
|
|
6330
|
-
// §2a: the consent note is shown at the moment editor mode is USED.
|
|
6331
|
-
console.log(` → execution mode: editor (opt-in)`);
|
|
6332
|
-
console.log(` ${EDITOR_MODE_CONSENT_NOTE}`);
|
|
6333
|
-
}
|
|
6334
|
-
} catch (e) {
|
|
6335
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6336
|
-
console.warn(` ⚠ execution mode read failed (non-fatal, defaulting to server): ${msg}`);
|
|
6337
|
-
orchestrateConfig.execution_mode = 'server';
|
|
6338
|
-
}
|
|
6339
|
-
|
|
6340
|
-
// HC-COMM-010 C0: composition transports — INERT until C1 reads them.
|
|
6341
|
-
// Overlays: .hone-local/ is adopter disk the server can never read
|
|
6342
|
-
// (the cross-validation finding), so raw texts travel in run config —
|
|
6343
|
-
// the codebaseFiles pattern, size/count-capped, degrade-to-empty.
|
|
6344
|
-
// Axes: explicit --axes flag for now; automated wiring from classify
|
|
6345
|
-
// runs is future work (documented in the sprint plan).
|
|
6346
|
-
try {
|
|
6347
|
-
const { readRuleOverlays } = require('./lib/overlay-transport');
|
|
6348
|
-
const ov = readRuleOverlays(process.cwd());
|
|
6349
|
-
if (ov.overlays.length > 0) {
|
|
6350
|
-
orchestrateConfig.rule_overlays = ov.overlays;
|
|
6351
|
-
console.log(` → bundled ${ov.overlays.length} rule overlay(s) for server-side composition`);
|
|
6352
|
-
}
|
|
6353
|
-
for (const skip of ov.skipped) console.warn(` ⚠ overlay skipped: ${skip}`);
|
|
6354
|
-
} catch (e) {
|
|
6355
|
-
console.warn(` ⚠ overlay read failed (non-fatal): ${(e && e.message || String(e)).split('\n')[0]}`);
|
|
6356
|
-
}
|
|
6357
|
-
if (opts.axes) {
|
|
6358
|
-
const axes = String(opts.axes).split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
|
|
6359
|
-
if (axes.length > 0) orchestrateConfig.classifier_axes = axes;
|
|
6360
|
-
}
|
|
6361
|
-
|
|
6362
|
-
// HC-019n-followup-28: resolve the E2E spec-generation mode (step_3b /
|
|
6363
|
-
// Playwright). Precedence mirrors ci.gate: per-run flag > .pipeline-config.yml
|
|
6364
|
-
// `e2e.spec_generation` > default `auto`. `auto` = the orchestrator runs
|
|
6365
|
-
// step_3b when the story requires an E2E plan (step_0 emitted `yes`); `never`
|
|
6366
|
-
// = step_3b is always skipped. The runtime "is E2E required?" decision stays
|
|
6367
|
-
// server-side (checkE2eRequirement reads step_0's output) — the CLI only
|
|
6368
|
-
// passes the MODE.
|
|
6369
|
-
try {
|
|
6370
|
-
const { readE2eSpecMode } = require('./lib/pipeline-config');
|
|
6371
|
-
let e2eMode, source;
|
|
6372
|
-
if (opts.e2eSpecs) { e2eMode = 'auto'; source = '--e2e-specs'; }
|
|
6373
|
-
else if (opts.skipE2eSpecs) { e2eMode = 'never'; source = '--skip-e2e-specs'; }
|
|
6374
|
-
else { e2eMode = readE2eSpecMode(process.cwd()); source = 'config/default'; }
|
|
6375
|
-
orchestrateConfig.e2e_spec_generation = e2eMode;
|
|
6376
|
-
// Enable step_3b (conditional) unless the adopter opted out. The row is
|
|
6377
|
-
// then created; the orchestrator still skips it at runtime for a story that
|
|
6378
|
-
// does not require an E2E plan (HC-019n-followup-28).
|
|
6379
|
-
if (e2eMode !== 'never') {
|
|
6380
|
-
const ec = new Set(orchestrateConfig.enableConditional || []);
|
|
6381
|
-
ec.add('step_3b');
|
|
6382
|
-
orchestrateConfig.enableConditional = [...ec];
|
|
6383
|
-
}
|
|
6384
|
-
console.log(` → E2E spec generation (step_3b): ${e2eMode} (source: ${source})`);
|
|
6385
|
-
} catch (e) {
|
|
6386
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6387
|
-
console.warn(` ⚠ e2e spec-mode read failed (non-fatal, defaulting to auto): ${msg}`);
|
|
6388
|
-
orchestrateConfig.e2e_spec_generation = 'auto';
|
|
6389
|
-
}
|
|
6390
|
-
|
|
6391
|
-
// HC-019n-followup-34: thread the closed-loop verification gate mode from
|
|
6392
|
-
// .pipeline-config.yml. Advisory (default) = the server RECORDS verify-patch/
|
|
6393
|
-
// verify-pr verdicts reported via --report; enforce (follow-up) = a red
|
|
6394
|
-
// verdict blocks. Default-safe: never fatal.
|
|
6395
|
-
try {
|
|
6396
|
-
const { readVerificationGateConfig, readVerificationGateSources } = require('./lib/pipeline-config');
|
|
6397
|
-
orchestrateConfig.verification_gate = readVerificationGateConfig(process.cwd());
|
|
6398
|
-
// HC-COMM-011-followup-6: which verdict sources may DRIVE the step_4 gate under
|
|
6399
|
-
// enforce (default ['verify-patch']). An adopter adds 'agent-eval' to let a
|
|
6400
|
-
// green agent-eval verdict auto-advance a prompt change.
|
|
6401
|
-
orchestrateConfig.verification_gate_sources = readVerificationGateSources(process.cwd());
|
|
6402
|
-
if (orchestrateConfig.verification_gate !== 'advisory') {
|
|
6403
|
-
console.log(` → verification gate: ${orchestrateConfig.verification_gate} (sources: ${orchestrateConfig.verification_gate_sources.join(', ')})`);
|
|
6404
|
-
}
|
|
6405
|
-
} catch (e) {
|
|
6406
|
-
const msg = (e && e.message) ? e.message.split('\n')[0] : String(e);
|
|
6407
|
-
console.warn(` ⚠ verification-gate read failed (non-fatal, defaulting to advisory): ${msg}`);
|
|
6408
|
-
orchestrateConfig.verification_gate = 'advisory';
|
|
6409
|
-
orchestrateConfig.verification_gate_sources = ['verify-patch'];
|
|
6410
|
-
}
|
|
6411
|
-
|
|
6412
|
-
// HC-019b: read per-story architect flags from .github/EXECUTION_PLAN.yml
|
|
6413
|
-
// and plumb them into workflow_runs.config. The orchestrator's
|
|
6414
|
-
// validateStepPreConditions (server/src/services/workflow-dag.js:340)
|
|
6415
|
-
// BLOCKS step_1 when architect_consulted=true but checklist_b_completed=false.
|
|
6416
|
-
// Without this plumbing, the HC-019a flags written by the architect prompt
|
|
6417
|
-
// never reach the server and every architect-engaged story deadlocks.
|
|
6418
|
-
// Defaults to {false, false} when the file/story/config is missing —
|
|
6419
|
-
// i.e., assume the architect was not consulted (no block).
|
|
6420
|
-
//
|
|
6421
|
-
// Code-review F2/F3: malformed YAML or missing story entry was previously
|
|
6422
|
-
// silent. The helper now returns a `diagnostic` string for those cases;
|
|
6423
|
-
// we surface it as a console.warn so operators see the silent-bypass.
|
|
6424
|
-
// HC-019b-followup-1 F1: use the resolved canonical story-id (HC-NNN)
|
|
6425
|
-
// not the raw `storyIdOrRunId` which is the issue number when invoked
|
|
6426
|
-
// as `hone run-story 104`. The HC-019n-followup-11 block above sets
|
|
6427
|
-
// resolvedStoryId to the title-extracted id when it can.
|
|
6428
|
-
const { readArchitectConfig } = require('./lib/architect-config');
|
|
6429
|
-
const arch = readArchitectConfig(process.cwd(), resolvedStoryId);
|
|
6430
|
-
orchestrateConfig.architect_consulted = arch.architect_consulted;
|
|
6431
|
-
orchestrateConfig.checklist_b_completed = arch.checklist_b_completed;
|
|
6432
|
-
if (arch.diagnostic) console.warn(` ⚠ ${arch.diagnostic}`);
|
|
6433
|
-
if (arch.architect_consulted) {
|
|
6434
|
-
console.log(` → architect_consulted: true, checklist_b_completed: ${arch.checklist_b_completed}`);
|
|
6435
|
-
}
|
|
6532
|
+
// HC-GF-097: the run config is built by cli/lib/run-context.js, the one assembler this
|
|
6533
|
+
// command and `queue-stories` share. It used to be assembled inline here, key by key, and
|
|
6534
|
+
// queue-stories was written beside it with two of the fourteen — so a queued story ran with
|
|
6535
|
+
// no story text, no bundle, no tree and the server's default CI gate. The module returns
|
|
6536
|
+
// what it decided as notes; they print in the order this command always printed them.
|
|
6537
|
+
const { resolveRepoContext, resolveStoryContext, buildRunConfig } = require('./lib/run-context');
|
|
6538
|
+
const rcIo = runContextIo(config);
|
|
6539
|
+
const repoCtx = resolveRepoContext(rcIo, { e2eSpecs: opts.e2eSpecs, skipE2eSpecs: opts.skipE2eSpecs });
|
|
6540
|
+
const storyCtx = await resolveStoryContext(rcIo, repoCtx, storyIdOrRunId, {
|
|
6541
|
+
explicitText: explicitStory,
|
|
6542
|
+
explicitLabel: opts.storyFile ? path.relative(process.cwd(), path.resolve(opts.storyFile)) : '--description',
|
|
6543
|
+
explicitSource: opts.storyFile ? { kind: 'file', label: path.basename(String(opts.storyFile)) } : { kind: 'description' },
|
|
6544
|
+
includePaths: opts.includePaths,
|
|
6545
|
+
axes: opts.axes,
|
|
6546
|
+
});
|
|
6547
|
+
printRunContextNotes(storyCtx.notes.pre);
|
|
6548
|
+
printRunContextNotes(repoCtx.notes);
|
|
6549
|
+
printRunContextNotes(storyCtx.notes.post);
|
|
6550
|
+
const orchestrateConfig = buildRunConfig(repoCtx, storyCtx);
|
|
6436
6551
|
|
|
6437
6552
|
try {
|
|
6438
6553
|
const { data } = await client.post('/orchestrate', {
|
|
@@ -6485,12 +6600,11 @@ program
|
|
|
6485
6600
|
.option('--list', 'List recent batches for this org')
|
|
6486
6601
|
.option('--limit <n>', 'Number of batches to list (default 20, max 100)', '20')
|
|
6487
6602
|
.option('--format <fmt>', 'Output format: pretty or json (with --report, also: md)', 'pretty')
|
|
6488
|
-
// HC-054: Night Shift mode.
|
|
6489
|
-
// when
|
|
6490
|
-
//
|
|
6491
|
-
//
|
|
6492
|
-
|
|
6493
|
-
.option('--overnight', 'Enable Night Shift mode (conditional auto-approve, longer timeouts, max 25 stories)', false)
|
|
6603
|
+
// HC-054: Night Shift mode. Extended LLM timeout (480s), orchestrator watchdog (900s/step), default token
|
|
6604
|
+
// budget of 400K × story_count when none is set, a cap of 25 stories, and an exhausted pause that fails the run
|
|
6605
|
+
// so its dependents are released (HC-GF-137b). step_4 and step_5 still pause: HC-GF-303 removed HC-054's
|
|
6606
|
+
// auto-approve on output text, which never ran. Approve overnight on evidence with `hone follow-batch`.
|
|
6607
|
+
.option('--overnight', 'Enable Night Shift mode (longer timeouts, max 25 stories; step_4/step_5 still pause for approval)', false)
|
|
6494
6608
|
.action(async (opts) => {
|
|
6495
6609
|
const config = getConfig();
|
|
6496
6610
|
const client = api(config);
|
|
@@ -6663,98 +6777,103 @@ program
|
|
|
6663
6777
|
catch { branch = null; }
|
|
6664
6778
|
}
|
|
6665
6779
|
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
// `depends:` token is case-sensitive and must come AFTER the storyId.
|
|
6682
|
-
// Multiple deps separated by commas, whitespace tolerant. Lines without
|
|
6683
|
-
// `depends:` yield no `dependsOn` (server validates absence vs empty).
|
|
6684
|
-
// HC-019b: attach per-story `config: { architect_consulted, checklist_b_completed }`
|
|
6685
|
-
// \u2014 the server's createBatch (batch-store.js:218) spreads s.config into each
|
|
6686
|
-
// workflow_runs.config row, so this is the load-bearing plumbing for
|
|
6687
|
-
// validateStepPreConditions (workflow-dag.js:340) in batch mode.
|
|
6688
|
-
const stories = storyIds.map(line => {
|
|
6689
|
-
const depsMatch = line.match(/^(\S+)\s+depends:(\S+)\s*$/);
|
|
6690
|
-
let storyId, dependsOn;
|
|
6691
|
-
if (depsMatch) {
|
|
6692
|
-
const [, id, depsCsv] = depsMatch;
|
|
6693
|
-
storyId = id;
|
|
6694
|
-
dependsOn = depsCsv.split(',').map(s => s.trim()).filter(Boolean);
|
|
6695
|
-
} else {
|
|
6696
|
-
// Reject ambiguous lines (storyId followed by garbage) \u2014 better than
|
|
6697
|
-
// silently treating `STORY-A something` as just `STORY-A`.
|
|
6698
|
-
if (/\s/.test(line)) {
|
|
6699
|
-
console.error(`Malformed line in --file: "${line}"`);
|
|
6700
|
-
console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
|
|
6701
|
-
process.exit(1);
|
|
6702
|
-
}
|
|
6703
|
-
storyId = line;
|
|
6704
|
-
dependsOn = undefined;
|
|
6705
|
-
}
|
|
6706
|
-
const arch = readArchitectConfigFromText(planText, storyId);
|
|
6707
|
-
// Code-review F2/F3: surface silent-bypass diagnostics per story in
|
|
6708
|
-
// the batch path too. Multi-story batches with one bad plan line
|
|
6709
|
-
// would previously disable the contract for ALL stories silently.
|
|
6710
|
-
if (arch.diagnostic) console.warn(` ⚠ [${storyId}] ${arch.diagnostic}`);
|
|
6711
|
-
const obj = {
|
|
6712
|
-
storyId,
|
|
6713
|
-
repoName,
|
|
6714
|
-
branch,
|
|
6715
|
-
config: {
|
|
6716
|
-
architect_consulted: arch.architect_consulted,
|
|
6717
|
-
checklist_b_completed: arch.checklist_b_completed,
|
|
6718
|
-
},
|
|
6719
|
-
};
|
|
6720
|
-
if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
|
|
6721
|
-
return obj;
|
|
6780
|
+
const { parseStoryLine } = require('./lib/story-line');
|
|
6781
|
+
const {
|
|
6782
|
+
resolveRepoContext, resolveStoryContext, buildRunConfig, hasStoryText, describeMissingText, overnightModeRefusal,
|
|
6783
|
+
} = require('./lib/run-context');
|
|
6784
|
+
|
|
6785
|
+
// HC-059: `STORY-A depends:STORY-B,STORY-C` per line. HC-GF-140 stage 2: the parse lives in
|
|
6786
|
+
// cli/lib so it is asserted by BEHAVIOUR. Every line is parsed before anything is resolved.
|
|
6787
|
+
const lines = storyIds.map(line => {
|
|
6788
|
+
const parsed = parseStoryLine(line);
|
|
6789
|
+
if (parsed.error) {
|
|
6790
|
+
console.error(parsed.error);
|
|
6791
|
+
console.error(` Expected: "STORY-ID" OR "STORY-ID depends:STORY-B,STORY-C"`);
|
|
6792
|
+
process.exit(1);
|
|
6793
|
+
}
|
|
6794
|
+
return parsed;
|
|
6722
6795
|
});
|
|
6723
6796
|
|
|
6724
|
-
// HC-
|
|
6725
|
-
// (
|
|
6726
|
-
//
|
|
6727
|
-
|
|
6728
|
-
|
|
6797
|
+
// HC-GF-097: each queued story gets the config `hone run-story` would build for it, from the
|
|
6798
|
+
// same module (cli/lib/run-context.js). This path used to send two architect flags per story,
|
|
6799
|
+
// so a queued story was groomed, planned and built from its id alone. The repository half is
|
|
6800
|
+
// computed once for the whole batch.
|
|
6801
|
+
const rcIo = runContextIo(config);
|
|
6802
|
+
// The byte cap on the tree's path lists is the batch's: its request limit is derived from it.
|
|
6803
|
+
const repoCtx = resolveRepoContext(rcIo, { treeByteCap: require('./lib/repo-tree').MAX_TRANSPORT_BYTES });
|
|
6804
|
+
|
|
6805
|
+
// Fail closed FIRST — before any story is resolved or anything is sent. An overnight batch
|
|
6806
|
+
// runs with nobody watching: an editor-mode repo would park every story at step_4, and an
|
|
6807
|
+
// unrecognised mode would run on a guess.
|
|
6729
6808
|
if (opts.overnight) {
|
|
6730
|
-
|
|
6809
|
+
const { OVERNIGHT_MAX_STORIES } = require('./lib/run-context');
|
|
6810
|
+
if (lines.length > OVERNIGHT_MAX_STORIES) {
|
|
6811
|
+
console.error(`✗ --overnight refused: ${lines.length} stories, over the ${OVERNIGHT_MAX_STORIES}-story overnight cap. ` +
|
|
6812
|
+
'Nothing was resolved or sent; split the file.');
|
|
6813
|
+
process.exit(1);
|
|
6814
|
+
}
|
|
6815
|
+
const refusal = overnightModeRefusal(repoCtx.execution);
|
|
6816
|
+
if (refusal) {
|
|
6817
|
+
console.error(refusal);
|
|
6818
|
+
process.exit(1);
|
|
6819
|
+
}
|
|
6731
6820
|
}
|
|
6821
|
+
printRunContextNotes(repoCtx.notes);
|
|
6732
6822
|
|
|
6733
|
-
//
|
|
6734
|
-
// in
|
|
6735
|
-
//
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
if (ciGate.gate !== 'github') {
|
|
6744
|
-
console.log(`CI gate mode for batch: ${ciGate.gate} (local_command: ${ciGate.local_command})`);
|
|
6823
|
+
// Resolved with bounded concurrency: each story can make a network call (its roadmap context,
|
|
6824
|
+
// or `gh issue view`), and fifty in series is minutes of waiting. The output is printed after
|
|
6825
|
+
// every story has resolved, in file order, so it reads exactly like a serial pass.
|
|
6826
|
+
const RESOLVE_CONCURRENCY = 5;
|
|
6827
|
+
const resolved = new Array(lines.length);
|
|
6828
|
+
let nextLine = 0;
|
|
6829
|
+
await Promise.all(Array.from({ length: Math.min(RESOLVE_CONCURRENCY, lines.length) }, async () => {
|
|
6830
|
+
while (nextLine < lines.length) {
|
|
6831
|
+
const i = nextLine++;
|
|
6832
|
+
resolved[i] = await resolveStoryContext(rcIo, repoCtx, lines[i].storyId);
|
|
6745
6833
|
}
|
|
6746
|
-
}
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6834
|
+
}));
|
|
6835
|
+
// One summary line per story, and every warning labelled with its story. The info lines
|
|
6836
|
+
// run-story prints for one run would be a dozen per story here.
|
|
6837
|
+
for (const ctx of resolved) {
|
|
6838
|
+
const { storyId } = ctx;
|
|
6839
|
+
const src = ctx.textSource ? `${ctx.textSource} text (${ctx.rawText.length} chars)` : 'no story text';
|
|
6840
|
+
console.log(` [${storyId}] ${src} · ${ctx.bundle.files} codebase file(s) bundled`);
|
|
6841
|
+
printRunContextNotes(ctx.notes.pre, { prefix: `[${storyId}]`, only: 'warn' });
|
|
6842
|
+
printRunContextNotes(ctx.notes.post, { prefix: `[${storyId}]`, only: 'warn' });
|
|
6843
|
+
}
|
|
6844
|
+
|
|
6845
|
+
// HC-GF-097: a story with no text grooms to placeholders. run-story warns and proceeds for a
|
|
6846
|
+
// run someone is watching; a batch refuses — naming EVERY such story — before anything is
|
|
6847
|
+
// queued. Judged on the raw text, never the composed description.
|
|
6848
|
+
const missing = resolved.filter(ctx => !hasStoryText(ctx));
|
|
6849
|
+
if (missing.length > 0) {
|
|
6850
|
+
console.error(`✗ ${missing.length} of ${resolved.length} ${resolved.length === 1 ? 'story' : 'stories'} ` +
|
|
6851
|
+
`${missing.length === 1 ? 'has' : 'have'} no story text, so nothing was queued:`);
|
|
6852
|
+
for (const ctx of missing) console.error(` ${describeMissingText(ctx)}`);
|
|
6853
|
+
console.error('Give each one text (a roadmap row or a GitHub issue number), or remove it from the file.');
|
|
6854
|
+
process.exit(1);
|
|
6754
6855
|
}
|
|
6755
6856
|
|
|
6857
|
+
const stories = lines.map(({ storyId, dependsOn }, i) => {
|
|
6858
|
+
const obj = { storyId, repoName, branch, config: buildRunConfig(repoCtx, resolved[i]) };
|
|
6859
|
+
if (Array.isArray(dependsOn) && dependsOn.length > 0) obj.dependsOn = dependsOn;
|
|
6860
|
+
return obj;
|
|
6861
|
+
});
|
|
6862
|
+
|
|
6863
|
+
// HC-054: Night Shift. The flag lives at batch level only; the server caps an overnight batch
|
|
6864
|
+
// at 25 and denormalizes the flag into each child itself.
|
|
6865
|
+
const body = { stories, config: {} };
|
|
6866
|
+
if (opts.overnight) body.config.overnight = true;
|
|
6867
|
+
// HC-101-followup-2: the CI gate also travels at batch level. Each story already carries its
|
|
6868
|
+
// own; a server from HC-GF-097 on fills the batch value into any story that did not.
|
|
6869
|
+
body.config.ci_gate = repoCtx.config.ci_gate;
|
|
6870
|
+
body.config.ci_local_command = repoCtx.config.ci_local_command;
|
|
6871
|
+
|
|
6872
|
+
// Sized to the body: the client's 30 s default covered the upload (review of #1100).
|
|
6873
|
+
const bodyBytes = Buffer.byteLength(JSON.stringify(body), 'utf8');
|
|
6874
|
+
const postTimeoutMs = require('./lib/run-context').batchPostTimeoutMs(bodyBytes);
|
|
6756
6875
|
try {
|
|
6757
|
-
const { data } = await client.post('/orchestrate/batch', body);
|
|
6876
|
+
const { data } = await client.post('/orchestrate/batch', body, { timeout: postTimeoutMs });
|
|
6758
6877
|
|
|
6759
6878
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
6760
6879
|
|
|
@@ -6774,6 +6893,25 @@ program
|
|
|
6774
6893
|
console.error(`Org concurrency limit reached: ${e.response.data.error}`);
|
|
6775
6894
|
} else if (e.response?.status === 400) {
|
|
6776
6895
|
console.error(`Rejected: ${e.response.data.error}`);
|
|
6896
|
+
} else if (e.response?.status === 413) {
|
|
6897
|
+
// HC-GF-097: full per-story context can outgrow the server's request limit. Say how big,
|
|
6898
|
+
// which stories, and what to do — not a bare "request entity too large".
|
|
6899
|
+
const total = Buffer.byteLength(JSON.stringify(body), 'utf8');
|
|
6900
|
+
console.error(`Rejected: the batch is ${(total / 1048576).toFixed(1)} MB, over the server's request limit (413).`);
|
|
6901
|
+
console.error('Largest stories by run-config size:');
|
|
6902
|
+
const sizes = stories.map(s => ({ id: s.storyId, bytes: Buffer.byteLength(JSON.stringify(s.config), 'utf8') }))
|
|
6903
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
6904
|
+
for (const s of sizes.slice(0, 10)) console.error(` ${s.id} ${(s.bytes / 1024).toFixed(0)} KB`);
|
|
6905
|
+
console.error('Split the file into smaller batches, or narrow codebase.source_dirs so each story carries less.');
|
|
6906
|
+
} else if (e.response?.status === 503) {
|
|
6907
|
+
// HC-GF-097 review: the server admits a few batch submissions at a time; nothing was queued.
|
|
6908
|
+
console.error(`Server busy, nothing was queued: ${e.response.data?.error || 'retry shortly'}`);
|
|
6909
|
+
} else if (!e.response && (e.code === 'ECONNABORTED' || /timeout/i.test(e.message || ''))) {
|
|
6910
|
+
// The body may have arrived and been committed before the timer fired. Re-running blind
|
|
6911
|
+
// would queue the same stories twice, so say so and point at the list.
|
|
6912
|
+
console.error(`Timed out after ${Math.round(postTimeoutMs / 1000)}s sending a ${(bodyBytes / 1048576).toFixed(1)} MB batch.`);
|
|
6913
|
+
console.error('The server may have queued it anyway. Check before re-running, or the stories will be queued twice:');
|
|
6914
|
+
console.error(' hone queue-stories --list');
|
|
6777
6915
|
} else {
|
|
6778
6916
|
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
6779
6917
|
}
|
|
@@ -6781,71 +6919,6 @@ program
|
|
|
6781
6919
|
}
|
|
6782
6920
|
});
|
|
6783
6921
|
|
|
6784
|
-
// ── HC-054g: Night-shift retroactive revert command ─────────────────────────
|
|
6785
|
-
//
|
|
6786
|
-
// Two-step operator workflow after rejected_rate drift (HC-054c) flags a
|
|
6787
|
-
// batch of bad overnight auto-approves:
|
|
6788
|
-
//
|
|
6789
|
-
// 1. `hone night-shift revert <runId> --step-key <key> --revert-pr <url>`
|
|
6790
|
-
// 2. CLI calls POST /night-shift/runs/:runId/retroactive-reject (if not yet)
|
|
6791
|
-
// then POST /night-shift/runs/:runId/revert with the revert PR URL.
|
|
6792
|
-
//
|
|
6793
|
-
// We deliberately don't take repo write access; the operator runs the git
|
|
6794
|
-
// revert themselves and passes the resulting revert-PR URL. This keeps the
|
|
6795
|
-
// server side free of GitHub credentials + repo-specific permissions while
|
|
6796
|
-
// still giving the audit log the revert provenance.
|
|
6797
|
-
const nightShiftCmd = program.command('night-shift').description('HC-054 night-shift audit + revert workflow');
|
|
6798
|
-
nightShiftCmd
|
|
6799
|
-
.command('revert <runId>')
|
|
6800
|
-
.description('Record a revert action for a retroactively-rejected overnight auto-approve')
|
|
6801
|
-
.requiredOption('--step-key <stepKey>', 'The step_key whose auto-approve produced the bad output (e.g. step_4 or step_5)')
|
|
6802
|
-
.requiredOption('--revert-pr <url>', 'GitHub PR URL of the revert commit (https://github.com/.../pull/<n>)')
|
|
6803
|
-
.option('--reason <reason>', 'Free-text reason for the retroactive rejection (recorded with the audit row)')
|
|
6804
|
-
.action(async (runId, opts) => {
|
|
6805
|
-
const config = getConfig();
|
|
6806
|
-
const client = api(config);
|
|
6807
|
-
try {
|
|
6808
|
-
// Step 1: retroactively-reject if not yet rejected. The endpoint
|
|
6809
|
-
// returns 409 if already rejected — we treat that as a no-op
|
|
6810
|
-
// (operator may have done step 1 yesterday, run revert today).
|
|
6811
|
-
try {
|
|
6812
|
-
await client.post(`/night-shift/runs/${runId}/retroactive-reject`, {
|
|
6813
|
-
stepKey: opts.stepKey,
|
|
6814
|
-
reason: opts.reason,
|
|
6815
|
-
});
|
|
6816
|
-
console.log(`[night-shift] retroactively rejected (runId=${runId}, stepKey=${opts.stepKey})`);
|
|
6817
|
-
} catch (e) {
|
|
6818
|
-
const status = e.response?.status;
|
|
6819
|
-
if (status === 409) {
|
|
6820
|
-
console.log(`[night-shift] already retroactively rejected (continuing to revert step)`);
|
|
6821
|
-
} else if (status === 404) {
|
|
6822
|
-
console.error(`hone night-shift revert failed: no auto-approve audit row for (runId=${runId}, stepKey=${opts.stepKey})`);
|
|
6823
|
-
process.exit(1);
|
|
6824
|
-
} else {
|
|
6825
|
-
throw e;
|
|
6826
|
-
}
|
|
6827
|
-
}
|
|
6828
|
-
|
|
6829
|
-
// Step 2: record the revert PR URL.
|
|
6830
|
-
const r = await client.post(`/night-shift/runs/${runId}/revert`, {
|
|
6831
|
-
stepKey: opts.stepKey,
|
|
6832
|
-
revertPrUrl: opts.revertPr,
|
|
6833
|
-
});
|
|
6834
|
-
console.log(`[night-shift] revert recorded:`);
|
|
6835
|
-
console.log(` runId: ${r.data.runId}`);
|
|
6836
|
-
console.log(` stepKey: ${r.data.stepKey}`);
|
|
6837
|
-
console.log(` revertInitiatedAt: ${r.data.revertInitiatedAt}`);
|
|
6838
|
-
console.log(` revertPrUrl: ${r.data.revertPrUrl}`);
|
|
6839
|
-
} catch (e) {
|
|
6840
|
-
const msg = e.response?.data?.error || e.message;
|
|
6841
|
-
console.error(`hone night-shift revert failed: ${msg}`);
|
|
6842
|
-
if (e.response?.data?.remediation) {
|
|
6843
|
-
console.error(`Remediation: ${e.response.data.remediation}`);
|
|
6844
|
-
}
|
|
6845
|
-
process.exit(1);
|
|
6846
|
-
}
|
|
6847
|
-
});
|
|
6848
|
-
|
|
6849
6922
|
// ── HC-056: Schedule install (GitHub Actions overnight template) ────────────
|
|
6850
6923
|
//
|
|
6851
6924
|
// Installs a parameterized .github/workflows/<name>.yml that runs `hone
|
|
@@ -7411,13 +7484,16 @@ program
|
|
|
7411
7484
|
const responseForCache = parsed && typeof parsed === 'object'
|
|
7412
7485
|
? parsed
|
|
7413
7486
|
: { raw: responseText };
|
|
7414
|
-
storeReviewCache({
|
|
7487
|
+
await storeReviewCache({
|
|
7415
7488
|
axios, apiBase, token,
|
|
7416
7489
|
contentHash: cacheContentHash,
|
|
7417
7490
|
model: modelForCache,
|
|
7418
7491
|
response: responseForCache,
|
|
7419
7492
|
tokensSaved: inputTokens + outputTokens,
|
|
7420
7493
|
banner,
|
|
7494
|
+
// HC-GF-260: awaited. The next thing this command does is compute an exit code and
|
|
7495
|
+
// leave, so a detached POST can lose the entry the cache exists to save — the same shape
|
|
7496
|
+
// as HC-GF-250, where a process returned before its write landed.
|
|
7421
7497
|
}).then(({ stored }) => {
|
|
7422
7498
|
if (stored) banner(`✓ Cache stored: ${cacheContentHash.slice(0, 16)}…`);
|
|
7423
7499
|
}).catch(() => { /* logged inside helper */ });
|
|
@@ -7580,6 +7656,9 @@ program
|
|
|
7580
7656
|
// (followup-20) for stories still on the old ## Patch contract.
|
|
7581
7657
|
let diff, source, noChanges;
|
|
7582
7658
|
const blocks = extractFileBlocks(output);
|
|
7659
|
+
// HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
|
|
7660
|
+
// silently re-scoping is how a finished build came back as "no file changes to verify".
|
|
7661
|
+
if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
|
|
7583
7662
|
if (blocks.noChanges) {
|
|
7584
7663
|
emit({ ok: true, verdict: 'no_changes' },
|
|
7585
7664
|
`○ ${opts.stepKey} declared NO CHANGES — nothing to apply.`);
|
|
@@ -7624,13 +7703,15 @@ program
|
|
|
7624
7703
|
|
|
7625
7704
|
// Write to a temp file and run git apply --check against HEAD. HC-GF-077: against an index read from HEAD,
|
|
7626
7705
|
// not the working copy — an editor's uncommitted edits are exactly what emit-pr's branch will NOT contain.
|
|
7627
|
-
|
|
7706
|
+
// HC-GF-304: a directory only this invocation created, not a name built from the clock.
|
|
7707
|
+
const tmpFile = require('./lib/private-temp').privateTempFile('hone-check-patch', 'patch.diff');
|
|
7708
|
+
const tmp = tmpFile.file;
|
|
7628
7709
|
let result;
|
|
7629
7710
|
try {
|
|
7630
7711
|
fs.writeFileSync(tmp, diff);
|
|
7631
7712
|
let code = 0, stderr = '';
|
|
7632
7713
|
try {
|
|
7633
|
-
const index = path.join(
|
|
7714
|
+
const index = path.join(path.dirname(tmp), 'index'); // HC-GF-304: inside this invocation's private directory
|
|
7634
7715
|
try {
|
|
7635
7716
|
try {
|
|
7636
7717
|
execFileSync('git', ['read-tree', `--index-output=${index}`, 'HEAD'], { cwd: process.cwd(), env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -7647,7 +7728,7 @@ program
|
|
|
7647
7728
|
}
|
|
7648
7729
|
result = interpretApplyCheck({ code, stderr });
|
|
7649
7730
|
} finally {
|
|
7650
|
-
|
|
7731
|
+
tmpFile.remove();
|
|
7651
7732
|
}
|
|
7652
7733
|
|
|
7653
7734
|
if (result.applies) {
|
|
@@ -7680,15 +7761,35 @@ program
|
|
|
7680
7761
|
.option('--keep', 'Leave the worktree in place for debugging')
|
|
7681
7762
|
.option('--no-provision', 'Do not install/link dependencies into the isolated worktree (HC-COMM-010-followup-6)')
|
|
7682
7763
|
.option('--report', 'HC-019n-followup-34: report the verdict back to the server (closed loop, advisory)')
|
|
7764
|
+
.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)')
|
|
7683
7765
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7684
7766
|
.action(async (workflowId, opts) => {
|
|
7685
7767
|
const { execFileSync, spawnSync } = require('child_process');
|
|
7768
|
+
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
7769
|
+
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
7770
|
+
// deep in child_process, and a blank value coerces to 0 and kills the
|
|
7771
|
+
// command the instant it starts.
|
|
7772
|
+
let timeoutMs;
|
|
7773
|
+
try {
|
|
7774
|
+
timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
|
|
7775
|
+
} catch (e) {
|
|
7776
|
+
console.error(e.message);
|
|
7777
|
+
process.exit(2);
|
|
7778
|
+
}
|
|
7686
7779
|
const os = require('os');
|
|
7687
7780
|
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7688
7781
|
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv, EXIT, resolveAuxGates, classifyGateRun } = require('./lib/verify-patch');
|
|
7689
7782
|
const { gitEnv } = require('./lib/git-env');
|
|
7690
7783
|
const config = getConfig();
|
|
7691
7784
|
const client = api(config);
|
|
7785
|
+
// HC-GF-270 (second review MEDIUM-1): the step key goes into a URL and a file name, and URL
|
|
7786
|
+
// resolution collapses `..` — `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's
|
|
7787
|
+
// patch while THIS run's config was assessed. A step key is a step key.
|
|
7788
|
+
if (!/^step_[0-9a-z_]+$/i.test(String(opts.stepKey || ''))) {
|
|
7789
|
+
console.error(`--step-key must name a step, like step_4 (got ${JSON.stringify(opts.stepKey)})`);
|
|
7790
|
+
process.exit(2);
|
|
7791
|
+
}
|
|
7792
|
+
const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
|
|
7692
7793
|
|
|
7693
7794
|
const emit = (obj, humanLine) => {
|
|
7694
7795
|
if (opts.format === 'json') console.log(JSON.stringify(obj));
|
|
@@ -7699,19 +7800,31 @@ program
|
|
|
7699
7800
|
// HC-GF-074: the attempt this run verified — reported with the verdict below.
|
|
7700
7801
|
let verifiedAttempt;
|
|
7701
7802
|
let output, runConfig;
|
|
7803
|
+
// HC-GF-270: the id shape is the legacy provenance rule's only input, and a FAILED run
|
|
7804
|
+
// fetch is untrusted regardless of it — the `catch { runConfig = {} }` below is the
|
|
7805
|
+
// fail-open this gate exists to close.
|
|
7806
|
+
let runStoryId = null;
|
|
7807
|
+
let runFetchFailed = false;
|
|
7702
7808
|
try {
|
|
7703
|
-
const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
|
|
7809
|
+
const url = `/orchestrate/${encodeURIComponent(workflowId)}/step/${encodeURIComponent(opts.stepKey)}` +
|
|
7704
7810
|
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7705
7811
|
const r = await client.get(url);
|
|
7812
|
+
// The step output must be THIS run's: nothing else ties the patch to the config assessed below.
|
|
7813
|
+
if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
|
|
7706
7814
|
output = r.data?.output;
|
|
7707
7815
|
// HC-GF-074: remember WHICH attempt this is. Without --attempt the server returns the latest, and the verdict
|
|
7708
7816
|
// used to be reported with `attempt: null` — so no verdict on record could say which build it judged.
|
|
7709
7817
|
verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
|
|
7710
7818
|
// best-effort: pull the run's ci_local_command for the default
|
|
7711
7819
|
try {
|
|
7712
|
-
const runR = await client.get(`/orchestrate/${workflowId}`);
|
|
7820
|
+
const runR = await client.get(`/orchestrate/${encodeURIComponent(workflowId)}`);
|
|
7821
|
+
// HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
|
|
7822
|
+
// `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
|
|
7823
|
+
// 200 with no config. And a body that is not THIS run is not this run's config.
|
|
7824
|
+
if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
|
|
7713
7825
|
runConfig = runR.data?.config || {};
|
|
7714
|
-
|
|
7826
|
+
runStoryId = runR.data?.storyId || runR.data?.story_id || null;
|
|
7827
|
+
} catch (e) { runConfig = {}; runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; }
|
|
7715
7828
|
} catch (e) {
|
|
7716
7829
|
const msg = e.response?.data?.error || e.message;
|
|
7717
7830
|
emit({ ok: false, reason: 'fetch_failed', detail: msg },
|
|
@@ -7719,12 +7832,58 @@ program
|
|
|
7719
7832
|
process.exit(EXIT.NO_DIFF);
|
|
7720
7833
|
}
|
|
7721
7834
|
|
|
7835
|
+
// ── HC-GF-270: the provenance gate ────────────────────────────────────────────────────
|
|
7836
|
+
//
|
|
7837
|
+
// HERE, and not one line later. Telling the operator at the poll loop is not enough: an
|
|
7838
|
+
// MCP agent reads the same untrusted issue text and can call `hone_verify_patch` itself
|
|
7839
|
+
// (cli/lib/mcp-tools.js), and a scripted `--approve` takes the same path. This runs
|
|
7840
|
+
// BEFORE the diff is materialized, before a worktree exists, and before provisioning
|
|
7841
|
+
// runs the PATCHED manifest's install as the operator — by the time `planProvision`
|
|
7842
|
+
// sees it, the code is already on disk, which is why worktree-provision.js is not the
|
|
7843
|
+
// layer for this.
|
|
7844
|
+
{
|
|
7845
|
+
let assessment;
|
|
7846
|
+
try {
|
|
7847
|
+
const { assessStorySource } = require('./lib/story-provenance');
|
|
7848
|
+
assessment = runFetchFailed
|
|
7849
|
+
? { trusted: false, label: 'run-fetch-failed',
|
|
7850
|
+
reason: runFetchFailed === 'mismatch'
|
|
7851
|
+
? 'the server answered for a different run, so this run\'s config was never read'
|
|
7852
|
+
: 'this run\'s config could not be read, so nothing is known about where its story text came from' }
|
|
7853
|
+
: assessStorySource(runConfig?.story_source, runStoryId);
|
|
7854
|
+
} catch (e) {
|
|
7855
|
+
assessment = { trusted: false, label: 'assessment-failed',
|
|
7856
|
+
reason: `story provenance could not be assessed: ${e.message}` };
|
|
7857
|
+
}
|
|
7858
|
+
if (!assessment.trusted && !opts.trustSource) {
|
|
7859
|
+
// NO --report on this path, deliberately. A non-pass verdict on record makes the
|
|
7860
|
+
// server refuse the human's later approval under `enforce`
|
|
7861
|
+
// (server/src/services/verification-refusal.js), and nothing was verified here —
|
|
7862
|
+
// this is a check that did not RUN, which is a third outcome, not a failure.
|
|
7863
|
+
emit({ ok: false, reason: 'untrusted_source', verdict: 'not_run', detail: assessment.reason },
|
|
7864
|
+
`✗ refusing to verify: ${assessment.reason}\n`
|
|
7865
|
+
+ ' Verifying runs this patch — the provisioning install first — as you, with your $HOME and network.\n'
|
|
7866
|
+
+ ` Read it: hone show step ${workflowId} ${opts.stepKey}\n`
|
|
7867
|
+
+ ` Then: hone verify-patch ${workflowId} --report --trust-source`);
|
|
7868
|
+
process.exit(EXIT.UNTRUSTED_SOURCE);
|
|
7869
|
+
}
|
|
7870
|
+
}
|
|
7871
|
+
|
|
7722
7872
|
// 2. Materialize the diff (shared with check-patch/emit-pr, followup-23/27).
|
|
7723
7873
|
// Condition 6 presupposes 5.
|
|
7724
7874
|
const blocks = extractFileBlocks(output);
|
|
7875
|
+
// HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
|
|
7876
|
+
// silently re-scoping is how a finished build came back as "no file changes to verify".
|
|
7877
|
+
if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
|
|
7725
7878
|
const hasWork = hasMaterializableWork(blocks);
|
|
7726
7879
|
if (blocks.noChanges || !hasWork) {
|
|
7727
|
-
|
|
7880
|
+
// HC-GF-190: this exit precedes planSandboxRun entirely, so a stateful story with a declared
|
|
7881
|
+
// sandbox produces NONE of the planner's three outcomes and, before this line, said nothing
|
|
7882
|
+
// about it — run 2a9b2af5's verdict was indistinguishable from an ordinary empty build.
|
|
7883
|
+
// `not reached` is a fourth outcome, not a variant of the stand-down.
|
|
7884
|
+
const missed = describeSandboxDisposition(runConfig);
|
|
7885
|
+
if (missed && opts.format !== 'json') console.warn(` ⚠ ${missed}`);
|
|
7886
|
+
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff', sandbox: missed || undefined },
|
|
7728
7887
|
`✗ ${opts.stepKey} produced no file changes to verify.`);
|
|
7729
7888
|
process.exit(EXIT.NO_DIFF);
|
|
7730
7889
|
}
|
|
@@ -7747,16 +7906,19 @@ program
|
|
|
7747
7906
|
|
|
7748
7907
|
// 3. Isolated worktree from HEAD.
|
|
7749
7908
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-verify-'));
|
|
7750
|
-
|
|
7909
|
+
// HC-GF-304: a directory only this invocation created. A clock-named file was shared by two verbs started in
|
|
7910
|
+
// the same millisecond, and one of them verified the other's patch.
|
|
7911
|
+
const diffTmp = require('./lib/private-temp').privateTempFile('hone-verify-diff', 'patch.diff');
|
|
7912
|
+
const diffFile = diffTmp.file;
|
|
7751
7913
|
fs.writeFileSync(diffFile, diff);
|
|
7752
|
-
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
|
|
7914
|
+
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
|
|
7753
7915
|
let cleanupDone = false;
|
|
7754
7916
|
const cleanup = () => {
|
|
7755
7917
|
if (cleanupDone || opts.keep) return;
|
|
7756
7918
|
cleanupDone = true;
|
|
7757
7919
|
try { execFileSync('git', ['worktree', 'remove', '--force', wt], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
|
|
7758
7920
|
try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
7759
|
-
|
|
7921
|
+
diffTmp.remove();
|
|
7760
7922
|
};
|
|
7761
7923
|
// Guard against leaving a worktree if the process is interrupted.
|
|
7762
7924
|
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
@@ -7772,6 +7934,9 @@ program
|
|
|
7772
7934
|
// HC-GF-026: measured verdicts for the gates step_4 asserts. Hoisted so they
|
|
7773
7935
|
// survive the try/finally to the --report POST, exactly like reportVerdict.
|
|
7774
7936
|
const reportGates = [];
|
|
7937
|
+
// HC-GF-171: hoisted for the same reason reportVerdict is — the teardown must survive the
|
|
7938
|
+
// try/finally. Seeded rows outliving a run is the worst outcome this feature can produce.
|
|
7939
|
+
let sandboxTeardown = [];
|
|
7775
7940
|
const ciCfg = require('./lib/pipeline-config').readCIConfigDetails(process.cwd());
|
|
7776
7941
|
try { headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
|
|
7777
7942
|
try {
|
|
@@ -7796,24 +7961,172 @@ program
|
|
|
7796
7961
|
// HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
|
|
7797
7962
|
// (link the repo's node_modules when the lockfile is unchanged, else run the
|
|
7798
7963
|
// lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
|
|
7964
|
+
// HC-GF-201: declared OUTSIDE the guard — the verdict needs provisioning state,
|
|
7965
|
+
// and with --no-provision there is none, which is not the same as a failure.
|
|
7966
|
+
let provAll = null;
|
|
7799
7967
|
if (opts.provision !== false) {
|
|
7800
|
-
const { planProvision,
|
|
7968
|
+
const { planProvision, discoverPackageRoots, provisionAllRoots } = require('./lib/worktree-provision');
|
|
7801
7969
|
const { readCIConfigDetails } = require('./lib/pipeline-config');
|
|
7802
|
-
|
|
7803
|
-
|
|
7970
|
+
// HC-GF-201: provision EVERY package root, not just the worktree's top level.
|
|
7971
|
+
// This repo has three; the two skipped are where ci.unit runs, so their absent
|
|
7972
|
+
// modules surfaced as `Cannot find module` and were counted as the PATCH failing
|
|
7973
|
+
// tests — a false RED on the merge-gating verdict (run b3117bde, 2026-09-20).
|
|
7974
|
+
const installCmd = readCIConfigDetails(process.cwd()).install_command;
|
|
7975
|
+
const provEntries = discoverPackageRoots(wt).map((root) => ({
|
|
7976
|
+
root,
|
|
7977
|
+
worktreeDir: root === '.' ? wt : path.join(wt, root),
|
|
7978
|
+
exec: spawnSync,
|
|
7979
|
+
plan: planProvision({
|
|
7980
|
+
worktreeDir: root === '.' ? wt : path.join(wt, root),
|
|
7981
|
+
parentDir: root === '.' ? process.cwd() : path.join(process.cwd(), root),
|
|
7982
|
+
installCommand: root === '.' ? installCmd : undefined,
|
|
7983
|
+
}),
|
|
7984
|
+
}));
|
|
7985
|
+
provAll = provisionAllRoots(provEntries);
|
|
7986
|
+
const prov = { ok: provAll.ok,
|
|
7987
|
+
summary: provAll.results.map((r) => `${r.root}: ${r.summary}`).join('; ')
|
|
7988
|
+
+ (provAll.ok ? '' : ` — NOT RUN: ${provAll.unprovisioned.map((u) => u.root).join(', ')} could not be provisioned`) };
|
|
7804
7989
|
if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
|
|
7805
7990
|
}
|
|
7991
|
+
// ── HC-GF-171: seed the adopter-declared sandbox before the tests run ────────────────
|
|
7992
|
+
// HC-GF-168/169/170/173 each shipped a piece and a run still touched no datastore, because
|
|
7993
|
+
// `assertScratchTarget` had no caller. This is that caller. It runs adopter-side, with the
|
|
7994
|
+
// adopter's credentials, exactly where `ci.local_command` already runs.
|
|
7995
|
+
//
|
|
7996
|
+
// REFUSE, NEVER SKIP. A misconfigured sandbox stops verification with a message naming the
|
|
7997
|
+
// fix; it does not quietly fall through to "tests passed", which would report a green
|
|
7998
|
+
// verdict for code whose stored-state behaviour was never exercised.
|
|
7999
|
+
try {
|
|
8000
|
+
const { readSandboxConfig, assertScratchTarget } = require('./lib/pipeline-config');
|
|
8001
|
+
const { planSandboxRun, parseProbeOutput, describeProbeResult, sandboxSuiteVerdict } = require('./lib/sandbox-lifecycle');
|
|
8002
|
+
const plan = planSandboxRun({
|
|
8003
|
+
sandbox: readSandboxConfig(process.cwd()),
|
|
8004
|
+
axes: runConfig?.classifier_axes || runConfig?.axes || [],
|
|
8005
|
+
capability: runConfig?.capability || null,
|
|
8006
|
+
});
|
|
8007
|
+
if (plan.refuse) {
|
|
8008
|
+
emit({ ok: false, verdict: 'sandbox_refused', detail: plan.reason },
|
|
8009
|
+
`✗ sandbox refused: ${plan.reason}`);
|
|
8010
|
+
exitCode = EXIT.FAIL;
|
|
8011
|
+
reportVerdict = 'sandbox_refused'; reportDetail = plan.reason;
|
|
8012
|
+
throw new Error('sandbox refused');
|
|
8013
|
+
}
|
|
8014
|
+
if (plan.engage) {
|
|
8015
|
+
// The probe runs in the REAL tree, not the worktree: it reads the adopter's live
|
|
8016
|
+
// datastore, which the worktree does not change.
|
|
8017
|
+
const probed = spawnSync(plan.probe, {
|
|
8018
|
+
cwd: process.cwd(), shell: true, encoding: 'utf8',
|
|
8019
|
+
timeout: 60_000, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'],
|
|
8020
|
+
});
|
|
8021
|
+
// A probe that exited non-zero is not evidence, whatever it left on stdout — a
|
|
8022
|
+
// command that fails partway can still print a stale or partial line, and reading it
|
|
8023
|
+
// would turn a failed check into a pass.
|
|
8024
|
+
if (probed.status !== 0) {
|
|
8025
|
+
throw new Error(`sandbox probe exited ${probed.status === null ? 'abnormally' : probed.status}`
|
|
8026
|
+
+ ` (${plan.probe}): ${((probed.stderr || '').split('\n')[0] || 'no stderr')}`);
|
|
8027
|
+
}
|
|
8028
|
+
// The guard reads the LIVE connection and nothing else (HC-GF-169).
|
|
8029
|
+
const probeEvidence = parseProbeOutput(probed.stdout);
|
|
8030
|
+
assertScratchTarget(plan.verify, probeEvidence);
|
|
8031
|
+
// HC-GF-190: say what the guard PROVED. It throws on refusal and returns nothing on
|
|
8032
|
+
// success, so a passing guard was silent — and HC-GF-185's acceptance ("the output
|
|
8033
|
+
// shows the probe, the guard verdict and the teardown") stayed open on run 6be117b3,
|
|
8034
|
+
// where all three had in fact happened. Printed after the check and before the first
|
|
8035
|
+
// write, so it describes something that has already occurred.
|
|
8036
|
+
if (opts.format !== 'json') console.log(` sandbox: ${describeProbeResult(plan.verify, probeEvidence)}`);
|
|
8037
|
+
// Armed BEFORE the first command, not after the last. `create` succeeding and
|
|
8038
|
+
// `migrate` failing would otherwise leave the created rows behind — exactly the
|
|
8039
|
+
// outcome the teardown exists to prevent, on the path where it matters most.
|
|
8040
|
+
sandboxTeardown = plan.teardown;
|
|
8041
|
+
for (const c of plan.commands) {
|
|
8042
|
+
if (opts.format !== 'json') console.log(` sandbox: ${c}`);
|
|
8043
|
+
const r = spawnSync(c, { cwd: wt, shell: true, encoding: 'utf8',
|
|
8044
|
+
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8045
|
+
if (r.status !== 0) throw new Error(`sandbox command failed: ${c}`);
|
|
8046
|
+
}
|
|
8047
|
+
|
|
8048
|
+
// HC-GF-192: run the suite that READS the sandbox, immediately after seeding and
|
|
8049
|
+
// before the adopter's own command.
|
|
8050
|
+
//
|
|
8051
|
+
// Run 4f463e54 seeded `hone_sandbox` and then ran `make ci` against
|
|
8052
|
+
// `hone_test_<checkout>`: a flawless lifecycle around a verification that could not
|
|
8053
|
+
// observe it. The adopter's whole CI command is the wrong consumer — a suite that
|
|
8054
|
+
// manages its own database fights a seeded one — so the sandbox declares the command
|
|
8055
|
+
// that reads it, and the connection is one WE publish rather than one an adopter
|
|
8056
|
+
// promises is wired up. That is what makes the result provable.
|
|
8057
|
+
//
|
|
8058
|
+
// First, because the seed is freshest here and the adopter's command can run for
|
|
8059
|
+
// minutes; and a failure names the stored-state behaviour rather than arriving after
|
|
8060
|
+
// an unrelated suite. Names are logged, never values: the connection holds credentials.
|
|
8061
|
+
if (opts.format !== 'json') {
|
|
8062
|
+
console.log(` sandbox: ${plan.test}`);
|
|
8063
|
+
console.log(` sandbox: published ${Object.keys(plan.env).join(', ')} to it`);
|
|
8064
|
+
}
|
|
8065
|
+
const sandboxRun = spawnSync(plan.test, {
|
|
8066
|
+
cwd: wt, shell: true, encoding: 'utf8', timeout: timeoutMs,
|
|
8067
|
+
env: verifyCommandEnv(gitEnv(plan.env)), stdio: ['ignore', 'pipe', 'pipe'],
|
|
8068
|
+
});
|
|
8069
|
+
|
|
8070
|
+
// HC-GF-193: the NEGATIVE CONTROL. A runner with nothing to run exits 0, so the status
|
|
8071
|
+
// above cannot tell "everything passed" from "nothing ran" — run 6be117b3's first
|
|
8072
|
+
// attempt was reported as verified on a glob that matched no files. Re-run with every
|
|
8073
|
+
// published variable replaced by an unusable value: a suite that observes the sandbox
|
|
8074
|
+
// must fail, and one that still passes is not reading what we published.
|
|
8075
|
+
//
|
|
8076
|
+
// Only when the real run PASSED. A red suite is already a failure, and re-running it
|
|
8077
|
+
// with a broken environment spends the timeout to learn nothing.
|
|
8078
|
+
let controlStatus = 1;
|
|
8079
|
+
if (sandboxRun.status === 0 && plan.controlEnv) {
|
|
8080
|
+
if (opts.format !== 'json') console.log(' sandbox: re-running the suite with the published variables broken (control)');
|
|
8081
|
+
const ctl = spawnSync(plan.test, {
|
|
8082
|
+
cwd: wt, shell: true, encoding: 'utf8', timeout: timeoutMs,
|
|
8083
|
+
env: verifyCommandEnv(gitEnv(plan.controlEnv)), stdio: ['ignore', 'pipe', 'pipe'],
|
|
8084
|
+
});
|
|
8085
|
+
controlStatus = ctl.status === 0 ? 0 : 1;
|
|
8086
|
+
}
|
|
8087
|
+
|
|
8088
|
+
const suiteVerdict = sandboxSuiteVerdict({ realStatus: sandboxRun.status, controlStatus });
|
|
8089
|
+
if (!suiteVerdict.ok) {
|
|
8090
|
+
const ran = ((sandboxRun.stdout || '') + (sandboxRun.stderr || ''))
|
|
8091
|
+
.split('\n').filter(Boolean).slice(-12).join('\n');
|
|
8092
|
+
const detail = suiteVerdict.kind === 'sandbox_tests_not_observing'
|
|
8093
|
+
? suiteVerdict.detail : ran;
|
|
8094
|
+
emit({ ok: false, verdict: suiteVerdict.kind, detail },
|
|
8095
|
+
`✗ ${suiteVerdict.kind === 'sandbox_tests_not_observing'
|
|
8096
|
+
? `the sandbox suite verified nothing:\n${detail}`
|
|
8097
|
+
: `the sandbox suite failed:\n${detail}`}`);
|
|
8098
|
+
exitCode = EXIT.FAIL;
|
|
8099
|
+
reportVerdict = suiteVerdict.kind; reportDetail = detail;
|
|
8100
|
+
// Thrown so the `finally` tears the sandbox down — seeded rows outliving a red run
|
|
8101
|
+
// is the worst outcome this capability can produce (HC-GF-171).
|
|
8102
|
+
throw new Error('sandbox tests failed');
|
|
8103
|
+
}
|
|
8104
|
+
} else if (opts.format !== 'json') {
|
|
8105
|
+
console.log(` sandbox: ${plan.reason}`);
|
|
8106
|
+
}
|
|
8107
|
+
} catch (e) {
|
|
8108
|
+
if (e.message === 'sandbox refused' || e.message === 'sandbox tests failed') throw e;
|
|
8109
|
+
// A sandbox that cannot be proven scratch STOPS the run. Continuing would report a
|
|
8110
|
+
// verdict for stored-state behaviour that was never exercised — a verdict we did not
|
|
8111
|
+
// obtain (HC-GF-177 §18.5).
|
|
8112
|
+
emit({ ok: false, verdict: 'sandbox_unusable', detail: e.message },
|
|
8113
|
+
`✗ sandbox unusable: ${e.message}`);
|
|
8114
|
+
exitCode = EXIT.FAIL;
|
|
8115
|
+
reportVerdict = 'sandbox_unusable'; reportDetail = e.message;
|
|
8116
|
+
throw e;
|
|
8117
|
+
}
|
|
8118
|
+
|
|
7806
8119
|
if (opts.format !== 'json') console.log(` running: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
7807
8120
|
const run = spawnSync(command, {
|
|
7808
8121
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
7809
|
-
timeout:
|
|
8122
|
+
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
7810
8123
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7811
8124
|
});
|
|
7812
8125
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
7813
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
|
|
8126
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
|
|
7814
8127
|
// HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
|
|
7815
8128
|
if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
|
|
7816
|
-
const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
|
|
8129
|
+
const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
|
|
7817
8130
|
|
|
7818
8131
|
// HC-GF-023: on a failure the operator needs the OUTPUT, not a quote from it.
|
|
7819
8132
|
// The full log is written OUTSIDE the worktree (cleanup removes the worktree)
|
|
@@ -7834,7 +8147,13 @@ program
|
|
|
7834
8147
|
+ v.failure.excerpt.map(l => ` │ ${l}`).join('\n');
|
|
7835
8148
|
}
|
|
7836
8149
|
if (logPath) pretty += `\n full output: ${logPath}`;
|
|
7837
|
-
if (v.verdict !== 'pass') pretty += `\n inspect the tree: re-run with --keep`;
|
|
8150
|
+
if (v.verdict !== 'pass' && v.verdict !== 'pass_narrowed') pretty += `\n inspect the tree: re-run with --keep`;
|
|
8151
|
+
// HC-GF-223: a narrowed run needs the command that did NOT run, not the worktree — the way
|
|
8152
|
+
// to a real verdict is the next line rather than a re-read of the story.
|
|
8153
|
+
if (v.verdict === 'pass_narrowed') {
|
|
8154
|
+
pretty += `\n not a verification: re-run without --command to verify with `
|
|
8155
|
+
+ `\`${commandReplaced.command}\``;
|
|
8156
|
+
}
|
|
7838
8157
|
|
|
7839
8158
|
emit(
|
|
7840
8159
|
{ ok: v.verdict === 'pass', verdict: v.verdict, command, detail: v.detail,
|
|
@@ -7858,7 +8177,7 @@ program
|
|
|
7858
8177
|
for (const gate of resolveAuxGates({ configured: ciCfg, cwd: process.cwd() })) {
|
|
7859
8178
|
const gr = spawnSync(gate.command, {
|
|
7860
8179
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
7861
|
-
timeout:
|
|
8180
|
+
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
7862
8181
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
7863
8182
|
});
|
|
7864
8183
|
const c = classifyGateRun({
|
|
@@ -7879,11 +8198,34 @@ program
|
|
|
7879
8198
|
}
|
|
7880
8199
|
}
|
|
7881
8200
|
} catch (e) {
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
8201
|
+
// HC-GF-171: a verdict already reported by the sandbox path is the SPECIFIC one — keep it.
|
|
8202
|
+
// Overwriting it with a generic `error` loses which check refused and emits a second,
|
|
8203
|
+
// vaguer line over the first, so the operator reads "verify-patch failed" instead of the
|
|
8204
|
+
// message naming the config key to add.
|
|
8205
|
+
if (reportVerdict) {
|
|
8206
|
+
exitCode = exitCode === EXIT.PASS ? 1 : exitCode;
|
|
8207
|
+
} else {
|
|
8208
|
+
emit({ ok: false, verdict: 'error', detail: e.message },
|
|
8209
|
+
`✗ verify-patch failed: ${e.message}`);
|
|
8210
|
+
exitCode = 1;
|
|
8211
|
+
reportVerdict = 'error'; reportDetail = e.message;
|
|
8212
|
+
}
|
|
7886
8213
|
} finally {
|
|
8214
|
+
// HC-GF-171: reset runs on EVERY path — pass, fail, refusal, throw. A sandbox that is only
|
|
8215
|
+
// cleaned on success leaves its rows behind exactly when something went wrong, which is when
|
|
8216
|
+
// the adopter is least able to tell seeded data from real.
|
|
8217
|
+
for (const c of sandboxTeardown) {
|
|
8218
|
+
const r = spawnSync(c, { cwd: process.cwd(), shell: true, encoding: 'utf8',
|
|
8219
|
+
timeout: 120_000, env: verifyCommandEnv(gitEnv()), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8220
|
+
if (opts.format !== 'json') {
|
|
8221
|
+
console.log(r.status === 0
|
|
8222
|
+
? ` sandbox: ${c}`
|
|
8223
|
+
: ` sandbox: TEARDOWN FAILED (${c}) — seeded rows may remain: ${(r.stderr || '').split('\n')[0]}`);
|
|
8224
|
+
}
|
|
8225
|
+
// HC-GF-183: keep going. If the reset succeeded and the restore failed, stopping here
|
|
8226
|
+
// leaves the sandbox unprobeable and the NEXT run refuses — the defect this fixes.
|
|
8227
|
+
}
|
|
8228
|
+
|
|
7887
8229
|
cleanup();
|
|
7888
8230
|
}
|
|
7889
8231
|
|
|
@@ -7892,7 +8234,7 @@ program
|
|
|
7892
8234
|
// change the local exit code (the verdict is already true locally).
|
|
7893
8235
|
if (opts.report && reportVerdict) {
|
|
7894
8236
|
try {
|
|
7895
|
-
await client.post(`/orchestrate/${workflowId}/verdict`, {
|
|
8237
|
+
await client.post(`/orchestrate/${encodeURIComponent(workflowId)}/verdict`, {
|
|
7896
8238
|
source: 'verify-patch',
|
|
7897
8239
|
stepKey: opts.stepKey,
|
|
7898
8240
|
verdict: reportVerdict,
|
|
@@ -7948,6 +8290,82 @@ program
|
|
|
7948
8290
|
}
|
|
7949
8291
|
});
|
|
7950
8292
|
|
|
8293
|
+
// ── HC-GF-098: follow-batch — a queued batch's step_4 patches checked and verified overnight ──
|
|
8294
|
+
// The server cannot execute, so a batch queued at night reached step_4 and waited for a person to type
|
|
8295
|
+
// check-patch, verify-patch and emit-pr. This is the I/O around cli/lib/batch-follow.js, which makes every
|
|
8296
|
+
// decision. The verbs run as children of THIS CLI, one at a time, so the loop runs exactly what a person runs.
|
|
8297
|
+
// Exit: 0 the batch completed, 1 fatal (refused, unknown batch, unreachable), 2 something needs you, 3 deadline.
|
|
8298
|
+
program
|
|
8299
|
+
.command('follow-batch <batchId>')
|
|
8300
|
+
.description('Follow a queued batch: check and verify each step_4 patch, and emit each completed run (HC-GF-098)')
|
|
8301
|
+
.option('--poll-interval <sec>', 'Seconds between polls', '30')
|
|
8302
|
+
.option('--deadline <min>', 'Stop after this many minutes, even if the batch is still running')
|
|
8303
|
+
.option('--max-offline <min>', 'Give up once the server has been unreachable this long', '15')
|
|
8304
|
+
.option('--open-pr', 'Push each emitted branch and open a draft PR (default: a local branch only)')
|
|
8305
|
+
.option('--repo <name>', 'Repository name the batch was queued under (default: this directory\'s name)')
|
|
8306
|
+
.action(async (batchId, opts) => {
|
|
8307
|
+
const { followBatch, EXIT } = require('./lib/batch-follow');
|
|
8308
|
+
const { spawn, spawnSync } = require('child_process');
|
|
8309
|
+
const positive = (raw, flag, unit) => {
|
|
8310
|
+
const n = Number(raw);
|
|
8311
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
8312
|
+
console.error(`${flag} must be a positive number of ${unit} (got ${JSON.stringify(raw)})`);
|
|
8313
|
+
process.exit(EXIT.FATAL);
|
|
8314
|
+
}
|
|
8315
|
+
return n;
|
|
8316
|
+
};
|
|
8317
|
+
const pollMs = positive(opts.pollInterval, '--poll-interval', 'seconds') * 1000;
|
|
8318
|
+
const maxOfflineMs = positive(opts.maxOffline, '--max-offline', 'minutes') * 60_000;
|
|
8319
|
+
const deadlineMs = opts.deadline === undefined ? null : positive(opts.deadline, '--deadline', 'minutes') * 60_000;
|
|
8320
|
+
|
|
8321
|
+
// Preflight: every verb reads this checkout, and --open-pr needs gh. Refuse before the first poll, not at 3am.
|
|
8322
|
+
const { gitEnv } = require('./lib/git-env');
|
|
8323
|
+
const inRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: process.cwd(), env: gitEnv(), encoding: 'utf8' });
|
|
8324
|
+
if (inRepo.status !== 0 || String(inRepo.stdout).trim() !== 'true') {
|
|
8325
|
+
console.error('follow-batch runs the batch\'s patches against this checkout: run it from the repository the batch was queued in.');
|
|
8326
|
+
process.exit(EXIT.FATAL);
|
|
8327
|
+
}
|
|
8328
|
+
if (opts.openPr && spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }).status !== 0) {
|
|
8329
|
+
console.error('--open-pr pushes and opens draft PRs with gh, and `gh auth status` failed: log in, or drop --open-pr.');
|
|
8330
|
+
process.exit(EXIT.FATAL);
|
|
8331
|
+
}
|
|
8332
|
+
|
|
8333
|
+
const config = getConfig();
|
|
8334
|
+
const client = api(config);
|
|
8335
|
+
const repoName = opts.repo || path.basename(process.cwd());
|
|
8336
|
+
const io = {
|
|
8337
|
+
fetchBatch: async (id) => (await client.get(`/orchestrate/batch/${encodeURIComponent(id)}`)).data,
|
|
8338
|
+
fetchRun: async (id) => (await client.get(`/orchestrate/${encodeURIComponent(id)}`)).data,
|
|
8339
|
+
runVerb: (argv) => new Promise((resolve) => {
|
|
8340
|
+
const child = spawn(process.execPath, [process.argv[1], ...argv],
|
|
8341
|
+
{ cwd: process.cwd(), env: process.env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
8342
|
+
child.on('error', (e) => resolve({ code: null, error: e }));
|
|
8343
|
+
child.on('close', (code) => resolve({ code }));
|
|
8344
|
+
}),
|
|
8345
|
+
// The consent and provenance rule run-story applies before it runs a patch (HC-GF-064, HC-GF-270).
|
|
8346
|
+
verifyDecision: (run) => {
|
|
8347
|
+
let assessment;
|
|
8348
|
+
try {
|
|
8349
|
+
assessment = require('./lib/story-provenance').assessStorySource(
|
|
8350
|
+
run && run.config && run.config.story_source, run && (run.storyId || run.story_id));
|
|
8351
|
+
} catch (e) {
|
|
8352
|
+
assessment = { trusted: false, label: 'assessment-failed', reason: `story provenance could not be assessed: ${e.message}` };
|
|
8353
|
+
}
|
|
8354
|
+
return require('./lib/auto-verify').decideAutoVerify({ stepKey: 'step_4', repoRoot: process.cwd(), assessment });
|
|
8355
|
+
},
|
|
8356
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
8357
|
+
now: () => Date.now(),
|
|
8358
|
+
log: (line) => console.log(line),
|
|
8359
|
+
};
|
|
8360
|
+
|
|
8361
|
+
console.log(`[follow-batch] following batch ${batchId} for ${repoName}: poll ${pollMs / 1000}s, `
|
|
8362
|
+
+ `${deadlineMs ? `deadline ${deadlineMs / 60_000} min` : 'no deadline'}, `
|
|
8363
|
+
+ `emit ${opts.openPr ? 'push + draft PR' : 'local branch only'}`);
|
|
8364
|
+
const res = await followBatch(batchId, io, { pollMs, deadlineMs, maxOfflineMs, openPr: !!opts.openPr, repoName });
|
|
8365
|
+
console.log(`[follow-batch] ${res.reason}`);
|
|
8366
|
+
process.exit(res.exitCode);
|
|
8367
|
+
});
|
|
8368
|
+
|
|
7951
8369
|
// ── HC-019n-followup-25: emit-pr (pipeline-recovery condition 7) ──────────────
|
|
7952
8370
|
// Condition 6 proved step_4's diff applies AND its tests pass in isolation.
|
|
7953
8371
|
// Condition 7 turns that verified diff into the reviewable artifact: a branch
|
|
@@ -7964,6 +8382,7 @@ program
|
|
|
7964
8382
|
.option('--open-pr', 'Open a draft PR (implies --push)')
|
|
7965
8383
|
.option('--ready', 'Open the PR ready-for-review instead of draft')
|
|
7966
8384
|
.option('--skip-verify', 'Do not re-run the tests before committing (downgrades provenance)')
|
|
8385
|
+
.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)')
|
|
7967
8386
|
.option('--command <cmd>', 'Test command for --verify (default: ci.local_command, else `make ci`)')
|
|
7968
8387
|
.option('--timeout <sec>', 'Seconds before the verify test command is killed', '600')
|
|
7969
8388
|
.option('--keep', 'Leave the worktree in place for debugging')
|
|
@@ -7971,15 +8390,35 @@ program
|
|
|
7971
8390
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
7972
8391
|
.action(async (workflowId, opts) => {
|
|
7973
8392
|
const { execFileSync, spawnSync } = require('child_process');
|
|
8393
|
+
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
8394
|
+
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
8395
|
+
// deep in child_process, and a blank value coerces to 0 and kills the
|
|
8396
|
+
// command the instant it starts.
|
|
8397
|
+
let timeoutMs;
|
|
8398
|
+
try {
|
|
8399
|
+
timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
|
|
8400
|
+
} catch (e) {
|
|
8401
|
+
console.error(e.message);
|
|
8402
|
+
process.exit(2);
|
|
8403
|
+
}
|
|
7974
8404
|
const os = require('os');
|
|
7975
8405
|
const { extractFileBlocks, buildDiff, headMaterializeIO, MaterializeError, hasMaterializableWork } = require('./lib/materialize-diff');
|
|
7976
8406
|
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
|
|
8407
|
+
const { verifyStateFor } = require('./lib/emit-pr');
|
|
7977
8408
|
const {
|
|
7978
8409
|
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
|
|
7979
8410
|
} = require('./lib/emit-pr');
|
|
7980
8411
|
const { gitEnv } = require('./lib/git-env');
|
|
7981
8412
|
const config = getConfig();
|
|
7982
8413
|
const client = api(config);
|
|
8414
|
+
// HC-GF-270 (second review MEDIUM-1): the step key goes into a URL and a file name, and URL
|
|
8415
|
+
// resolution collapses `..` — `--step-key ../../<other run>/step/step_4` fetched ANOTHER run's
|
|
8416
|
+
// patch while THIS run's config was assessed. A step key is a step key.
|
|
8417
|
+
if (!/^step_[0-9a-z_]+$/i.test(String(opts.stepKey || ''))) {
|
|
8418
|
+
console.error(`--step-key must name a step, like step_4 (got ${JSON.stringify(opts.stepKey)})`);
|
|
8419
|
+
process.exit(2);
|
|
8420
|
+
}
|
|
8421
|
+
const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
|
|
7983
8422
|
|
|
7984
8423
|
// --open-pr implies --push (cannot open a PR against an unpushed branch).
|
|
7985
8424
|
const doPush = Boolean(opts.push || opts.openPr);
|
|
@@ -7993,16 +8432,29 @@ program
|
|
|
7993
8432
|
|
|
7994
8433
|
// 1. Fetch step_4 output + the run's storyId (for naming/provenance).
|
|
7995
8434
|
let output, storyId = 'story', runConfig = {};
|
|
8435
|
+
// HC-GF-270: a swallowed run fetch leaves storyId as the non-numeric 'story', which the
|
|
8436
|
+
// legacy rule would read as trusted. A failed fetch is untrusted on its own evidence.
|
|
8437
|
+
let runFetchFailed = false;
|
|
8438
|
+
// The run's REAL story id, for the provenance rule. `storyId` above falls back to 'story' for
|
|
8439
|
+
// branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
|
|
8440
|
+
// never arrived.
|
|
8441
|
+
let runStoryId = null;
|
|
7996
8442
|
try {
|
|
7997
|
-
const url = `/orchestrate/${workflowId}/step/${opts.stepKey}` +
|
|
8443
|
+
const url = `/orchestrate/${encodeURIComponent(workflowId)}/step/${encodeURIComponent(opts.stepKey)}` +
|
|
7998
8444
|
(opts.attempt ? `?attempt=${opts.attempt}` : '');
|
|
7999
8445
|
const r = await client.get(url);
|
|
8446
|
+
// The step output must be THIS run's: nothing else ties the patch to the config assessed below.
|
|
8447
|
+
if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
|
|
8000
8448
|
output = r.data?.output;
|
|
8001
8449
|
try {
|
|
8002
|
-
const runR = await client.get(`/orchestrate/${workflowId}`);
|
|
8003
|
-
|
|
8450
|
+
const runR = await client.get(`/orchestrate/${encodeURIComponent(workflowId)}`);
|
|
8451
|
+
// HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
|
|
8452
|
+
// is not THIS run does not count as its config.
|
|
8453
|
+
if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
|
|
8454
|
+
runStoryId = runR.data?.storyId || runR.data?.story_id || null;
|
|
8455
|
+
storyId = runStoryId || 'story';
|
|
8004
8456
|
runConfig = runR.data?.config || {};
|
|
8005
|
-
} catch { /* naming falls back to 'story'; command falls back to make ci */ }
|
|
8457
|
+
} catch (e) { runFetchFailed = e && e.message === 'run id mismatch' ? 'mismatch' : true; /* naming falls back to 'story'; command falls back to make ci */ }
|
|
8006
8458
|
} catch (e) {
|
|
8007
8459
|
const msg = e.response?.data?.error || e.message;
|
|
8008
8460
|
emit({ ok: false, reason: 'fetch_failed', detail: msg },
|
|
@@ -8010,9 +8462,41 @@ program
|
|
|
8010
8462
|
process.exit(EXIT.NO_DIFF);
|
|
8011
8463
|
}
|
|
8012
8464
|
|
|
8465
|
+
// ── HC-GF-270: the provenance gate (see verify-patch's copy for the full argument) ─────
|
|
8466
|
+
//
|
|
8467
|
+
// emit-pr runs `--verify` by default and creates a branch, so it is a second execution
|
|
8468
|
+
// surface and a second publishing surface for the same untrusted text. Its own guard, so
|
|
8469
|
+
// its own mutation kills its own test (Checklist B item 6).
|
|
8470
|
+
{
|
|
8471
|
+
let assessment;
|
|
8472
|
+
try {
|
|
8473
|
+
const { assessStorySource } = require('./lib/story-provenance');
|
|
8474
|
+
assessment = runFetchFailed
|
|
8475
|
+
? { trusted: false, label: 'run-fetch-failed',
|
|
8476
|
+
reason: runFetchFailed === 'mismatch'
|
|
8477
|
+
? 'the server answered for a different run, so this run\'s config was never read'
|
|
8478
|
+
: 'this run\'s config could not be read, so nothing is known about where its story text came from' }
|
|
8479
|
+
: assessStorySource(runConfig?.story_source, runStoryId);
|
|
8480
|
+
} catch (e) {
|
|
8481
|
+
assessment = { trusted: false, label: 'assessment-failed',
|
|
8482
|
+
reason: `story provenance could not be assessed: ${e.message}` };
|
|
8483
|
+
}
|
|
8484
|
+
if (!assessment.trusted && !opts.trustSource) {
|
|
8485
|
+
emit({ ok: false, reason: 'untrusted_source', detail: assessment.reason },
|
|
8486
|
+
`✗ refusing to emit: ${assessment.reason}\n`
|
|
8487
|
+
+ ' No branch was created and nothing was executed.\n'
|
|
8488
|
+
+ ` Read it: hone show step ${workflowId} ${opts.stepKey}\n`
|
|
8489
|
+
+ ` Then: hone emit-pr ${workflowId} --trust-source`);
|
|
8490
|
+
process.exit(EXIT.UNTRUSTED_SOURCE);
|
|
8491
|
+
}
|
|
8492
|
+
}
|
|
8493
|
+
|
|
8013
8494
|
// 2. Materialize the diff (shared with check-patch/verify-patch,
|
|
8014
8495
|
// followup-23/27). Condition 7 presupposes 5.
|
|
8015
8496
|
const blocks = extractFileBlocks(output);
|
|
8497
|
+
// HC-GF-191: the parser had to look outside `## Changed Files` to find anything. Say so —
|
|
8498
|
+
// silently re-scoping is how a finished build came back as "no file changes to verify".
|
|
8499
|
+
if (blocks.scopeFallback) console.warn(` ⚠ ${blocks.reason}`);
|
|
8016
8500
|
const hasWork = hasMaterializableWork(blocks);
|
|
8017
8501
|
if (blocks.noChanges || !hasWork) {
|
|
8018
8502
|
emit({ ok: false, verdict: 'no_diff', reason: 'no_changes_or_diff' },
|
|
@@ -8078,9 +8562,11 @@ program
|
|
|
8078
8562
|
|
|
8079
8563
|
// 4. Isolated worktree with a NAMED branch off HEAD (adopter tree untouched).
|
|
8080
8564
|
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-emit-'));
|
|
8081
|
-
|
|
8565
|
+
// HC-GF-304: see verify-patch. emit-pr would COMMIT the other invocation's patch onto this run's branch.
|
|
8566
|
+
const diffTmp = require('./lib/private-temp').privateTempFile('hone-emit-diff', 'patch.diff');
|
|
8567
|
+
const diffFile = diffTmp.file;
|
|
8082
8568
|
fs.writeFileSync(diffFile, diff);
|
|
8083
|
-
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
|
|
8569
|
+
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
|
|
8084
8570
|
// `worktree add -b` creates the branch ref BEFORE apply/verify. If we bail
|
|
8085
8571
|
// before a successful commit (apply fails, verify fails), that empty branch
|
|
8086
8572
|
// (pointing at HEAD) must be torn down too — else "a failing diff never
|
|
@@ -8097,7 +8583,7 @@ program
|
|
|
8097
8583
|
try { execFileSync('git', ['branch', '-D', branch], { cwd: process.cwd(), env: gitEnv(), stdio: 'ignore' }); } catch { /* best-effort */ }
|
|
8098
8584
|
}
|
|
8099
8585
|
try { fs.rmSync(wt, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
8100
|
-
|
|
8586
|
+
diffTmp.remove();
|
|
8101
8587
|
};
|
|
8102
8588
|
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
8103
8589
|
|
|
@@ -8128,23 +8614,48 @@ program
|
|
|
8128
8614
|
// HC-COMM-010-followup-6: a bare worktree has no dependencies — provision it
|
|
8129
8615
|
// (link the repo's node_modules when the lockfile is unchanged, else run the
|
|
8130
8616
|
// lockfile's install, or ci.install_command). Fail-open; --no-provision opts out.
|
|
8617
|
+
// HC-GF-201: declared OUTSIDE the guard — the verdict needs provisioning state,
|
|
8618
|
+
// and with --no-provision there is none, which is not the same as a failure.
|
|
8619
|
+
let provAll = null;
|
|
8131
8620
|
if (opts.provision !== false) {
|
|
8132
|
-
const { planProvision,
|
|
8621
|
+
const { planProvision, discoverPackageRoots, provisionAllRoots } = require('./lib/worktree-provision');
|
|
8133
8622
|
const { readCIConfigDetails } = require('./lib/pipeline-config');
|
|
8134
|
-
|
|
8135
|
-
|
|
8623
|
+
// HC-GF-201: provision EVERY package root, not just the worktree's top level.
|
|
8624
|
+
// This repo has three; the two skipped are where ci.unit runs, so their absent
|
|
8625
|
+
// modules surfaced as `Cannot find module` and were counted as the PATCH failing
|
|
8626
|
+
// tests — a false RED on the merge-gating verdict (run b3117bde, 2026-09-20).
|
|
8627
|
+
const installCmd = readCIConfigDetails(process.cwd()).install_command;
|
|
8628
|
+
const provEntries = discoverPackageRoots(wt).map((root) => ({
|
|
8629
|
+
root,
|
|
8630
|
+
worktreeDir: root === '.' ? wt : path.join(wt, root),
|
|
8631
|
+
exec: spawnSync,
|
|
8632
|
+
plan: planProvision({
|
|
8633
|
+
worktreeDir: root === '.' ? wt : path.join(wt, root),
|
|
8634
|
+
parentDir: root === '.' ? process.cwd() : path.join(process.cwd(), root),
|
|
8635
|
+
installCommand: root === '.' ? installCmd : undefined,
|
|
8636
|
+
}),
|
|
8637
|
+
}));
|
|
8638
|
+
provAll = provisionAllRoots(provEntries);
|
|
8639
|
+
const prov = { ok: provAll.ok,
|
|
8640
|
+
summary: provAll.results.map((r) => `${r.root}: ${r.summary}`).join('; ')
|
|
8641
|
+
+ (provAll.ok ? '' : ` — NOT RUN: ${provAll.unprovisioned.map((u) => u.root).join(', ')} could not be provisioned`) };
|
|
8136
8642
|
if (opts.format !== 'json') console.log(` provisioning: ${prov.summary}`);
|
|
8137
8643
|
}
|
|
8138
8644
|
if (opts.format !== 'json') console.log(` verifying: ${command} (in isolated worktree, timeout ${opts.timeout}s)`);
|
|
8139
8645
|
const run = spawnSync(command, {
|
|
8140
8646
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8141
|
-
timeout:
|
|
8647
|
+
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
8142
8648
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8143
8649
|
});
|
|
8144
8650
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
8145
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource });
|
|
8146
|
-
|
|
8147
|
-
|
|
8651
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
|
|
8652
|
+
// HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
|
|
8653
|
+
// narrowed verdict fell into the else below, which printed "verify failed, no branch
|
|
8654
|
+
// emitted" and then exited with that verdict's own exit code — zero. A failure path that
|
|
8655
|
+
// exits 0 is the HC-GF-207 shape this session opened by fixing.
|
|
8656
|
+
const emitState = verifyStateFor(v.verdict);
|
|
8657
|
+
if (emitState) {
|
|
8658
|
+
verifyState = emitState;
|
|
8148
8659
|
} else {
|
|
8149
8660
|
verifyOk = false;
|
|
8150
8661
|
// HC-GF-008: a command that never started is not a failed verify.
|
|
@@ -8160,14 +8671,15 @@ program
|
|
|
8160
8671
|
// 4c. Commit the applied diff onto the branch, inside the worktree.
|
|
8161
8672
|
// No summary source (GET /orchestrate omits config/story text), so the
|
|
8162
8673
|
// commit subject falls back to the storyId + default in buildCommitMessage.
|
|
8163
|
-
const message = buildCommitMessage({ storyId, summary: '', workflowId, headSha, verifyState });
|
|
8164
|
-
const
|
|
8674
|
+
const message = buildCommitMessage({ storyId, summary: '', workflowId, headSha, verifyState, replacedCommand: commandReplaced && commandReplaced.command });
|
|
8675
|
+
const msgTmp = require('./lib/private-temp').privateTempFile('hone-emit-msg', 'message.txt');
|
|
8676
|
+
const msgFile = msgTmp.file;
|
|
8165
8677
|
fs.writeFileSync(msgFile, message);
|
|
8166
8678
|
try {
|
|
8167
8679
|
execFileSync('git', ['-C', wt, 'add', '-A'], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8168
8680
|
execFileSync('git', ['-C', wt, 'commit', '-F', msgFile], { env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8169
8681
|
committed = true; // the branch now holds the deliverable — keep it past cleanup
|
|
8170
|
-
} finally {
|
|
8682
|
+
} finally { msgTmp.remove(); }
|
|
8171
8683
|
|
|
8172
8684
|
const stat = (() => {
|
|
8173
8685
|
try {
|
|
@@ -8191,8 +8703,9 @@ program
|
|
|
8191
8703
|
}
|
|
8192
8704
|
}
|
|
8193
8705
|
if (pushed && doOpenPr) {
|
|
8194
|
-
const prBody = buildPrBody({ storyId, workflowId, headSha, verifyState, command: doVerify ? command : undefined });
|
|
8195
|
-
const
|
|
8706
|
+
const prBody = buildPrBody({ storyId, workflowId, headSha, verifyState, command: doVerify ? command : undefined, replacedCommand: commandReplaced && commandReplaced.command });
|
|
8707
|
+
const bodyTmp = require('./lib/private-temp').privateTempFile('hone-emit-prbody', 'body.md');
|
|
8708
|
+
const bodyFile = bodyTmp.file;
|
|
8196
8709
|
fs.writeFileSync(bodyFile, prBody);
|
|
8197
8710
|
const title = `${storyId}: Hone step_4 change (workflow ${String(workflowId).slice(0, 8)})`;
|
|
8198
8711
|
const ghArgs = ['pr', 'create', '--head', branch, '--title', title, '--body-file', bodyFile];
|
|
@@ -8206,7 +8719,7 @@ program
|
|
|
8206
8719
|
emit({ ok: false, verdict: 'publish_failed', stage: 'open_pr', detail, branch },
|
|
8207
8720
|
`✗ branch pushed, but opening the PR failed: ${detail}\n → open it manually, or check \`gh auth status\`.`);
|
|
8208
8721
|
exitCode = EXIT.PUBLISH_FAILED;
|
|
8209
|
-
} finally {
|
|
8722
|
+
} finally { bodyTmp.remove(); }
|
|
8210
8723
|
}
|
|
8211
8724
|
|
|
8212
8725
|
if (exitCode === EXIT.PASS) {
|
|
@@ -8254,6 +8767,17 @@ program
|
|
|
8254
8767
|
.option('--format <fmt>', 'pretty or json', 'pretty')
|
|
8255
8768
|
.action(async (prOrBranch, opts) => {
|
|
8256
8769
|
const { execFileSync, spawnSync } = require('child_process');
|
|
8770
|
+
// HC-GF-200: refuse a mistyped bound HERE, naming the flag. Left to node,
|
|
8771
|
+
// a NaN becomes `RangeError: ... It must be an integer. Received NaN` from
|
|
8772
|
+
// deep in child_process, and a blank value coerces to 0 and kills the
|
|
8773
|
+
// command the instant it starts.
|
|
8774
|
+
let timeoutMs;
|
|
8775
|
+
try {
|
|
8776
|
+
timeoutMs = parseTimeoutSeconds(opts.timeout) * 1000;
|
|
8777
|
+
} catch (e) {
|
|
8778
|
+
console.error(e.message);
|
|
8779
|
+
process.exit(2);
|
|
8780
|
+
}
|
|
8257
8781
|
const { readCIGateConfig } = require('./lib/pipeline-config');
|
|
8258
8782
|
const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
|
|
8259
8783
|
const { EXIT, resolveGateMode, interpretGhChecks, ciGateVerdict, renderStep5cArtifact } = require('./lib/verify-pr');
|
|
@@ -8275,15 +8799,19 @@ program
|
|
|
8275
8799
|
const mode = resolveGateMode(opts.mode, configGate);
|
|
8276
8800
|
|
|
8277
8801
|
// 3. Skill audit — reuse the working `hone step-5b` command (deterministic
|
|
8278
|
-
// SA-001 engine over the PR diff).
|
|
8802
|
+
// SA-001 engine over the PR diff). HC-GF-227: its BLOCKER verdict (exit 2) fails this command
|
|
8803
|
+
// too, once the CI gate itself has passed; any other failure to run it is reported, not a verdict.
|
|
8279
8804
|
let skillAudit = { ran: false };
|
|
8280
8805
|
if (!opts.skipSkillAudit) {
|
|
8806
|
+
const artifact = `.github/pipeline/${storyId}/step-5b-skill-audit.md`;
|
|
8281
8807
|
try {
|
|
8282
8808
|
execFileSync(process.execPath, [__filename, 'step-5b', '--story-id', storyId, '--base', opts.base],
|
|
8283
8809
|
{ cwd: repoRoot, env: process.env, stdio: opts.format === 'json' ? 'ignore' : 'inherit' });
|
|
8284
|
-
skillAudit = { ran: true, artifact
|
|
8810
|
+
skillAudit = { ran: true, artifact };
|
|
8285
8811
|
} catch (e) {
|
|
8286
|
-
skillAudit =
|
|
8812
|
+
skillAudit = e.status === STEP_5B_BLOCKED
|
|
8813
|
+
? { ran: true, blocked: true, artifact }
|
|
8814
|
+
: { ran: true, error: (e.message || '').split('\n')[0] };
|
|
8287
8815
|
}
|
|
8288
8816
|
}
|
|
8289
8817
|
|
|
@@ -8314,7 +8842,7 @@ program
|
|
|
8314
8842
|
if (opts.format !== 'json') console.log(` running local CI: ${command} (timeout ${opts.timeout}s)`);
|
|
8315
8843
|
// HC-GF-008: the adopter's own command, without make state inherited from whoever ran hone.
|
|
8316
8844
|
const { verifyCommandEnv } = require('./lib/verify-patch');
|
|
8317
|
-
const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout:
|
|
8845
|
+
const run = spawnSync(command, { cwd: repoRoot, shell: true, encoding: 'utf8', timeout: timeoutMs, stdio: ['ignore', 'inherit', 'inherit'], env: verifyCommandEnv(gitEnv()) });
|
|
8318
8846
|
local = { code: run.status == null ? 1 : run.status, timedOut: !!(run.error && run.error.code === 'ETIMEDOUT') };
|
|
8319
8847
|
}
|
|
8320
8848
|
|
|
@@ -8354,14 +8882,16 @@ program
|
|
|
8354
8882
|
}
|
|
8355
8883
|
}
|
|
8356
8884
|
|
|
8357
|
-
// 7. Report.
|
|
8885
|
+
// 7. Report. A failing CI gate keeps its own exit code; when CI passed, a BLOCKER skill assertion
|
|
8886
|
+
// fails the command with EXIT.SKILL_BLOCKER (HC-GF-227). The report sent above is the CI gate's.
|
|
8887
|
+
const exitCode = verdict.exitCode === EXIT.PASS && skillAudit.blocked ? EXIT.SKILL_BLOCKER : verdict.exitCode;
|
|
8358
8888
|
const sym = (verdict.verdict === 'pass' || verdict.verdict === 'disabled') ? '✓' : verdict.verdict === 'pending' ? '⏳' : '✗';
|
|
8359
8889
|
emit(
|
|
8360
|
-
{ ok:
|
|
8890
|
+
{ ok: exitCode === EXIT.PASS, exitCode, storyId, mode, skillAudit, ciGate: verdict, gh, local, reported },
|
|
8361
8891
|
`${sym} CI gate (${mode}): ${verdict.verdict} — ${verdict.detail}\n` +
|
|
8362
|
-
` skill audit: ${opts.skipSkillAudit ? 'skipped' : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
|
|
8892
|
+
` skill audit: ${opts.skipSkillAudit ? 'skipped' : skillAudit.blocked ? `✗ BLOCKER finding(s) — ${skillAudit.artifact}` : (skillAudit.artifact || skillAudit.error || 'done')}\n` +
|
|
8363
8893
|
` → .github/pipeline/${storyId}/step-5c-ci.md`);
|
|
8364
|
-
process.exit(
|
|
8894
|
+
process.exit(exitCode);
|
|
8365
8895
|
});
|
|
8366
8896
|
|
|
8367
8897
|
// ── CLI setup ─────────────────────────────────────────────────────────────────
|