@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.
- package/bin/hone.js +2 -0
- package/hone-cli.js +4006 -0
- package/lib/README.md +119 -0
- package/lib/adversarial-negative-lint.js +149 -0
- package/lib/audit.js +156 -0
- package/lib/auto-detect.js +213 -0
- package/lib/autofix-guardrails.js +124 -0
- package/lib/branch-protection.js +256 -0
- package/lib/ci-classifier.js +150 -0
- package/lib/ci-failures.js +173 -0
- package/lib/claude-md-tokens.js +71 -0
- package/lib/compliance-check.js +62 -0
- package/lib/config-augment.js +133 -0
- package/lib/config-update.js +70 -0
- package/lib/dependency-audit.js +108 -0
- package/lib/derive-domain.js +185 -0
- package/lib/doc-registry.js +63 -0
- package/lib/doctor-admin-merge.js +185 -0
- package/lib/doctor-bind-default.js +118 -0
- package/lib/doctor-docs.js +205 -0
- package/lib/doctor-placeholders.js +144 -0
- package/lib/doctor-skill-staleness.js +122 -0
- package/lib/domain-skill-template.md +114 -0
- package/lib/editor-detect.js +169 -0
- package/lib/fast-track-ratify.js +133 -0
- package/lib/git-helpers.js +109 -0
- package/lib/hook-templates/pre-commit.sh +54 -0
- package/lib/hook-templates/pre-push.sh +72 -0
- package/lib/install-hooks.js +205 -0
- package/lib/knowledge-graph.js +188 -0
- package/lib/learnings-audit.js +254 -0
- package/lib/learnings-parse.js +331 -0
- package/lib/learnings-sync.js +75 -0
- package/lib/mcp-detect.js +154 -0
- package/lib/metrics-collect.js +214 -0
- package/lib/overlay-merge.js +267 -0
- package/lib/performance-analyzer.js +142 -0
- package/lib/pipeline-config.js +83 -0
- package/lib/pipeline-status.js +207 -0
- package/lib/pipeline-validate.js +322 -0
- package/lib/platform-detect.js +86 -0
- package/lib/platform-discover.js +334 -0
- package/lib/publish-learning.js +160 -0
- package/lib/python-install.js +84 -0
- package/lib/refresh-check.js +67 -0
- package/lib/refresh-knowledge.js +360 -0
- package/lib/rule-resolver.js +146 -0
- package/lib/security-scanner.js +168 -0
- package/lib/setup-grounding.js +138 -0
- package/lib/skill-assertions.js +276 -0
- package/lib/skill-audit-render.js +158 -0
- package/lib/skill-audit.js +391 -0
- package/lib/stack-detect.js +170 -0
- package/lib/stack-paths.js +285 -0
- package/lib/story-classifier-extract.js +203 -0
- package/lib/story-classifier.js +282 -0
- package/lib/sync-overwrite.js +47 -0
- package/lib/synthetic-pipeline.js +299 -0
- package/lib/validate-metadata.js +175 -0
- 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
|
+
};
|