@hone-ai/cli 1.38.1 → 1.39.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.
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ /**
3
+ * classify-spec.js — how `hone tests organize` sorts an existing spec file: its type (browser, API,
4
+ * hybrid), its module, where it moves to, and whether it is a smoke candidate.
5
+ *
6
+ * HC-CI-019-followup-3: moved verbatim out of cli/hone-cli.js, which cannot be required (it parses
7
+ * argv on load), so a test can call it. Before the move no suite asserted any of its fields.
8
+ */
9
+ const fs = require('fs');
10
+
11
+ function classifySpec(file, repoRoot) {
12
+ let content = '';
13
+ try { content = fs.readFileSync(file.fullPath, 'utf8'); } catch { }
14
+
15
+ const hasPage = /page\.(goto|click|locator|fill|waitFor|getBy)/.test(content);
16
+ const hasRequest = /request\.(get|post|put|delete|patch)\(/.test(content);
17
+ const hasExpectResponse = /response\.(status|json|ok|body)/.test(content) ||
18
+ /expect\(resp/.test(content);
19
+
20
+ // Detect type
21
+ let type = 'UNKNOWN';
22
+ if (hasPage && (hasRequest || hasExpectResponse)) type = 'HYBRID';
23
+ else if (hasPage) type = 'BROWSER';
24
+ else if (hasRequest || hasExpectResponse) type = 'API';
25
+ else if (/import.*playwright|from.*@playwright/.test(content)) type = 'BROWSER';
26
+ else if (/import.*supertest|from.*supertest/.test(content)) type = 'API';
27
+
28
+ // Detect module from filename or content
29
+ // Detect module — filename takes priority over content, most specific first
30
+ let module = 'general';
31
+ const name = file.name.toLowerCase();
32
+ const contentLower = content.toLowerCase();
33
+
34
+ // Pass 1: check filename (strongest signal)
35
+ if (/auth|login|signup|oauth|session/.test(name)) module = 'auth';
36
+ else if (/trade|order|execution/.test(name)) module = 'trading';
37
+ else if (/position|portfolio|pnl|p&l/.test(name)) module = 'portfolio';
38
+ else if (/alert|notification/.test(name)) module = 'alerts';
39
+ else if (/dashboard|widget|overview/.test(name)) module = 'dashboard';
40
+ else if (/scanner|scan|market/.test(name)) module = 'market';
41
+ else if (/setting|config|preference/.test(name)) module = 'settings';
42
+
43
+ // Pass 2: fallback to content (if filename didn't match)
44
+ if (module === 'general') {
45
+ if (/login|authenticate|sign.?in/.test(contentLower)) module = 'auth';
46
+ else if (/trading|order|execute/.test(contentLower)) module = 'trading';
47
+ else if (/portfolio/.test(contentLower)) module = 'portfolio';
48
+ else if (/alert|notification/.test(contentLower)) module = 'alerts';
49
+ else if (/dashboard/.test(contentLower)) module = 'dashboard';
50
+ else if (/market|scanner/.test(contentLower)) module = 'market';
51
+ }
52
+
53
+ // Determine destination
54
+ let destination;
55
+ if (type === 'API') {
56
+ destination = `e2e/api/${file.name}`;
57
+ } else {
58
+ destination = `e2e/regression/${module}/${file.name}`;
59
+ }
60
+
61
+ // Smoke candidate: login, auth, critical navigation, app loads
62
+ const smoke = /login|auth|app.load|critical|smoke|health/.test(name) ||
63
+ /login|authenticate/.test(contentLower);
64
+
65
+ return { ...file, type, module, destination, smoke };
66
+ }
67
+
68
+ module.exports = { classifySpec };
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+ /**
3
+ * context-reserve.js — HC-GF-351: serve the files a hard-paused run asked for, once, without a person.
4
+ *
5
+ * A step that cannot proceed for want of a file says so (HC-GF-263 records the paths it named), and a batch had
6
+ * nothing to serve them with. `follow-batch` now kills that run and queues its story again with those paths
7
+ * added to the story's line. This module is the part that decides WHETHER a request may be served and WHAT the
8
+ * line says. Pure: data in, data out. The loop's I/O is injected (cli/lib/batch-follow.js).
9
+ *
10
+ * THE TRUST BOUNDARY. The paths came out of an LLM artifact and the bundler reads each one and sends its contents
11
+ * to a model. An agent can be steered by the bundled source and the story text, so a request is a claim to check,
12
+ * not an instruction. A request is served only when EVERY path in it:
13
+ * - passes hard-pause-remedy's token allowlist (the characters a shell could misread are refused outright),
14
+ * - is a plain relative path: no `..`, nothing absolute, home-relative or option-like, no empty segment,
15
+ * - is not a credential file or a hidden file or directory (`.env`, `*.pem`, `id_rsa*`, `.ssh/`, `.git/`),
16
+ * - is a REGULAR FILE that git tracks in this checkout. A tracked symlink is refused: the bundler follows it,
17
+ * so `notes.txt -> ~/.ssh/id_rsa` would be a tracked path that leaves the checkout.
18
+ * - resolves, on the disk as it is NOW, to a path inside this checkout that passes the same rules. The index
19
+ * says what git recorded; a file or a directory replaced by a symlink since is invisible to it, and the
20
+ * bundler reads through the link (`realPath`, repoRealPath).
21
+ * The paths the story's own line carried (config.include_paths) are held to the same rules: any client of the org
22
+ * can write a run's config, so they are no more trusted than an agent's.
23
+ * One path that fails refuses the whole request, and so does a request the server cut off (`truncated`). A partly served request is a run that pauses again for what was
24
+ * left out, and the loop does not serve twice.
25
+ *
26
+ * Why not just "exists in the working tree": that includes untracked and ignored files, which is exactly where an
27
+ * `.env`, a local key or a scratch file with a pasted token lives. Tracked means a reviewer already sees it.
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const { remedyFor, partitionSafePaths, tokenOf, isSafeToken } = require('./hard-pause-remedy');
33
+ const { parseIncludeTarget } = require('./include-paths');
34
+ const { isSecretPath } = require('./bundle-context');
35
+ const { isBatchId } = require('./run-target');
36
+
37
+ /** The start of the reason a re-served run is killed with. The loop reads it back to find the new batch. */
38
+ const RESERVED_PREFIX = 're-served as batch ';
39
+
40
+ /** What the kill reason says; `reservedBatchOf` reads it back. */
41
+ function reserveReason(batchId, stepKey) {
42
+ return `${RESERVED_PREFIX}${batchId}: ${stepKey} named files it was not given`;
43
+ }
44
+
45
+ /**
46
+ * The batch a killed run was re-served as, or null. The kill is how the loop remembers, so a restart follows the
47
+ * new batch and does not serve the same story a second time.
48
+ * @param {object} run a row of `GET /orchestrate/batch/:id` runs (untrusted shape)
49
+ */
50
+ function reservedBatchOf(run) {
51
+ if (!run || run.status !== 'killed' || typeof run.errorMessage !== 'string') return null;
52
+ const m = run.errorMessage.match(/^re-served as batch (\S+?)(?::|\s|$)/);
53
+ return m && isBatchId(m[1]) ? m[1] : null;
54
+ }
55
+
56
+ /**
57
+ * The request a run is hard-paused on, or null. Scoped by hard-pause-remedy's reader to the run's CURRENT step,
58
+ * and the step the pause message names must be that step, so a request recorded on an earlier step that the run
59
+ * has since moved past is never served as if the current pause had asked for it.
60
+ * @param {object} run a `GET /orchestrate/:runId` payload
61
+ * @returns {{stepKey: string, paths: object[], truncated: boolean}|null}
62
+ */
63
+ function hardPauseRequest(run) {
64
+ if (!run || run.status !== 'paused' || typeof run.errorMessage !== 'string') return null;
65
+ const at = run.errorMessage.match(/^hard_pause at (\S+?)(?::|\s|$)/);
66
+ const remedy = at ? remedyFor(run) : null;
67
+ return remedy && remedy.stepKey === at[1] ? remedy : null;
68
+ }
69
+
70
+ // Credential-looking names the bundler's own refusal (isSecretPath) does not cover: a public half of a key pair,
71
+ // key containers, anything the author named a secret or a credential.
72
+ const SECRET_LOOKING = [/(^|[._-])id_(rsa|dsa|ecdsa|ed25519)/i, /\.(key|pfx|jks|keystore|p8)$/i, /(secret|credential)/i];
73
+
74
+ /** Why a path is not a plain relative path, or null. `file` has no `:line` or `:symbol` suffix. */
75
+ function structuralRefusal(file) {
76
+ if (typeof file !== 'string' || file === '') return 'empty';
77
+ if (/^[-/~]/.test(file)) return 'absolute, home-relative or option-like';
78
+ if (/[\\\0]/.test(file)) return 'not a plain relative path';
79
+ if (file.split('/').some((s) => s === '' || s === '.' || s === '..')) return 'not a plain relative path';
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Why the CONTENT of a path (a plain file path, no `:line`) must not be sent to a model, or null: the three
85
+ * rules that need no file list. HC-GF-217 reads this directly for the lines of a search result, so a search
86
+ * and a re-serve cannot disagree about what is withheld (they used to be one inline sequence in requestRefusal).
87
+ * @param {string} file
88
+ */
89
+ function contentRefusal(file) {
90
+ const plain = structuralRefusal(file);
91
+ if (plain) return plain;
92
+ if (isSecretPath(file) || SECRET_LOOKING.some((re) => re.test(path.posix.basename(file)))) return 'looks like a credential file';
93
+ if (file.split('/').some((s) => s.startsWith('.'))) return 'a hidden file or directory';
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Why a requested path (`file`, `file:line` or `file:symbol`) must not be served, or null.
99
+ * @param {string} token
100
+ * @param {Set<string>|null} tracked the regular files git tracks here; null when they could not be listed
101
+ */
102
+ function requestRefusal(token, tracked) {
103
+ const file = parseIncludeTarget(token).path;
104
+ const why = contentRefusal(file);
105
+ if (why) return why;
106
+ if (!(tracked instanceof Set)) return 'this checkout\'s tracked files could not be listed';
107
+ if (!tracked.has(file)) return 'not a regular file git tracks in this checkout';
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * May this request be served? All of it, or none of it.
113
+ * @param {{paths: object[], truncated?: boolean}} request from hardPauseRequest
114
+ * @param {{tracked?: Set<string>|null, realPath?: ((file: string) => string|null)|null, original?: string[]}} [ctx]
115
+ * `realPath`: where `file` resolves on the disk now, relative to the checkout, or null if outside it or unreadable;
116
+ * without it nothing is served (a request is not checked on a disk nobody looked at). `original`: the entries of
117
+ * the story's own line, judged by the same rules and written before what the step asked for.
118
+ * @returns {{ok: true, tokens: string[]}|{ok: false, reason: string}}
119
+ */
120
+ function judgeRequest(request, { tracked = null, realPath = null, original = [] } = {}) {
121
+ if (request && request.truncated === true) {
122
+ return { ok: false, reason: 'the step asked for more paths than were recorded, and a part of a request pauses again; nothing was served' };
123
+ }
124
+ const { safe, dropped } = partitionSafePaths(request && request.paths);
125
+ const candidates = [...original.map((token) => ({ token, own: true })), ...safe.map((p) => ({ token: tokenOf(p) }))];
126
+ const tokens = [];
127
+ const refused = [];
128
+ for (const { token, own } of candidates) {
129
+ const why = requestRefusal(token, tracked) || resolutionRefusal(token, tracked, realPath);
130
+ if (why) refused.push(`${token} (${why}${own ? ', on the story\'s own line' : ''})`);
131
+ else if (!tokens.includes(token)) tokens.push(token);
132
+ }
133
+ if (refused.length === 0 && dropped.length === 0) return { ok: true, tokens };
134
+ // A path the allowlist dropped is never printed: it holds characters a terminal or a shell could misread.
135
+ const parts = [];
136
+ if (refused.length) parts.push(`refused ${refused.slice(0, 5).join(', ')}${refused.length > 5 ? `, and ${refused.length - 5} more` : ''}`);
137
+ if (dropped.length) parts.push(`${dropped.length} requested path(s) hold characters a shell could misread`);
138
+ return { ok: false, reason: `${parts.join('; ')}; nothing was served` };
139
+ }
140
+
141
+ /**
142
+ * Where a path that passed requestRefusal really points. Null when it is fine, else why not. The path the bundler
143
+ * will read is the one the disk resolves, so THAT path has to pass the rules: a tracked `notes.txt` that is a link to
144
+ * the checkout's own `.env` is a credential read, and one that is a link out of the checkout is a read of anything.
145
+ */
146
+ function resolutionRefusal(token, tracked, realPath) {
147
+ if (typeof realPath !== 'function') return 'where it resolves on the disk was not checked';
148
+ const file = parseIncludeTarget(token).path;
149
+ const real = realPath(file);
150
+ if (real === null || real === undefined) return 'it does not resolve to a readable path inside this checkout';
151
+ if (real === file) return null;
152
+ const why = requestRefusal(real, tracked);
153
+ return why ? `it resolves to ${real}, which is refused: ${why}` : null;
154
+ }
155
+
156
+ /**
157
+ * `rel` resolved through every symlink on the disk, relative to the checkout (POSIX separators), or null when it
158
+ * does not exist or ends outside the checkout. The one place this module touches the file system.
159
+ */
160
+ function repoRealPath(cwd, rel) {
161
+ try {
162
+ const root = fs.realpathSync(cwd);
163
+ const inside = path.relative(root, fs.realpathSync(path.resolve(root, rel)));
164
+ if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return null;
165
+ return inside.split(path.sep).join('/');
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * The paths the story's own line carried, recorded on its run config as `include_paths` (cli/lib/run-context.js).
173
+ * An entry that is not a safe token is counted, never passed on; the rest are judged by judgeRequest with the
174
+ * request, because the config can be written by any client of the org.
175
+ * @param {object} run a `GET /orchestrate/:runId` payload
176
+ * @returns {{paths: string[], dropped: number}}
177
+ */
178
+ function originalPathsOf(run) {
179
+ const list = run && run.config && Array.isArray(run.config.include_paths) ? run.config.include_paths : [];
180
+ const paths = [];
181
+ let dropped = 0;
182
+ for (const entry of list) {
183
+ if (!isSafeToken(entry)) dropped += 1;
184
+ else if (!paths.includes(entry)) paths.push(entry);
185
+ }
186
+ return { paths, dropped };
187
+ }
188
+
189
+ /**
190
+ * The batch id `hone queue-stories --format json` printed. The command prints its per-story lines first and the
191
+ * response document last, and the document starts at a line that is exactly `{`.
192
+ * @returns {string|null} a batch id, or null when no document with one is there
193
+ */
194
+ function batchIdFromQueueOutput(stdout) {
195
+ const text = String(stdout || '');
196
+ const starts = [...text.matchAll(/^\{$/gm)].map((m) => m.index).reverse();
197
+ for (const at of starts) {
198
+ try {
199
+ const doc = JSON.parse(text.slice(at));
200
+ if (doc && isBatchId(doc.batchId)) return doc.batchId;
201
+ } catch { /* not the document; try the next opening brace back */ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Has this story already been served? From `GET /orchestrate?storyId=&repoName=` rows (id, story_id, repo_name,
208
+ * status, created_at, error_message), newest first.
209
+ * - `later`: a run of the story in this repository, created AFTER the paused one and not killed. A re-serve that
210
+ * queued but whose kill failed, or that died between the two, leaves exactly this and no marker.
211
+ * - `earlier`: a run of the story killed with `re-served as batch <id>` BEFORE this one, so this run is that
212
+ * re-serve (the loop was started on the new batch alone) and a second pause here is the second.
213
+ * - `unknown`: the paused run's creation time is not a date, so neither can be told.
214
+ * @returns {{kind: 'later'|'earlier'|'unknown', runId?: string, batchId?: string}|null}
215
+ */
216
+ function serveHistory(rows, { runId, storyId, repoName, since }) {
217
+ const t0 = Date.parse(since);
218
+ if (!Number.isFinite(t0)) return { kind: 'unknown' };
219
+ for (const row of Array.isArray(rows) ? rows : []) {
220
+ if (!row || row.id === runId || row.story_id !== storyId || row.repo_name !== repoName) continue;
221
+ const t = Date.parse(row.created_at);
222
+ if (!Number.isFinite(t)) continue;
223
+ if (t > t0 && row.status !== 'killed') return { kind: 'later', runId: row.id };
224
+ const batchId = t < t0 ? reservedBatchOf({ status: row.status, errorMessage: row.error_message }) : null;
225
+ if (batchId) return { kind: 'earlier', runId: row.id, batchId };
226
+ }
227
+ return null;
228
+ }
229
+
230
+ /**
231
+ * serveHistory over the server, and, for a `later` run, the batch it is in (found among the batches created since the
232
+ * paused run, newest first, at most 10; a lookup that fails only costs the batch id).
233
+ * @param {{listRuns: (storyId: string, repoName: string) => Promise<object[]>, listBatches: () => Promise<object[]>,
234
+ * fetchBatch: (id: string) => Promise<object>}} io
235
+ * @param {{runId: string, storyId: string, repoName: string|null, since: string|null}} action
236
+ */
237
+ async function findServe(io, action) {
238
+ const hit = serveHistory(await io.listRuns(action.storyId, action.repoName), action);
239
+ if (!hit || hit.kind !== 'later') return hit;
240
+ try {
241
+ const since = Date.parse(action.since) - 60_000;
242
+ const recent = (await io.listBatches()).filter((b) => b && isBatchId(b.batchId) && !(Date.parse(b.createdAt) < since)).slice(0, 10);
243
+ for (const b of recent) {
244
+ const batch = await io.fetchBatch(b.batchId);
245
+ if ((Array.isArray(batch && batch.runs) ? batch.runs : []).some((r) => r && r.runId === hit.runId)) return { ...hit, batchId: b.batchId };
246
+ }
247
+ } catch { /* the batch is only worth following if it can be found */ }
248
+ return hit;
249
+ }
250
+
251
+ /**
252
+ * The regular files in `git ls-files -z -s` output. A symlink (120000) and a submodule (160000) are tracked but
253
+ * are not files this checkout holds, and a symlink can point anywhere; a conflicted path (stage above 0) has no
254
+ * single content.
255
+ * @param {string} output
256
+ * @returns {Set<string>}
257
+ */
258
+ function trackedRegularFiles(output) {
259
+ const files = new Set();
260
+ for (const entry of String(output || '').split('\0')) {
261
+ const m = entry.match(/^(\d{6}) [0-9a-f]+ (\d)\t(.+)$/s);
262
+ if (m && m[2] === '0' && (m[1] === '100644' || m[1] === '100755')) files.add(m[3]);
263
+ }
264
+ return files;
265
+ }
266
+
267
+ module.exports = {
268
+ RESERVED_PREFIX, reserveReason, reservedBatchOf, hardPauseRequest, requestRefusal, contentRefusal, judgeRequest, repoRealPath,
269
+ originalPathsOf, serveHistory, findServe, batchIdFromQueueOutput, trackedRegularFiles,
270
+ };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ /**
3
+ * detect-test-framework.js — what `hone setup` finds already installed in an adopter repo: the stack,
4
+ * test framework, package manager, Playwright, configs and test script, read from package.json, the
5
+ * lockfiles and the config files on disk.
6
+ *
7
+ * HC-CI-019-followup-3: moved verbatim out of cli/hone-cli.js, which cannot be required (it parses
8
+ * argv on load), so a test can call it. Before the move no suite asserted any of its fields:
9
+ * deleting each one left every suite the continuity gate selects green. The I/O stays here rather
10
+ * than behind injected callbacks, so the move changes no behaviour.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ function detectExistingTestFramework(repoRoot) {
16
+ const pkgPath = path.join(repoRoot, 'package.json');
17
+ if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
18
+
19
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
20
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
21
+
22
+ // Detect stack
23
+ let stack = 'node';
24
+ if (allDeps['react']) stack = allDeps['vite'] ? 'react-vite' : 'react';
25
+ if (allDeps['next']) stack = 'nextjs';
26
+ if (allDeps['@nestjs/core']) stack = 'nestjs';
27
+
28
+ // Detect existing test framework
29
+ let testFw = null;
30
+ if (allDeps['vitest']) testFw = 'vitest';
31
+ else if (allDeps['jest']) testFw = 'jest';
32
+ else if (allDeps['mocha']) testFw = 'mocha';
33
+
34
+ // Detect Playwright
35
+ const hasPlaywright = !!allDeps['@playwright/test'];
36
+
37
+ // Detect package manager
38
+ let pkgMgr = 'npm';
39
+ if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) pkgMgr = 'yarn';
40
+ if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) pkgMgr = 'pnpm';
41
+
42
+ // Detect existing configs
43
+ const hasTestConfig = ['vitest.config.js', 'vitest.config.ts', 'jest.config.js', 'jest.config.ts', 'jest.config.cjs']
44
+ .some(f => fs.existsSync(path.join(repoRoot, f)));
45
+ const hasPlaywrightConfig = fs.existsSync(path.join(repoRoot, 'playwright.config.js'))
46
+ || fs.existsSync(path.join(repoRoot, 'playwright.config.ts'));
47
+
48
+ // Detect existing test script
49
+ const hasTestScript = !!pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1';
50
+
51
+ return { stack, testFw, hasPlaywright, pkgMgr, hasTestConfig, hasPlaywrightConfig, hasTestScript, pkg };
52
+ }
53
+
54
+ module.exports = { detectExistingTestFramework };
@@ -98,4 +98,4 @@ function renderIncludePathsCommand(storyId, paths) {
98
98
  return `hone run-story ${id} ${safe.map((p) => `--include-paths ${tokenOf(p)}`).join(' ')}`;
99
99
  }
100
100
 
101
- module.exports = { remedyFor, renderIncludePathsCommand, partitionSafePaths };
101
+ module.exports = { remedyFor, renderIncludePathsCommand, partitionSafePaths, tokenOf, isSafeToken };
@@ -67,7 +67,8 @@ function updateTopLevelStatus(yamlText, newStatus) {
67
67
  return { text: yamlText, updated: false };
68
68
  }
69
69
  return {
70
- text: yamlText.replace(re, `status: ${newStatus}`),
70
+ // A function, so a `$&` or `$'` in the status is written as itself, not expanded as a replacement pattern.
71
+ text: yamlText.replace(re, () => `status: ${newStatus}`),
71
72
  updated: true,
72
73
  };
73
74
  }
@@ -438,6 +438,10 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
438
438
  // JSON-lines repo).
439
439
  const bundle = { files: 0, chars: 0 };
440
440
  const includePaths = opts.includePaths || [];
441
+ // HC-GF-351: what the operator named, recorded on the run so follow-batch can carry it into a re-serve. Never
442
+ // inferred: the story's own paths and the defaults are re-derived whenever a story is resolved again.
443
+ const operatorPaths = parseIncludePaths(includePaths);
444
+ if (operatorPaths.length) config.include_paths = operatorPaths;
441
445
  const defaultIncludePaths = repoCtx.defaultIncludePaths || [];
442
446
  if (rawText || parseIncludePaths(includePaths).length > 0 || defaultIncludePaths.length > 0) {
443
447
  try {
@@ -576,6 +580,23 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
576
580
  pre.push(warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js'));
577
581
  }
578
582
 
583
+ // ── Call sites (HC-GF-217) ────────────────────────────────────────────────────────────────
584
+ // The planner is told to enumerate every call site of a shared function it changes, and a server-executed
585
+ // agent has no checkout to search (cwd is a temp dir, deliberately). This CLI has the checkout: it searches for
586
+ // the compound identifiers the story puts in backticks and sends the result for the server to render. It runs
587
+ // whatever the classifier axes turn out to be, because the shared-chokepoint axis is harvested from step_0's
588
+ // output, after this bundle has been sent. Fail-soft: a search that cannot run costs the planner its
589
+ // enumeration (it then says so and pauses), never the queueing of the story.
590
+ if (rawText && typeof io.callSites === 'function') {
591
+ try {
592
+ const found = await io.callSites(rawText);
593
+ if (found && found.callSites) config.call_sites = found.callSites;
594
+ for (const n of (found && found.notes) || []) pre.push(n);
595
+ } catch (e) {
596
+ pre.push(warn(` ⚠ call-site search failed (non-fatal): ${firstLine(e)} — the planner will have no call-site enumeration for this story`));
597
+ }
598
+ }
599
+
579
600
  // ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
580
601
  // Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
581
602
  // come from the groomer at step_0 and are harvested server-side. Fail-open.
package/lib/story-line.js CHANGED
@@ -2,9 +2,15 @@
2
2
  /**
3
3
  * story-line.js — one line of a `hone queue-stories --file` list.
4
4
  *
5
- * `STORY-ID` or `STORY-ID depends:DEP1,DEP2`. The `depends:` token is case-sensitive and must come
6
- * after the storyId; multiple deps are comma-separated and whitespace tolerant. A line with no
7
- * `depends:` yields no `dependsOn` at all — the server validates absence differently from empty.
5
+ * `STORY-ID`, optionally followed by `depends:DEP1,DEP2` and/or `paths:a.js,b.js`, in either order. Both
6
+ * tokens are case-sensitive, must come after the storyId, and may each appear once; their lists are
7
+ * comma-separated and whitespace tolerant. A line with no `depends:` yields no `dependsOn` at all — the server
8
+ * validates absence differently from empty — and a line with no `paths:` yields no `paths`.
9
+ *
10
+ * `paths:` (HC-GF-351) is `run-story --include-paths` for one story of a batch: the files the bundler must put
11
+ * in that story's context. A batch cannot carry the flag, and `run-story` runs carry no batch id, so
12
+ * `follow-batch` cannot follow them. The entries are checked where `--include-paths` entries are, by the
13
+ * bundler (cli/lib/run-context.js), not here: this module only splits the line.
8
14
  *
9
15
  * Extracted by HC-GF-140 stage 2. The regex was pinned by its SOURCE TEXT
10
16
  * (`assert.match(cliSrc, /\^\(\\S\+\)\\s\+depends:\(\\S\+\)\\s\*\$/)`), which is the worst shape a
@@ -19,22 +25,52 @@ function isStoryLine(line) {
19
25
  return Boolean(t) && !t.startsWith('#');
20
26
  }
21
27
 
28
+ const csv = (s) => s.split(',').map((x) => x.trim()).filter(Boolean);
29
+ const unique = (list) => list.filter((x, i) => list.indexOf(x) === i);
30
+
22
31
  /**
23
32
  * @param {string} line
24
- * @returns {{storyId: string, dependsOn?: string[]}|{error: string}}
33
+ * @returns {{storyId: string, dependsOn?: string[], paths?: string[]}|{error: string}}
25
34
  * `error` for a line the caller must reject — a storyId followed by anything unrecognised.
26
- * Silently treating `STORY-A something` as `STORY-A` loses the operator's intent.
35
+ * Silently treating `STORY-A something` as `STORY-A` loses the operator's intent. So does a
36
+ * `paths:` that names no path: the operator meant to bundle something.
27
37
  */
28
38
  function parseStoryLine(line) {
29
39
  const t = String(line).trim();
30
- const m = t.match(/^(\S+)\s+depends:(\S+)\s*$/);
31
- if (m) {
32
- return { storyId: m[1], dependsOn: m[2].split(',').map(s => s.trim()).filter(Boolean) };
40
+ const bad = { error: `Malformed line in --file: "${t}"` };
41
+ const [storyId, ...tokens] = t.split(/\s+/);
42
+ const out = { storyId };
43
+ for (const tok of tokens) {
44
+ if (tok.startsWith('depends:') && tok.length > 'depends:'.length && !('dependsOn' in out)) {
45
+ out.dependsOn = csv(tok.slice('depends:'.length));
46
+ } else if (tok.startsWith('paths:') && !('paths' in out)) {
47
+ const paths = unique(csv(tok.slice('paths:'.length)));
48
+ if (paths.length === 0) return bad;
49
+ out.paths = paths;
50
+ } else {
51
+ return bad;
52
+ }
33
53
  }
34
- if (/\s/.test(t)) {
35
- return { error: `Malformed line in --file: "${t}"` };
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * The line `parseStoryLine` reads back as the same story — for a caller that writes a stories file
59
+ * (follow-batch's re-serve). Throws on a value the line cannot carry, rather than writing a line that parses as
60
+ * something else: whitespace splits a token, and a comma splits a list.
61
+ * @param {{storyId: string, dependsOn?: string[], paths?: string[]}} story
62
+ */
63
+ function formatStoryLine({ storyId, dependsOn, paths } = {}) {
64
+ const carries = (v) => typeof v === 'string' && v !== '' && !/[\s,]/.test(v);
65
+ if (!carries(storyId)) throw new TypeError(`formatStoryLine: ${JSON.stringify(storyId)} is not a story id a line can carry`);
66
+ const parts = [storyId];
67
+ for (const [token, list] of [['depends', dependsOn], ['paths', paths]]) {
68
+ if (!Array.isArray(list) || list.length === 0) continue;
69
+ const bad = list.find((v) => !carries(v));
70
+ if (bad !== undefined) throw new TypeError(`formatStoryLine: ${JSON.stringify(bad)} cannot be written in ${token}:`);
71
+ parts.push(`${token}:${list.join(',')}`);
36
72
  }
37
- return { storyId: t };
73
+ return parts.join(' ');
38
74
  }
39
75
 
40
- module.exports = { parseStoryLine, isStoryLine };
76
+ module.exports = { parseStoryLine, isStoryLine, formatStoryLine };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.38.1",
3
+ "version": "1.39.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {