@hone-ai/cli 1.36.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hone-cli.js +97 -18
- package/lib/asset-fetch-list.js +7 -3
- package/lib/bundle-mount-follow.js +118 -0
- package/lib/bundle-paths.js +12 -1
- package/lib/layout-detect.js +45 -6
- package/lib/operator-question.js +94 -0
- package/lib/run-context.js +19 -1
- package/lib/run-report.js +29 -0
- package/lib/run-target.js +33 -12
- package/lib/verify-patch.js +18 -1
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -39,9 +39,18 @@ const { parseReviewJSON } = require('./lib/parse-review-json');
|
|
|
39
39
|
const { resolveBaseRef, getMaxDiffChars } = require('./lib/release-review-config');
|
|
40
40
|
const { gitEnv } = require('./lib/git-env');
|
|
41
41
|
const { collectIncludePaths } = require('./lib/include-paths');
|
|
42
|
-
const { runArgsError, orchestratePath } = require('./lib/run-target'); // HC-GF-270-followup-1
|
|
42
|
+
const { runArgsError, orchestratePath, apiPath } = require('./lib/run-target'); // HC-GF-270-followup-1, -2
|
|
43
|
+
const { normalizeQuestion, questionLines, gateQuestionLines, countPhrase } = require('./lib/operator-question'); // HC-GF-234
|
|
43
44
|
const program = new Command();
|
|
44
45
|
|
|
46
|
+
// HC-GF-331: spawnSync's default stdout/stderr buffer is 1 MiB. A verification suite over that
|
|
47
|
+
// (this repo's own `make ci`) overflowed it, spawnSync returned status: null with error.code
|
|
48
|
+
// ENOBUFS, and verdictFromRun/classifyGateRun read that null status as a plain test failure —
|
|
49
|
+
// not as "the output could not be captured." Generous relative to any suite measured so far
|
|
50
|
+
// (`make ci` prints low hundreds of KB; 64 MiB is two orders of magnitude of headroom) without
|
|
51
|
+
// holding an unbounded amount of memory for a runaway command.
|
|
52
|
+
const VERIFY_MAX_BUFFER = 64 * 1024 * 1024;
|
|
53
|
+
|
|
45
54
|
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
46
55
|
const RC_PATH = path.join(os.homedir(), '.honerc');
|
|
47
56
|
|
|
@@ -1746,7 +1755,7 @@ program
|
|
|
1746
1755
|
await sleep(pollMs);
|
|
1747
1756
|
process.stdout.write('.');
|
|
1748
1757
|
try {
|
|
1749
|
-
const r = await client.get(
|
|
1758
|
+
const r = await client.get(apiPath('derive', [jobId]));
|
|
1750
1759
|
if (r.data.status === 'complete') {
|
|
1751
1760
|
result = r.data;
|
|
1752
1761
|
break;
|
|
@@ -1983,6 +1992,10 @@ program
|
|
|
1983
1992
|
.requiredOption('--job <jobId>', 'Job ID returned by "hone derive --refresh"')
|
|
1984
1993
|
.option('--changes <mode>', 'Which changes to apply: all (default)', 'all')
|
|
1985
1994
|
.action(async (opts) => {
|
|
1995
|
+
// HC-GF-270-followup-2: this GETs the job named by `--job` and then POSTs its approval, so the id is
|
|
1996
|
+
// checked before any config read or request: `drv_x/../../orchestrate/<run>/approve?` became a POST
|
|
1997
|
+
// to another endpoint's approval.
|
|
1998
|
+
refuseBadRunArgs([{ label: '--job', kind: 'deriveJobId', value: opts.job }]);
|
|
1986
1999
|
const config = getConfig();
|
|
1987
2000
|
const client = api(config);
|
|
1988
2001
|
|
|
@@ -1991,7 +2004,7 @@ program
|
|
|
1991
2004
|
// Verify job is ready
|
|
1992
2005
|
let jobData;
|
|
1993
2006
|
try {
|
|
1994
|
-
const r = await client.get(
|
|
2007
|
+
const r = await client.get(apiPath('derive', [opts.job]));
|
|
1995
2008
|
jobData = r.data;
|
|
1996
2009
|
} catch (e) {
|
|
1997
2010
|
console.error(`Failed to fetch job: ${e.response?.data?.error || e.message}`);
|
|
@@ -2009,7 +2022,7 @@ program
|
|
|
2009
2022
|
|
|
2010
2023
|
// Apply changes
|
|
2011
2024
|
try {
|
|
2012
|
-
const r = await client.post(
|
|
2025
|
+
const r = await client.post(apiPath('derive', [opts.job, 'approve']), {
|
|
2013
2026
|
approvedChanges: opts.changes,
|
|
2014
2027
|
});
|
|
2015
2028
|
console.log('✓ Changes approved and applied');
|
|
@@ -2505,6 +2518,7 @@ program
|
|
|
2505
2518
|
.option('--with-learnings-report', 'Also fetch promoted-learnings report + update local YAML status (H-013)')
|
|
2506
2519
|
.option('--force', 'Overwrite local edits (default: skip files with local changes — H-024)')
|
|
2507
2520
|
.action(async (opts) => {
|
|
2521
|
+
refuseBadRunArgs([{ label: '--repo', kind: 'pathName', value: opts.repo, optional: true }]); // HC-GF-270-followup-2
|
|
2508
2522
|
const config = getConfig();
|
|
2509
2523
|
const client = api(config);
|
|
2510
2524
|
const repoName = opts.repo || path.basename(process.cwd());
|
|
@@ -2540,7 +2554,7 @@ program
|
|
|
2540
2554
|
// ── 1. Sync derived skills ──────────────────────────────────────────────
|
|
2541
2555
|
if (!opts.agentsOnly) {
|
|
2542
2556
|
try {
|
|
2543
|
-
const r = await client.get(
|
|
2557
|
+
const r = await client.get(apiPath('skills', [repoName, 'derived']));
|
|
2544
2558
|
const { skills } = r.data;
|
|
2545
2559
|
if (!skills.length) {
|
|
2546
2560
|
console.log('No derived skills found. Run "hone derive" first.');
|
|
@@ -2620,7 +2634,7 @@ program
|
|
|
2620
2634
|
content = editorStubContent(agent);
|
|
2621
2635
|
console.log(` → ${agent}: editor mode — syncing runtime stub (full prompt served ephemerally)`);
|
|
2622
2636
|
} else {
|
|
2623
|
-
const r = await client.get(
|
|
2637
|
+
const r = await client.get(apiPath('prompts', [agent]), {
|
|
2624
2638
|
responseType: 'text',
|
|
2625
2639
|
headers: { Accept: 'text/markdown' },
|
|
2626
2640
|
});
|
|
@@ -2931,8 +2945,7 @@ program
|
|
|
2931
2945
|
// The default request is byte-identical to the one that shipped — no query string at all —
|
|
2932
2946
|
// so nothing that already calls this path shifts, including HC-GF-210's pin on it. The
|
|
2933
2947
|
// parameter appears only when the operator asked for a status other than the default.
|
|
2934
|
-
const
|
|
2935
|
-
const { data } = await client.get(`/learnings/promoted${qs}`);
|
|
2948
|
+
const { data } = await client.get(apiPath('learnings', ['promoted'], { status: status === 'promoted' ? undefined : status }));
|
|
2936
2949
|
const all = Array.isArray(data.learnings) ? data.learnings : [];
|
|
2937
2950
|
// The two ways a promoted row reaches no run. `targetKind` is 'unrouted' when HC-GF-159a's
|
|
2938
2951
|
// router found no target; a row promoted before that story carries neither field at all.
|
|
@@ -5950,11 +5963,7 @@ program
|
|
|
5950
5963
|
const config = getConfig();
|
|
5951
5964
|
const client = api(config);
|
|
5952
5965
|
try {
|
|
5953
|
-
const
|
|
5954
|
-
if (opts.status) params.set('status', opts.status);
|
|
5955
|
-
if (opts.limit) params.set('limit', opts.limit);
|
|
5956
|
-
const qs = params.toString();
|
|
5957
|
-
const { data } = await client.get(`/orchestrate${qs ? `?${qs}` : ''}`);
|
|
5966
|
+
const { data } = await client.get(apiPath('orchestrate', [], { status: opts.status, limit: opts.limit }));
|
|
5958
5967
|
if (opts.format === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
|
|
5959
5968
|
if (!data.runs.length) { console.log('No workflow runs found.'); return; }
|
|
5960
5969
|
const age = (ts) => {
|
|
@@ -6138,7 +6147,7 @@ function runContextIo(apiConfig) {
|
|
|
6138
6147
|
}
|
|
6139
6148
|
},
|
|
6140
6149
|
fetchStoryContext: async (storyId) => {
|
|
6141
|
-
const { data } = await api(apiConfig).get(
|
|
6150
|
+
const { data } = await api(apiConfig).get(apiPath('stories', [storyId, 'context']));
|
|
6142
6151
|
return data;
|
|
6143
6152
|
},
|
|
6144
6153
|
recentlyModifiedOverlap: (touched) =>
|
|
@@ -6165,6 +6174,8 @@ program
|
|
|
6165
6174
|
.option('--kill', 'Kill a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
|
|
6166
6175
|
.option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
|
|
6167
6176
|
.option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0) on a run ID, or on a story ID\'s latest run in this repository. Exit codes: 2 = paused at a human gate, 3 = parked for your editor (execution.mode: editor)')
|
|
6177
|
+
.option('--reject <step>', 'HC-GF-323: send a paused gate BACK for a re-run instead of approving it or killing the whole run (e.g., --reject step_4). Requires --reason. Steps before it are left approved; only this step re-runs, with the reason in its context.')
|
|
6178
|
+
.option('--reason <text>', 'With --reject: why this attempt is being sent back. Required, and reaches the re-run as feedback the same way a code-reviewer\'s FAIL verdict does.')
|
|
6168
6179
|
.option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
|
|
6169
6180
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
6170
6181
|
.option('--poll-interval <s>', 'Poll interval in seconds', '5')
|
|
@@ -6409,6 +6420,16 @@ program
|
|
|
6409
6420
|
console.log(` (auto-verify skipped: ${e.message})`);
|
|
6410
6421
|
}
|
|
6411
6422
|
|
|
6423
|
+
// HC-GF-234: show what the step ASKED where the operator decides. Approving records
|
|
6424
|
+
// `approved` and moves on — it does not answer the question, and until now nothing at
|
|
6425
|
+
// this prompt said there was one. Printed last, right above the prompt (auto-verify's
|
|
6426
|
+
// output above can run to pages), and in both the gate and the exhausted branch: the
|
|
6427
|
+
// step's question rides on the attempt the operator is about to decide about.
|
|
6428
|
+
// Never throws: a print must not end a poll loop.
|
|
6429
|
+
try {
|
|
6430
|
+
for (const line of gateQuestionLines(status, d.stepKey)) console.log(line);
|
|
6431
|
+
} catch { /* a print must never end a poll loop */ }
|
|
6432
|
+
|
|
6412
6433
|
if (process.stdin.isTTY) {
|
|
6413
6434
|
const readline = require('readline');
|
|
6414
6435
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -6471,6 +6492,14 @@ program
|
|
|
6471
6492
|
// step says so instead of listing a stale failed row above a completed one.
|
|
6472
6493
|
const tries = (s.attempts || 1) > 1 ? ` [attempt ${s.attempt} of ${s.attempts}]` : '';
|
|
6473
6494
|
console.log(` ${icon} ${s.stepKey} (${s.agent}) ${s.status}${gate}${tries}`);
|
|
6495
|
+
// HC-GF-234: `status: completed` / `gateResult: approved` alone cannot tell "did its
|
|
6496
|
+
// work" apart from "asked a question nobody answered" — batch mode has no human gate
|
|
6497
|
+
// on steps 0-3b, so this line is the only place that question ever surfaces here.
|
|
6498
|
+
const asked = normalizeQuestion(s.operatorQuestion);
|
|
6499
|
+
if (asked) {
|
|
6500
|
+
console.log(` asked ${countPhrase(asked.count)} nobody answered:`);
|
|
6501
|
+
for (const l of questionLines(asked, { max: 120 })) console.log(` ${l}`);
|
|
6502
|
+
}
|
|
6474
6503
|
}
|
|
6475
6504
|
if (data.awaitingApproval && isEditorParked(data)) {
|
|
6476
6505
|
console.log('');
|
|
@@ -6479,6 +6508,7 @@ program
|
|
|
6479
6508
|
console.log('');
|
|
6480
6509
|
console.log(`Awaiting approval at ${data.currentStep}.`);
|
|
6481
6510
|
console.log(`Approve: hone run-story ${data.runId} --approve ${data.currentStep}`);
|
|
6511
|
+
console.log(`Reject: hone run-story ${data.runId} --reject ${data.currentStep} --reason "<why>"`);
|
|
6482
6512
|
}
|
|
6483
6513
|
console.log('');
|
|
6484
6514
|
// HC-GF-016: at a TERMINAL state, print the report unasked. The whole
|
|
@@ -6548,6 +6578,37 @@ program
|
|
|
6548
6578
|
return;
|
|
6549
6579
|
}
|
|
6550
6580
|
|
|
6581
|
+
// Reject mode (HC-GF-323)
|
|
6582
|
+
if (opts.reject) {
|
|
6583
|
+
if (!opts.reason || !String(opts.reason).trim()) {
|
|
6584
|
+
console.error('--reject requires --reason <text>');
|
|
6585
|
+
process.exit(1);
|
|
6586
|
+
}
|
|
6587
|
+
let rejectId;
|
|
6588
|
+
try {
|
|
6589
|
+
rejectId = await existingRunId(`--reject ${opts.reject}`);
|
|
6590
|
+
await client.post(orchestratePath([rejectId, 'reject']),
|
|
6591
|
+
{ stepKey: opts.reject, reason: opts.reason });
|
|
6592
|
+
console.log(`Gate ${opts.reject} rejected. Re-running with the reason. Workflow resuming.`);
|
|
6593
|
+
} catch (e) {
|
|
6594
|
+
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
6595
|
+
process.exit(1);
|
|
6596
|
+
}
|
|
6597
|
+
// HC-GF-070: same FOLLOW behaviour as --approve — a caller told to poll does not.
|
|
6598
|
+
if (opts.follow === false) {
|
|
6599
|
+
console.log(`Poll: hone run-story ${rejectId} --status`);
|
|
6600
|
+
return;
|
|
6601
|
+
}
|
|
6602
|
+
try {
|
|
6603
|
+
await pollUntilDecision(rejectId);
|
|
6604
|
+
} catch (e) {
|
|
6605
|
+
console.error(`Rejected, but following the run failed: ${e.message}`);
|
|
6606
|
+
console.error(`Poll: hone run-story ${rejectId} --status`);
|
|
6607
|
+
process.exit(1);
|
|
6608
|
+
}
|
|
6609
|
+
return;
|
|
6610
|
+
}
|
|
6611
|
+
|
|
6551
6612
|
// Start workflow (storyId required, runId not accepted for starting)
|
|
6552
6613
|
if (isUuid) {
|
|
6553
6614
|
console.error('Cannot start workflow with a runId. Use a story ID like HC-042.');
|
|
@@ -7651,6 +7712,16 @@ showCmd
|
|
|
7651
7712
|
console.log(` startedAt: ${s.startedAt || '(not started)'}`);
|
|
7652
7713
|
console.log(` completedAt: ${s.completedAt || '(not completed)'}`);
|
|
7653
7714
|
if (s.tokenUsage) console.log(` tokenUsage: ${JSON.stringify(s.tokenUsage)}`);
|
|
7715
|
+
// HC-GF-234: `status`/`gateResult` above read identically whether this step did its
|
|
7716
|
+
// work or ended by asking the operator something nobody answered — surface it here so
|
|
7717
|
+
// an operator does not have to read raw step output to find out.
|
|
7718
|
+
const asked = normalizeQuestion(s.operatorQuestion);
|
|
7719
|
+
if (asked) {
|
|
7720
|
+
console.log(``);
|
|
7721
|
+
console.log(`── operator question(s) — asked, not answered ──────────`);
|
|
7722
|
+
console.log(` asked ${asked.count} question(s):`);
|
|
7723
|
+
for (const l of questionLines(asked)) console.log(` ${l}`);
|
|
7724
|
+
}
|
|
7654
7725
|
if (s.errorContext) {
|
|
7655
7726
|
console.log(``);
|
|
7656
7727
|
console.log(`── error_context (retry feedback) ──────────────────────`);
|
|
@@ -7881,7 +7952,11 @@ program
|
|
|
7881
7952
|
verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
|
|
7882
7953
|
// best-effort: pull the run's ci_local_command for the default
|
|
7883
7954
|
try {
|
|
7884
|
-
|
|
7955
|
+
// HC-GF-302-followup-1: on a connection of its own. This GET follows the step GET at once, and
|
|
7956
|
+
// on the socket that one pooled a server closing it idle resets the request - a failed config
|
|
7957
|
+
// read, so the provenance gate below refused a trusted run (exit 9). Nothing is retried: a read
|
|
7958
|
+
// that fails on its own connection still refuses.
|
|
7959
|
+
const runR = await client.get(orchestratePath([workflowId]), require('./lib/fresh-connection').freshConnection());
|
|
7885
7960
|
// HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
|
|
7886
7961
|
// `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
|
|
7887
7962
|
// 200 with no config. And a body that is not THIS run is not this run's config.
|
|
@@ -8185,9 +8260,10 @@ program
|
|
|
8185
8260
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8186
8261
|
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
8187
8262
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8263
|
+
maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: a >1 MiB suite (this repo's own `make ci`) must not ENOBUFS
|
|
8188
8264
|
});
|
|
8189
8265
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
8190
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
|
|
8266
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced, error: run.error }, provAll);
|
|
8191
8267
|
// HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
|
|
8192
8268
|
if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
|
|
8193
8269
|
const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
|
|
@@ -8243,6 +8319,7 @@ program
|
|
|
8243
8319
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8244
8320
|
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
8245
8321
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8322
|
+
maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: shares the shape at :8184 — same fix
|
|
8246
8323
|
});
|
|
8247
8324
|
const c = classifyGateRun({
|
|
8248
8325
|
code: gr.status, error: gr.error,
|
|
@@ -8523,7 +8600,8 @@ program
|
|
|
8523
8600
|
output = r.data?.output;
|
|
8524
8601
|
stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
|
|
8525
8602
|
try {
|
|
8526
|
-
|
|
8603
|
+
// HC-GF-302-followup-1: its own connection, as verify-patch's (see there).
|
|
8604
|
+
const runR = await client.get(orchestratePath([workflowId]), require('./lib/fresh-connection').freshConnection());
|
|
8527
8605
|
// HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
|
|
8528
8606
|
// is not THIS run does not count as its config.
|
|
8529
8607
|
if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
|
|
@@ -8738,9 +8816,10 @@ program
|
|
|
8738
8816
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8739
8817
|
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
8740
8818
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8819
|
+
maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: emit-pr's verify shares verify-patch's spawnSync shape
|
|
8741
8820
|
});
|
|
8742
8821
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
8743
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
|
|
8822
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced, error: run.error }, provAll);
|
|
8744
8823
|
// HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
|
|
8745
8824
|
// narrowed verdict fell into the else below, which printed "verify failed, no branch
|
|
8746
8825
|
// emitted" and then exited with that verdict's own exit code — zero. A failure path that
|
package/lib/asset-fetch-list.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* network or a filesystem.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
// HC-GF-270-followup-2: every path goes through the one builder. The derived set's repository is the
|
|
15
|
+
// checkout's directory name, which can hold `?`, `#` or `%`; hand-built, `x?y` fetched `/skills/x`.
|
|
16
|
+
const { apiPath } = require('./run-target');
|
|
17
|
+
|
|
14
18
|
/**
|
|
15
19
|
* @param {object} args
|
|
16
20
|
* @param {string[]} args.agents agent names
|
|
@@ -39,12 +43,12 @@ function assetFetchList({ agents = [], skills = [], derivedRepo = null, get, wri
|
|
|
39
43
|
return [
|
|
40
44
|
...agents.map(agent => ({
|
|
41
45
|
key: `${agent}.agent.md`,
|
|
42
|
-
promise: get(
|
|
46
|
+
promise: get(apiPath('scripts', ['agent-stub', agent])),
|
|
43
47
|
write: (data) => writeAgent(agent, data),
|
|
44
48
|
})),
|
|
45
49
|
...skills.map(skill => ({
|
|
46
50
|
key: `skills/${skill}/SKILL.md`,
|
|
47
|
-
promise: get(
|
|
51
|
+
promise: get(apiPath('skills', [skill])),
|
|
48
52
|
write: (data) => writeSkill(skill, data),
|
|
49
53
|
})),
|
|
50
54
|
// ── HC-GF-184: the per-repo DERIVED set ────────────────────────────────────────────────────
|
|
@@ -59,7 +63,7 @@ function assetFetchList({ agents = [], skills = [], derivedRepo = null, get, wri
|
|
|
59
63
|
// derived, so setup behaves exactly as before for them.
|
|
60
64
|
...(derivedRepo ? [{
|
|
61
65
|
key: `skills/<derived for ${derivedRepo}>`,
|
|
62
|
-
promise: get(
|
|
66
|
+
promise: get(apiPath('skills', [derivedRepo, 'derived'])),
|
|
63
67
|
// Tolerant by construction: this runs mid-setup, AFTER the baseline has landed, so a shape
|
|
64
68
|
// change at the endpoint must not abort an adopter's install half-written.
|
|
65
69
|
write: (data) => {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* bundle-mount-follow.js — HC-GF-325.
|
|
4
|
+
*
|
|
5
|
+
* `cli/lib/bundle-paths.js` bundles only paths the story NAMES — it has no mount, prefix or
|
|
6
|
+
* import following. step_2 (API test writer) then has no way to know a router file's routes are
|
|
7
|
+
* served under a prefix, because nothing in the bundle says so. Measured live: step_2 wrote 7
|
|
8
|
+
* tests against `/properties` for an app that mounts every router under `/api/v1`
|
|
9
|
+
* (`app.include_router(properties.router, prefix=settings.api_prefix)`); all 7 failed on a 404.
|
|
10
|
+
*
|
|
11
|
+
* This module answers one question given a bundled file's path and content: "if this file
|
|
12
|
+
* defines routes, which OTHER tracked file mounts it, and which existing test already exercises
|
|
13
|
+
* a router mounted the same way?" Both answers are returned as extra paths for the caller to
|
|
14
|
+
* bundle alongside the file the story named — it does not read the filesystem itself, so it stays
|
|
15
|
+
* as pure/testable as the rest of `cli/lib`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
// A file DEFINES routes when it either builds a router object (FastAPI's `APIRouter()`, Express's
|
|
21
|
+
// `Router()`) or decorates/registers at least one HTTP verb against one (`@router.get(...)`,
|
|
22
|
+
// `router.get(...)`) — the object alone (an empty router) is not enough to justify following it.
|
|
23
|
+
const DEFINES_ROUTER_OBJECT = /\bAPIRouter\s*\(|\b(?:express\.)?Router\s*\(\s*\)/;
|
|
24
|
+
const REGISTERS_A_ROUTE = /@\w+\.(get|post|put|delete|patch)\s*\(|\b\w+\.(get|post|put|delete|patch)\s*\(\s*['"`]/i;
|
|
25
|
+
|
|
26
|
+
function definesRoutes(content) {
|
|
27
|
+
const text = String(content || '');
|
|
28
|
+
return DEFINES_ROUTER_OBJECT.test(text) && REGISTERS_A_ROUTE.test(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Where an app typically registers its routers/routes — bounded, so following a mount never
|
|
32
|
+
// means reading every tracked file's content. FastAPI/Flask/Express conventions only; a repo
|
|
33
|
+
// using neither simply gets no mount file, same as today.
|
|
34
|
+
const ENTRYPOINT_NAME_RE = /(^|\/)(main|app|asgi|wsgi|server|index|__init__)\.(py|js|mjs|cjs|ts)$/i;
|
|
35
|
+
|
|
36
|
+
// `app.include_router(...)` (FastAPI/Starlette) or `app.use(...)` (Express) — the call that
|
|
37
|
+
// registers a router built elsewhere. Requires the SAME file to also reference the router
|
|
38
|
+
// file's module name, so a coincidental `app.use(` for an unrelated router does not match.
|
|
39
|
+
const MOUNT_CALL_RE = /\b\w+\.include_router\s*\(|\b\w+\.use\s*\(/;
|
|
40
|
+
|
|
41
|
+
function baseModuleName(filePath) {
|
|
42
|
+
return path.posix.basename(String(filePath || ''), path.posix.extname(String(filePath || '')));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mentionsModule(content, moduleName) {
|
|
46
|
+
if (!moduleName) return false;
|
|
47
|
+
const re = new RegExp(`\\b${moduleName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
48
|
+
return re.test(String(content || ''));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} o
|
|
53
|
+
* @param {string} o.routeFilePath the bundled file being checked
|
|
54
|
+
* @param {string} o.routeFileContent
|
|
55
|
+
* @param {string[]} o.repoFiles every tracked path (the caller's git ls-files list)
|
|
56
|
+
* @param {(p:string)=>string} o.readFile reads a candidate mount file; the caller's disk I/O
|
|
57
|
+
* @returns {string|null} the path of the file that mounts routeFilePath's router, or null
|
|
58
|
+
*/
|
|
59
|
+
function findMountFile({ routeFilePath, routeFileContent, repoFiles, readFile }) {
|
|
60
|
+
if (!definesRoutes(routeFileContent)) return null;
|
|
61
|
+
const moduleName = baseModuleName(routeFilePath);
|
|
62
|
+
const candidates = (Array.isArray(repoFiles) ? repoFiles : [])
|
|
63
|
+
.filter((p) => p !== routeFilePath && ENTRYPOINT_NAME_RE.test(p));
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
let content;
|
|
66
|
+
try { content = readFile(candidate); } catch { continue; }
|
|
67
|
+
if (MOUNT_CALL_RE.test(content) && mentionsModule(content, moduleName)) return candidate;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The same directory-prefix + name convention `cli/lib/layout-detect.js` uses for a test root —
|
|
73
|
+
// kept independent (not imported) because this module's job is narrower: ONE existing file to
|
|
74
|
+
// show the test writer the mounted-prefix convention, not a directory computation.
|
|
75
|
+
const TEST_PATH_RE = /(^|\/)(tests?|__tests__|spec|specs|e2e)(\/|$)|\.(test|spec)\.[a-z]+$/i;
|
|
76
|
+
// A test that actually drives an HTTP client — the convention worth copying — over one that
|
|
77
|
+
// merely lives in a test directory (a fixtures or conftest file, say).
|
|
78
|
+
const API_TEST_SIGNAL_RE = /\bTestClient\s*\(|\bsupertest\s*\(|\brequest\s*\(\s*app\s*\)|\bclient\.(get|post|put|delete|patch)\s*\(/;
|
|
79
|
+
|
|
80
|
+
function topLevelDir(filePath) {
|
|
81
|
+
const norm = String(filePath || '').replace(/^\.\//, '');
|
|
82
|
+
return norm.includes('/') ? norm.split('/')[0] : '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {object} o
|
|
87
|
+
* @param {string} o.routeFilePath
|
|
88
|
+
* @param {string[]} o.repoFiles
|
|
89
|
+
* @param {(p:string)=>string} o.readFile
|
|
90
|
+
* @returns {string|null} an existing test file demonstrating the same kind of route testing
|
|
91
|
+
*/
|
|
92
|
+
function findExistingApiTest({ routeFilePath, repoFiles, readFile }) {
|
|
93
|
+
const dir = topLevelDir(routeFilePath);
|
|
94
|
+
const candidates = (Array.isArray(repoFiles) ? repoFiles : [])
|
|
95
|
+
.filter((p) => p !== routeFilePath && (!dir || p.startsWith(dir + '/')) && TEST_PATH_RE.test(p));
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
let content;
|
|
98
|
+
try { content = readFile(candidate); } catch { continue; }
|
|
99
|
+
if (API_TEST_SIGNAL_RE.test(content)) return candidate;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The one entry point `run-context.js` calls per already-bundled ("present") file.
|
|
106
|
+
* @returns {string[]} zero, one or two extra paths to bundle (mount file, existing API test)
|
|
107
|
+
*/
|
|
108
|
+
function followRouterMount({ routeFilePath, routeFileContent, repoFiles, readFile }) {
|
|
109
|
+
if (!definesRoutes(routeFileContent)) return [];
|
|
110
|
+
const extra = [];
|
|
111
|
+
const mountFile = findMountFile({ routeFilePath, routeFileContent, repoFiles, readFile });
|
|
112
|
+
if (mountFile) extra.push(mountFile);
|
|
113
|
+
const testFile = findExistingApiTest({ routeFilePath, repoFiles, readFile });
|
|
114
|
+
if (testFile) extra.push(testFile);
|
|
115
|
+
return extra;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { definesRoutes, findMountFile, findExistingApiTest, followRouterMount };
|
package/lib/bundle-paths.js
CHANGED
|
@@ -464,8 +464,19 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
464
464
|
// The radius is sized before any window exists, so it prices the marker at its widest:
|
|
465
465
|
// the longest label among the sites, with line numbers at the file's own width.
|
|
466
466
|
const widest = { startLine: totalLines, endLine: totalLines, totalLines };
|
|
467
|
+
// HC-GF-334: the footer repeats `filePath` in EVERY kept window (see `footer` above), so
|
|
468
|
+
// sizing `radius` off its real length made window boundaries — and therefore which sites
|
|
469
|
+
// and collapsed hits are reachable — depend on how long the file's path happens to be.
|
|
470
|
+
// Measured: with real files and roadmap rows, a fake path fixed at length 80 or 90 dropped
|
|
471
|
+
// a hit that lengths 10-70 and 100-200 all kept, for the SAME content and story — a path a
|
|
472
|
+
// few characters different flips whether a window's char-radius snaps past a line boundary.
|
|
473
|
+
// Reserve a fixed, generous footer cost for SIZING the radius instead of the real one, so
|
|
474
|
+
// the estimate — and the windows it produces — no longer shift with path length. `costOf`
|
|
475
|
+
// and `budget()` below still charge the REAL footer length, so the cap is still never
|
|
476
|
+
// exceeded even for a path longer than this reserve; it only stops falling short of it.
|
|
477
|
+
const FOOTER_SIZING_RESERVE_CHARS = 300;
|
|
467
478
|
const markerCost = Math.max(...chosen.map((s) => header(widest, s.anchor, totalLines).length))
|
|
468
|
-
+ footer.length + 1;
|
|
479
|
+
+ Math.max(footer.length, FOOTER_SIZING_RESERVE_CHARS) + 1;
|
|
469
480
|
// HC-GF-288: the 800 covers ONE window's marker, and every further window brings its
|
|
470
481
|
// own. Sized from `limit - 800` alone, n windows overran the cap from n = 3 — so the
|
|
471
482
|
// LAST region was always dropped however much room the rest left. Measured on
|
package/lib/layout-detect.js
CHANGED
|
@@ -20,6 +20,14 @@ const EXCLUDE = /(^|\/)(node_modules|dist|build|coverage|\.git|\.next|vendor|__p
|
|
|
20
20
|
const SOURCE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|py|java|kt|rb|go|rs|php|cs|scala|swift|cls|trigger|apex)$/i;
|
|
21
21
|
const TEST_HINT = /(^|\/)(tests?|__tests__|spec|specs|e2e)(\/|$)|\.(test|spec)\.[a-z]+$/i;
|
|
22
22
|
|
|
23
|
+
// HC-GF-020-followup-1: a documentation directory routinely carries a handful of genuinely
|
|
24
|
+
// source-extension files (Sphinx's conf.py, a docs-site build script) that clear MIN_FILES on
|
|
25
|
+
// extension count alone — measured live: a docs/ of mostly .html/.svg pages qualified as a
|
|
26
|
+
// source dir because it also held two .py files. Extension MIX, not source-extension COUNT, is
|
|
27
|
+
// what tells a docs directory apart from a package: if a top-level dir's tracked files are more
|
|
28
|
+
// documentation than source, it is documentation, however many stray source-ext files it holds.
|
|
29
|
+
const DOC_EXT = /\.(md|mdx|rst|adoc|txt|html?|svg|png|jpe?g|gif|ico|pdf)$/i;
|
|
30
|
+
|
|
23
31
|
// Directories whose NAME means "this is where the code is". A repo that puts code directly at the
|
|
24
32
|
// root of a package still gets that package dir, via the manifest-dir fallback below.
|
|
25
33
|
const SOURCE_DIR_NAMES = /^(src|lib|app|source|server|client|api|packages|apps|core)$/i;
|
|
@@ -61,6 +69,27 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
|
|
|
61
69
|
const srcFiles = paths.filter(p => SOURCE_EXT.test(p) && !TEST_HINT.test(p));
|
|
62
70
|
const testFiles = paths.filter(p => SOURCE_EXT.test(p) && TEST_HINT.test(p));
|
|
63
71
|
|
|
72
|
+
// Extension mix per top-level dir, over EVERY tracked path (not just the source-ext-filtered
|
|
73
|
+
// srcFiles/testFiles) — a docs/ directory's defining feature is the files roll() never looks
|
|
74
|
+
// at otherwise (its .html/.svg pages), so the doc-vs-source comparison needs the full list.
|
|
75
|
+
const extCountsByTop = new Map();
|
|
76
|
+
for (const p of paths) {
|
|
77
|
+
const top = p.includes('/') ? p.split('/')[0] : '.';
|
|
78
|
+
if (!extCountsByTop.has(top)) extCountsByTop.set(top, { doc: 0, src: 0 });
|
|
79
|
+
const c = extCountsByTop.get(top);
|
|
80
|
+
if (DOC_EXT.test(p)) c.doc += 1;
|
|
81
|
+
else if (SOURCE_EXT.test(p)) c.src += 1;
|
|
82
|
+
}
|
|
83
|
+
function isDocumentationOnly(top) {
|
|
84
|
+
// A dotdir (.github, .storybook, .devcontainer, ...) is tooling/config by convention, never
|
|
85
|
+
// a published-docs directory, even when it holds far more Markdown records (pipeline history,
|
|
86
|
+
// learnings) than source-ext scripts — hone-server's OWN .github/scripts/ is real code that
|
|
87
|
+
// must stay visible (HC-DF-005) alongside .github/pipeline/'s hundreds of Markdown records.
|
|
88
|
+
if (top.startsWith('.')) return false;
|
|
89
|
+
const c = extCountsByTop.get(top);
|
|
90
|
+
return !!c && c.doc > c.src;
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
function roll(files, nameRe = SOURCE_DIR_NAMES) {
|
|
65
94
|
const byTop = new Map();
|
|
66
95
|
for (const f of files) {
|
|
@@ -71,6 +100,7 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
|
|
|
71
100
|
const out = [];
|
|
72
101
|
for (const [top, list] of byTop) {
|
|
73
102
|
if (top === '.') continue; // root-level scripts are not a source ROOT
|
|
103
|
+
if (isDocumentationOnly(top)) continue; // docs/ with a stray conf.py is still docs/
|
|
74
104
|
const qualifies = nameRe.test(top) || manifestDirs.has(top) || list.length >= minFiles;
|
|
75
105
|
if (!qualifies) continue;
|
|
76
106
|
// Descend one level when this is a container of packages, or when a conventional source
|
|
@@ -83,14 +113,23 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
|
|
|
83
113
|
// Files sitting DIRECTLY in this dir (cli/hone-cli.js). If any exist, the dir itself is
|
|
84
114
|
// the source root — descending would silently drop them, which is how an early cut of this
|
|
85
115
|
// rule emitted `cli/lib` and lost cli/hone-cli.js (5.8k lines, the whole CLI surface).
|
|
86
|
-
// Descend ONLY into a package container (packages/, apps/). Anywhere else, prefer the
|
|
87
|
-
// PARENT: a wider scope is the safe error for grounding, a narrower one hides real files.
|
|
88
|
-
// An earlier rule descended into conventional children and emitted `server/src`, which
|
|
89
|
-
// dropped `server/scripts/` — the exact residual the followup-42 dogfood run hit, where the
|
|
90
|
-
// planner could not see the agent prompts and proposed inventing them elsewhere.
|
|
91
116
|
const direct = list.filter(f => f.split('/').length === 2).length;
|
|
92
117
|
const container = /^(packages|apps|services|modules)$/i.test(top);
|
|
93
|
-
|
|
118
|
+
// HC-GF-020-followup-1: TEST dirs alone also descend into a sole, unambiguous child (a repo's
|
|
119
|
+
// real test dir was reported as `backend` because every test sat under `backend/tests`, a name
|
|
120
|
+
// this rule never recognized). Deliberately NOT extended to source dirs: an earlier rule
|
|
121
|
+
// descended into ANY conventional child regardless of ambiguity and emitted `server/src`,
|
|
122
|
+
// which dropped `server/scripts/` — the exact residual the followup-42 dogfood run hit, where
|
|
123
|
+
// the planner could not see the agent prompts and proposed inventing them elsewhere. `server/`
|
|
124
|
+
// has both `server/src` and `server/scripts` (2 kids) so it is unaffected either way, but a
|
|
125
|
+
// repo where `server/` holds ONLY `server/src` must still stay at `server` for source dirs —
|
|
126
|
+
// measured by this file's own regression pins — so the single-kid case is test-dirs-only.
|
|
127
|
+
// ...and only when `top` itself is NOT already a conventionally-named test root: a `tests/`
|
|
128
|
+
// dir with a single `tests/integration` child is intentionally left at `tests` — descending
|
|
129
|
+
// would report a narrower, equally-arbitrary name in place of the convention that already
|
|
130
|
+
// says exactly what this directory is.
|
|
131
|
+
const soleUnambiguousTestChild = nameRe === TEST_DIR_NAMES && !nameRe.test(top) && kids.size === 1 && direct === 0;
|
|
132
|
+
if ((container || soleUnambiguousTestChild) && kids.size > 0 && direct === 0) { out.push(...[...kids.keys()]); continue; }
|
|
94
133
|
out.push(top);
|
|
95
134
|
}
|
|
96
135
|
return [...new Set(out)].sort();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* operator-question.js — HC-GF-234: render the operator question(s) a step asked and nobody answered.
|
|
3
|
+
*
|
|
4
|
+
* A step that ends by asking the operator something used to be recorded exactly like a step that
|
|
5
|
+
* did its work (`status: completed`, `gateResult: approved`). The server now stores
|
|
6
|
+
* `{ count, questions }` on the step's attempt row and carries it as `operatorQuestion` on
|
|
7
|
+
* `GET /orchestrate/:runId` and `.../step/:stepKey`. Four CLI surfaces print it — `--status`,
|
|
8
|
+
* `show step`, the human-gate prompt, and the NEEDS YOU list — and they share this module so the
|
|
9
|
+
* wording and the two honesty rules below cannot drift apart.
|
|
10
|
+
*
|
|
11
|
+
* PURE: data in, strings out. No fs, no console, no process — a leaf, so run-report.js can keep
|
|
12
|
+
* its "derived, never narrated" contract and require it.
|
|
13
|
+
*
|
|
14
|
+
* TWO HONESTY RULES.
|
|
15
|
+
* - `count` is the TRUE number of questions the step asked; `questions` holds at most the first
|
|
16
|
+
* five. When count exceeds what is stored, the surfaces say "+N more not stored" instead of
|
|
17
|
+
* letting a short list read as the whole of it.
|
|
18
|
+
* - The text is AGENT-WRITTEN and goes to a terminal. The server scrubs control characters
|
|
19
|
+
* before storing, but this CLI may talk to an older or different server, so it scrubs again
|
|
20
|
+
* at the point of printing: C0/C1 controls, DEL, and the bidirectional controls that reorder
|
|
21
|
+
* what a terminal draws.
|
|
22
|
+
*
|
|
23
|
+
* TOLERANT OF AN OLDER SERVER BY CONSTRUCTION: a missing key, null, or a payload of the wrong
|
|
24
|
+
* shape normalises to null, which is today's output (nothing printed).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// U+061C ARABIC LETTER MARK is the third bidi mark beside LRM/RLM (review round 3 F3). Written as
|
|
28
|
+
// \u escapes: a literal bidi character in this source would reorder how THIS file reads.
|
|
29
|
+
// eslint-disable-next-line no-control-regex -- the whole point is to match control chars.
|
|
30
|
+
const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
31
|
+
|
|
32
|
+
function scrub(text) {
|
|
33
|
+
return String(text == null ? '' : text).replace(TERMINAL_UNSAFE_RE, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {*} oq a step's `operatorQuestion`, straight off the network (not trusted to have a shape)
|
|
38
|
+
* @returns {{count:number, questions:string[], more:number}|null} null = the step did not ask
|
|
39
|
+
*/
|
|
40
|
+
function normalizeQuestion(oq) {
|
|
41
|
+
if (!oq || typeof oq !== 'object') return null;
|
|
42
|
+
const count = Math.floor(Number(oq.count));
|
|
43
|
+
if (!Number.isFinite(count) || count < 1) return null;
|
|
44
|
+
const questions = (Array.isArray(oq.questions) ? oq.questions : []).map(scrub);
|
|
45
|
+
return { count, questions, more: Math.max(0, count - questions.length) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Clips by CODE POINT, not UTF-16 unit (review round 3 F4): a `.slice(0, max)` that lands between
|
|
49
|
+
// the halves of an emoji prints a lone surrogate, which a terminal draws as U+FFFD.
|
|
50
|
+
function clip(text, max) {
|
|
51
|
+
if (!Number.isFinite(max)) return text;
|
|
52
|
+
const points = Array.from(text);
|
|
53
|
+
return points.length > max ? `${points.slice(0, max).join('')}...` : text;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One entry per stored question (`- <question>`, clipped to `max` chars) and, when the step asked
|
|
58
|
+
* more than was stored, a closing `+ N more not stored`. Callers add their own indentation.
|
|
59
|
+
*/
|
|
60
|
+
function questionLines(asked, { max = Infinity } = {}) {
|
|
61
|
+
if (!asked) return [];
|
|
62
|
+
const lines = asked.questions.map((q) => `- ${clip(q, max)}`);
|
|
63
|
+
if (asked.more > 0) lines.push(`+ ${asked.more} more not stored`);
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countPhrase(count) {
|
|
68
|
+
return `${count} question${count === 1 ? '' : 's'}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The block printed at the human gate, right before "Approve?" — the one place the operator
|
|
73
|
+
* decides, and where approving silently consumes the question without answering it.
|
|
74
|
+
*
|
|
75
|
+
* SCOPED TO THE STEP THE RUN IS PAUSED AT (`stepKey`), the same discipline as
|
|
76
|
+
* hard-pause-remedy.js's remedyFor: the column is durable, so a question step_3b asked must not
|
|
77
|
+
* be re-printed as if it belonged to an unrelated step_4 gate.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} status a GET /orchestrate/:runId payload
|
|
80
|
+
* @param {string} stepKey the step the gate is for
|
|
81
|
+
* @returns {string[]} lines to print (empty when that step asked nothing)
|
|
82
|
+
*/
|
|
83
|
+
function gateQuestionLines(status, stepKey) {
|
|
84
|
+
const steps = status && Array.isArray(status.steps) ? status.steps : [];
|
|
85
|
+
const step = steps.find((x) => x && typeof x === 'object' && x.stepKey === stepKey);
|
|
86
|
+
const asked = step ? normalizeQuestion(step.operatorQuestion) : null;
|
|
87
|
+
if (!asked) return [];
|
|
88
|
+
return [
|
|
89
|
+
` ${stepKey} asked ${countPhrase(asked.count)} that nobody has answered — approving does not answer ${asked.count === 1 ? 'it' : 'them'}:`,
|
|
90
|
+
...questionLines(asked, { max: 240 }).map((l) => ` ${l}`),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase };
|
package/lib/run-context.js
CHANGED
|
@@ -30,6 +30,7 @@ const {
|
|
|
30
30
|
extractPathRefs, extractBareRefs, resolveBareName, alreadyCovered, planFileExcerpt,
|
|
31
31
|
} = require('./bundle-paths');
|
|
32
32
|
const { selectBundle, auditLines } = require('./bundle-context');
|
|
33
|
+
const { followRouterMount } = require('./bundle-mount-follow');
|
|
33
34
|
const { parseIncludePaths, partitionIncludeTargets, statedAnchorsFor, anchorWarnings } = require('./include-paths');
|
|
34
35
|
const { buildRepoTree, allDirs, MAX_TRANSPORT_PATHS, MAX_TRANSPORT_DIRS } = require('./repo-tree');
|
|
35
36
|
const { classifyStory } = require('./story-classifier');
|
|
@@ -497,7 +498,24 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
497
498
|
// HC-GF-073: what exists, what is pending, plus repo-level signal files and existing
|
|
498
499
|
// neighbours of the pending paths. Order is priority order: the char cap cuts from the end.
|
|
499
500
|
const selection = selectBundle({ named: [...candidates], repoFiles: repoCtx.repoFiles, exists: io.exists });
|
|
500
|
-
|
|
501
|
+
// HC-GF-325: a present file that DEFINES routes says nothing about where they are MOUNTED —
|
|
502
|
+
// step_2 wrote 7 tests against `/properties` for an app that serves every router under
|
|
503
|
+
// `/api/v1`. Right after `present`, so a mount file and an existing API test of the same
|
|
504
|
+
// kind get priority over signal files and siblings within the char budget, same as a path
|
|
505
|
+
// the story named directly would.
|
|
506
|
+
const alreadySelected = new Set([...selection.present, ...selection.signal, ...selection.siblings]);
|
|
507
|
+
const mountFollowed = [];
|
|
508
|
+
for (const p of selection.present) {
|
|
509
|
+
let content;
|
|
510
|
+
try { content = io.readFile(p); } catch { continue; }
|
|
511
|
+
for (const extra of followRouterMount({ routeFilePath: p, routeFileContent: content, repoFiles: repoCtx.repoFiles, readFile: io.readFile })) {
|
|
512
|
+
if (alreadySelected.has(extra)) continue;
|
|
513
|
+
alreadySelected.add(extra);
|
|
514
|
+
mountFollowed.push(extra);
|
|
515
|
+
pre.push(info(` → bundled ${extra} — mounts/tests the router in ${p} (HC-GF-325)`));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const ordered = [...selection.present, ...mountFollowed, ...selection.signal, ...selection.siblings];
|
|
501
519
|
const codebaseFiles = [];
|
|
502
520
|
let totalChars = 0;
|
|
503
521
|
let budgetSpent = false; // HC-GF-288: so a not-bundled warning can say WHY
|
package/lib/run-report.js
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
* Rendering is separate so both are testable without a server.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
// HC-GF-234: the one wording for a step's unanswered question, shared with `--status`, `show
|
|
20
|
+
// step` and the human-gate prompt. A pure leaf module, so this file stays pure.
|
|
21
|
+
const { normalizeQuestion, questionLines, countPhrase } = require('./operator-question');
|
|
22
|
+
|
|
19
23
|
/** Gate verdicts the pipeline already speaks. Do not invent synonyms. */
|
|
20
24
|
const ACTIONABLE_GATE_VERDICTS = ['fail', 'unavailable'];
|
|
21
25
|
|
|
@@ -324,6 +328,31 @@ function buildReport(run, extra = {}) {
|
|
|
324
328
|
}
|
|
325
329
|
}
|
|
326
330
|
|
|
331
|
+
// 1e. HC-GF-234: a step that ended by asking the operator something.
|
|
332
|
+
//
|
|
333
|
+
// `status: completed` / `gateResult: approved` cannot say this: batch mode has no human gate on
|
|
334
|
+
// steps 0-3b, and approving a gate records `approved` whether or not the question was read. The
|
|
335
|
+
// step row carries the question on its own column; this is the only place a TERMINAL run's
|
|
336
|
+
// report can tell "did its work" from "asked, and nobody here answered".
|
|
337
|
+
//
|
|
338
|
+
// Says only what is MEASURED — the step asked, and the run has no answer to it. It does not say
|
|
339
|
+
// the step produced no deliverable or that the run proceeded on a guess: nothing checks either.
|
|
340
|
+
//
|
|
341
|
+
// ADVISORY (HC-026c: a new signal REPORTS before it GATES): `hone run-report` exits 1 on any
|
|
342
|
+
// non-advisory item, and an agent that asks one benign question must not turn an adopter's
|
|
343
|
+
// passing script red overnight. Visible in the report, absent from the exit code.
|
|
344
|
+
for (const s of steps) {
|
|
345
|
+
const asked = normalizeQuestion(s.operatorQuestion);
|
|
346
|
+
if (!asked) continue;
|
|
347
|
+
needsYou.push({
|
|
348
|
+
what: `${s.stepKey} (${s.agent || 'agent'}) asked ${countPhrase(asked.count)} that nothing in this run answered`,
|
|
349
|
+
detail: questionLines(asked, { max: 200 }),
|
|
350
|
+
how: `hone show step ${run.runId} ${s.stepKey}`,
|
|
351
|
+
howLabel: 'inspect',
|
|
352
|
+
advisory: true,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
327
356
|
// 2. The last verification is ONE event, so it becomes ONE item with sub-lines.
|
|
328
357
|
// Listing "verification failed" and "lint failed" separately gave two entries
|
|
329
358
|
// for one problem resolved by one fix — and NEEDS YOU is a list of DISTINCT
|
package/lib/run-target.js
CHANGED
|
@@ -104,17 +104,26 @@ async function resolveRunTarget({ client, idOrStory, repoName, flag }) {
|
|
|
104
104
|
const isStepKey = (s) => typeof s === 'string' && /^step_[0-9a-z_]+$/i.test(s);
|
|
105
105
|
const isAttempt = (s) => (typeof s === 'string' || typeof s === 'number') && /^[1-9]\d{0,8}$/.test(String(s));
|
|
106
106
|
const isBatchId = isRunId;
|
|
107
|
+
// HC-GF-270-followup-2: POST /derive is the only producer of a derive job id (server/src/routes/derive.js),
|
|
108
|
+
// and it issues `drv_` + 12 lower-case hex characters into a TEXT key compared exactly, so no other shape
|
|
109
|
+
// can name a job. `hone approve --job` GETs and then POSTs under that id.
|
|
110
|
+
const isDeriveJobId = (s) => typeof s === 'string' && /^drv_[0-9a-f]{12}$/.test(s);
|
|
111
|
+
// A name that becomes ONE path segment, such as a repository name. Encoding makes any other character
|
|
112
|
+
// safe; these three it cannot, because `new URL()` resolves `.` and `..` as directory steps.
|
|
113
|
+
const isPathName = (s) => typeof s === 'string' && s !== '' && s !== '.' && s !== '..';
|
|
107
114
|
|
|
108
115
|
const KINDS = {
|
|
109
116
|
runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
|
|
110
117
|
batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
|
|
111
118
|
stepKey: { ok: isStepKey, why: 'must name a step, like step_4', hint: '' },
|
|
112
119
|
attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
|
|
120
|
+
deriveJobId: { ok: isDeriveJobId, why: 'must be a derive job ID, like drv_0123456789ab', hint: ' — the ID "hone derive --refresh" printed' },
|
|
121
|
+
pathName: { ok: isPathName, why: 'must be a name, not empty, "." or ".."', hint: '' },
|
|
113
122
|
};
|
|
114
123
|
|
|
115
124
|
/**
|
|
116
125
|
* The first failed check's message, or null when every check passes.
|
|
117
|
-
* @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt', value: any, optional?: boolean }>} checks
|
|
126
|
+
* @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName', value: any, optional?: boolean }>} checks
|
|
118
127
|
* `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
|
|
119
128
|
* is undefined (a flag that was not given); an empty string is always checked.
|
|
120
129
|
*/
|
|
@@ -130,23 +139,29 @@ function runArgsError(checks) {
|
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
/**
|
|
133
|
-
* The one way
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
142
|
+
* The one way the CLI builds a path on the Hone API: `/<prefix>/<segment>/…?<query>`. `prefix` is a
|
|
143
|
+
* literal resource name (`derive`, `skills`, …), never a value. Each segment is encoded exactly once, and
|
|
144
|
+
* the segments encoding cannot make safe (empty, `.`, `..`, null, undefined) throw: every caller-supplied
|
|
145
|
+
* value has passed `runArgsError` first, so a throw here is a bug, not an input error. Query values that
|
|
146
|
+
* are null, undefined, false or '' are dropped; the rest go through URLSearchParams.
|
|
137
147
|
*
|
|
138
148
|
* Callers pass raw values. A pre-encoded segment would be encoded twice (`%` becomes `%25`), and the
|
|
139
|
-
* HC-GF-270-followup-
|
|
149
|
+
* HC-GF-270-followup-2 scan fails on one.
|
|
150
|
+
*
|
|
151
|
+
* HC-GF-270-followup-2: generalized from orchestratePath. HC-GF-270-followup-1 closed the defect under
|
|
152
|
+
* `/orchestrate/`; `/derive/`, `/skills/`, `/prompts/`, `/scripts/` and `/stories/` were still built by
|
|
153
|
+
* hand, and `hone approve --job` POSTed under an id nothing checked.
|
|
140
154
|
*/
|
|
141
|
-
function
|
|
142
|
-
if (
|
|
155
|
+
function apiPath(prefix, segments, query) {
|
|
156
|
+
if (typeof prefix !== 'string' || !/^[a-z][a-z0-9-]*$/.test(prefix)) throw new TypeError(`apiPath: prefix must be a resource name, got ${JSON.stringify(prefix)}`);
|
|
157
|
+
if (!Array.isArray(segments)) throw new TypeError('apiPath: segments must be an array');
|
|
143
158
|
const parts = segments.map((v, i) => {
|
|
144
|
-
if (v === null || v === undefined) throw new TypeError(`
|
|
159
|
+
if (v === null || v === undefined) throw new TypeError(`apiPath: segment ${i} is ${v}`);
|
|
145
160
|
const s = String(v);
|
|
146
|
-
if (s === '' || s === '.' || s === '..') throw new TypeError(`
|
|
161
|
+
if (s === '' || s === '.' || s === '..') throw new TypeError(`apiPath: segment ${i} is ${JSON.stringify(s)}`);
|
|
147
162
|
return encodeURIComponent(s);
|
|
148
163
|
});
|
|
149
|
-
const base = ['',
|
|
164
|
+
const base = ['', prefix, ...parts].join('/');
|
|
150
165
|
const qs = new URLSearchParams();
|
|
151
166
|
for (const [k, v] of Object.entries(query || {})) {
|
|
152
167
|
if (v === null || v === undefined || v === false || v === '') continue;
|
|
@@ -156,7 +171,13 @@ function orchestratePath(segments, query) {
|
|
|
156
171
|
return q ? `${base}?${q}` : base;
|
|
157
172
|
}
|
|
158
173
|
|
|
174
|
+
/** An `/orchestrate/…` path: `apiPath('orchestrate', …)` with at least one segment (HC-GF-270-followup-1). */
|
|
175
|
+
function orchestratePath(segments, query) {
|
|
176
|
+
if (!Array.isArray(segments) || segments.length === 0) throw new TypeError('orchestratePath: segments must be a non-empty array');
|
|
177
|
+
return apiPath('orchestrate', segments, query);
|
|
178
|
+
}
|
|
179
|
+
|
|
159
180
|
module.exports = {
|
|
160
181
|
resolveRunTarget, isRunId, LOOKUP_LIMIT,
|
|
161
|
-
isStepKey, isAttempt, isBatchId, runArgsError, orchestratePath,
|
|
182
|
+
isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, runArgsError, apiPath, orchestratePath,
|
|
162
183
|
};
|
package/lib/verify-patch.js
CHANGED
|
@@ -196,7 +196,7 @@ function verifyCommandEnv(base = process.env) {
|
|
|
196
196
|
* Pass the `command` that ran and the resolver's `source` for it: without them an absent command is
|
|
197
197
|
* indistinguishable from a failing one (HC-GF-008), and the CLI's two executors both do.
|
|
198
198
|
*
|
|
199
|
-
* @param {{ code: number|null, timedOut: boolean, stdout?: string, stderr?: string, command?: string, source?: string }} run
|
|
199
|
+
* @param {{ code: number|null, timedOut: boolean, stdout?: string, stderr?: string, command?: string, source?: string, error?: { code?: string } }} run
|
|
200
200
|
* @returns {{ verdict: 'pass'|'fail'|'timeout'|'unresolved', exitCode: number, detail: string }}
|
|
201
201
|
*/
|
|
202
202
|
function verdictFromRun(run, provisioning = null) {
|
|
@@ -216,6 +216,18 @@ function verdictFromRun(run, provisioning = null) {
|
|
|
216
216
|
if (run.timedOut) {
|
|
217
217
|
return { verdict: 'timeout', exitCode: EXIT.TIMEOUT, detail: 'test command exceeded the timeout' };
|
|
218
218
|
}
|
|
219
|
+
// HC-GF-331: spawnSync kills the child and reports status: null with error.code ENOBUFS when
|
|
220
|
+
// its stdout/stderr exceed maxBuffer. A null status otherwise falls through to the generic
|
|
221
|
+
// "tests failed (exit null)" path below — the suite may have passed; nothing conclusive was
|
|
222
|
+
// observed about it either way, so this is its own outcome, never a reported failure.
|
|
223
|
+
if (run.error && run.error.code === 'ENOBUFS') {
|
|
224
|
+
return {
|
|
225
|
+
verdict: 'unresolved',
|
|
226
|
+
exitCode: EXIT.UNRESOLVED,
|
|
227
|
+
detail: 'test output exceeded the capture buffer (ENOBUFS) — nothing conclusive was observed '
|
|
228
|
+
+ 'about this change; raise maxBuffer or have the command print less',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
219
231
|
if (run.code === 0) {
|
|
220
232
|
// HC-GF-223: a pass under an operator-supplied command is evidence about a SUBSET. Measured on
|
|
221
233
|
// run `437246a7`: `--command "node --test <3 files>"` reported `pass` to the server and the same
|
|
@@ -401,6 +413,11 @@ function classifyGateRun(run) {
|
|
|
401
413
|
return { verdict: 'unavailable', detail: 'command not found — gate NOT run' };
|
|
402
414
|
}
|
|
403
415
|
if (run.timedOut) return { verdict: 'unavailable', detail: 'timed out — gate NOT run to completion' };
|
|
416
|
+
// HC-GF-331: shares verdictFromRun's shape — a null status from an ENOBUFS overflow is not a
|
|
417
|
+
// finding, it is the gate not having run to a readable conclusion.
|
|
418
|
+
if (run.error && run.error.code === 'ENOBUFS') {
|
|
419
|
+
return { verdict: 'unavailable', detail: 'output exceeded the capture buffer (ENOBUFS) — gate NOT run to completion' };
|
|
420
|
+
}
|
|
404
421
|
if (run.code === 0) return { verdict: 'pass', detail: 'clean' };
|
|
405
422
|
const sum = summarizeFailure(run, { maxLines: 6 });
|
|
406
423
|
// A linter prints its findings from the TOP, so unlike a test runner the most
|