@hone-ai/cli 1.30.0 → 1.31.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 CHANGED
@@ -5604,6 +5604,7 @@ program
5604
5604
  .option('--kill', 'Kill the latest workflow run for this story')
5605
5605
  .option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
5606
5606
  .option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0). Exit codes: 2 = paused at a human gate, 3 = parked for your editor (execution.mode: editor)')
5607
+ .option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
5607
5608
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
5608
5609
  .option('--poll-interval <s>', 'Poll interval in seconds', '5')
5609
5610
  .option('--story-file <path>', 'HC-GF-010: read the story text from a local file. Skips the GitHub-issue and Hone-roadmap lookups entirely, so a repo with neither can still run the pipeline.')
@@ -5915,21 +5916,24 @@ program
5915
5916
  // bundler can WINDOW a too-big file around its edit site instead of
5916
5917
  // head-truncating past it (the demo's line 787 sits at char 37K).
5917
5918
  const candidateLines = new Map();
5919
+ // HC-GF-073: hoisted out of the bare-ref branch below, because the sibling tier
5920
+ // needs the same list on stories that contain no bare refs at all — which is most
5921
+ // of them.
5922
+ const { gitEnv } = require('./lib/git-env');
5923
+ let repoFiles = null;
5924
+ const listRepoFiles = () => {
5925
+ if (repoFiles) return repoFiles;
5926
+ try {
5927
+ repoFiles = execSync('git ls-files', {
5928
+ cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
5929
+ }).split('\n').filter(Boolean);
5930
+ } catch { repoFiles = []; }
5931
+ return repoFiles;
5932
+ };
5918
5933
  if (issueBodyForFiles) {
5919
5934
  const { extractBareRefs, resolveBareName, alreadyCovered } = require('./lib/bundle-paths');
5920
- const { gitEnv } = require('./lib/git-env');
5921
5935
  const bareRefs = extractBareRefs(issueBodyForFiles);
5922
5936
  if (bareRefs.length > 0) {
5923
- let repoFiles = null;
5924
- const listRepoFiles = () => {
5925
- if (repoFiles) return repoFiles;
5926
- try {
5927
- repoFiles = execSync('git ls-files', {
5928
- cwd: process.cwd(), env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
5929
- }).split('\n').filter(Boolean);
5930
- } catch { repoFiles = []; }
5931
- return repoFiles;
5932
- };
5933
5937
  for (const { name, line } of bareRefs) {
5934
5938
  if (alreadyCovered(name, candidates)) continue;
5935
5939
  const r = resolveBareName(name, listRepoFiles());
@@ -5954,9 +5958,32 @@ program
5954
5958
  const MAX_FILES = 30;
5955
5959
  const MAX_TOTAL_CHARS = 150_000;
5956
5960
  const MAX_PER_FILE_CHARS = 25_000;
5961
+ // HC-GF-073: `candidates` is only what the STORY named, and a story names the
5962
+ // files it will CREATE — so on a greenfield repo every one of them fails the
5963
+ // existsSync below and the bundle is EMPTY. Measured across FlowForge's 33 real
5964
+ // stories: 29 extracted zero existing files. The dogfood runs looked fine only
5965
+ // because an operator had typed --include-paths once; 12 of 12 runs then bundled
5966
+ // the same three files across five different stories.
5967
+ //
5968
+ // selectBundle() splits them into what exists, what is pending, and adds two
5969
+ // tiers the story can never name: repo-level signal files (whose ABSENCE is what
5970
+ // a security reviewer must be able to check — HC-GF-063) and existing neighbours
5971
+ // of the pending paths (the conventions a new module has to match). Order is
5972
+ // priority order, because the char cap below truncates from the end.
5973
+ const { selectBundle, auditLines } = require('./lib/bundle-context');
5974
+ const selection = selectBundle({
5975
+ named: [...candidates],
5976
+ repoFiles: listRepoFiles(),
5977
+ exists: (rel) => {
5978
+ if (rel.startsWith('/') || rel.startsWith('~') || rel.includes('..')) return false;
5979
+ const abs = path.resolve(process.cwd(), rel);
5980
+ return abs.startsWith(process.cwd() + path.sep) && fs.existsSync(abs);
5981
+ },
5982
+ });
5983
+ const ordered = [...selection.present, ...selection.signal, ...selection.siblings];
5957
5984
  const codebaseFiles = [];
5958
5985
  let totalChars = 0;
5959
- for (const candidate of candidates) {
5986
+ for (const candidate of ordered) {
5960
5987
  if (codebaseFiles.length >= MAX_FILES) break;
5961
5988
  if (candidate.startsWith('/') || candidate.startsWith('~') || candidate.includes('..')) continue;
5962
5989
  const abs = path.resolve(process.cwd(), candidate);
@@ -5989,6 +6016,11 @@ program
5989
6016
  totalChars += content.length;
5990
6017
  codebaseFiles.push({ path: candidate, content });
5991
6018
  }
6019
+ // HC-GF-073: the old line printed a COUNT, and a count cannot distinguish three
6020
+ // files the story named from three it did not. That is why 12 consecutive runs
6021
+ // bundled an unrelated module and nothing said so — the "running blind" warning
6022
+ // below fires on an EMPTY bundle, and these were not empty.
6023
+ for (const line of auditLines(selection)) console.log(line);
5992
6024
  if (codebaseFiles.length > 0) {
5993
6025
  orchestrateConfig.codebaseFiles = codebaseFiles;
5994
6026
  console.log(` → bundled ${codebaseFiles.length} codebase file(s) for step_1/step_4 context (${totalChars} chars)`);
@@ -6233,6 +6265,10 @@ program
6233
6265
 
6234
6266
  // Poll loop
6235
6267
  const interval = parseInt(opts.pollInterval, 10) * 1000;
6268
+ // HC-GF-064: at most ONE verification per attempt per process. Without this the
6269
+ // advisory path re-verifies on every poll tick, because advisory never advances
6270
+ // the run and the pause it re-observes is the same pause.
6271
+ const _verifiedAttempts = new Set();
6236
6272
  while (true) {
6237
6273
  await new Promise(r => setTimeout(r, interval));
6238
6274
 
@@ -6265,6 +6301,50 @@ program
6265
6301
  }
6266
6302
  console.log(`⏳ Step ${status.currentStep} (${step?.agent || 'unknown'}) — awaiting approval`);
6267
6303
 
6304
+ // HC-GF-064: verify BEFORE asking anyone to approve.
6305
+ //
6306
+ // The gate this feeds has existed since HC-019n-followup-34/35 and had never
6307
+ // fired — 22 of 22 runs advisory, 5 of 22 with any verdict at all — because
6308
+ // nothing ever ran the executing step. The only prompt was a run-report line,
6309
+ // and 5-of-22 is what that nag was worth.
6310
+ //
6311
+ // Consent is DERIVED: `ci.local_command` being explicitly set is the adopter's
6312
+ // own statement of how to test this repo (see cli/lib/auto-verify.js and
6313
+ // docs/architecture/adr-verification-invocation.md). Never throws — a failure
6314
+ // here must leave the operator exactly where they were, which is being asked
6315
+ // to approve.
6316
+ try {
6317
+ const { decideAutoVerify, announce, shouldRepoll, alreadyVerified } = require('./lib/auto-verify');
6318
+ const attemptKey = `${status.currentStep}#${step?.attempt ?? 1}`;
6319
+ const decision = decideAutoVerify({
6320
+ stepKey: status.currentStep,
6321
+ repoRoot: process.cwd(),
6322
+ noVerifyFlag: opts.verify === false,
6323
+ });
6324
+ if (decision.run && !alreadyVerified(attemptKey, _verifiedAttempts)) {
6325
+ _verifiedAttempts.add(attemptKey);
6326
+ console.log(announce(decision, data.runId));
6327
+ const { spawnSync } = require('child_process');
6328
+ const r = spawnSync(process.execPath,
6329
+ [process.argv[1], 'verify-patch', data.runId, '--report'],
6330
+ { stdio: 'inherit', env: process.env });
6331
+ // A green verdict under `enforce` AUTO-APPROVES this gate, so the run may
6332
+ // already have moved on. Re-poll rather than prompting for an approval that
6333
+ // is no longer wanted — asking a human to approve something already approved
6334
+ // is how an operator learns to distrust the output.
6335
+ if (shouldRepoll({ gate: decision.gate, verdictOk: r.status === 0,
6336
+ attemptKey, verifiedAttempts: null })) {
6337
+ console.log(' → verified; enforce may have advanced the run. Re-checking...');
6338
+ continue;
6339
+ }
6340
+ console.log(r.status === 0
6341
+ ? ` → verified and recorded (gate: ${decision.gate}). The gate still needs you.`
6342
+ : ' → verification did not pass; the gate still needs you.');
6343
+ }
6344
+ } catch (e) {
6345
+ console.log(` (auto-verify skipped: ${e.message})`);
6346
+ }
6347
+
6268
6348
  // Interactive prompt
6269
6349
  if (process.stdin.isTTY) {
6270
6350
  const readline = require('readline');
@@ -7344,11 +7424,15 @@ showCmd
7344
7424
  .command('step <workflowId> <stepKey>')
7345
7425
  .description('Print the actual LLM output of an orchestrator step (latest attempt by default)')
7346
7426
  .option('--attempt <n>', 'Specific attempt number (default: latest)')
7427
+ .option('--prompt', 'Also print the exact prompt this step sent to the model (HC-GF-055)')
7347
7428
  .action(async (workflowId, stepKey, opts) => {
7348
7429
  const config = getConfig();
7349
7430
  const client = api(config);
7350
7431
  try {
7351
- const url = `/orchestrate/${workflowId}/step/${stepKey}${opts.attempt ? `?attempt=${opts.attempt}` : ''}`;
7432
+ const q = [];
7433
+ if (opts.attempt) q.push(`attempt=${opts.attempt}`);
7434
+ if (opts.prompt) q.push('prompt=1');
7435
+ const url = `/orchestrate/${workflowId}/step/${stepKey}${q.length ? `?${q.join('&')}` : ''}`;
7352
7436
  const r = await client.get(url);
7353
7437
  const s = r.data;
7354
7438
  console.log(`── step output ──────────────────────────────────────────`);
@@ -7366,6 +7450,21 @@ showCmd
7366
7450
  console.log(`── error_context (retry feedback) ──────────────────────`);
7367
7451
  console.log(s.errorContext);
7368
7452
  }
7453
+ // HC-GF-055: say whether the prompt was recorded, ALWAYS. The rule this repo keeps
7454
+ // relearning is that a stored field nobody surfaces is the same as no field at all —
7455
+ // `input_artifact` sat in the schema with zero writers for months and nobody noticed
7456
+ // because nothing ever mentioned it.
7457
+ console.log(``);
7458
+ if (s.promptChars == null) {
7459
+ console.log(`── prompt ─────────────────────────────────────────────`);
7460
+ console.log(` (not recorded for this attempt — pre-HC-GF-055 runs have none)`);
7461
+ } else if (s.prompt != null) {
7462
+ console.log(`── prompt SENT to the model (${s.promptChars} chars) ────────`);
7463
+ console.log(s.prompt);
7464
+ } else {
7465
+ console.log(`── prompt ─────────────────────────────────────────────`);
7466
+ console.log(` ${s.promptChars} chars recorded — re-run with --prompt to print it`);
7467
+ }
7369
7468
  console.log(``);
7370
7469
  console.log(`── output (the LLM's actual response) ──────────────────`);
7371
7470
  console.log(s.output || '(no output recorded — step may not have completed)');
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+ /**
3
+ * auto-verify.js — HC-GF-064: decide whether the CLI should run `verify-patch` itself
4
+ * when a run pauses at the step_4 gate.
5
+ *
6
+ * WHY THIS EXISTS. The closed-loop gate has been shipped and integration-tested since
7
+ * HC-019n-followup-34/35, and had never fired: 22 of 22 runs `advisory`, 5 of 22 with any
8
+ * verdict recorded at all, and zero automatic invocations. The only prompt was a line
9
+ * `run-report` prints — "NO verification was reported for this run" — and 5-of-22 is what
10
+ * that nag is worth. A gate that never receives a verdict is indistinguishable from a gate
11
+ * that does not exist.
12
+ *
13
+ * The value is measured, not assumed. On run 77ed4f1b `verify-patch` found three ruff
14
+ * errors that BOTH the code-reviewer and the security agent had passed — neither could
15
+ * have caught them, because no reviewing step executes anything. And on run 6964b87e a
16
+ * green verdict auto-approved step_4 with no human approval, which is the first time
17
+ * enforce mode has been demonstrated end to end.
18
+ *
19
+ * CONSENT IS DERIVED, NOT REQUESTED. Running an adopter's test suite unasked is a real
20
+ * intrusion — it provisions dependencies, writes to disk, and costs wall clock. But
21
+ * `ci.local_command` and `ci.install_command` are ALREADY in their `.pipeline-config.yml`:
22
+ * an adopter who filled those in has declared, in their own words, how to build and test
23
+ * this repo locally. Running exactly those commands, in an isolated worktree, at the
24
+ * moment their pipeline pauses for their approval, is inside what they have sanctioned.
25
+ * An adopter who has not configured them gets today's behaviour, unchanged.
26
+ *
27
+ * So this defaults ON where consent already exists and OFF where it does not — neither a
28
+ * blanket opt-in (which recreates the 5-of-22 failure this work exists to fix) nor a
29
+ * blanket opt-out.
30
+ *
31
+ * THIS DOES NOT VIOLATE HC-026c's "ships advisory by default" discipline, though it looks
32
+ * like it might. That rule is about not letting a detector CHANGE FLOW before it has
33
+ * earned trust. This changes no flow: it produces a verdict. Whether the verdict gates
34
+ * anything remains `verification.gate`, which stays `advisory` by default and is untouched
35
+ * here. Running the tests is safe-by-default; ACTING on them stays opt-in.
36
+ *
37
+ * Design: docs/architecture/adr-verification-invocation.md
38
+ */
39
+
40
+ const { readCIConfigDetails, readVerificationGateConfig } = require('./pipeline-config');
41
+
42
+ /** The only gate a `verify-patch` verdict can inform (gate_sources default). */
43
+ const VERIFIABLE_STEP = 'step_4';
44
+
45
+ /**
46
+ * @returns {{ run: boolean, reason: string, command: string|null, gate: string }}
47
+ *
48
+ * `reason` is always populated, including when `run` is true, because the caller ANNOUNCES
49
+ * it. An adopter discovering their test suite mid-run should be told why it is running and
50
+ * how to stop it — the surprise is the real cost of this feature, and an unexplained
51
+ * surprise is the version that gets it reverted.
52
+ */
53
+ function decideAutoVerify({ stepKey, repoRoot, noVerifyFlag = false, env = process.env } = {}) {
54
+ const gate = safeGate(repoRoot);
55
+ const off = (reason) => ({ run: false, reason, command: null, gate });
56
+
57
+ if (noVerifyFlag) return off('--no-verify was passed');
58
+ if (String(env.HONE_NO_AUTO_VERIFY || '') === '1') return off('HONE_NO_AUTO_VERIFY=1');
59
+
60
+ // Only step_4. A verdict for any other step has no gate to inform, so running an
61
+ // adopter's suite there would spend their time for nothing.
62
+ if (stepKey !== VERIFIABLE_STEP) {
63
+ return off(`only ${VERIFIABLE_STEP} has a verification gate (paused at ${stepKey})`);
64
+ }
65
+
66
+ const auto = readVerificationAuto(repoRoot);
67
+ if (auto === false) return off('verification.auto is false in .pipeline-config.yml');
68
+
69
+ const ci = safeCi(repoRoot);
70
+ if (!ci.local_command_explicit) {
71
+ // The consent gate. NOT an error and NOT a nag — an adopter who has not told us how
72
+ // to test their repo has not consented to us guessing, and `make ci` is a guess.
73
+ return off('ci.local_command is not set in .pipeline-config.yml — no consent to run tests');
74
+ }
75
+
76
+ return { run: true, reason: `ci.local_command is configured (${ci.local_command})`, command: ci.local_command, gate };
77
+ }
78
+
79
+ /** `verification.auto` — true unless explicitly false. Unset means "use the consent rule". */
80
+ function readVerificationAuto(repoRoot) {
81
+ const block = readYamlBlock(repoRoot, 'verification');
82
+ if (!block || typeof block !== 'object') return null;
83
+ if (block.auto === false || String(block.auto).toLowerCase() === 'false') return false;
84
+ if (block.auto === true || String(block.auto).toLowerCase() === 'true') return true;
85
+ return null;
86
+ }
87
+
88
+ function readYamlBlock(repoRoot, key) {
89
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
90
+ const fs = require('fs'); const path = require('path');
91
+ for (const p of [path.join(repoRoot, '.pipeline-config.yml'), path.join(repoRoot, '.github/.pipeline-config.yml')]) {
92
+ if (!fs.existsSync(p)) continue;
93
+ try {
94
+ const parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8'));
95
+ if (parsed && typeof parsed === 'object' && parsed[key] !== undefined) return parsed[key];
96
+ } catch { /* a malformed config must not break the run — fall through to null */ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function safeCi(repoRoot) {
102
+ try { return readCIConfigDetails(repoRoot); }
103
+ catch { return { local_command: null, local_command_explicit: false, install_command: null }; }
104
+ }
105
+ function safeGate(repoRoot) {
106
+ try { return readVerificationGateConfig(repoRoot) || 'advisory'; }
107
+ catch { return 'advisory'; }
108
+ }
109
+
110
+ /** What the operator sees before their test suite starts. */
111
+ function announce(decision, runId) {
112
+ if (!decision.run) return null;
113
+ return [
114
+ ` → verifying step_4 before you approve it: ${decision.command}`,
115
+ ` (in an isolated worktree; gate mode: ${decision.gate}. Skip with --no-verify.)`,
116
+ ].join('\n');
117
+ }
118
+
119
+
120
+ /**
121
+ * After a verdict is posted, should the caller RE-POLL (the run may have moved) or fall
122
+ * through to the normal approve prompt?
123
+ *
124
+ * HC-GF-064-followup: the first version always re-polled on a green verdict, which is an
125
+ * UNBOUNDED LOOP under the default `advisory` mode — advisory only RECORDS the verdict, so
126
+ * the run stays paused, the caller re-polls, sees the same pause, and runs the adopter's
127
+ * test suite again, provisioning a worktree every time, once per poll interval, forever.
128
+ * It was invisible during development because the repo it was built against is set to
129
+ * `enforce`, where a green verdict really does advance the run. Every adopter on the
130
+ * DEFAULT config would have hit it.
131
+ *
132
+ * Two independent guards, because either alone is thin:
133
+ * - only `enforce` can advance a run, so only `enforce` justifies re-polling;
134
+ * - an attempt is verified AT MOST ONCE per process regardless of mode, so a
135
+ * misconfiguration or a future third mode cannot resurrect the loop.
136
+ */
137
+ function shouldRepoll({ gate, verdictOk, attemptKey, verifiedAttempts }) {
138
+ if (!verdictOk) return false;
139
+ if (gate !== 'enforce') return false;
140
+ if (verifiedAttempts && verifiedAttempts.has(attemptKey)) return false;
141
+ return true;
142
+ }
143
+
144
+ /** Has this exact attempt already been verified by this process? */
145
+ function alreadyVerified(attemptKey, verifiedAttempts) {
146
+ return Boolean(verifiedAttempts && verifiedAttempts.has(attemptKey));
147
+ }
148
+
149
+ module.exports = { decideAutoVerify, announce, readVerificationAuto, shouldRepoll, alreadyVerified, VERIFIABLE_STEP };
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+ /**
3
+ * bundle-context.js — HC-GF-073: decide what source the pipeline actually gets to read.
4
+ *
5
+ * WHY THIS EXISTS. The bundler drops every path that fails `existsSync`, which on a
6
+ * greenfield story is all of them: a story names what it will CREATE. Measured across the
7
+ * 33 real FlowForge stories, 29 extracted ZERO existing files. The dogfood runs looked
8
+ * healthy only because an operator had typed `--include-paths` once — 12 of 12 runs then
9
+ * bundled the same three files across five different stories, none of which named them.
10
+ *
11
+ * Three recorded failures trace back here, and none of them looked like a bundling bug:
12
+ * HC-GF-063 a HIGH security finding on `.gitignore` "being unverified" — it was never
13
+ * bundled; `.gitignore:2` already contained `.env`.
14
+ * HC-GF-069 step_5 aborted: "two ACs are blocked on files that have never been bundled".
15
+ * HC-GF-072 the builder invented `FLOWFORGE_MODE` while `.env.example`, unbundled,
16
+ * already read `MODE=paper`.
17
+ *
18
+ * THREE TIERS, in priority order, because the CLI's char budget truncates from the end:
19
+ * present — paths the STORY named that exist. The best context there is.
20
+ * signal — small repo-level files no story ever names but every reviewer needs:
21
+ * ignore files, manifests, env TEMPLATES. Their ABSENCE is the finding.
22
+ * siblings — existing neighbours of the paths the story will create, taken from the
23
+ * nearest ancestor directory that has files. This is the greenfield answer:
24
+ * you cannot show the builder a file that does not exist, but you can show
25
+ * it how this package writes the ones that do.
26
+ *
27
+ * SECRETS ARE REFUSED AT EVERY TIER, including when the story names one outright. `named`
28
+ * is untrusted input — story prose, or an `--include-paths` flag. `.env.example` is a
29
+ * template and safe; `.env` holds live broker credentials, and `git ls-files` is no guard
30
+ * because a misconfigured repo can track one. A refusal is REPORTED, never silent.
31
+ *
32
+ * The reporting half is not decoration. Today's "running blind" warning fires on an EMPTY
33
+ * bundle, so a bundle of three irrelevant files is indistinguishable from a good one and
34
+ * the operator is told nothing — a check that cannot fail, at the input stage.
35
+ */
36
+
37
+ const path = require('path');
38
+
39
+ /**
40
+ * Repo-level files worth bundling regardless of what the story says. Deliberately
41
+ * cross-stack: a list that only knows `pyproject.toml` hands every Node adopter the old
42
+ * behaviour. Kept to files that are small and that answer "does this repo already handle
43
+ * X" — the question a reviewer cannot otherwise settle.
44
+ */
45
+ const SIGNAL_FILES = Object.freeze([
46
+ '.gitignore', '.dockerignore', '.npmignore',
47
+ '.env.example', '.env.sample', '.env.template',
48
+ 'package.json', 'pyproject.toml', 'setup.cfg', 'go.mod', 'Cargo.toml',
49
+ 'Gemfile', 'pom.xml', 'build.gradle', 'composer.json',
50
+ ]);
51
+
52
+ /**
53
+ * A path that must never reach an agent prompt. Matched on the BASENAME so a nested
54
+ * `config/.env` is caught too. The `.example`/`.sample`/`.template` suffixes are the
55
+ * documented exception and are handled by testing them first.
56
+ */
57
+ const SECRET_BASENAMES = [/^\.env($|\.)/i, /^secrets?\./i, /\.pem$/i, /\.p12$/i, /(^|[._-])id_(rsa|dsa|ecdsa|ed25519)$/i];
58
+ const SECRET_EXEMPT = /\.(example|sample|template|dist)$/i;
59
+
60
+ /** Is this path a credential file? `.env.example` is not; `.env.production` is. */
61
+ function isSecretPath(p) {
62
+ const base = path.posix.basename(String(p || '').replace(/\\/g, '/'));
63
+ if (SECRET_EXEMPT.test(base)) return false;
64
+ return SECRET_BASENAMES.some((re) => re.test(base));
65
+ }
66
+
67
+ /** Directory of a repo-relative path, '' at the root. */
68
+ const dirOf = (p) => {
69
+ const d = path.posix.dirname(String(p).replace(/\\/g, '/'));
70
+ return d === '.' ? '' : d;
71
+ };
72
+
73
+ /**
74
+ * Existing files that are DIRECT children of `dir` (not of its subdirectories — a
75
+ * recursive walk would drag a whole tree in and blow the char budget on the first story
76
+ * that touches a package root).
77
+ */
78
+ function directChildren(dir, repoFiles) {
79
+ return repoFiles.filter((f) => dirOf(f) === dir);
80
+ }
81
+
82
+ /**
83
+ * Walk from a pending path's own directory toward the root, stopping at the FIRST
84
+ * directory that has files. `flowforge/config/settings.py` in a repo with no
85
+ * `flowforge/config/` yields `flowforge/`'s children; it does not climb to the root and
86
+ * return the entire repo.
87
+ */
88
+ function nearestPopulatedAncestor(pendingPath, repoFiles) {
89
+ let dir = dirOf(pendingPath);
90
+ for (;;) {
91
+ const kids = directChildren(dir, repoFiles);
92
+ if (kids.length > 0) return { dir, kids };
93
+ if (dir === '') return { dir: '', kids: [] };
94
+ dir = dirOf(dir);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * @param {object} o
100
+ * @param {string[]} o.named paths the story named (or --include-paths supplied)
101
+ * @param {string[]} o.repoFiles tracked files, `git ls-files`
102
+ * @param {(p:string)=>boolean} o.exists the CLI passes an fs check, so an untracked-but-
103
+ * present file is not miscounted as pending
104
+ * @param {number} [o.maxSiblings=6]
105
+ * @returns {{present:string[], pending:string[], signal:string[], siblings:string[], refused:string[]}}
106
+ */
107
+ function selectBundle({ named = [], repoFiles = [], exists = () => false, maxSiblings = 6 } = {}) {
108
+ const present = [], pending = [], refused = [];
109
+ for (const p of named) {
110
+ if (isSecretPath(p)) { refused.push(p); continue; }
111
+ (exists(p) ? present : pending).push(p);
112
+ }
113
+
114
+ const taken = new Set(present);
115
+ // No isSecretPath() filter here: SIGNAL_FILES is a frozen literal, so the guarantee
116
+ // belongs to the LIST and is asserted against it directly. A filter here could never
117
+ // fire, and a guard that cannot fire is indistinguishable from one that is missing.
118
+ const signal = SIGNAL_FILES.filter((f) => !taken.has(f) && exists(f));
119
+ signal.forEach((f) => taken.add(f));
120
+
121
+ // Prefer a sibling sharing the pending file's extension — the convention a new .py must
122
+ // match lives in another .py, not in the package's README.
123
+ const siblings = [];
124
+ for (const p of pending) {
125
+ if (siblings.length >= maxSiblings) break;
126
+ const ext = path.posix.extname(p);
127
+ const { kids } = nearestPopulatedAncestor(p, repoFiles);
128
+ const ranked = [...kids].sort((a, b) =>
129
+ (path.posix.extname(b) === ext) - (path.posix.extname(a) === ext));
130
+ for (const k of ranked) {
131
+ if (siblings.length >= maxSiblings) break;
132
+ if (taken.has(k) || isSecretPath(k)) continue;
133
+ taken.add(k); siblings.push(k);
134
+ }
135
+ }
136
+
137
+ return { present, pending, signal, siblings, refused };
138
+ }
139
+
140
+ /**
141
+ * What the operator reads before the run starts.
142
+ *
143
+ * The load-bearing line is the ratio: `0 of 2 paths this story names`. It is what makes a
144
+ * bundle that matched nothing distinguishable from one that matched everything — the
145
+ * distinction the current empty-bundle warning cannot draw. The reason is always given
146
+ * alongside it, because a create-only story is NORMAL and a warning that reads as a defect
147
+ * on every greenfield story gets tuned out within a day, taking the real one with it.
148
+ */
149
+ function auditLines(sel) {
150
+ const { present = [], pending = [], signal = [], siblings = [], refused = [] } = sel || {};
151
+ const named = present.length + pending.length;
152
+ const out = [];
153
+
154
+ if (named > 0) {
155
+ out.push(` → bundle: ${present.length} of ${named} path${named === 1 ? '' : 's'} this story names`);
156
+ if (pending.length > 0) {
157
+ out.push(` ${pending.length} do not yet exist (to be created): ${pending.join(', ')}`);
158
+ }
159
+ } else {
160
+ // The regression this fix nearly introduced, caught reviewing it before merge. A
161
+ // story yielding zero paths used to produce an EMPTY bundle, which tripped the
162
+ // caller's "running blind" warning. The signal tier means the bundle is never empty
163
+ // again — a .gitignore and a manifest are always there — so that warning would be
164
+ // silently suppressed on the one condition it exists for. Bundling two config files
165
+ // is not source context, and saying nothing about it would be this change committing
166
+ // the defect it was written to fix.
167
+ out.push(' ⚠ the story named no source paths — step_1 / step_4 will run WITHOUT source context');
168
+ }
169
+ if (siblings.length > 0) {
170
+ out.push(` + ${siblings.length} existing neighbour(s) for convention: ${siblings.join(', ')}`);
171
+ }
172
+ if (signal.length > 0) out.push(` + ${signal.length} repo-level file(s): ${signal.join(', ')}`);
173
+ if (refused.length > 0) {
174
+ out.push(` ⚠ refused ${refused.length} credential file(s), never bundled: ${refused.join(', ')}`);
175
+ }
176
+ if (present.length === 0 && siblings.length === 0 && signal.length === 0) {
177
+ out.push(' ⚠ nothing to bundle — the code steps will run WITHOUT source context');
178
+ }
179
+ return out;
180
+ }
181
+
182
+ module.exports = { selectBundle, auditLines, isSecretPath, SIGNAL_FILES };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {