@hone-ai/cli 1.28.0 → 1.29.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,78 @@
1
+ 'use strict';
2
+ /**
3
+ * doctor-source-dirs.js — HC-DF-002.
4
+ *
5
+ * The misconfiguration this catches is SILENT: `hone setup` hardcoded `source_dirs: src/` for
6
+ * every Node repo with no existence check, and since HC-019n-followup-42 that field scopes the
7
+ * repository tree the whole pipeline is grounded on. An adopter whose code is not at src/ got a
8
+ * tree of 2 of 1855 paths under a prompt asserting it was the real layout, and `hone derive`
9
+ * bundled zero files. Nothing surfaced it until a plan came back wrong, several billed steps in.
10
+ *
11
+ * States (four, deliberately — a dead entry among live ones is not the same as a total miss):
12
+ * ok — every configured dir exists (or none configured: the tree is the whole repo)
13
+ * drift — NOTHING configured exists: the scope selects nothing real. This is the DF-002 bug.
14
+ * warn — some configured dirs are missing, others are live
15
+ * info — configured dirs are live but detection finds a substantial source dir outside them
16
+ * skip — no pipeline config, or not a git repo
17
+ * Never throws.
18
+ */
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const { execSync } = require('node:child_process');
22
+
23
+ function checkSourceDirs({ repoRoot, trackedPaths = null } = {}) {
24
+ const name = 'source-dirs';
25
+ try {
26
+ if (!repoRoot || !fs.existsSync(repoRoot)) return { name, status: 'skip', reason: 'no repo root' };
27
+ const { readSourceDirs } = require('./pipeline-config');
28
+ const raw = readSourceDirs(repoRoot, { repair: false });
29
+ const cfgExists = ['.pipeline-config.yml', '.github/.pipeline-config.yml'].some(f => fs.existsSync(path.join(repoRoot, f)));
30
+ if (!cfgExists) return { name, status: 'skip', reason: 'no .pipeline-config.yml — run `hone setup` first' };
31
+
32
+ let tracked = trackedPaths;
33
+ if (!tracked) {
34
+ try {
35
+ const { gitEnv } = require('./git-env');
36
+ tracked = execSync('git ls-files', { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
37
+ } catch { return { name, status: 'skip', reason: 'not a git repository' }; }
38
+ }
39
+
40
+ // A glob is inert, not broken: selectPaths prefix-matches and there is no glob engine in the
41
+ // path, so `src/**/__tests__/` never matched anything. Report it, but never as a failure.
42
+ const isGlob = (d) => String(d).includes('*');
43
+ const strip = (d) => String(d).replace(/"/g, '').replace(/\/+$/, '');
44
+ const configured = raw.source_dirs.filter(d => !isGlob(d));
45
+ const inert = [...raw.source_dirs, ...raw.test_dirs].filter(isGlob);
46
+ const onDisk = (d) => { try { return fs.statSync(path.join(repoRoot, strip(d))).isDirectory(); } catch { return false; } };
47
+ const live = configured.filter(onDisk);
48
+ const dead = configured.filter(d => !onDisk(d));
49
+
50
+ const { detectLayout } = require('./layout-detect');
51
+ const detected = detectLayout({ root: repoRoot, trackedPaths: tracked });
52
+ const covered = (p) => live.some(d => p === strip(d) || p.startsWith(strip(d) + '/'));
53
+ const uncovered = detected.source_dirs.filter(d => !covered(d));
54
+ const fixLine = `run \`hone detect-layout\`, then set codebase.source_dirs in .github/.pipeline-config.yml`;
55
+
56
+ if (configured.length === 0) {
57
+ return { name, status: 'ok', reason: 'no source_dirs configured — the repository tree covers the whole repo', detail: inert.length ? `inert glob patterns ignored: ${inert.join(', ')}` : undefined };
58
+ }
59
+ if (live.length === 0) {
60
+ return {
61
+ name, status: 'drift',
62
+ reason: `none of the configured source_dirs exist: ${dead.join(', ')} — the pipeline is grounded on a near-empty repository tree and \`hone derive\` bundles no files`,
63
+ detail: `real source directories in this repo: ${detected.source_dirs.join(', ') || '(none found)'}. Fix: ${fixLine}`,
64
+ };
65
+ }
66
+ if (dead.length > 0) {
67
+ return { name, status: 'warn', reason: `some configured source_dirs do not exist: ${dead.join(', ')}`, detail: `live: ${live.join(', ')}. Fix: ${fixLine}` };
68
+ }
69
+ if (uncovered.length > 0) {
70
+ return { name, status: 'info', reason: `source directories outside the configured scope: ${uncovered.join(', ')}`, detail: `they will be invisible to the groomer, planner and builder. Fix: ${fixLine}` };
71
+ }
72
+ return { name, status: 'ok', reason: `source_dirs match the repository: ${live.join(', ')}`, detail: inert.length ? `inert glob patterns ignored: ${inert.join(', ')}` : undefined };
73
+ } catch (e) {
74
+ return { name, status: 'skip', reason: `check could not run: ${(e && e.message) || e}` };
75
+ }
76
+ }
77
+
78
+ module.exports = { checkSourceDirs };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+ /**
3
+ * layout-detect.js — HC-DF-002 (absorbing HC-DF-003): find where the code ACTUALLY lives.
4
+ *
5
+ * Why: `setup-ai-pipeline.sh` detected the stack by reading ONLY the root manifest and then
6
+ * HARDCODED the layout (`DETECTED_SOURCE_DIRS="src/"` for every Node repo, no existence check).
7
+ * That was a config annoyance until HC-019n-followup-42/-43 made `source_dirs` the load-bearing
8
+ * input to the repository tree: on hone-server the `src/` default yields a tree of 2 of 1855
9
+ * tracked paths (`Makefile`, `package.json`) under a prompt asserting it is the real layout.
10
+ * It also guts `hone derive`, which bundles files from `source_dirs` and skips absent ones.
11
+ *
12
+ * Pure and deterministic: takes the tracked path list (the caller does the git I/O), returns
13
+ * sorted results. Mirrors cli/lib/repo-tree.js so the two agree on what a source path is.
14
+ */
15
+
16
+ // Directories that are never source, whatever the stack. Kept in sync with repo-tree.js's
17
+ // ALWAYS_EXCLUDE, plus agent worktrees (gitignored, but an on-disk fallback walk can see them).
18
+ const EXCLUDE = /(^|\/)(node_modules|dist|build|coverage|\.git|\.next|vendor|__pycache__|target|\.venv|venv|\.claude|out|bin|obj)(\/|$)/;
19
+
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
+ const TEST_HINT = /(^|\/)(tests?|__tests__|spec|specs|e2e)(\/|$)|\.(test|spec)\.[a-z]+$/i;
22
+
23
+ // Directories whose NAME means "this is where the code is". A repo that puts code directly at the
24
+ // root of a package still gets that package dir, via the manifest-dir fallback below.
25
+ const SOURCE_DIR_NAMES = /^(src|lib|app|source|server|client|api|packages|apps|core)$/i;
26
+ // A directory named `tests/` qualifies as a test root on its name alone — a repo with a single
27
+ // integration test still has a test root, and it must be visible to the pipeline.
28
+ const TEST_DIR_NAMES = /^(tests?|spec|specs|e2e|__tests__)$/i;
29
+
30
+ function _norm(d) { return String(d || '').replace(/^\.\//, '').replace(/\/+$/, ''); }
31
+ function _dir(p) { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); }
32
+
33
+ /** Every manifest in the repo (not just the root one) — the HC-DF-003 half of the bug. */
34
+ function detectManifests(trackedPaths) {
35
+ const MANIFEST = /(^|\/)(package\.json|pyproject\.toml|requirements\.txt|pom\.xml|build\.gradle(\.kts)?|Cargo\.toml|go\.mod|Gemfile|composer\.json|[^/]+\.csproj|sfdx-project\.json)$/;
36
+ return (Array.isArray(trackedPaths) ? trackedPaths : [])
37
+ .filter(p => !EXCLUDE.test(p) && MANIFEST.test(p))
38
+ .sort();
39
+ }
40
+
41
+ /**
42
+ * Source and test directories, derived from where source files actually sit.
43
+ *
44
+ * Rule (deliberately conservative — an over-broad scope is as wrong as a narrow one):
45
+ * 1. Bucket every tracked source file under its TOP-LEVEL directory (or `.` for root files).
46
+ * 2. Keep a top-level dir when it is a recognized source-dir name OR holds a manifest OR holds
47
+ * at least MIN_FILES source files. This is what stops `docs/` and `.github/` qualifying.
48
+ * 3. Descend one level when the top-level dir is a container (packages/, apps/) or when the
49
+ * child is a conventional source root (server/src), so the scope is tight but real.
50
+ * 4. Test dirs are the same computation over test-shaped paths. PREFIX directories only —
51
+ * `selectPaths` prefix-matches and there is no glob engine anywhere in the path, so a
52
+ * pattern like `src/**​/__tests__/` would silently match nothing.
53
+ */
54
+ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
55
+ void root; // reserved: an on-disk fallback walk when git is unavailable
56
+ const paths = (Array.isArray(trackedPaths) ? trackedPaths : [])
57
+ .map(p => _norm(p)).filter(p => p && !EXCLUDE.test(p));
58
+ const manifests = detectManifests(paths);
59
+ const manifestDirs = new Set(manifests.map(m => _dir(m)));
60
+
61
+ const srcFiles = paths.filter(p => SOURCE_EXT.test(p) && !TEST_HINT.test(p));
62
+ const testFiles = paths.filter(p => SOURCE_EXT.test(p) && TEST_HINT.test(p));
63
+
64
+ function roll(files, nameRe = SOURCE_DIR_NAMES) {
65
+ const byTop = new Map();
66
+ for (const f of files) {
67
+ const top = f.includes('/') ? f.split('/')[0] : '.';
68
+ if (!byTop.has(top)) byTop.set(top, []);
69
+ byTop.get(top).push(f);
70
+ }
71
+ const out = [];
72
+ for (const [top, list] of byTop) {
73
+ if (top === '.') continue; // root-level scripts are not a source ROOT
74
+ const qualifies = nameRe.test(top) || manifestDirs.has(top) || list.length >= minFiles;
75
+ if (!qualifies) continue;
76
+ // Descend one level when this is a container of packages, or when a conventional source
77
+ // root sits directly under it (server/src) — keeps the scope tight without inventing depth.
78
+ const kids = new Map();
79
+ for (const f of list) {
80
+ const seg = f.split('/');
81
+ if (seg.length > 2) { const k = seg[0] + '/' + seg[1]; kids.set(k, (kids.get(k) || 0) + 1); }
82
+ }
83
+ // Files sitting DIRECTLY in this dir (cli/hone-cli.js). If any exist, the dir itself is
84
+ // the source root — descending would silently drop them, which is how an early cut of this
85
+ // 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
+ const direct = list.filter(f => f.split('/').length === 2).length;
92
+ const container = /^(packages|apps|services|modules)$/i.test(top);
93
+ if (container && kids.size > 0 && direct === 0) { out.push(...[...kids.keys()]); continue; }
94
+ out.push(top);
95
+ }
96
+ return [...new Set(out)].sort();
97
+ }
98
+
99
+ return {
100
+ version: 1,
101
+ source_dirs: roll(srcFiles),
102
+ test_dirs: roll(testFiles, TEST_DIR_NAMES),
103
+ manifests,
104
+ ...detectFrameworks(paths, manifests),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * HC-DF-003: framework + test framework from the UNION of manifests, not just the root one.
110
+ * hone-server's root package.json has ZERO dependencies — express and `node --test` live in
111
+ * server/package.json — so root-only detection wrote `framework: none, test_framework: none`.
112
+ * `readManifest` is injected so this stays pure and testable.
113
+ */
114
+ const FRAMEWORKS = [
115
+ ['express', /"express"\s*:/], ['nestjs', /"@nestjs\/core"\s*:/], ['fastify', /"fastify"\s*:/],
116
+ ['nextjs', /"next"\s*:/], ['koa', /"koa"\s*:/], ['hapi', /"@hapi\/hapi"\s*:/],
117
+ ['django', /^\s*django\b/im], ['flask', /^\s*flask\b/im], ['fastapi', /^\s*fastapi\b/im],
118
+ ['spring-boot', /spring-boot/],
119
+ ];
120
+ const TEST_FRAMEWORKS = [
121
+ ['jest', /"jest"\s*:/], ['vitest', /"vitest"\s*:/], ['mocha', /"mocha"\s*:/],
122
+ // node:test ships with Node and appears only as a SCRIPT, never as a dependency — which is
123
+ // why the old dependency-only chain missed it entirely (grep count in the setup script: 0).
124
+ ['node:test', /node\s+--test|["']node:test["']|require\(['"]node:test['"]\)/],
125
+ ['pytest', /^\s*pytest\b/im], ['junit', /junit/i],
126
+ ];
127
+ function detectFrameworks(paths, manifests, readManifest = null) {
128
+ const blobs = [];
129
+ if (typeof readManifest === 'function') {
130
+ for (const m of manifests) { const t = readManifest(m); if (t) blobs.push(t); }
131
+ }
132
+ const hay = blobs.join('\n');
133
+ const pick = (table) => { for (const [name, re] of table) if (re.test(hay)) return name; return ''; };
134
+ return { framework: pick(FRAMEWORKS), test_framework: pick(TEST_FRAMEWORKS), _manifestCount: manifests.length };
135
+ }
136
+
137
+ /** Which configured dirs are real? The DIRECT signal — no ratio guesswork. */
138
+ function classifyConfiguredDirs(configuredDirs, knownDirs) {
139
+ const known = new Set((knownDirs || []).map(_norm));
140
+ const live = []; const dead = [];
141
+ for (const d of (configuredDirs || []).map(_norm).filter(Boolean)) (known.has(d) ? live : dead).push(d);
142
+ return { live, dead };
143
+ }
144
+
145
+ module.exports = { detectLayout, detectManifests, detectFrameworks, classifyConfiguredDirs, EXCLUDE, SOURCE_EXT };
@@ -33,6 +33,7 @@ const yaml = require('js-yaml');
33
33
  const SCHEMA_A = 'A';
34
34
  const SCHEMA_B = 'B';
35
35
  const SCHEMA_C = 'C';
36
+ const SCHEMA_D = 'D';
36
37
  const SCHEMA_UNKNOWN = 'unknown';
37
38
 
38
39
  // LC-001 / #58 G2 partial: optional categorization. Per architect plan for
@@ -72,6 +73,15 @@ function detectSchema(parsed) {
72
73
  // Schema C wins on collision (both arrays present); caller emits warning.
73
74
  if (Array.isArray(parsed.learnings)) return SCHEMA_C;
74
75
  if (Array.isArray(parsed.enterprise_candidates)) return SCHEMA_B;
76
+ // H-029-followup-4: Schema D — a FLAT single-learning dict. Not a new format invented here:
77
+ // it is what 120 of 226 files already use, written consistently across four months
78
+ // (2026-05..2026-09) by every agent that produced a learning. All 120 carry
79
+ // id/story_id/title/category/severity/captured_at/observation; 119 add proposed_fix + impact.
80
+ // They were parsing to `unknown` and returning zero eligible learnings, so `hone promote`,
81
+ // `hone audit-learnings` and step_5b never saw them. Recognizing the de facto shape beats
82
+ // migrating 120 files, because a migration leaves the next agent writing the same thing.
83
+ // Checked LAST so it can never shadow A/B/C.
84
+ if (typeof parsed.observation === 'string' && (parsed.id || parsed.story_id)) return SCHEMA_D;
75
85
  return SCHEMA_UNKNOWN;
76
86
  }
77
87
 
@@ -115,6 +125,7 @@ function parseLearningsFile(content, opts = {}) {
115
125
  if (schema === SCHEMA_A) return { schema, eligible: parseSchemaA(parsed, includePromoted), warning };
116
126
  if (schema === SCHEMA_B) return { schema, eligible: parseSchemaB(parsed, includePromoted), warning };
117
127
  if (schema === SCHEMA_C) return { schema, eligible: parseSchemaC(parsed, includePromoted), warning };
128
+ if (schema === SCHEMA_D) return { schema, eligible: parseSchemaD(parsed, includePromoted), warning };
118
129
  } catch (e) {
119
130
  return { schema, eligible: [], warning, parseError: e };
120
131
  }
@@ -206,6 +217,40 @@ function parseSchemaC(doc, includePromoted = false) {
206
217
  }));
207
218
  }
208
219
 
220
+ /**
221
+ * H-029-followup-4: Schema D — one flat dict IS one learning.
222
+ *
223
+ * The flat shape has no `enterprise_candidate` flag and no `enterprise_summary`, so the C-style
224
+ * filters would drop every file. The mapping instead: the learning is an enterprise candidate
225
+ * when the file says `share_enterprise: true` OR carries a `proposed_fix` (the field that makes a
226
+ * lesson actionable elsewhere), and the summary is the proposed fix, falling back to the title.
227
+ * Deliberately permissive on the way IN — a learning that reaches a human to triage is worth far
228
+ * more than one silently dropped, which is the failure this story exists to fix.
229
+ */
230
+ function parseSchemaD(doc, includePromoted = false) {
231
+ const status = doc.status || 'pending';
232
+ if (!includePromoted && status !== 'pending' && status !== 'captured') return [];
233
+ const summary = [doc.proposed_fix, doc.recommendation, doc.title]
234
+ .map(v => (typeof v === 'string' ? v.trim() : '')).find(v => v.length > 0);
235
+ if (!summary) return [];
236
+ const candidate = doc.share_enterprise === true
237
+ || (doc.share_enterprise === undefined && typeof doc.proposed_fix === 'string' && doc.proposed_fix.trim().length > 0);
238
+ if (!candidate) return [];
239
+ return [{
240
+ id: doc.id || doc.story_id,
241
+ story: doc.story_id || doc.id,
242
+ enterprise_summary: summary,
243
+ skill_update: null,
244
+ status: 'pending',
245
+ enterprise_candidate: true,
246
+ _schema: SCHEMA_D,
247
+ category: validateCategory(doc.category),
248
+ referenced_skill: doc.referenced_skill || null,
249
+ referenced_section: doc.referenced_section || null,
250
+ evidence: Array.isArray(doc.evidence) ? doc.evidence : (doc.evidence ? [doc.evidence] : null),
251
+ }];
252
+ }
253
+
209
254
  // ─────────────────────────────────────────────────────────────────────
210
255
  // writeBackPromoted — schema-aware idempotent regex rewrite
211
256
  //
@@ -215,6 +260,7 @@ function parseSchemaC(doc, includePromoted = false) {
215
260
  // ─────────────────────────────────────────────────────────────────────
216
261
  function writeBackPromoted(content, schema, idsPromoted) {
217
262
  if (!idsPromoted || idsPromoted.length === 0) return content;
263
+ if (schema === SCHEMA_D) return writeBackD(content, idsPromoted);
218
264
  if (schema === SCHEMA_A) return writeBackA(content, idsPromoted);
219
265
  if (schema === SCHEMA_B) return writeBackB(content, idsPromoted);
220
266
  if (schema === SCHEMA_C) return writeBackC(content, idsPromoted);
@@ -269,6 +315,21 @@ function writeBackB(content, idsPromoted) {
269
315
  // line. Idempotent: skip if any `status:` already exists in the item's
270
316
  // block. Top-level `status:` MUST NOT be touched — it's the story-level
271
317
  // status, separate from per-learning status.
318
+ /**
319
+ * H-029-followup-4: a flat dict is one learning with ONE top-level status, so promotion is a
320
+ * top-level `status:` rewrite (insert if absent). `hone retract` reads the same field.
321
+ */
322
+ function writeBackD(content, ids) {
323
+ if (!ids || ids.length === 0) return content;
324
+ if (/^status:\s*\S+/m.test(content)) return content.replace(/^status:\s*\S+.*$/m, 'status: promoted');
325
+ const lines = content.split('\n');
326
+ let at = lines.findIndex(l => /^captured_at:/.test(l));
327
+ if (at < 0) at = lines.findIndex(l => /^severity:/.test(l));
328
+ if (at < 0) at = 0;
329
+ lines.splice(at + 1, 0, 'status: promoted');
330
+ return lines.join('\n');
331
+ }
332
+
272
333
  function writeBackC(content, ids) {
273
334
  const idSet = new Set(ids.map(String));
274
335
  if (idSet.size === 0) return content;
@@ -309,6 +370,9 @@ function rewriteWithStatusInsertion(content, matcher) {
309
370
  }
310
371
 
311
372
  module.exports = {
373
+ SCHEMA_D,
374
+ parseSchemaD,
375
+ writeBackD,
312
376
  SCHEMA_A,
313
377
  SCHEMA_B,
314
378
  SCHEMA_C,
@@ -24,8 +24,30 @@ const path = require('node:path');
24
24
  /**
25
25
  * A ```file:<path> fenced block, capturing the path and the verbatim body.
26
26
  * Info-string tolerant: ```file:foo, ``` file: foo, ```FILE:foo all match.
27
+ *
28
+ * HC-GF-022: the closing fence is anchored to its OWN LINE (`^...$` under /m).
29
+ * The previous form spelled the terminator `\r?\n``` `, which REQUIRED a newline
30
+ * between the opener and the closer and so could not match an EMPTY block. On a
31
+ * legitimately empty file (`flowforge/core/__init__.py` — every Python package
32
+ * has one) the body then swallowed its own closing fence, matched the NEXT
33
+ * block's opening fence as its terminator, and consumed it: the empty file was
34
+ * written containing a literal ``` `, and the following file — 2,888 bytes of
35
+ * pipeline logic — vanished from the diff entirely. `check-patch` reported
36
+ * "applies cleanly", because the diff git built from the surviving blocks was
37
+ * perfectly valid. Line-anchoring is also strictly better for a body that
38
+ * contains an indented or info-tagged fence of its own.
27
39
  */
28
- const FILE_BLOCK_RE = /```[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)\r?\n```/gi;
40
+ const FILE_BLOCK_RE = /^[ \t]*```[ \t]*file[ \t]*:[ \t]*([^\n`]+?)[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*\r?$/gim;
41
+
42
+ /**
43
+ * The line-anchored closer necessarily absorbs the newline that terminates the
44
+ * last content line. Strip exactly one so `contents` keeps the meaning it has
45
+ * always had (the body WITHOUT its terminating newline) — and so an empty block
46
+ * stays the empty string rather than becoming "\n".
47
+ */
48
+ function blockBody(raw) {
49
+ return String(raw == null ? '' : raw).replace(/\r?\n$/, '');
50
+ }
29
51
 
30
52
  /**
31
53
  * A ```edit:<path> fenced block — HC-019n-followup-27. For a file too big to
@@ -97,7 +119,7 @@ function extractFileBlocks(output) {
97
119
  if (!rel) continue; // rejected (traversal/absolute) — see below
98
120
  if (seen.has(rel)) continue; // first block wins on a dup path
99
121
  seen.add(rel);
100
- files.push({ path: rel, contents: m[2] });
122
+ files.push({ path: rel, contents: blockBody(m[2]) });
101
123
  }
102
124
 
103
125
  // HC-019n-followup-27: region edits for files too big to bundle whole.
@@ -287,8 +309,29 @@ function buildFileDiff({ repoPath, isNew, runGitDiff }) {
287
309
  return { diff: ensureTrailingNewline(rewriteDiffPaths(stdout, repoPath, isNew)), changed: true };
288
310
  }
289
311
 
312
+ /**
313
+ * HC-GF-029 — APPEND ONLY. This must never remove anything.
314
+ *
315
+ * It used to be `s.replace(/\s*$/, '') + '\n'`, and `\s*$` matches the whole
316
+ * trailing whitespace RUN — which swallows a final blank CONTEXT LINE. A hunk
317
+ * ending on a blank source line (a blank line before the next def, i.e. ordinary
318
+ * Python and JS spacing) therefore lost one line while its `@@` header still
319
+ * declared the original count, and `git apply` rejected the result as
320
+ * `corrupt patch at line N`. Measured on run 74fc6519: `flowforge/app.py`
321
+ * header said `-12/+7`, body carried `-11/+6`.
322
+ *
323
+ * It stayed invisible because a NEW file's diff is pure additions with no
324
+ * trailing context to lose. It fires the first time a story MODIFIES an existing
325
+ * file — that is, every story after the first one on any repository.
326
+ *
327
+ * This function sits at the end of the one path whose entire premise is that
328
+ * git's arithmetic is authoritative ("a diff git produces is one `git apply`
329
+ * accepts by construction", top of this file). Rewriting git's output after the
330
+ * fact is what broke that premise, so the only safe normalisation here is to add
331
+ * a terminator when one is missing.
332
+ */
290
333
  function ensureTrailingNewline(s) {
291
- return s.replace(/\s*$/, '') + '\n';
334
+ return s.endsWith('\n') ? s : `${s}\n`;
292
335
  }
293
336
 
294
337
  /**
@@ -33,7 +33,8 @@ const path = require('node:path');
33
33
  const KNOWN_THRESHOLD_KEYS = ['estimate_full_sdlc', 'recently_modified_window_days'];
34
34
 
35
35
  const CI_GATE_MODES = ['github', 'local', 'both', 'none'];
36
- const KNOWN_CI_GATE_KEYS = ['gate', 'local_command', 'install_command']; // install_command: HC-COMM-010-followup-6
36
+ const KNOWN_CI_GATE_KEYS = ['gate', 'local_command', 'install_command',
37
+ 'lint_command', 'typecheck_command']; // install_command: HC-COMM-010-followup-6; lint/typecheck: HC-GF-026
37
38
  const DEFAULT_CI_LOCAL_COMMAND = 'make ci';
38
39
 
39
40
  /**
@@ -119,7 +120,14 @@ function normalizeCIGate(raw) {
119
120
  * @returns {{ local_command: string|null, local_command_explicit: boolean, install_command: string|null }}
120
121
  */
121
122
  function readCIConfigDetails(repoRoot) {
122
- const none = { local_command: null, local_command_explicit: false, install_command: null };
123
+ // HC-GF-026: lint_command / typecheck_command. step_4 emitted a shell transcript
124
+ // claiming `ruff check → All checks passed!` for a command it never ran (it has no
125
+ // execution surface), and step_5 repeated it as `STANDARDS: Clean`. Measured against
126
+ // the emitted branch: 6 real violations of the repo's OWN pyproject rules. The cure
127
+ // is not prompt wording — it is running the command and having a verdict to compare
128
+ // against, which is what these two keys make possible.
129
+ const none = { local_command: null, local_command_explicit: false, install_command: null,
130
+ lint_command: null, typecheck_command: null };
123
131
  if (!repoRoot || typeof repoRoot !== 'string') return none;
124
132
  for (const p of [path.join(repoRoot, '.pipeline-config.yml'), path.join(repoRoot, '.github/.pipeline-config.yml')]) {
125
133
  if (!fs.existsSync(p)) continue;
@@ -129,7 +137,11 @@ function readCIConfigDetails(repoRoot) {
129
137
  if (!block || typeof block !== 'object') continue;
130
138
  const lc = typeof block.local_command === 'string' && block.local_command.trim() ? block.local_command.trim() : null;
131
139
  const ic = typeof block.install_command === 'string' && block.install_command.trim() ? block.install_command.trim() : null;
132
- return { local_command: lc, local_command_explicit: lc !== null, install_command: ic };
140
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
141
+ return {
142
+ local_command: lc, local_command_explicit: lc !== null, install_command: ic,
143
+ lint_command: str(block.lint_command), typecheck_command: str(block.typecheck_command),
144
+ };
133
145
  }
134
146
  return none;
135
147
  }
@@ -158,20 +170,71 @@ function readCodebaseDefaults(repoRoot) {
158
170
  /**
159
171
  * HC-019n-followup-42: `source_dirs` / `test_dirs` from the pipeline config (setup
160
172
  * writes them) — used to scope the repository-structure signal. Absent ⇒ [].
161
- * @returns {{ source_dirs: string[], test_dirs: string[] }}
173
+ *
174
+ * HC-DF-002: this is now THE chokepoint, and it SELF-HEALS. `hone setup` wrote a hardcoded
175
+ * `src/` for every Node repo with no existence check, which silently broke three consumers that
176
+ * each read this field: run-story's repository tree (2 of 1855 paths on this repo), `hone derive`
177
+ * (bundled 0 files, so the derived skills came from nothing) and `hone check-refresh` (counted 0,
178
+ * so staleness never fired). Fixing only the setup script would leave every already-installed
179
+ * adopter broken, because generate_config() returns early when the config exists. So the READ
180
+ * repairs itself: configured directories that do not exist on disk are dropped, and if that
181
+ * leaves nothing, the layout is detected from the repo. Config still wins whenever it is real.
182
+ *
183
+ * @param {string} repoRoot
184
+ * @param {{trackedPaths?: string[], repair?: boolean}} [opts] repair=false returns the raw config
185
+ * @returns {{source_dirs: string[], test_dirs: string[], repaired: boolean, dropped: string[], detected: boolean}}
162
186
  */
163
- function readSourceDirs(repoRoot) {
164
- const none = { source_dirs: [], test_dirs: [] };
187
+ function readSourceDirs(repoRoot, opts = {}) {
188
+ const none = { source_dirs: [], test_dirs: [], repaired: false, dropped: [], inert: [], detected: false };
165
189
  if (!repoRoot || typeof repoRoot !== 'string') return none;
190
+ let raw = null;
166
191
  for (const p of [path.join(repoRoot, '.pipeline-config.yml'), path.join(repoRoot, '.github/.pipeline-config.yml')]) {
167
192
  if (!fs.existsSync(p)) continue;
168
193
  let parsed;
169
194
  try { parsed = require('js-yaml').load(fs.readFileSync(p, 'utf8')); } catch { continue; }
170
195
  if (!parsed || typeof parsed !== 'object') continue;
171
196
  const list = (v) => (Array.isArray(v) ? v : []).filter(x => typeof x === 'string' && x.trim()).map(x => x.trim());
172
- return { source_dirs: list(parsed.source_dirs), test_dirs: list(parsed.test_dirs) };
197
+ raw = { source_dirs: list(parsed.source_dirs), test_dirs: list(parsed.test_dirs) };
198
+ break;
173
199
  }
174
- return none;
200
+ if (!raw) return none;
201
+ if (opts.repair === false) return { ...raw, repaired: false, dropped: [], inert: [], detected: false };
202
+
203
+ // A glob is INERT, not broken: `selectPaths` prefix-matches and there is no glob engine
204
+ // anywhere in the path, so `src/**/__tests__/` never matched anything even before this story.
205
+ // Dropping it is correct, but it is not a repair and must not raise an operator warning.
206
+ const isGlob = (d) => String(d).includes('*');
207
+ const exists = (d) => {
208
+ const clean = String(d).replace(/"/g, '').replace(/\/+$/, '');
209
+ if (!clean || isGlob(clean)) return false;
210
+ try { return fs.existsSync(path.join(repoRoot, clean)) && fs.statSync(path.join(repoRoot, clean)).isDirectory(); }
211
+ catch { return false; }
212
+ };
213
+ const liveSrc = raw.source_dirs.filter(exists);
214
+ const liveTest = raw.test_dirs.filter(exists);
215
+ // Only a real directory that is ABSENT counts as a repair.
216
+ const dropped = [...raw.source_dirs, ...raw.test_dirs].filter(d => !isGlob(d) && !exists(d));
217
+ const inert = [...raw.source_dirs, ...raw.test_dirs].filter(isGlob);
218
+
219
+ // An EMPTY configured list is a legitimate, healthy state — it means "the whole repo".
220
+ // Only a list that was non-empty and is now empty signals a broken config worth detecting for.
221
+ if (liveSrc.length > 0 || raw.source_dirs.length === 0) {
222
+ return { source_dirs: liveSrc, test_dirs: liveTest, repaired: dropped.length > 0, dropped, inert, detected: false };
223
+ }
224
+ try {
225
+ const { detectLayout } = require('./layout-detect');
226
+ let tracked = Array.isArray(opts.trackedPaths) ? opts.trackedPaths : null;
227
+ if (!tracked) {
228
+ const { execSync } = require('child_process');
229
+ const { gitEnv } = require('./git-env');
230
+ tracked = execSync('git ls-files', { cwd: repoRoot, env: gitEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n').filter(Boolean);
231
+ }
232
+ const d = detectLayout({ root: repoRoot, trackedPaths: tracked });
233
+ if (d.source_dirs.length > 0) {
234
+ return { source_dirs: d.source_dirs, test_dirs: d.test_dirs, repaired: true, dropped, inert, detected: true };
235
+ }
236
+ } catch { /* detection is best-effort: a broken scope beats a crashed command */ }
237
+ return { source_dirs: liveSrc, test_dirs: liveTest, repaired: dropped.length > 0, dropped, inert, detected: false };
175
238
  }
176
239
 
177
240
  function readCIGateConfig(repoRoot) {
package/lib/repo-tree.js CHANGED
@@ -116,7 +116,13 @@ function buildRepoTree(allPaths, { sourceDirs = [], testDirs = [], maxEntries, m
116
116
  const r = renderTree(selected, { maxEntries, maxBytes });
117
117
  const dirs = allDirs(allPaths);
118
118
  const tree = {
119
- version: 2,
119
+ // v3 (HC-019n-followup-45): adds `detected_source_dirs` + `omitted_dirs` so the server can
120
+ // grade coverage instead of asking a binary question. A ratio alone cannot distinguish a
121
+ // deliberately narrow scope (server/src only = 6% of this repo, correct) from an accidental
122
+ // one (server/src+cli = 39%, missing scripts/ and server/scripts/ where real code lives).
123
+ // The DETECTOR's opinion is the semantic signal: which source-bearing dirs did the config
124
+ // leave out? Old servers ignore the new fields; old CLIs simply do not send them.
125
+ version: 3,
120
126
  source_dirs: sourceDirs.map(_norm).filter(Boolean),
121
127
  test_dirs: testDirs.map(_norm).filter(Boolean),
122
128
  tracked_total: Array.isArray(allPaths) ? allPaths.length : 0,
@@ -127,6 +133,15 @@ function buildRepoTree(allPaths, { sourceDirs = [], testDirs = [], maxEntries, m
127
133
  if (selected.length <= MAX_TRANSPORT_PATHS && dirs.length <= MAX_TRANSPORT_DIRS) {
128
134
  tree.paths = selected; tree.dirs = dirs;
129
135
  }
136
+ try {
137
+ const { detectLayout } = require('./layout-detect');
138
+ const d = detectLayout({ trackedPaths: allPaths });
139
+ tree.detected_source_dirs = d.source_dirs;
140
+ // A detected source dir is "omitted" when no configured dir contains it (or equals it).
141
+ const cfg = tree.source_dirs.map(_norm).filter(Boolean);
142
+ tree.omitted_dirs = cfg.length === 0 ? [] : d.source_dirs.filter(
143
+ dir => !cfg.some(c => dir === c || dir.startsWith(c + '/') || c.startsWith(dir + '/')));
144
+ } catch { /* detection is best-effort; without it the server falls back to the ratio */ }
130
145
  return tree;
131
146
  }
132
147