@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.
- package/README.md +90 -0
- package/hone-cli.js +223 -5
- package/lib/doctor-local-run.js +183 -0
- package/lib/doctor-source-dirs.js +78 -0
- package/lib/layout-detect.js +145 -0
- package/lib/learnings-parse.js +64 -0
- package/lib/materialize-diff.js +46 -3
- package/lib/pipeline-config.js +71 -8
- package/lib/repo-tree.js +16 -1
- package/lib/run-report.js +217 -0
- package/lib/verify-patch.js +157 -4
- package/lib/worktree-provision.js +71 -2
- package/package.json +5 -3
|
@@ -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 };
|
package/lib/learnings-parse.js
CHANGED
|
@@ -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,
|
package/lib/materialize-diff.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
334
|
+
return s.endsWith('\n') ? s : `${s}\n`;
|
|
292
335
|
}
|
|
293
336
|
|
|
294
337
|
/**
|
package/lib/pipeline-config.js
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|