@hone-ai/cli 1.4.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.
Files changed (60) hide show
  1. package/bin/hone.js +2 -0
  2. package/hone-cli.js +4006 -0
  3. package/lib/README.md +119 -0
  4. package/lib/adversarial-negative-lint.js +149 -0
  5. package/lib/audit.js +156 -0
  6. package/lib/auto-detect.js +213 -0
  7. package/lib/autofix-guardrails.js +124 -0
  8. package/lib/branch-protection.js +256 -0
  9. package/lib/ci-classifier.js +150 -0
  10. package/lib/ci-failures.js +173 -0
  11. package/lib/claude-md-tokens.js +71 -0
  12. package/lib/compliance-check.js +62 -0
  13. package/lib/config-augment.js +133 -0
  14. package/lib/config-update.js +70 -0
  15. package/lib/dependency-audit.js +108 -0
  16. package/lib/derive-domain.js +185 -0
  17. package/lib/doc-registry.js +63 -0
  18. package/lib/doctor-admin-merge.js +185 -0
  19. package/lib/doctor-bind-default.js +118 -0
  20. package/lib/doctor-docs.js +205 -0
  21. package/lib/doctor-placeholders.js +144 -0
  22. package/lib/doctor-skill-staleness.js +122 -0
  23. package/lib/domain-skill-template.md +114 -0
  24. package/lib/editor-detect.js +169 -0
  25. package/lib/fast-track-ratify.js +133 -0
  26. package/lib/git-helpers.js +109 -0
  27. package/lib/hook-templates/pre-commit.sh +54 -0
  28. package/lib/hook-templates/pre-push.sh +72 -0
  29. package/lib/install-hooks.js +205 -0
  30. package/lib/knowledge-graph.js +188 -0
  31. package/lib/learnings-audit.js +254 -0
  32. package/lib/learnings-parse.js +331 -0
  33. package/lib/learnings-sync.js +75 -0
  34. package/lib/mcp-detect.js +154 -0
  35. package/lib/metrics-collect.js +214 -0
  36. package/lib/overlay-merge.js +267 -0
  37. package/lib/performance-analyzer.js +142 -0
  38. package/lib/pipeline-config.js +83 -0
  39. package/lib/pipeline-status.js +207 -0
  40. package/lib/pipeline-validate.js +322 -0
  41. package/lib/platform-detect.js +86 -0
  42. package/lib/platform-discover.js +334 -0
  43. package/lib/publish-learning.js +160 -0
  44. package/lib/python-install.js +84 -0
  45. package/lib/refresh-check.js +67 -0
  46. package/lib/refresh-knowledge.js +360 -0
  47. package/lib/rule-resolver.js +146 -0
  48. package/lib/security-scanner.js +168 -0
  49. package/lib/setup-grounding.js +138 -0
  50. package/lib/skill-assertions.js +276 -0
  51. package/lib/skill-audit-render.js +158 -0
  52. package/lib/skill-audit.js +391 -0
  53. package/lib/stack-detect.js +170 -0
  54. package/lib/stack-paths.js +285 -0
  55. package/lib/story-classifier-extract.js +203 -0
  56. package/lib/story-classifier.js +282 -0
  57. package/lib/sync-overwrite.js +47 -0
  58. package/lib/synthetic-pipeline.js +299 -0
  59. package/lib/validate-metadata.js +175 -0
  60. package/package.json +41 -0
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+ /**
3
+ * refresh-check.js — H-015 skill freshness evaluator.
4
+ *
5
+ * Pure helpers — no I/O. Caller (hone-cli.js) reads the config + walks
6
+ * the source dirs to count files; passes those values in.
7
+ *
8
+ * Closes #24 parts 1+2 (time + growth triggers). Architecture trigger
9
+ * (new top-level dir) deferred to v2 because storing arrays in
10
+ * .pipeline-config.yml requires a comment-preserving YAML lib (vs the
11
+ * surgical-regex strategy used here per H-021/L2).
12
+ *
13
+ * 8th instance of pure-helpers + thin-CLI-shell pattern in cli/lib/
14
+ * after H-018, H-035, H-009, H-008, H-021, H-061, H-010.
15
+ */
16
+
17
+ // ─────────────────────────────────────────────────────────────────────
18
+ // evaluateRefresh — pure freshness evaluator
19
+ // ─────────────────────────────────────────────────────────────────────
20
+ // Inputs:
21
+ // now: Date | string (defaults to Date.now())
22
+ // lastDerived: string | null (ISO timestamp from config)
23
+ // lastDerivedSrcFiles: number | null (saved src-file count)
24
+ // currentSrcFiles: number (current src-file count)
25
+ // refreshIntervalDays: number (default 90)
26
+ // growthPct: number (default 50, i.e. 1.5x)
27
+ //
28
+ // Returns: { stale: boolean, reasons: string[] }
29
+ // reasons can include: 'never-derived', 'time-based', 'growth-based'
30
+ function evaluateRefresh(opts = {}) {
31
+ const reasons = [];
32
+
33
+ // ── Never-derived ───────────────────────────────────
34
+ if (!opts.lastDerived) {
35
+ reasons.push('never-derived');
36
+ return { stale: true, reasons };
37
+ }
38
+
39
+ // ── Time-based trigger ────────────────────────────
40
+ const lastMs = new Date(opts.lastDerived).getTime();
41
+ const nowMs = (opts.now ? new Date(opts.now) : new Date()).getTime();
42
+ if (Number.isFinite(lastMs) && Number.isFinite(nowMs)) {
43
+ const ageDays = (nowMs - lastMs) / 86400000;
44
+ const intervalDays = (typeof opts.refreshIntervalDays === 'number' && opts.refreshIntervalDays > 0)
45
+ ? opts.refreshIntervalDays
46
+ : 90;
47
+ if (ageDays > intervalDays) {
48
+ reasons.push('time-based');
49
+ }
50
+ }
51
+
52
+ // ── Growth-based trigger ──────────────────────────
53
+ // Skip when previous count is missing or zero (E2 graceful — avoid div-by-zero).
54
+ if (typeof opts.lastDerivedSrcFiles === 'number'
55
+ && opts.lastDerivedSrcFiles > 0
56
+ && typeof opts.currentSrcFiles === 'number') {
57
+ const growthPct = (typeof opts.growthPct === 'number') ? opts.growthPct : 50;
58
+ const ratio = opts.currentSrcFiles / opts.lastDerivedSrcFiles;
59
+ if (ratio >= 1 + growthPct / 100) {
60
+ reasons.push('growth-based');
61
+ }
62
+ }
63
+
64
+ return { stale: reasons.length > 0, reasons };
65
+ }
66
+
67
+ module.exports = { evaluateRefresh };
@@ -0,0 +1,360 @@
1
+ 'use strict';
2
+ /**
3
+ * refresh-knowledge.js — HC-003 (first multi-channel orchestration wrapper).
4
+ *
5
+ * Implements OptionsFlow E22-E-L3 (deferred during SC-008 absorption).
6
+ *
7
+ * Orchestrates `hone sync` (enterprise skills from server) + `hone derive`
8
+ * (domain skills from local codebase) with backup-before-write +
9
+ * section-aware restore + halt-and-restore-on-failure.
10
+ *
11
+ * Pure-helper-with-injected-IO style (Category B per cli/lib/README.md):
12
+ * - `runChannel` is injectable for testability
13
+ * - All filesystem ops via Node fs (could be injected; kept simple here)
14
+ * - Returns {findings, summary, backupPath, channelResults, ...}
15
+ * - Never throws
16
+ *
17
+ * Architecture decisions (per HC-003 step-1-plan.md):
18
+ * - Backup mechanism: tar archive (.hone/backups/<ISO>.tar.gz)
19
+ * - Channel runner: injectable via `runChannel(name, repoRoot) → result`
20
+ * - Restore strategy: section-aware (re-apply user sections from backup)
21
+ * - Failure handling: halt-and-restore (any channel error → restore + report)
22
+ */
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const crypto = require('node:crypto');
27
+ const { execSync } = require('node:child_process');
28
+
29
+ const KNOWLEDGE_DIRS = [
30
+ '.github/skills',
31
+ '.github/agents',
32
+ '.claude/agents',
33
+ '.github/copilot-instructions.md',
34
+ ];
35
+
36
+ const ENTERPRISE_MANAGED_START_RE = /<!--\s*ENTERPRISE-MANAGED[^>]*-->/;
37
+ const ENTERPRISE_MANAGED_END_RE = /<!--\s*END ENTERPRISE-MANAGED\s*-->/;
38
+ const FRONTMATTER_MANAGED_RE = /^---[\s\S]*?managed_by:\s*\S+[\s\S]*?---/m;
39
+
40
+ /**
41
+ * Default channel runner — invokes `hone <name>` via execSync.
42
+ * Tests inject a fake runner; real CLI uses this default.
43
+ */
44
+ function defaultRunChannel(name, repoRoot) {
45
+ const cliPath = path.resolve(__dirname, '..', 'hone-cli.js');
46
+ try {
47
+ const out = execSync(`node "${cliPath}" ${name}`, {
48
+ cwd: repoRoot,
49
+ encoding: 'utf8',
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ timeout: 5 * 60 * 1000,
52
+ });
53
+ return { ok: true, exitCode: 0, stdout: out };
54
+ } catch (e) {
55
+ return {
56
+ ok: false,
57
+ exitCode: e.status || 1,
58
+ stdout: (e.stdout || '').toString(),
59
+ stderr: (e.stderr || e.message || '').toString(),
60
+ error: e.message,
61
+ };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Multi-channel refresh wrapper.
67
+ *
68
+ * @param {object} args
69
+ * @param {string} args.repoRoot
70
+ * @param {string[]} [args.channels=['sync','derive']] user-specified order
71
+ * @param {boolean} [args.preserveUserSections=true]
72
+ * @param {string} [args.backupDir='.hone/backups']
73
+ * @param {boolean} [args.dryRun=false]
74
+ * @param {boolean} [args.force=false]
75
+ * @param {Function}[args.runChannel] injectable; defaults to defaultRunChannel
76
+ * @param {Function}[args.now=() => new Date()]
77
+ * @returns {{
78
+ * findings: Array,
79
+ * summary: { total, errors, warnings, info },
80
+ * backupPath: string|null,
81
+ * channelResults: Array<{name, ok, exitCode}>,
82
+ * diffSummary: object,
83
+ * restored: string[],
84
+ * dryRun: boolean,
85
+ * }}
86
+ */
87
+ function refreshKnowledge(args = {}) {
88
+ const {
89
+ repoRoot,
90
+ channels = ['sync', 'derive'],
91
+ preserveUserSections = true,
92
+ backupDir = '.hone/backups',
93
+ dryRun = false,
94
+ force = false,
95
+ runChannel = defaultRunChannel,
96
+ now = () => new Date(),
97
+ } = args;
98
+
99
+ const findings = [];
100
+ const channelResults = [];
101
+ const restored = [];
102
+ let backupPath = null;
103
+
104
+ if (!repoRoot || typeof repoRoot !== 'string') {
105
+ return wrap(
106
+ [{ severity: 'ERROR', code: 'invalid-args', message: 'repoRoot is required' }],
107
+ backupPath, channelResults, restored, dryRun,
108
+ );
109
+ }
110
+
111
+ if (!Array.isArray(channels) || channels.length === 0) {
112
+ return wrap(
113
+ [{ severity: 'ERROR', code: 'invalid-channels', message: 'channels must be a non-empty array' }],
114
+ backupPath, channelResults, restored, dryRun,
115
+ );
116
+ }
117
+
118
+ // HC-005-B: --force is a real alias for preserveUserSections=false.
119
+ // Both flags effectively disable section-aware restore. `force=true`
120
+ // wins over preserveUserSections=true for user clarity (HC-001-L1
121
+ // marker discipline naming convention).
122
+ // Push the WARN finding AFTER arg validation so a never-actually-ran
123
+ // invocation doesn't carry the warning alongside its invalid-args error.
124
+ const effectivePreserve = force ? false : preserveUserSections;
125
+ if (force) {
126
+ findings.push({
127
+ severity: 'WARN',
128
+ code: 'force-mode',
129
+ message: '--force enabled; user sections WILL NOT be preserved (DANGEROUS — alias for --no-preserve)',
130
+ });
131
+ }
132
+
133
+ // Snapshot current adopter knowledge content (for section-aware restore + tar contents)
134
+ const knowledgeFiles = collectKnowledgeFiles(repoRoot);
135
+ const userSectionSnapshot = effectivePreserve
136
+ ? snapshotUserSections(knowledgeFiles)
137
+ : new Map();
138
+
139
+ // Dry-run: print plan, no side effects
140
+ if (dryRun) {
141
+ findings.push({
142
+ severity: 'INFO',
143
+ code: 'dry-run-plan',
144
+ message: `Would run channels in order: ${channels.join(' → ')}; backup to ${backupDir}; preserveUserSections=${preserveUserSections}`,
145
+ });
146
+ return wrap(findings, null, channelResults, restored, true);
147
+ }
148
+
149
+ // 1. Create backup BEFORE running any channel
150
+ try {
151
+ backupPath = createBackup({ repoRoot, backupDir, knowledgeFiles, now });
152
+ if (!backupPath) {
153
+ findings.push({
154
+ severity: 'WARN',
155
+ code: 'no-knowledge-files',
156
+ message: 'No adopter knowledge files found; running channels without backup',
157
+ });
158
+ }
159
+ } catch (e) {
160
+ findings.push({
161
+ severity: 'ERROR',
162
+ code: 'backup-failed',
163
+ message: `failed to create backup: ${e.message}`,
164
+ });
165
+ return wrap(findings, null, channelResults, restored, false);
166
+ }
167
+
168
+ // 2. Run channels in order; halt-and-restore on first failure
169
+ for (const channelName of channels) {
170
+ const result = runChannel(channelName, repoRoot);
171
+ channelResults.push({ name: channelName, ok: result.ok, exitCode: result.exitCode });
172
+ if (!result.ok) {
173
+ findings.push({
174
+ severity: 'ERROR',
175
+ code: 'channel-failed',
176
+ message: `channel '${channelName}' failed (exit ${result.exitCode}): ${result.error || result.stderr || 'unknown'}`,
177
+ });
178
+ // Halt-and-restore
179
+ if (backupPath && fs.existsSync(backupPath)) {
180
+ try {
181
+ restoreFromBackup({ repoRoot, backupPath });
182
+ findings.push({
183
+ severity: 'INFO',
184
+ code: 'restored-from-backup',
185
+ message: `restored adopter knowledge files from ${backupPath} after channel '${channelName}' failure`,
186
+ });
187
+ } catch (re) {
188
+ // HC-005-B: explicit manual-recovery instructions when restore itself fails.
189
+ // This is the 4th state HC-003-L1 didn't acknowledge:
190
+ // all-success / partial-completion-with-restore / all-fail-channel-1 / restore-failed.
191
+ findings.push({
192
+ severity: 'ERROR',
193
+ code: 'restore-failed',
194
+ manualInterventionRequired: true,
195
+ backupPath,
196
+ message: `RESTORE-FAILED — manual intervention required. Backup tar archive at ${backupPath}; recover with: tar -xzf "${backupPath}". Underlying error: ${re.message}`,
197
+ });
198
+ }
199
+ }
200
+ return wrap(findings, backupPath, channelResults, restored, false);
201
+ }
202
+ }
203
+
204
+ // 3. Section-aware restore: re-apply adopter user sections that channel runs may have overwritten
205
+ if (effectivePreserve && userSectionSnapshot.size > 0) {
206
+ for (const [filePath, userSection] of userSectionSnapshot.entries()) {
207
+ try {
208
+ if (!fs.existsSync(filePath)) continue;
209
+ const current = fs.readFileSync(filePath, 'utf8');
210
+ if (!current.includes(userSection.trim()) && userSection.trim().length > 0) {
211
+ // Channel run dropped the user section; re-apply
212
+ const merged = mergeUserSection(current, userSection);
213
+ fs.writeFileSync(filePath, merged);
214
+ restored.push(filePath);
215
+ }
216
+ } catch (e) {
217
+ findings.push({
218
+ severity: 'WARN',
219
+ code: 'restore-section-failed',
220
+ message: `failed to restore user section in ${filePath}: ${e.message}`,
221
+ });
222
+ }
223
+ }
224
+ }
225
+
226
+ return wrap(findings, backupPath, channelResults, restored, false);
227
+ }
228
+
229
+ // ─── Internal helpers (exported for testability) ────────────────────────
230
+
231
+ function collectKnowledgeFiles(repoRoot) {
232
+ const files = [];
233
+ for (const dir of KNOWLEDGE_DIRS) {
234
+ const fullPath = path.join(repoRoot, dir);
235
+ if (!fs.existsSync(fullPath)) continue;
236
+ const stat = fs.statSync(fullPath);
237
+ if (stat.isFile()) {
238
+ files.push(fullPath);
239
+ } else if (stat.isDirectory()) {
240
+ walkDir(fullPath, files);
241
+ }
242
+ }
243
+ return files;
244
+ }
245
+
246
+ function walkDir(dir, out) {
247
+ for (const entry of fs.readdirSync(dir)) {
248
+ const ep = path.join(dir, entry);
249
+ const stat = fs.statSync(ep);
250
+ if (stat.isDirectory()) walkDir(ep, out);
251
+ else out.push(ep);
252
+ }
253
+ }
254
+
255
+ function snapshotUserSections(files) {
256
+ const map = new Map();
257
+ for (const f of files) {
258
+ try {
259
+ const content = fs.readFileSync(f, 'utf8');
260
+ const userSection = extractUserSections(content);
261
+ if (userSection.trim().length > 0) map.set(f, userSection);
262
+ } catch {}
263
+ }
264
+ return map;
265
+ }
266
+
267
+ /**
268
+ * Extract everything AFTER the closing ENTERPRISE-MANAGED marker.
269
+ * That's adopter-authored content that the framework should never touch.
270
+ *
271
+ * @param {string} content
272
+ * @returns {string}
273
+ */
274
+ function extractUserSections(content) {
275
+ const endMatch = content.match(ENTERPRISE_MANAGED_END_RE);
276
+ if (!endMatch) return '';
277
+ const after = content.slice(endMatch.index + endMatch[0].length);
278
+ return after;
279
+ }
280
+
281
+ function mergeUserSection(current, userSection) {
282
+ const endMatch = current.match(ENTERPRISE_MANAGED_END_RE);
283
+ if (endMatch) {
284
+ // Replace whatever's after the END marker with the snapshotted user section
285
+ return current.slice(0, endMatch.index + endMatch[0].length) + userSection;
286
+ }
287
+ // No boundary in current; append the user section
288
+ return current.trimEnd() + '\n\n' + userSection.trimStart();
289
+ }
290
+
291
+ function createBackup({ repoRoot, backupDir, knowledgeFiles, now }) {
292
+ if (knowledgeFiles.length === 0) return null;
293
+
294
+ const fullBackupDir = path.join(repoRoot, backupDir);
295
+ fs.mkdirSync(fullBackupDir, { recursive: true });
296
+
297
+ // HC-005-B: append 4-char random suffix to prevent same-millisecond
298
+ // collision when concurrent `hone refresh-knowledge` runs land in the
299
+ // same backup dir.
300
+ const stamp = now().toISOString().replace(/[:.]/g, '-');
301
+ const suffix = crypto.randomBytes(2).toString('hex'); // 4 hex chars
302
+ const filename = `knowledge-${stamp}-${suffix}.tar.gz`;
303
+ const backupPath = path.join(fullBackupDir, filename);
304
+
305
+ // Build relative paths for tar
306
+ const relPaths = knowledgeFiles
307
+ .map(f => path.relative(repoRoot, f))
308
+ .filter(Boolean);
309
+
310
+ if (relPaths.length === 0) return null;
311
+
312
+ // Use system tar for portability
313
+ try {
314
+ execSync(`tar -czf "${backupPath}" ${relPaths.map(p => `"${p}"`).join(' ')}`, {
315
+ cwd: repoRoot,
316
+ stdio: ['ignore', 'pipe', 'pipe'],
317
+ });
318
+ } catch (e) {
319
+ throw new Error(`tar failed: ${e.message}`);
320
+ }
321
+
322
+ return backupPath;
323
+ }
324
+
325
+ function restoreFromBackup({ repoRoot, backupPath }) {
326
+ execSync(`tar -xzf "${backupPath}"`, {
327
+ cwd: repoRoot,
328
+ stdio: ['ignore', 'pipe', 'pipe'],
329
+ });
330
+ }
331
+
332
+ function wrap(findings, backupPath, channelResults, restored, dryRun) {
333
+ const summary = {
334
+ total: findings.length,
335
+ errors: findings.filter(f => f.severity === 'ERROR').length,
336
+ warnings: findings.filter(f => f.severity === 'WARN').length,
337
+ info: findings.filter(f => f.severity === 'INFO').length,
338
+ };
339
+ const diffSummary = {
340
+ filesRestored: restored.length,
341
+ };
342
+ return {
343
+ findings,
344
+ summary,
345
+ backupPath,
346
+ channelResults,
347
+ diffSummary,
348
+ restored,
349
+ dryRun,
350
+ };
351
+ }
352
+
353
+ module.exports = {
354
+ refreshKnowledge,
355
+ defaultRunChannel,
356
+ extractUserSections,
357
+ mergeUserSection,
358
+ collectKnowledgeFiles,
359
+ KNOWLEDGE_DIRS,
360
+ };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+ /**
3
+ * rule-resolver.js — H-090 single source of truth for "what rules are in
4
+ * effect in this repo?"
5
+ *
6
+ * Composition primitive for the editor-integrity helpers. Reads canonical
7
+ * rule files from `.hone/agents/<step>.agent.md` + adopter overlays from
8
+ * `.hone-local/rule-overlays/<step>.<rule>.md` and returns a unified view:
9
+ *
10
+ * { canonical, overlays, merged }
11
+ *
12
+ * Three downstream consumers (per architect plan #117):
13
+ * - cli/lib/pipeline-validate.js (H-091): asserts editor projections
14
+ * match `merged` (not canonical alone) when overlays exist
15
+ * - cli/lib/synthetic-pipeline.js (H-092): synthetic verifier uses
16
+ * `merged` as the assertion baseline
17
+ * - cli/hone-cli.js (future): debug surfacing for adopters
18
+ *
19
+ * Pure helper with injected I/O (same shape as platform-detect.js).
20
+ * Caller provides `fileExists` + `readFile`; helper handles the
21
+ * canonical/overlay loading + merge composition.
22
+ *
23
+ * IMPORTANT: This helper DELEGATES merge math to overlay-merge.js.
24
+ * It does NOT reimplement insert-mode logic. The architect's risk R2
25
+ * ("resolver re-implements merge semantics, drifts over time") is
26
+ * mitigated by always calling mergeOverlays() rather than building
27
+ * a parallel implementation.
28
+ *
29
+ * Closes architect plan #117 H-090.
30
+ */
31
+
32
+ const { mergeOverlays } = require('./overlay-merge');
33
+
34
+ const CANONICAL_AGENTS_DIR = '.hone/agents';
35
+ const OVERLAYS_DIR = '.hone-local/rule-overlays';
36
+
37
+ /**
38
+ * Resolve all rules in effect — canonical + overlays + merged.
39
+ *
40
+ * @param {object} opts
41
+ * @param {string} opts.repoRoot - absolute path to repo root
42
+ * @param {(relativePath: string) => boolean} opts.fileExists - filesystem check
43
+ * @param {(relativePath: string) => string|null} opts.readFile - file reader
44
+ * @param {string} [opts.step] - filter to one step name (e.g., 'code-reviewer')
45
+ * @param {string} [opts.rule] - filter to one rule_id within that step
46
+ * @returns {{
47
+ * canonical: Array<{step, path, content}>,
48
+ * overlays: Array<{step, ruleId, path, content}>,
49
+ * merged: Array<{step, content, hasOverlay, overlayCount, errors}>
50
+ * }}
51
+ */
52
+ function resolveRules(opts = {}) {
53
+ const { repoRoot, fileExists, readFile, step: stepFilter, rule: ruleFilter } = opts;
54
+
55
+ // Defensive: missing required I/O → empty result (non-throwing)
56
+ if (typeof fileExists !== 'function' || typeof readFile !== 'function') {
57
+ return {
58
+ canonical: [],
59
+ overlays: [],
60
+ merged: [],
61
+ _error: 'fileExists and readFile callbacks required',
62
+ };
63
+ }
64
+
65
+ // ─── 1. Canonical rule files in .hone/agents/<step>.agent.md ───
66
+ const canonical = [];
67
+ // We don't walk the directory here — caller's fileExists + readFile must
68
+ // be backed by something that can list. Instead, we look up by the
69
+ // standard step names. Caller can extend STANDARD_STEPS by passing a
70
+ // custom step filter.
71
+ const STANDARD_STEPS = [
72
+ 'story-groomer', 'implementation-planner', 'unit-test-writer',
73
+ 'e2e-qa-planner', 'e2e-test-spec-writer', 'code-builder', 'code-reviewer',
74
+ ];
75
+ const stepsToCheck = stepFilter ? [stepFilter] : STANDARD_STEPS;
76
+
77
+ for (const stepName of stepsToCheck) {
78
+ const path = `${CANONICAL_AGENTS_DIR}/${stepName}.agent.md`;
79
+ if (fileExists(path)) {
80
+ const content = readFile(path);
81
+ if (typeof content === 'string') {
82
+ canonical.push({ step: stepName, path, content });
83
+ }
84
+ }
85
+ }
86
+
87
+ // ─── 2. Adopter overlays in .hone-local/rule-overlays/<step>.<rule>.md ───
88
+ // We can't list a directory through fileExists/readFile alone. Caller may
89
+ // supply opts.overlayPaths if they've walked the dir; otherwise we attempt
90
+ // common overlay file names.
91
+ const overlays = [];
92
+ const overlayPaths = Array.isArray(opts.overlayPaths) ? opts.overlayPaths : [];
93
+ for (const overlayPath of overlayPaths) {
94
+ if (!overlayPath.startsWith(`${OVERLAYS_DIR}/`)) continue;
95
+ const fname = overlayPath.slice(OVERLAYS_DIR.length + 1); // strip prefix
96
+ // Filename convention: `<step>.<rule_id>.md`
97
+ const m = fname.match(/^([a-z-]+)\.([A-Za-z0-9-]+)\.md$/);
98
+ if (!m) continue;
99
+ const [, step, ruleId] = m;
100
+ if (stepFilter && step !== stepFilter) continue;
101
+ if (ruleFilter && ruleId !== ruleFilter) continue;
102
+ if (fileExists(overlayPath)) {
103
+ const content = readFile(overlayPath);
104
+ if (typeof content === 'string') {
105
+ overlays.push({ step, ruleId, path: overlayPath, content });
106
+ }
107
+ }
108
+ }
109
+
110
+ // ─── 3. Merged view per step (canonical + overlays for that step) ───
111
+ const merged = [];
112
+ for (const c of canonical) {
113
+ const stepOverlays = overlays.filter((o) => o.step === c.step);
114
+ if (stepOverlays.length === 0) {
115
+ // No overlays — merged equals canonical byte-for-byte
116
+ merged.push({
117
+ step: c.step,
118
+ content: c.content,
119
+ hasOverlay: false,
120
+ overlayCount: 0,
121
+ errors: [],
122
+ });
123
+ continue;
124
+ }
125
+ // Delegate to overlay-merge.js for the actual merge math
126
+ const result = mergeOverlays(c.content, stepOverlays.map((o) => ({
127
+ path: o.path,
128
+ content: o.content,
129
+ })));
130
+ merged.push({
131
+ step: c.step,
132
+ content: result.merged,
133
+ hasOverlay: true,
134
+ overlayCount: stepOverlays.length,
135
+ errors: result.errors,
136
+ });
137
+ }
138
+
139
+ return { canonical, overlays, merged };
140
+ }
141
+
142
+ module.exports = {
143
+ CANONICAL_AGENTS_DIR,
144
+ OVERLAYS_DIR,
145
+ resolveRules,
146
+ };