@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
package/hone-cli.js ADDED
@@ -0,0 +1,4006 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * @hone-ai/cli — Hone AI Command Line Interface
5
+ *
6
+ * Bridges the gap between setup-ai-pipeline.sh v3.1 and the Hone server.
7
+ * The key insight: setup-ai-pipeline.sh needs ENTERPRISE_SOURCE = enterprise-github/.github
8
+ * The CLI downloads this from the Hone server into a temp directory,
9
+ * then runs the script with --source pointing to that temp dir.
10
+ *
11
+ * Commands:
12
+ * hone setup — Download + run setup-ai-pipeline.sh v3.1
13
+ * hone derive — Bundle repo + POST /derive
14
+ * hone refresh — POST /derive/refresh (diff mode)
15
+ * hone verify — Health check against Hone server
16
+ * hone sync — Pull latest skills from server to local .github/skills/
17
+ */
18
+
19
+ const { Command } = require('commander');
20
+ const axios = require('axios');
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const crypto = require('crypto');
25
+ const { execSync } = require('child_process');
26
+
27
+ const pkg = require('./package.json');
28
+ const program = new Command();
29
+
30
+ // ── Config resolution ─────────────────────────────────────────────────────────
31
+ const RC_PATH = path.join(os.homedir(), '.honerc');
32
+
33
+ function readRc() {
34
+ try {
35
+ if (fs.existsSync(RC_PATH)) return JSON.parse(fs.readFileSync(RC_PATH, 'utf8'));
36
+ } catch {}
37
+ return {};
38
+ }
39
+
40
+ function getConfig() {
41
+ // Priority: env var → ~/.honerc → error
42
+ const rc = readRc();
43
+ const token = process.env.HONE_TOKEN || rc.token;
44
+ const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
45
+
46
+ if (!token) {
47
+ console.error('Error: Hone token not found.');
48
+ console.error('Run "hone init --token <token>" to configure,');
49
+ console.error('or set the HONE_TOKEN environment variable.');
50
+ process.exit(1);
51
+ }
52
+
53
+ return { token, apiUrl };
54
+ }
55
+
56
+ function api(config) {
57
+ return axios.create({
58
+ baseURL: config.apiUrl,
59
+ headers: {
60
+ Authorization: `Bearer ${config.token}`,
61
+ 'User-Agent': `@hone-ai/cli/${pkg.version}`,
62
+ },
63
+ timeout: 30_000,
64
+ });
65
+ }
66
+
67
+ // ── SETUP command ─────────────────────────────────────────────────────────────
68
+ program
69
+ .command('setup')
70
+ .description('Run setup-ai-pipeline.sh v3.1 — detects stack, scaffolds agents + skills')
71
+ .option('--dry-run', 'Preview what would be created without writing files')
72
+ .option('--non-interactive', 'Use detected defaults without prompting')
73
+ .option('--stack <stack>', 'Override stack detection (node|java|python|dotnet|salesforce)')
74
+ .option('--install-tests', 'Install unit test framework (vitest/jest/pytest) + create config')
75
+ .option('--e2e', 'Also install Playwright E2E framework (use with --install-tests)')
76
+ .option('--no-e2e', 'Skip Playwright even when --install-tests is set')
77
+ .option('--no-branch-protection', 'Skip installing GitHub branch protection on the default branch (H-001)')
78
+ .option('--refresh', 'Re-scan platform metadata without re-running full setup (HC-013c)')
79
+ .action(async (opts) => {
80
+ const config = getConfig();
81
+ const client = api(config);
82
+
83
+ // ── --refresh mode: re-ground platform config only ──────────────
84
+ if (opts.refresh) {
85
+ console.log('Hone AI — Platform Refresh');
86
+ console.log('==============================');
87
+ console.log('');
88
+
89
+ const repoRoot = process.cwd();
90
+ const configPath = path.join(repoRoot, '.github', '.pipeline-config.yml');
91
+
92
+ if (!fs.existsSync(configPath)) {
93
+ console.error('No .pipeline-config.yml found. Run `hone setup` first.');
94
+ process.exit(1);
95
+ }
96
+
97
+ const yaml = require('js-yaml');
98
+ const configText = fs.readFileSync(configPath, 'utf8');
99
+ const parsedConfig = yaml.load(configText);
100
+
101
+ const { groundPlatformConfig } = require('./lib/setup-grounding');
102
+ const result = groundPlatformConfig({
103
+ repoRoot,
104
+ configText,
105
+ parsedConfig,
106
+ refresh: true,
107
+ readFile: (rel) => {
108
+ try { return fs.readFileSync(path.join(repoRoot, rel), 'utf8'); }
109
+ catch { return null; }
110
+ },
111
+ listDir: (rel) => {
112
+ try { return fs.readdirSync(path.join(repoRoot, rel)); }
113
+ catch { return []; }
114
+ },
115
+ exec: (cmd) => {
116
+ try {
117
+ const stdout = require('child_process').execSync(cmd, {
118
+ encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
119
+ });
120
+ return { stdout: stdout.trim(), exitCode: 0 };
121
+ } catch (e) {
122
+ return { stdout: e.stdout || '', exitCode: e.status || 1 };
123
+ }
124
+ },
125
+ loadRegistry: (platform) => {
126
+ const rp = path.join(__dirname, '..', 'server', 'seeds', 'platform-docs', `${platform}.yml`);
127
+ try {
128
+ if (fs.existsSync(rp)) return yaml.load(fs.readFileSync(rp, 'utf8'));
129
+ } catch { /* no registry */ }
130
+ return null;
131
+ },
132
+ });
133
+
134
+ if (!result.skipped) {
135
+ fs.writeFileSync(configPath, result.augmentedConfig);
136
+ const platforms = Object.keys(result.platforms);
137
+ console.log(`✓ Platform grounding refreshed: ${platforms.join(', ')}`);
138
+ for (const [p, data] of Object.entries(result.platforms)) {
139
+ const mc = data.discovery.metadata_types;
140
+ const total = mc.code.length + mc.config.length + mc.test.length;
141
+ console.log(` ${p}: ${total} metadata types, ${data.selectedDocs.length} doc URLs, MCP: ${data.mcp.available ? 'available' : 'not detected'}`);
142
+ }
143
+ for (const w of (result.warnings || [])) console.log(` ⚠ ${w}`);
144
+ } else {
145
+ console.log(`ℹ Refresh skipped: ${result.reason}`);
146
+ }
147
+ return;
148
+ }
149
+
150
+ console.log('Hone AI — Pipeline Setup');
151
+ console.log('==============================');
152
+ console.log('');
153
+
154
+ // 1. Fetch org config to confirm connectivity and tier
155
+ let orgConfig;
156
+ try {
157
+ const r = await client.get('/scripts/config');
158
+ orgConfig = r.data;
159
+ console.log(`Org: ${orgConfig.org} | Tier: ${orgConfig.tier}`);
160
+ } catch (e) {
161
+ console.error(`Cannot connect to Hone server: ${e.message}`);
162
+ process.exit(1);
163
+ }
164
+
165
+ // 2. Create temp enterprise-github/.github directory
166
+ // This is the ENTERPRISE_SOURCE that setup-ai-pipeline.sh needs
167
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hone-'));
168
+ const enterpriseGH = path.join(tmpDir, 'enterprise-github', '.github');
169
+ const agentsDir = path.join(enterpriseGH, 'agents');
170
+ const skillsDir = path.join(enterpriseGH, 'skills');
171
+
172
+ fs.mkdirSync(agentsDir, { recursive: true });
173
+ fs.mkdirSync(skillsDir, { recursive: true });
174
+
175
+ console.log('');
176
+ console.log('Downloading enterprise assets from Hone server...');
177
+
178
+ // 3–5b. Download ALL enterprise assets in parallel (agents, skills, copilot-instructions, workflows, reviewer)
179
+ // Previous: 21 sequential HTTP calls (~3s). Now: 1 parallel batch (~0.5s).
180
+ const agents = [
181
+ 'story-groomer', 'implementation-planner', 'unit-test-case-writer',
182
+ 'e2e-qa-planner', 'e2e-test-spec-writer', 'e2e-qa-spec-healer',
183
+ 'code-builder', 'code-reviewer', 'delivery-architect',
184
+ ];
185
+ const skills = [
186
+ 'architecture-patterns', 'api-standards', 'auth-service',
187
+ 'error-handling', 'test-patterns', 'pr-review-standards',
188
+ 'reliability', 'webapp-testing',
189
+ ];
190
+
191
+ const workflowsDir = path.join(enterpriseGH, 'workflows');
192
+ const scriptsEntDir = path.join(enterpriseGH, 'scripts');
193
+ fs.mkdirSync(workflowsDir, { recursive: true });
194
+ fs.mkdirSync(scriptsEntDir, { recursive: true });
195
+
196
+ // Build parallel fetch list
197
+ const fetches = [
198
+ // Agent stubs
199
+ ...agents.map(agent => ({
200
+ key: `${agent}.agent.md`,
201
+ promise: client.get(`/scripts/agent-stub/${agent}`).then(r => r.data),
202
+ write: (data) => fs.writeFileSync(path.join(agentsDir, `${agent}.agent.md`), data),
203
+ })),
204
+ // Skill files
205
+ ...skills.map(skill => ({
206
+ key: `skills/${skill}/SKILL.md`,
207
+ promise: client.get(`/skills/${skill}`).then(r => r.data),
208
+ write: (data) => {
209
+ const skillDir = path.join(skillsDir, skill);
210
+ fs.mkdirSync(skillDir, { recursive: true });
211
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), data);
212
+ },
213
+ })),
214
+ // Copilot instructions
215
+ {
216
+ key: 'copilot-instructions.md',
217
+ promise: client.get('/scripts/copilot-instructions').then(r => r.data),
218
+ write: (data) => fs.writeFileSync(path.join(enterpriseGH, 'copilot-instructions.md'), data),
219
+ },
220
+ // AI review workflow template
221
+ {
222
+ key: 'workflows/ai-review.yml',
223
+ promise: client.get('/scripts/workflow-template', { responseType: 'text', headers: { Accept: 'text/yaml' } }).then(r => r.data),
224
+ write: (data) => fs.writeFileSync(path.join(workflowsDir, 'ai-review.yml'), data),
225
+ },
226
+ // AI reviewer script
227
+ {
228
+ key: 'scripts/ai-reviewer.js',
229
+ promise: client.get('/scripts/reviewer-script', { responseType: 'text', headers: { Accept: 'text/javascript' } }).then(r => r.data),
230
+ write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'ai-reviewer.js'), data),
231
+ },
232
+ // H-007: Learnings gate helper (imported by ai-reviewer at CI time).
233
+ // Both files are ESM. The .github/scripts/package.json (next entry below)
234
+ // declares ESM scope for this directory so node treats both .js files as
235
+ // ES modules regardless of the adopter's root package.json (see #16).
236
+ {
237
+ key: 'scripts/check-learnings-gate.js',
238
+ promise: client.get('/scripts/learnings-gate-script', { responseType: 'text', headers: { Accept: 'text/javascript' } }).then(r => r.data),
239
+ write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'check-learnings-gate.js'), data),
240
+ },
241
+ // H-007: Subdir package.json declares ESM scope for .github/scripts/.
242
+ // Both ai-reviewer.js and check-learnings-gate.js use `import` syntax;
243
+ // without this file, node treats them as CommonJS (the npm-init default)
244
+ // and the workflow fails with `Cannot use import statement outside a
245
+ // module`. Subdir package.json scope OVERRIDES the adopter's root —
246
+ // adopter's main project module semantics are unaffected. See #16.
247
+ {
248
+ key: 'scripts/package.json',
249
+ promise: Promise.resolve('{"type": "module"}\n'),
250
+ write: (data) => fs.writeFileSync(path.join(scriptsEntDir, 'package.json'), data),
251
+ },
252
+ // H-004: Auto-promote workflow template (installed at setup time;
253
+ // adopters customize {{BRANCHES}} via .pipeline-config.yml's
254
+ // ci.trigger_branches setting via setup-ai-pipeline.sh substitution).
255
+ // Goes to .github/workflows/learnings-promote.yml in the adopter repo.
256
+ {
257
+ key: 'workflows/learnings-promote.yml',
258
+ promise: client.get('/scripts/learnings-promote-template', { responseType: 'text', headers: { Accept: 'text/yaml' } }).then(r => r.data),
259
+ write: (data) => fs.writeFileSync(path.join(workflowsDir, 'learnings-promote.yml'), data),
260
+ },
261
+ // Metrics README (schema doc — needed by agents on ALL tiers to write per-story scorecards)
262
+ {
263
+ key: 'metrics/README.md',
264
+ promise: client.get('/scripts/metrics/readme', { responseType: 'text' }).then(r => r.data),
265
+ write: (data) => {
266
+ const metricsDir = path.join(enterpriseGH, 'metrics');
267
+ fs.mkdirSync(metricsDir, { recursive: true });
268
+ fs.writeFileSync(path.join(metricsDir, 'README.md'), data);
269
+ },
270
+ },
271
+ // Docs README (pipeline asset index + freshness rules — all tiers)
272
+ {
273
+ key: 'docs/sdlc/README.md',
274
+ promise: client.get('/scripts/docs/readme', { responseType: 'text' }).then(r => r.data),
275
+ write: (data) => {
276
+ const docsDir = path.join(tmpDir, 'enterprise-github', 'docs', 'sdlc');
277
+ fs.mkdirSync(docsDir, { recursive: true });
278
+ fs.writeFileSync(path.join(docsDir, 'README.md'), data);
279
+ },
280
+ },
281
+ // CLAUDE.md template for Claude Code integration (installed at repo root)
282
+ {
283
+ key: 'CLAUDE.md (Claude Code)',
284
+ promise: client.get('/scripts/claude-md', { responseType: 'text' }).then(r => r.data),
285
+ write: (data) => {
286
+ // H-002b: Substitute stack-aware test/lint commands at install time.
287
+ // Stack derived from detected primary; falls back to node defaults
288
+ // gracefully (E5). Unknown {{TOKEN}}s left intact (E4).
289
+ const { substituteClaudeMdTokens, getClaudeMdVarsForStack } = require('./lib/claude-md-tokens');
290
+ const detected = detectExistingTestFramework(process.cwd());
291
+ const stack = detected.stack === 'unknown' ? 'node' : detected.stack;
292
+ const vars = getClaudeMdVarsForStack(stack);
293
+ const substituted = substituteClaudeMdTokens(data, vars);
294
+ // Write substituted output to temp dir — copied to repo root after bash script runs
295
+ fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), substituted);
296
+ },
297
+ },
298
+ ];
299
+
300
+ // Execute all in parallel
301
+ const results = await Promise.allSettled(fetches.map(f => f.promise));
302
+ results.forEach((result, i) => {
303
+ const { key, write } = fetches[i];
304
+ if (result.status === 'fulfilled') {
305
+ write(result.value);
306
+ process.stdout.write(` ✓ ${key}\n`);
307
+ } else {
308
+ process.stdout.write(` ⚠ ${key} — ${result.reason?.message || 'failed'}\n`);
309
+ }
310
+ });
311
+
312
+ // 6. Download setup-ai-pipeline.sh, verify HMAC, write to tmp
313
+ let scriptContent;
314
+ try {
315
+ const r = await client.get('/scripts/setup', {
316
+ headers: { Accept: 'text/plain' },
317
+ responseType: 'text',
318
+ });
319
+ scriptContent = r.data;
320
+ const hmacHeader = r.headers['x-hone-hmac'];
321
+
322
+ // Verify HMAC if secret is known (optional — server enforces auth anyway)
323
+ if (process.env.HONE_HMAC_SECRET && hmacHeader) {
324
+ const computed = crypto
325
+ .createHmac('sha256', process.env.HONE_HMAC_SECRET)
326
+ .update(scriptContent)
327
+ .digest('hex');
328
+ if (computed !== hmacHeader) {
329
+ console.error('HMAC verification failed — script may have been tampered with');
330
+ process.exit(1);
331
+ }
332
+ console.log(' ✓ Script HMAC verified');
333
+ }
334
+ } catch (e) {
335
+ console.error(`Failed to download setup script: ${e.message}`);
336
+ process.exit(1);
337
+ }
338
+
339
+ // 7. Write script to tmp, make executable
340
+ const scriptPath = path.join(tmpDir, 'setup-ai-pipeline.sh');
341
+ fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
342
+
343
+ // 7b. Stage cli/lib/stack-detect.js inside <tmpDir>/enterprise-github/cli/lib/
344
+ // so the bash rebalance helper at setup-ai-pipeline.sh can resolve it
345
+ // via `${ENTERPRISE_SOURCE}/../cli/lib/stack-detect.js`. H-002a (#11).
346
+ // The helper is required by the rebalance_node_python() function
347
+ // when both Node and Python manifests are present.
348
+ try {
349
+ const stackDetectSrc = path.join(__dirname, 'lib', 'stack-detect.js');
350
+ if (fs.existsSync(stackDetectSrc)) {
351
+ const stackDetectDstDir = path.join(tmpDir, 'enterprise-github', 'cli', 'lib');
352
+ fs.mkdirSync(stackDetectDstDir, { recursive: true });
353
+ fs.copyFileSync(stackDetectSrc, path.join(stackDetectDstDir, 'stack-detect.js'));
354
+ }
355
+ } catch (e) {
356
+ // Non-fatal: bash script falls back to greedy default when helper missing.
357
+ console.log(` ⚠ Could not stage stack-detect.js: ${e.message} (rebalance will skip)`);
358
+ }
359
+
360
+ // 8. Run setup-ai-pipeline.sh with --source pointing to our temp enterprise-github dir
361
+ console.log('');
362
+ console.log('Running setup-ai-pipeline.sh v3.1...');
363
+ console.log('');
364
+
365
+ const flags = [
366
+ `--source "${path.join(tmpDir, 'enterprise-github')}"`,
367
+ opts.dryRun ? '--dry-run' : '',
368
+ opts.nonInteractive? '--non-interactive' : '',
369
+ ].filter(Boolean).join(' ');
370
+
371
+ try {
372
+ execSync(`bash "${scriptPath}" ${flags}`, {
373
+ stdio: 'inherit',
374
+ cwd: process.cwd(),
375
+ env: { ...process.env },
376
+ });
377
+ } catch (e) {
378
+ console.error('Setup script failed:', e.message);
379
+ process.exit(1);
380
+ }
381
+
382
+ // ── Phase 1b: Install Claude Code files (CLAUDE.md + .claude/agents/) ────
383
+ const repoRoot = process.cwd();
384
+ const claudeMdSrc = path.join(tmpDir, 'CLAUDE.md');
385
+ const claudeMdDst = path.join(repoRoot, 'CLAUDE.md');
386
+
387
+ // Install CLAUDE.md (only if it doesn't already exist — don't overwrite custom project context)
388
+ if (fs.existsSync(claudeMdSrc)) {
389
+ if (!fs.existsSync(claudeMdDst)) {
390
+ fs.copyFileSync(claudeMdSrc, claudeMdDst);
391
+ console.log(' ✓ CLAUDE.md installed (Claude Code pipeline context)');
392
+ } else {
393
+ console.log(' CLAUDE.md already exists — skipping (manual merge recommended)');
394
+ }
395
+ }
396
+
397
+ // Create .claude/agents/ with symlinks or copies of .github/agents/
398
+ const ghAgentsDir = path.join(repoRoot, '.github', 'agents');
399
+ const claudeAgentsDir = path.join(repoRoot, '.claude', 'agents');
400
+ if (fs.existsSync(ghAgentsDir)) {
401
+ fs.mkdirSync(claudeAgentsDir, { recursive: true });
402
+ const agentFiles = fs.readdirSync(ghAgentsDir).filter(f => f.endsWith('.agent.md'));
403
+ for (const file of agentFiles) {
404
+ const src = path.join(ghAgentsDir, file);
405
+ const dst = path.join(claudeAgentsDir, file);
406
+ fs.copyFileSync(src, dst);
407
+ }
408
+ console.log(` ✓ .claude/agents/ created (${agentFiles.length} agents mirrored from .github/agents/)`);
409
+ }
410
+
411
+ // ── Phase 1c: Platform grounding (HC-013b-setup) ──────────────────────
412
+ // Runs AFTER bash script writes .pipeline-config.yml.
413
+ // Discovers metadata types, detects MCP, selects doc registry URLs,
414
+ // augments config with platform.metadata_types + config_paths + mcp.
415
+ try {
416
+ const configPath = path.join(repoRoot, '.github', '.pipeline-config.yml');
417
+ if (fs.existsSync(configPath)) {
418
+ const yaml = require('js-yaml');
419
+ const configText = fs.readFileSync(configPath, 'utf8');
420
+ const parsedConfig = yaml.load(configText);
421
+
422
+ const { groundPlatformConfig } = require('./lib/setup-grounding');
423
+ const groundResult = groundPlatformConfig({
424
+ repoRoot,
425
+ configText,
426
+ parsedConfig,
427
+ readFile: (rel) => {
428
+ try { return fs.readFileSync(path.join(repoRoot, rel), 'utf8'); }
429
+ catch { return null; }
430
+ },
431
+ listDir: (rel) => {
432
+ try { return fs.readdirSync(path.join(repoRoot, rel)); }
433
+ catch { return []; }
434
+ },
435
+ exec: (cmd) => {
436
+ try {
437
+ const stdout = require('child_process').execSync(cmd, {
438
+ encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
439
+ });
440
+ return { stdout: stdout.trim(), exitCode: 0 };
441
+ } catch (e) {
442
+ return { stdout: e.stdout || '', exitCode: e.status || 1 };
443
+ }
444
+ },
445
+ loadRegistry: (platform) => {
446
+ // Load from server seeds (available in dev) or from tmp download
447
+ const registryPaths = [
448
+ path.join(__dirname, '..', 'server', 'seeds', 'platform-docs', `${platform}.yml`),
449
+ path.join(tmpDir, 'enterprise-github', 'platform-docs', `${platform}.yml`),
450
+ ];
451
+ for (const rp of registryPaths) {
452
+ try {
453
+ if (fs.existsSync(rp)) return yaml.load(fs.readFileSync(rp, 'utf8'));
454
+ } catch { /* continue to next path */ }
455
+ }
456
+ return null;
457
+ },
458
+ });
459
+
460
+ if (!groundResult.skipped) {
461
+ fs.writeFileSync(configPath, groundResult.augmentedConfig);
462
+ const platformNames = Object.keys(groundResult.platforms);
463
+ console.log(` ✓ Platform grounding: ${platformNames.join(', ')} (metadata discovered + config augmented)`);
464
+ for (const w of (groundResult.warnings || [])) {
465
+ console.log(` ⚠ ${w}`);
466
+ }
467
+ } else {
468
+ console.log(` ℹ Platform grounding skipped: ${groundResult.reason}`);
469
+ }
470
+ }
471
+ } catch (e) {
472
+ // Non-fatal: setup continues without platform grounding
473
+ console.log(` ⚠ Platform grounding failed: ${e.message} (setup continues)`);
474
+ }
475
+
476
+ // 9. Clean up temp directory
477
+ fs.rmSync(tmpDir, { recursive: true, force: true });
478
+
479
+ // ── Phase 2: Test framework installation (opt-in via --install-tests) ────
480
+ if (opts.installTests) {
481
+ await installTestFramework(process.cwd(), opts);
482
+ } else {
483
+ // Show recommendation if no test framework detected
484
+ recommendTestFramework(process.cwd());
485
+ }
486
+
487
+ // ── Phase 3: Branch protection (H-001) ───────────────────────────────────
488
+ //
489
+ // Install GitHub branch protection on the default branch so the
490
+ // AI Code Review gate we just scaffolded actually blocks merges —
491
+ // admin-merge cannot silently bypass it. Skips gracefully on
492
+ // non-GitHub remotes, missing gh CLI, no admin rights, plan
493
+ // restrictions, or org-level rulesets. Never fails the setup.
494
+ const skipBranchProtection =
495
+ opts.branchProtection === false ||
496
+ /^(1|true|yes)$/i.test(process.env.HONE_SKIP_BRANCH_PROTECTION || '');
497
+
498
+ const { installBranchProtection } = require('./lib/branch-protection');
499
+ const protResult = await installBranchProtection({
500
+ log: console.log,
501
+ opts: {
502
+ skip: skipBranchProtection,
503
+ requiredChecks: ['ai-code-review'],
504
+ },
505
+ });
506
+ console.log('');
507
+ if (protResult.status === 'installed') {
508
+ console.log(` ✓ Branch protection installed on '${protResult.branch}'`);
509
+ } else if (protResult.status === 'skipped') {
510
+ console.log(` ⚠ Branch protection skipped: ${protResult.reason}`);
511
+ if (protResult.manualCommand) {
512
+ console.log('');
513
+ console.log(' To install manually, run:');
514
+ console.log(protResult.manualCommand.split('\n').map(l => ' ' + l).join('\n'));
515
+ }
516
+ } else if (protResult.status === 'failed') {
517
+ console.log(` ⚠ Branch protection failed: ${protResult.reason}`);
518
+ if (protResult.manualCommand) {
519
+ console.log('');
520
+ console.log(' To install manually, run:');
521
+ console.log(protResult.manualCommand.split('\n').map(l => ' ' + l).join('\n'));
522
+ }
523
+ }
524
+ });
525
+
526
+ // ── Test framework installation helpers ────────────────────────────────────────
527
+
528
+ function detectExistingTestFramework(repoRoot) {
529
+ const pkgPath = path.join(repoRoot, 'package.json');
530
+ if (!fs.existsSync(pkgPath)) return { stack: 'unknown', testFw: null, pkgMgr: null };
531
+
532
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
533
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
534
+
535
+ // Detect stack
536
+ let stack = 'node';
537
+ if (allDeps['react']) stack = allDeps['vite'] ? 'react-vite' : 'react';
538
+ if (allDeps['next']) stack = 'nextjs';
539
+ if (allDeps['@nestjs/core']) stack = 'nestjs';
540
+
541
+ // Detect existing test framework
542
+ let testFw = null;
543
+ if (allDeps['vitest']) testFw = 'vitest';
544
+ else if (allDeps['jest']) testFw = 'jest';
545
+ else if (allDeps['mocha']) testFw = 'mocha';
546
+
547
+ // Detect Playwright
548
+ const hasPlaywright = !!allDeps['@playwright/test'];
549
+
550
+ // Detect package manager
551
+ let pkgMgr = 'npm';
552
+ if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) pkgMgr = 'yarn';
553
+ if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) pkgMgr = 'pnpm';
554
+
555
+ // Detect existing configs
556
+ const hasTestConfig = ['vitest.config.js', 'vitest.config.ts', 'jest.config.js', 'jest.config.ts', 'jest.config.cjs']
557
+ .some(f => fs.existsSync(path.join(repoRoot, f)));
558
+ const hasPlaywrightConfig = fs.existsSync(path.join(repoRoot, 'playwright.config.js'))
559
+ || fs.existsSync(path.join(repoRoot, 'playwright.config.ts'));
560
+
561
+ // Detect existing test script
562
+ const hasTestScript = !!pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1';
563
+
564
+ return { stack, testFw, hasPlaywright, pkgMgr, hasTestConfig, hasPlaywrightConfig, hasTestScript, pkg };
565
+ }
566
+
567
+ function getInstallPackages(stack) {
568
+ const packages = {
569
+ 'react-vite': {
570
+ unit: ['vitest', '@testing-library/react', '@testing-library/jest-dom', 'jsdom'],
571
+ e2e: ['@playwright/test'],
572
+ testCmd: 'vitest run',
573
+ testWatchCmd: 'vitest',
574
+ e2eCmd: 'playwright test',
575
+ configFile: 'vitest.config.js',
576
+ },
577
+ 'react': {
578
+ unit: ['vitest', '@testing-library/react', '@testing-library/jest-dom', 'jsdom'],
579
+ e2e: ['@playwright/test'],
580
+ testCmd: 'vitest run',
581
+ testWatchCmd: 'vitest',
582
+ e2eCmd: 'playwright test',
583
+ configFile: 'vitest.config.js',
584
+ },
585
+ 'nextjs': {
586
+ unit: ['jest', '@testing-library/react', '@testing-library/jest-dom', 'jest-environment-jsdom'],
587
+ e2e: ['@playwright/test'],
588
+ testCmd: 'jest',
589
+ testWatchCmd: 'jest --watch',
590
+ e2eCmd: 'playwright test',
591
+ configFile: 'jest.config.js',
592
+ },
593
+ 'node': {
594
+ unit: ['vitest'],
595
+ e2e: ['@playwright/test'],
596
+ testCmd: 'vitest run',
597
+ testWatchCmd: 'vitest',
598
+ e2eCmd: 'playwright test',
599
+ configFile: 'vitest.config.js',
600
+ },
601
+ 'nestjs': {
602
+ unit: ['jest', '@nestjs/testing', 'ts-jest'],
603
+ e2e: ['@playwright/test'],
604
+ testCmd: 'jest',
605
+ testWatchCmd: 'jest --watch',
606
+ e2eCmd: 'playwright test',
607
+ configFile: 'jest.config.js',
608
+ },
609
+ // H-002b: Python entry. Branches off via `pkgManagerKind: 'python'`.
610
+ 'python': require('./lib/python-install').PYTHON_INSTALL_CONFIG,
611
+ };
612
+ return packages[stack] || packages['node'];
613
+ }
614
+
615
+ // H-002b: Python install path — pip/poetry/pipenv branches.
616
+ // pyproject.toml gets [tool.pytest.ini_options] section (NOT package.json).
617
+ async function installPythonTestFramework(repoRoot, detected, pkgs, opts) {
618
+ const py = require('./lib/python-install');
619
+ const pyMgr = py.detectPythonPackageManager(repoRoot, (p) => fs.existsSync(p));
620
+ const cmd = py.getPythonInstallCommand(pyMgr);
621
+
622
+ console.log(` Stack: python (pkgMgr: ${pyMgr}) → ${cmd}`);
623
+ try {
624
+ execSync(cmd, { stdio: 'inherit', cwd: repoRoot });
625
+ console.log(` ✓ pytest installed`);
626
+ } catch (e) {
627
+ console.error(` ✗ Install failed: ${e.message}`);
628
+ return;
629
+ }
630
+
631
+ // Write [tool.pytest.ini_options] to pyproject.toml (create file if missing)
632
+ const tomlPath = path.join(repoRoot, 'pyproject.toml');
633
+ const sectionText = py.buildPyprojectTomlSection();
634
+ if (fs.existsSync(tomlPath)) {
635
+ const existing = fs.readFileSync(tomlPath, 'utf8');
636
+ if (/\[tool\.pytest\.ini_options\]/.test(existing)) {
637
+ console.log(` pyproject.toml already has [tool.pytest.ini_options] ✓ (skipping)`);
638
+ } else {
639
+ fs.writeFileSync(tomlPath, existing + (existing.endsWith('\n') ? '' : '\n') + '\n' + sectionText);
640
+ console.log(` ✓ [tool.pytest.ini_options] appended to pyproject.toml`);
641
+ }
642
+ } else {
643
+ fs.writeFileSync(tomlPath, sectionText);
644
+ console.log(` ✓ pyproject.toml created with [tool.pytest.ini_options]`);
645
+ }
646
+
647
+ // Sample test file
648
+ const testDir = path.join(repoRoot, 'tests');
649
+ const sampleTest = path.join(testDir, 'test_pipeline.py');
650
+ if (!fs.existsSync(sampleTest)) {
651
+ fs.mkdirSync(testDir, { recursive: true });
652
+ fs.writeFileSync(sampleTest, [
653
+ `def test_pipeline_setup():`,
654
+ ` """Smoke test — H-002b scaffold confirms pytest discovers tests."""`,
655
+ ` assert 1 + 1 == 2`,
656
+ ``,
657
+ `def test_environment_configured():`,
658
+ ` import sys`,
659
+ ` assert sys.version_info.major >= 3`,
660
+ ].join('\n'));
661
+ console.log(` ✓ tests/test_pipeline.py created`);
662
+ }
663
+
664
+ // E2E (Playwright Python) — opt-in via opts.e2e
665
+ const installE2e = opts && opts.e2e !== false && opts.noE2e !== true;
666
+ if (installE2e) {
667
+ console.log('');
668
+ console.log(` Skipping Playwright (Python) e2e auto-install — adopters opt in via \`pip install pytest-playwright\` separately.`);
669
+ console.log(` See pytest-playwright docs for details.`);
670
+ }
671
+ }
672
+
673
+ async function installTestFramework(repoRoot, opts) {
674
+ console.log('');
675
+ console.log('── Test Framework Setup ────────────────────────────────');
676
+ console.log('');
677
+
678
+ const detected = detectExistingTestFramework(repoRoot);
679
+ const pkgs = getInstallPackages(detected.stack);
680
+
681
+ // H-002b: Python branch — different install commands + pyproject.toml (not package.json)
682
+ if (pkgs.pkgManagerKind === 'python') {
683
+ return installPythonTestFramework(repoRoot, detected, pkgs, opts);
684
+ }
685
+
686
+ const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
687
+
688
+ // ── Unit test framework ────────────────────────────────
689
+ if (detected.testFw) {
690
+ console.log(` Test framework detected: ${detected.testFw} ✓ (skipping install)`);
691
+ } else {
692
+ console.log(` Stack: ${detected.stack} → Installing: ${pkgs.unit.join(', ')}`);
693
+ try {
694
+ execSync(`${installCmd} ${pkgs.unit.join(' ')}`, { stdio: 'inherit', cwd: repoRoot });
695
+ console.log(` ✓ Unit test framework installed`);
696
+ } catch (e) {
697
+ console.error(` ✗ Install failed: ${e.message}`);
698
+ return;
699
+ }
700
+ }
701
+
702
+ // ── Test config file ───────────────────────────────────
703
+ if (detected.hasTestConfig) {
704
+ console.log(` Test config exists ✓ (skipping creation)`);
705
+ } else {
706
+ const configSrc = path.join(__dirname, '..', 'server', 'scripts', 'test-configs', pkgs.configFile + '.template');
707
+ if (fs.existsSync(configSrc)) {
708
+ fs.copyFileSync(configSrc, path.join(repoRoot, pkgs.configFile));
709
+ console.log(` ✓ ${pkgs.configFile} created`);
710
+ } else {
711
+ // Inline fallback config for vitest
712
+ if (pkgs.configFile === 'vitest.config.js') {
713
+ fs.writeFileSync(path.join(repoRoot, pkgs.configFile), [
714
+ `import { defineConfig } from 'vitest/config';`,
715
+ ``,
716
+ `export default defineConfig({`,
717
+ ` test: {`,
718
+ ` environment: 'jsdom',`,
719
+ ` globals: true,`,
720
+ ` setupFiles: [],`,
721
+ ` include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}', 'test/**/*.{test,spec}.{js,jsx,ts,tsx}'],`,
722
+ ` },`,
723
+ ` resolve: {`,
724
+ ` alias: { '@': new URL('./src', import.meta.url).pathname },`,
725
+ ` },`,
726
+ `});`,
727
+ ].join('\n'));
728
+ console.log(` ✓ ${pkgs.configFile} created (inline)`);
729
+ }
730
+ }
731
+ }
732
+
733
+ // ── npm test script ────────────────────────────────────
734
+ if (!detected.hasTestScript) {
735
+ const pkgPath = path.join(repoRoot, 'package.json');
736
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
737
+ pkg.scripts = pkg.scripts || {};
738
+ pkg.scripts.test = pkgs.testCmd;
739
+ pkg.scripts['test:watch'] = pkgs.testWatchCmd;
740
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
741
+ console.log(` ✓ "test": "${pkgs.testCmd}" added to package.json`);
742
+ } else {
743
+ console.log(` Test script exists ✓ (skipping)`);
744
+ }
745
+
746
+ // ── Sample test file ───────────────────────────────────
747
+ const testDir = path.join(repoRoot, 'src', '__tests__');
748
+ const sampleTest = path.join(testDir, 'example.test.jsx');
749
+ if (!fs.existsSync(sampleTest)) {
750
+ fs.mkdirSync(testDir, { recursive: true });
751
+ fs.writeFileSync(sampleTest, [
752
+ `import { describe, it, expect } from 'vitest';`,
753
+ ``,
754
+ `describe('Pipeline setup', () => {`,
755
+ ` it('test framework is working', () => {`,
756
+ ` expect(1 + 1).toBe(2);`,
757
+ ` });`,
758
+ ``,
759
+ ` it('environment is configured', () => {`,
760
+ ` expect(typeof window).toBe('object');`,
761
+ ` });`,
762
+ `});`,
763
+ ].join('\n'));
764
+ console.log(` ✓ src/__tests__/example.test.jsx created`);
765
+ }
766
+
767
+ // ── E2E framework (Playwright) ─────────────────────────
768
+ const installE2e = opts.e2e !== false && opts.noE2e !== true;
769
+ if (installE2e) {
770
+ console.log('');
771
+ if (detected.hasPlaywright) {
772
+ console.log(` Playwright detected ✓ (skipping install)`);
773
+ } else {
774
+ console.log(` Installing Playwright...`);
775
+ try {
776
+ execSync(`${installCmd} @playwright/test`, { stdio: 'inherit', cwd: repoRoot });
777
+ console.log(` ✓ @playwright/test installed`);
778
+ console.log(` Installing Chromium browser...`);
779
+ execSync('npx playwright install chromium', { stdio: 'inherit', cwd: repoRoot });
780
+ console.log(` ✓ Chromium installed`);
781
+ } catch (e) {
782
+ console.error(` ✗ Playwright install failed: ${e.message}`);
783
+ }
784
+ }
785
+
786
+ // Playwright config
787
+ if (!detected.hasPlaywrightConfig) {
788
+ fs.writeFileSync(path.join(repoRoot, 'playwright.config.js'), [
789
+ `import { defineConfig } from '@playwright/test';`,
790
+ ``,
791
+ `export default defineConfig({`,
792
+ ` testDir: './e2e',`,
793
+ ` timeout: 30_000,`,
794
+ ` retries: process.env.CI ? 2 : 0,`,
795
+ ` use: {`,
796
+ ` baseURL: process.env.TEST_BASE_URL || 'http://localhost:5173',`,
797
+ ` headless: true,`,
798
+ ` screenshot: 'only-on-failure',`,
799
+ ` },`,
800
+ ` projects: [`,
801
+ ` { name: 'chromium', use: { browserName: 'chromium' } },`,
802
+ ` ],`,
803
+ `});`,
804
+ ].join('\n'));
805
+ console.log(` ✓ playwright.config.js created`);
806
+ }
807
+
808
+ // E2E npm script
809
+ const pkgPath = path.join(repoRoot, 'package.json');
810
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
811
+ if (!pkg.scripts?.['test:e2e']) {
812
+ pkg.scripts = pkg.scripts || {};
813
+ pkg.scripts['test:e2e'] = pkgs.e2eCmd;
814
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
815
+ console.log(` ✓ "test:e2e": "${pkgs.e2eCmd}" added to package.json`);
816
+ }
817
+
818
+ // Sample E2E spec
819
+ const e2eDir = path.join(repoRoot, 'e2e');
820
+ const sampleSpec = path.join(e2eDir, 'example.spec.js');
821
+ if (!fs.existsSync(sampleSpec)) {
822
+ fs.mkdirSync(e2eDir, { recursive: true });
823
+ fs.writeFileSync(sampleSpec, [
824
+ `import { test, expect } from '@playwright/test';`,
825
+ ``,
826
+ `test('app loads without errors', async ({ page }) => {`,
827
+ ` await page.goto('/');`,
828
+ ` // Verify the page loaded (no crash, no blank screen)`,
829
+ ` await expect(page).not.toHaveTitle('');`,
830
+ `});`,
831
+ ].join('\n'));
832
+ console.log(` ✓ e2e/example.spec.js created`);
833
+ }
834
+ }
835
+
836
+ // ── Interactive test context (.env.test) ───────────────
837
+ console.log('');
838
+ await createTestEnv(repoRoot, opts);
839
+
840
+ // ── Verify ─────────────────────────────────────────────
841
+ if (!detected.testFw) {
842
+ console.log('');
843
+ console.log('── Verification ────────────────────────────────────────');
844
+ try {
845
+ execSync('npm test 2>&1', { cwd: repoRoot, stdio: 'inherit', timeout: 30_000 });
846
+ console.log(' ✓ npm test passed');
847
+ } catch {
848
+ console.log(' ⚠ npm test had issues — review test output above');
849
+ }
850
+ }
851
+
852
+ console.log('');
853
+ console.log('────────────────────────────────────────────────────────');
854
+ console.log(' Test framework ready. Write tests using the Unit Test Writer agent (Step 2).');
855
+ }
856
+
857
+ async function createTestEnv(repoRoot, opts) {
858
+ const envTestPath = path.join(repoRoot, 'env.test');
859
+ const envExamplePath = path.join(repoRoot, '.env.test.example');
860
+
861
+ // .env.test.example (always created — committed as team template)
862
+ const template = [
863
+ '# Test environment template — copy to .env.test and fill in values',
864
+ '# Created by: hone setup --install-tests',
865
+ '# Documentation: docs/sdlc/README.md',
866
+ '',
867
+ '# Required for E2E tests (Playwright baseURL)',
868
+ 'TEST_BASE_URL=http://localhost:5173',
869
+ '',
870
+ '# Required for authenticated E2E flows',
871
+ 'TEST_USER_EMAIL=',
872
+ 'TEST_USER_PASSWORD=',
873
+ '',
874
+ '# Optional — API URL if different from app URL',
875
+ '# TEST_API_URL=',
876
+ '',
877
+ '# Optional — test database',
878
+ '# TEST_DATABASE_URL=',
879
+ ].join('\n');
880
+
881
+ if (!fs.existsSync(envExamplePath)) {
882
+ fs.writeFileSync(envExamplePath, template);
883
+ console.log(' ✓ .env.test.example created (commit this — team template)');
884
+ }
885
+
886
+ // .env.test (interactive prompts — gitignored)
887
+ if (opts.nonInteractive) {
888
+ console.log(' Non-interactive: skipping .env.test creation. Copy .env.test.example to .env.test and fill in values.');
889
+ return;
890
+ }
891
+
892
+ if (fs.existsSync(path.join(repoRoot, '.env.test'))) {
893
+ console.log(' .env.test exists ✓ (skipping)');
894
+ return;
895
+ }
896
+
897
+ // Interactive prompts via readline
898
+ const readline = await import('readline');
899
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
900
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
901
+
902
+ console.log(' E2E tests need runtime context. Answer these prompts (or press Enter for defaults):');
903
+ console.log('');
904
+
905
+ const baseUrl = await ask(' ? App URL for E2E tests (http://localhost:5173): ') || 'http://localhost:5173';
906
+ const email = await ask(' ? Test user email (skip if none): ') || '';
907
+ const password = email ? await ask(' ? Test user password: ') || '' : '';
908
+ const apiUrl = await ask(' ? API base URL (skip if same as app): ') || '';
909
+
910
+ rl.close();
911
+
912
+ const envContent = [
913
+ '# Test environment — created by hone setup --install-tests',
914
+ '# This file is gitignored. See .env.test.example for documentation.',
915
+ '',
916
+ `TEST_BASE_URL=${baseUrl}`,
917
+ '',
918
+ email ? `TEST_USER_EMAIL=${email}` : '# TEST_USER_EMAIL=',
919
+ password ? `TEST_USER_PASSWORD=${password}` : '# TEST_USER_PASSWORD=',
920
+ '',
921
+ apiUrl ? `TEST_API_URL=${apiUrl}` : '# TEST_API_URL=',
922
+ ].join('\n');
923
+
924
+ fs.writeFileSync(path.join(repoRoot, '.env.test'), envContent);
925
+ console.log('');
926
+ console.log(' ✓ .env.test created (gitignored — contains your test credentials)');
927
+
928
+ // Ensure .env.test is in .gitignore
929
+ const gitignorePath = path.join(repoRoot, '.gitignore');
930
+ if (fs.existsSync(gitignorePath)) {
931
+ const gitignore = fs.readFileSync(gitignorePath, 'utf8');
932
+ if (!gitignore.includes('.env.test')) {
933
+ fs.appendFileSync(gitignorePath, '\n# Test environment (contains credentials)\n.env.test\n');
934
+ console.log(' ✓ .env.test added to .gitignore');
935
+ }
936
+ }
937
+ }
938
+
939
+ function recommendTestFramework(repoRoot) {
940
+ const detected = detectExistingTestFramework(repoRoot);
941
+ if (detected.testFw) return; // Already has tests — no recommendation needed
942
+
943
+ const pkgs = getInstallPackages(detected.stack);
944
+ const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
945
+
946
+ console.log('');
947
+ console.log('── Test Framework Recommendation ───────────────────────');
948
+ console.log('');
949
+ console.log(` Detected: No test framework installed.`);
950
+ console.log(` Recommended for ${detected.stack}:`);
951
+ console.log(` Unit: ${pkgs.unit.join(', ')}`);
952
+ console.log(` E2E: @playwright/test (chromium only)`);
953
+ console.log('');
954
+ console.log(` To install now:`);
955
+ console.log(` hone setup --install-tests --e2e`);
956
+ console.log('');
957
+ console.log(` Or manually:`);
958
+ console.log(` ${installCmd} ${pkgs.unit.join(' ')}`);
959
+ console.log(` ${installCmd} @playwright/test && npx playwright install chromium`);
960
+ console.log('');
961
+ console.log('────────────────────────────────────────────────────────');
962
+ }
963
+
964
+ // ── DERIVE command ────────────────────────────────────────────────────────────
965
+ program
966
+ .command('derive')
967
+ .description('Derive domain skills from this repository\'s codebase')
968
+ .option('--refresh', 'Run in refresh mode (diff-based, human approval required)')
969
+ .option('--repo <name>', 'Repository name override')
970
+ .option('--max-files <n>', 'Max source files to send (default: 30)', '30')
971
+ .option('--poll-interval <s>','Poll interval in seconds (default: 5)', '5')
972
+ .action(async (opts) => {
973
+ const config = getConfig();
974
+ const client = api(config);
975
+ const repoRoot = process.cwd();
976
+ const configPath = path.join(repoRoot, '.github', '.pipeline-config.yml');
977
+ const isRefresh = opts.refresh;
978
+
979
+ console.log(`Hone AI — ${isRefresh ? 'Skill Refresh' : 'Domain Skill Derivation'}`);
980
+ console.log('='.repeat(50));
981
+
982
+ // 1. Read .pipeline-config.yml
983
+ if (!fs.existsSync(configPath)) {
984
+ console.error('Error: .github/.pipeline-config.yml not found.');
985
+ console.error('Run "hone setup" first to generate this file.');
986
+ process.exit(1);
987
+ }
988
+
989
+ const yaml = require('js-yaml');
990
+ const pipelineConfig = yaml.load(fs.readFileSync(configPath, 'utf8'));
991
+ const stack = pipelineConfig?.stack?.primary || 'unknown';
992
+ const repoName = opts.repo || path.basename(repoRoot);
993
+
994
+ console.log(`Stack: ${stack} | Repo: ${repoName}`);
995
+
996
+ // 2. Bundle source files
997
+ const sourceDirs = pipelineConfig?.source_dirs || ['src/'];
998
+ const files = {};
999
+ const maxFiles = parseInt(opts.maxFiles);
1000
+ let fileCount = 0;
1001
+
1002
+ for (const dir of sourceDirs) {
1003
+ const dirPath = path.join(repoRoot, dir.replace(/"/g, ''));
1004
+ if (!fs.existsSync(dirPath)) continue;
1005
+ bundleDirectory(dirPath, repoRoot, files, maxFiles, fileCount);
1006
+ fileCount = Object.keys(files).length;
1007
+ if (fileCount >= maxFiles) break;
1008
+ }
1009
+
1010
+ console.log(`Bundling ${Object.keys(files).length} source files...`);
1011
+
1012
+ // 3. Bundle existing skills if refresh mode
1013
+ let existingSkills = {};
1014
+ if (isRefresh) {
1015
+ const skillsDir = path.join(repoRoot, '.github', 'skills');
1016
+ if (fs.existsSync(skillsDir)) {
1017
+ bundleDirectory(skillsDir, repoRoot, existingSkills, 50, 0);
1018
+ console.log(`Bundling ${Object.keys(existingSkills).length} existing skill files...`);
1019
+ }
1020
+ }
1021
+
1022
+ // 4. Collect doc URLs
1023
+ const docUrls = (pipelineConfig?.documentation?.external_urls || [])
1024
+ .filter(u => u && !u.includes('confluence.example.com'));
1025
+
1026
+ // 5. POST /derive or /derive/refresh
1027
+ const endpoint = isRefresh ? '/derive/refresh' : '/derive';
1028
+ console.log(`\nSubmitting to Hone server (${endpoint})...`);
1029
+
1030
+ let jobId;
1031
+ try {
1032
+ const r = await client.post(endpoint, {
1033
+ files,
1034
+ pipelineConfig,
1035
+ repoName,
1036
+ docUrls,
1037
+ isRefresh,
1038
+ existingSkills,
1039
+ });
1040
+ jobId = r.data.jobId;
1041
+ console.log(`Job queued: ${jobId}`);
1042
+ console.log(`Estimated: ~${r.data.estimatedSeconds}s`);
1043
+ } catch (e) {
1044
+ console.error(`Submit failed: ${e.response?.data?.error || e.message}`);
1045
+ process.exit(1);
1046
+ }
1047
+
1048
+ // 6. Poll for completion
1049
+ const pollMs = parseInt(opts.pollInterval) * 1000;
1050
+ process.stdout.write('Processing');
1051
+ let result;
1052
+
1053
+ while (true) {
1054
+ await sleep(pollMs);
1055
+ process.stdout.write('.');
1056
+ try {
1057
+ const r = await client.get(`/derive/${jobId}`);
1058
+ if (r.data.status === 'complete') {
1059
+ result = r.data;
1060
+ break;
1061
+ }
1062
+ if (r.data.status === 'failed') {
1063
+ console.error(`\nJob failed: ${r.data.error}`);
1064
+ process.exit(1);
1065
+ }
1066
+ } catch (e) {
1067
+ console.warn(`\nPoll error: ${e.message}`);
1068
+ }
1069
+ }
1070
+
1071
+ console.log('\n');
1072
+
1073
+ if (isRefresh) {
1074
+ // Show change report and wait for approval
1075
+ console.log('═'.repeat(60));
1076
+ console.log('SKILL REFRESH REPORT');
1077
+ console.log('═'.repeat(60));
1078
+ console.log(result.changeReport || '[No change report generated]');
1079
+ console.log('');
1080
+ console.log(`To apply changes: hone approve --job ${jobId}`);
1081
+ console.log(`To skip: hone derive --refresh (run again later)`);
1082
+
1083
+ // Save change report to file
1084
+ const reportPath = path.join(repoRoot, '.github', 'skill-refresh-report.md');
1085
+ fs.writeFileSync(reportPath, result.changeReport || '');
1086
+ console.log(`\nChange report saved to: .github/skill-refresh-report.md`);
1087
+ } else {
1088
+ // Write generated skills to disk
1089
+ console.log('Writing generated skills...');
1090
+ if (result.skills) {
1091
+ for (const [skillName, content] of Object.entries(result.skills)) {
1092
+ if (!content || content.length < 50) continue;
1093
+ const skillDir = path.join(repoRoot, '.github', 'skills', skillName);
1094
+ const skillFile = path.join(skillDir, 'SKILL.md');
1095
+ fs.mkdirSync(skillDir, { recursive: true });
1096
+ fs.writeFileSync(skillFile, content);
1097
+ console.log(` ✓ .github/skills/${skillName}/SKILL.md`);
1098
+ }
1099
+ }
1100
+
1101
+ // H-022: surface parser warnings so silent drops become VISIBLE failures.
1102
+ // The parser already emits warnings (H-016 fix added the array); the CLI
1103
+ // had been ignoring them, which caused the H-022 regression where
1104
+ // python-architect silently disappeared. Adopters now see exactly which
1105
+ // skills failed to extract and which patterns were tried.
1106
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
1107
+ console.warn('');
1108
+ console.warn(`⚠ Parser warnings (${result.warnings.length}):`);
1109
+ for (const w of result.warnings) {
1110
+ console.warn(` - ${w}`);
1111
+ }
1112
+ console.warn(
1113
+ '\n These skills were not written. If a heading shape was missed, ' +
1114
+ 'open an issue at https://github.com/subbareddyvani/hone-server/issues ' +
1115
+ 'with the heading text — we extend the pattern list there.'
1116
+ );
1117
+ }
1118
+
1119
+ // Write living architecture doc if generated
1120
+ if (result.architectureSummary) {
1121
+ const archDir = path.join(repoRoot, 'docs', 'sdlc');
1122
+ fs.mkdirSync(archDir, { recursive: true });
1123
+ fs.writeFileSync(path.join(archDir, 'ARCHITECTURE.md'), result.architectureSummary);
1124
+ console.log(' ✓ docs/sdlc/ARCHITECTURE.md (living architecture — regenerated from codebase scan)');
1125
+ }
1126
+
1127
+ // Update .pipeline-config.yml with discovered URLs
1128
+ if (result.updatedConfig) {
1129
+ const yaml = require('js-yaml');
1130
+ fs.writeFileSync(configPath, yaml.dump(result.updatedConfig));
1131
+ console.log(' ✓ .github/.pipeline-config.yml (updated with platform URLs)');
1132
+ }
1133
+
1134
+ // H-021: Stamp skill_refresh.last_derived with the current ISO timestamp.
1135
+ // The .pipeline-config.yml schema docs say it's "updated each time
1136
+ // derive-domain-skills runs" but the CLI never wrote it (issue #31).
1137
+ // Surgical regex preserves all comments + other fields. Graceful no-op
1138
+ // if `last_derived:` line is absent.
1139
+ // H-015: Also stamp skill_refresh.last_derived_src_files for the
1140
+ // growth-based refresh trigger (issue #24).
1141
+ try {
1142
+ const { stampLastDerived, stampLastDerivedSrcFiles } = require('./lib/config-update');
1143
+ let currentText = fs.readFileSync(configPath, 'utf8');
1144
+ const stamped = stampLastDerived(currentText, new Date().toISOString());
1145
+ if (stamped.updated) {
1146
+ currentText = stamped.text;
1147
+ console.log(` ✓ .github/.pipeline-config.yml — skill_refresh.last_derived stamped`);
1148
+ }
1149
+ // H-015: src-file count for growth trigger
1150
+ const stampedCount = stampLastDerivedSrcFiles(currentText, Object.keys(files).length);
1151
+ if (stampedCount.updated) {
1152
+ currentText = stampedCount.text;
1153
+ console.log(` ✓ skill_refresh.last_derived_src_files = ${Object.keys(files).length}`);
1154
+ }
1155
+ if (stamped.updated || stampedCount.updated) {
1156
+ fs.writeFileSync(configPath, currentText);
1157
+ }
1158
+ } catch (e) {
1159
+ console.warn(` (skipped last_derived stamp: ${e.message})`);
1160
+ }
1161
+
1162
+ // Show health scorecard
1163
+ if (result.scorecard) {
1164
+ console.log('');
1165
+ console.log('Health Scorecard:');
1166
+ console.log(result.scorecard.raw || `Grade: ${result.scorecard.grade} (${result.scorecard.score}/${result.scorecard.max})`);
1167
+ }
1168
+
1169
+ console.log('');
1170
+ console.log('Next step: Review generated skills, then commit:');
1171
+ console.log(' git add .github/skills/ .github/.pipeline-config.yml');
1172
+ console.log(' git commit -m "feat: derive domain skills"');
1173
+ }
1174
+ });
1175
+
1176
+ // ── INIT command ──────────────────────────────────────────────────────────────
1177
+ program
1178
+ .command('init')
1179
+ .description('Save HONE_TOKEN and API URL to ~/.honerc')
1180
+ .option('--token <token>', 'Hone API token')
1181
+ .option('--api <url>', 'Hone API base URL (default: https://api.hone.ai)')
1182
+ .action(async (opts) => {
1183
+ const token = opts.token || process.env.HONE_TOKEN;
1184
+ const apiUrl = opts.api || process.env.HONE_API || 'https://api.hone.ai';
1185
+
1186
+ if (!token) {
1187
+ console.error('Error: --token is required (or set HONE_TOKEN env var).');
1188
+ console.error('Get your token from your Hone admin.');
1189
+ process.exit(1);
1190
+ }
1191
+
1192
+ console.log('Hone — Initialising...');
1193
+
1194
+ // Verify token before saving
1195
+ try {
1196
+ const client = axios.create({
1197
+ baseURL: apiUrl,
1198
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': `@hone-ai/cli/${pkg.version}` },
1199
+ timeout: 10_000,
1200
+ });
1201
+ const r = await client.get('/scripts/config');
1202
+ console.log(`✓ Token valid — org: ${r.data.org}, tier: ${r.data.tier}`);
1203
+ } catch (e) {
1204
+ console.error(`✗ Token verification failed: ${e.response?.data?.error || e.message}`);
1205
+ process.exit(1);
1206
+ }
1207
+
1208
+ // Save to ~/.honerc (mode 0600 — owner-read-only)
1209
+ const rc = { token, api: apiUrl, savedAt: new Date().toISOString() };
1210
+ fs.writeFileSync(RC_PATH, JSON.stringify(rc, null, 2), { mode: 0o600 });
1211
+
1212
+ console.log(`✓ Config saved to ${RC_PATH}`);
1213
+ console.log(` API: ${apiUrl}`);
1214
+ console.log('');
1215
+ console.log('You can now run "hone setup" in any repository.');
1216
+ });
1217
+
1218
+ // ── APPROVE command ────────────────────────────────────────────────────────────
1219
+ program
1220
+ .command('approve')
1221
+ .description('Approve a skill refresh to apply the change report')
1222
+ .requiredOption('--job <jobId>', 'Job ID returned by "hone derive --refresh"')
1223
+ .option('--changes <mode>', 'Which changes to apply: all (default)', 'all')
1224
+ .action(async (opts) => {
1225
+ const config = getConfig();
1226
+ const client = api(config);
1227
+
1228
+ console.log(`Hone AI — Approving refresh job: ${opts.job}`);
1229
+
1230
+ // Verify job is ready
1231
+ let jobData;
1232
+ try {
1233
+ const r = await client.get(`/derive/${opts.job}`);
1234
+ jobData = r.data;
1235
+ } catch (e) {
1236
+ console.error(`Failed to fetch job: ${e.response?.data?.error || e.message}`);
1237
+ process.exit(1);
1238
+ }
1239
+
1240
+ if (jobData.status !== 'complete') {
1241
+ console.error(`Job is not ready (status: ${jobData.status}). Wait for it to finish.`);
1242
+ process.exit(1);
1243
+ }
1244
+ if (!jobData.changeReport) {
1245
+ console.error('No change report found — this may not be a refresh job.');
1246
+ process.exit(1);
1247
+ }
1248
+
1249
+ // Apply changes
1250
+ try {
1251
+ const r = await client.post(`/derive/${opts.job}/approve`, {
1252
+ approvedChanges: opts.changes,
1253
+ });
1254
+ console.log('✓ Changes approved and applied');
1255
+ if (r.data.skillsUpdated) console.log(` Skills updated: ${r.data.skillsUpdated}`);
1256
+ console.log('');
1257
+ console.log('Run "hone sync" to pull updated skills to your local .github/skills/');
1258
+ } catch (e) {
1259
+ console.error(`Approval failed: ${e.response?.data?.error || e.message}`);
1260
+ process.exit(1);
1261
+ }
1262
+ });
1263
+
1264
+ // ── VERIFY command ────────────────────────────────────────────────────────────
1265
+ program
1266
+ .command('verify')
1267
+ .description('Verify Hone server connectivity and configuration')
1268
+ .action(async () => {
1269
+ const config = getConfig();
1270
+ const client = api(config);
1271
+
1272
+ console.log('Hone — Server Verification');
1273
+ console.log('================================');
1274
+
1275
+ try {
1276
+ const health = await axios.get(`${config.apiUrl}/health`);
1277
+ console.log(`✓ API health: ${health.data.status} (v${health.data.version})`);
1278
+ } catch (e) {
1279
+ console.error(`✗ API health: ${e.message}`);
1280
+ }
1281
+
1282
+ try {
1283
+ const cfg = await client.get('/scripts/config');
1284
+ console.log(`✓ Auth valid: org=${cfg.data.org}, tier=${cfg.data.tier}`);
1285
+ console.log(` Features: ${JSON.stringify(cfg.data.features)}`);
1286
+ console.log(` Pipeline: v${cfg.data.pipeline.version} (${cfg.data.pipeline.stages} stages)`);
1287
+ } catch (e) {
1288
+ console.error(`✗ Auth check: ${e.message}`);
1289
+ }
1290
+
1291
+ try {
1292
+ await client.get('/skills/pr-review-standards');
1293
+ console.log('✓ Enterprise skills: pr-review-standards accessible');
1294
+ } catch (e) {
1295
+ console.error(`✗ Enterprise skills: ${e.message}`);
1296
+ }
1297
+
1298
+ console.log('');
1299
+ console.log('Verification complete.');
1300
+ });
1301
+
1302
+ // ── SYNC command ──────────────────────────────────────────────────────────────
1303
+ program
1304
+ .command('sync')
1305
+ .description('Pull latest derived skills and agent prompts from Hone server')
1306
+ .option('--repo <name>', 'Repository name (defaults to current directory name)')
1307
+ .option('--skills-only', 'Only sync derived skills, skip agent prompts')
1308
+ .option('--agents-only', 'Only sync agent prompts, skip derived skills')
1309
+ .option('--with-learnings-report', 'Also fetch promoted-learnings report + update local YAML status (H-013)')
1310
+ .option('--force', 'Overwrite local edits (default: skip files with local changes — H-024)')
1311
+ .action(async (opts) => {
1312
+ const config = getConfig();
1313
+ const client = api(config);
1314
+ const repoName = opts.repo || path.basename(process.cwd());
1315
+ const { detectLocalEdits, formatSkipMessage } = require('./lib/sync-overwrite');
1316
+
1317
+ console.log(`Hone — Sync for ${repoName}`);
1318
+
1319
+ // H-024: collect skipped files so we can summarize at the end with the
1320
+ // corrective action. Better one summary block than 15 separate warnings
1321
+ // scrolling off the user's terminal.
1322
+ const skipped = [];
1323
+
1324
+ /**
1325
+ * Write a file unless local edits would be silently destroyed.
1326
+ * Returns: 'wrote' | 'skipped' | 'created'
1327
+ */
1328
+ function safeWrite(absPath, relPath, serverContent) {
1329
+ const localContent = fs.existsSync(absPath)
1330
+ ? fs.readFileSync(absPath, 'utf8')
1331
+ : null;
1332
+ const { kind } = detectLocalEdits(serverContent, localContent);
1333
+
1334
+ if (kind === 'modified' && !opts.force) {
1335
+ skipped.push(relPath);
1336
+ console.log(formatSkipMessage(relPath));
1337
+ return 'skipped';
1338
+ }
1339
+ // 'new' | 'identical' | 'modified'+force → write
1340
+ fs.writeFileSync(absPath, serverContent);
1341
+ return kind === 'new' ? 'created' : 'wrote';
1342
+ }
1343
+
1344
+ // ── 1. Sync derived skills ──────────────────────────────────────────────
1345
+ if (!opts.agentsOnly) {
1346
+ try {
1347
+ const r = await client.get(`/skills/${repoName}/derived`);
1348
+ const { skills } = r.data;
1349
+ if (!skills.length) {
1350
+ console.log('No derived skills found. Run "hone derive" first.');
1351
+ } else {
1352
+ console.log(`\nSyncing ${skills.length} derived skills...`);
1353
+ for (const skill of skills) {
1354
+ const skillDir = path.join(process.cwd(), '.github', 'skills', skill.skill_name);
1355
+ fs.mkdirSync(skillDir, { recursive: true });
1356
+ const dest = path.join(skillDir, 'SKILL.md');
1357
+ const rel = `skills/${skill.skill_name}/SKILL.md`;
1358
+ const result = safeWrite(dest, rel, skill.content);
1359
+ if (result !== 'skipped') console.log(` ✓ ${rel}`);
1360
+ }
1361
+ }
1362
+ } catch (e) {
1363
+ console.error(`Skills sync failed: ${e.message}`);
1364
+ }
1365
+ }
1366
+
1367
+ // ── 2. Sync agent prompts into .github/agents/*.agent.md ───────────────
1368
+ if (!opts.skillsOnly) {
1369
+ const agents = [
1370
+ 'story-groomer',
1371
+ 'implementation-planner',
1372
+ 'unit-test-case-writer',
1373
+ 'e2e-qa-planner',
1374
+ 'e2e-test-spec-writer',
1375
+ 'e2e-qa-spec-healer',
1376
+ 'code-builder',
1377
+ 'code-reviewer',
1378
+ 'delivery-architect',
1379
+ ];
1380
+
1381
+ console.log('\nSyncing agent prompts...');
1382
+ const agentsDir = path.join(process.cwd(), '.github', 'agents');
1383
+ fs.mkdirSync(agentsDir, { recursive: true });
1384
+
1385
+ let synced = 0;
1386
+ for (const agent of agents) {
1387
+ try {
1388
+ const r = await client.get(`/prompts/${agent}`, {
1389
+ responseType: 'text',
1390
+ headers: { Accept: 'text/markdown' },
1391
+ });
1392
+ const dest = path.join(agentsDir, `${agent}.agent.md`);
1393
+ const rel = `agents/${agent}.agent.md`;
1394
+ const result = safeWrite(dest, rel, r.data);
1395
+ if (result !== 'skipped') {
1396
+ console.log(` ✓ ${rel}`);
1397
+ synced++;
1398
+ }
1399
+ } catch (e) {
1400
+ if (e.response?.status === 404) {
1401
+ console.log(` ⚠ agents/${agent}.agent.md — prompt not seeded yet (run seed-agent-prompts.js)`);
1402
+ } else {
1403
+ console.log(` ⚠ agents/${agent}.agent.md — ${e.message}`);
1404
+ }
1405
+ }
1406
+ }
1407
+ if (synced > 0) console.log(`\nSynced ${synced} agent prompts.`);
1408
+
1409
+ // Mirror to .claude/agents/ for Claude Code. This mirror is a derived
1410
+ // copy of .github/agents/, so it's safe to overwrite — local edits
1411
+ // belong in the source-of-truth dir. We still respect skipped files:
1412
+ // if the source was skipped, don't overwrite the mirror either.
1413
+ const claudeAgentsDir = path.join(process.cwd(), '.claude', 'agents');
1414
+ const ghAgentsDir = path.join(process.cwd(), '.github', 'agents');
1415
+ if (fs.existsSync(ghAgentsDir)) {
1416
+ fs.mkdirSync(claudeAgentsDir, { recursive: true });
1417
+ const files = fs.readdirSync(ghAgentsDir).filter(f => f.endsWith('.agent.md'));
1418
+ for (const f of files) {
1419
+ fs.copyFileSync(path.join(ghAgentsDir, f), path.join(claudeAgentsDir, f));
1420
+ }
1421
+ console.log(` ✓ .claude/agents/ mirrored (${files.length} agents)`);
1422
+ }
1423
+ }
1424
+
1425
+ // ── 3. Sync copilot-instructions.md from server ──────────────────────
1426
+ try {
1427
+ const r = await client.get('/scripts/copilot-instructions', {
1428
+ responseType: 'text',
1429
+ headers: { Accept: 'text/markdown' },
1430
+ });
1431
+ const instrPath = path.join(process.cwd(), '.github', 'copilot-instructions.md');
1432
+ const result = safeWrite(instrPath, 'copilot-instructions.md', r.data);
1433
+ if (result !== 'skipped') {
1434
+ console.log('\n ✓ copilot-instructions.md synced from server');
1435
+ }
1436
+ } catch (e) {
1437
+ console.log(`\n ⚠ copilot-instructions.md — ${e.message}`);
1438
+ }
1439
+
1440
+ // ── H-024 summary: tell the user what was skipped + how to fix ──────
1441
+ // (placed before the CLAUDE.md block so the recap is the first thing
1442
+ // the user sees if they scroll up after the command finishes)
1443
+ if (skipped.length > 0) {
1444
+ console.log('');
1445
+ console.log(`⚠ ${skipped.length} file(s) skipped to preserve local edits:`);
1446
+ for (const f of skipped) console.log(` ${f}`);
1447
+ console.log('');
1448
+ console.log(' To upstream local additions: hone promote');
1449
+ console.log(' To overwrite anyway: hone sync --force');
1450
+ }
1451
+
1452
+ // ── 4. Sync CLAUDE.md from server ────────────────────────────────────
1453
+ try {
1454
+ const r = await client.get('/scripts/claude-md', {
1455
+ responseType: 'text',
1456
+ headers: { Accept: 'text/markdown' },
1457
+ });
1458
+ const claudePath = path.join(process.cwd(), 'CLAUDE.md');
1459
+ if (!fs.existsSync(claudePath)) {
1460
+ fs.writeFileSync(claudePath, r.data);
1461
+ console.log(' ✓ CLAUDE.md synced from server');
1462
+ } else {
1463
+ console.log(' CLAUDE.md exists — skipping (manual merge if needed)');
1464
+ }
1465
+ } catch (e) {
1466
+ console.log(` ⚠ CLAUDE.md — ${e.message}`);
1467
+ }
1468
+
1469
+ // ── 5. Promoted-learnings report (H-013, --with-learnings-report) ──
1470
+ if (opts.withLearningsReport) {
1471
+ try {
1472
+ const r = await client.get(`/promoted-learnings-report?repo=${encodeURIComponent(repoName)}`);
1473
+ const { markdown = '', entries = [] } = r.data || {};
1474
+ const learningsRoot = path.join(process.cwd(), '.github', 'learnings');
1475
+ fs.mkdirSync(learningsRoot, { recursive: true });
1476
+ const reportPath = path.join(learningsRoot, 'promoted-report.md');
1477
+ fs.writeFileSync(reportPath, markdown);
1478
+ console.log(`\n ✓ ${path.relative(process.cwd(), reportPath)} written`);
1479
+
1480
+ const { updateTopLevelStatus } = require('./lib/learnings-sync');
1481
+ let updatedCount = 0, skippedCount = 0;
1482
+ for (const e of entries) {
1483
+ if (!e || !e.id || !e.status) continue;
1484
+ // Map E13-A-L1 → E13-A (story id strips trailing -L<n>)
1485
+ const storyId = String(e.id).replace(/-L\d+$/i, '');
1486
+ const yamlPath = path.join(learningsRoot, `${storyId}.yml`);
1487
+ if (!fs.existsSync(yamlPath)) {
1488
+ console.log(` ⚠ no local learnings file for ${storyId} — skip`);
1489
+ skippedCount++;
1490
+ continue;
1491
+ }
1492
+ const text = fs.readFileSync(yamlPath, 'utf8');
1493
+ const result = updateTopLevelStatus(text, e.status);
1494
+ if (result.updated) {
1495
+ fs.writeFileSync(yamlPath, result.text);
1496
+ console.log(` ✓ ${storyId}.yml status → ${e.status}`);
1497
+ updatedCount++;
1498
+ } else {
1499
+ console.log(` ⚠ ${storyId}.yml — no top-level status to update`);
1500
+ skippedCount++;
1501
+ }
1502
+ }
1503
+ console.log(` Promoted-report: ${updatedCount} updated, ${skippedCount} skipped`);
1504
+ } catch (e) {
1505
+ const status = e.response?.status;
1506
+ if (status === 404 || status === 501) {
1507
+ console.log(`\n ⚠ Server doesn't yet support promoted-learnings-report. Skipping.`);
1508
+ } else {
1509
+ console.log(`\n ⚠ promoted-learnings report fetch failed: ${e.message}`);
1510
+ }
1511
+ }
1512
+ }
1513
+ });
1514
+
1515
+ // ── PROMOTE command ──────────────────────────────────────────────────────────
1516
+ program
1517
+ .command('promote')
1518
+ .description('Promote enterprise-candidate learnings from this repo to the Hone server')
1519
+ .option('--dry-run', 'Show what would be promoted without sending')
1520
+ .option('--auto-confirm', 'Skip the y/N prompt — for CI use only (H-004)')
1521
+ .option('-y, --yes', 'Alias for --auto-confirm (matches hone retract syntax)')
1522
+ .action(async (opts) => {
1523
+ const config = getConfig();
1524
+ const client = api(config);
1525
+ const repoRoot = process.cwd();
1526
+ const repoName = path.basename(repoRoot);
1527
+
1528
+ console.log('Hone AI — Learning Promotion');
1529
+ console.log('================================');
1530
+
1531
+ // 1. Check consent: learnings.share_enterprise in .pipeline-config.yml
1532
+ const configPath = path.join(repoRoot, '.github', '.pipeline-config.yml');
1533
+ if (fs.existsSync(configPath)) {
1534
+ const yaml = require('js-yaml');
1535
+ const pipelineConfig = yaml.load(fs.readFileSync(configPath, 'utf8'));
1536
+ const shareEnabled = pipelineConfig?.learnings?.share_enterprise;
1537
+ if (!shareEnabled) {
1538
+ console.log('');
1539
+ console.log(' Learning sharing is not enabled for this repo.');
1540
+ console.log(' To enable, add to .github/.pipeline-config.yml:');
1541
+ console.log('');
1542
+ console.log(' learnings:');
1543
+ console.log(' share_enterprise: true');
1544
+ console.log('');
1545
+ return;
1546
+ }
1547
+ } else {
1548
+ console.log('No .pipeline-config.yml found. Run "hone setup" first.');
1549
+ return;
1550
+ }
1551
+
1552
+ // 2. Scan .github/learnings/*.yml for eligible learnings
1553
+ const learningsDir = path.join(repoRoot, '.github', 'learnings');
1554
+ if (!fs.existsSync(learningsDir)) {
1555
+ console.log('No .github/learnings/ directory found. No learnings to promote.');
1556
+ return;
1557
+ }
1558
+
1559
+ // H-035: schema-aware parser (3 active YAML schemas).
1560
+ // See cli/lib/learnings-parse.js + .github/pipeline/H-035/.
1561
+ const { parseLearningsFile, writeBackPromoted } = require('./lib/learnings-parse');
1562
+ const files = fs.readdirSync(learningsDir).filter(f => f.endsWith('.yml'));
1563
+ const eligible = [];
1564
+ const fileSchemas = {}; // _filePath → schema (used by write-back below)
1565
+
1566
+ for (const file of files) {
1567
+ try {
1568
+ const filePath = path.join(learningsDir, file);
1569
+ const content = fs.readFileSync(filePath, 'utf8');
1570
+ const result = parseLearningsFile(content, { fileLabel: file });
1571
+
1572
+ if (result.parseError) {
1573
+ console.warn(` ⚠ ${file}: parse error — ${result.parseError.message}`);
1574
+ continue;
1575
+ }
1576
+ if (result.warning) console.log(` ⚠ ${result.warning}`);
1577
+
1578
+ fileSchemas[filePath] = result.schema;
1579
+ for (const e of result.eligible) {
1580
+ eligible.push({ ...e, _file: file, _filePath: filePath, repo_name: repoName });
1581
+ }
1582
+ } catch (e) {
1583
+ console.warn(` ⚠ ${file}: read error — ${e.message}`);
1584
+ }
1585
+ }
1586
+
1587
+ if (eligible.length === 0) {
1588
+ console.log('No pending enterprise-candidate learnings found.');
1589
+ console.log('Learnings need: enterprise_candidate: true + status: pending');
1590
+ return;
1591
+ }
1592
+
1593
+ // 3. Display each eligible learning
1594
+ console.log(`\n${eligible.length} learning(s) eligible for promotion:\n`);
1595
+ for (const l of eligible) {
1596
+ console.log(` ${l.id || l._file}`);
1597
+ console.log(` Type: ${l.type || 'unknown'} | Agent: ${l.agent || 'unknown'}`);
1598
+ console.log(` Enterprise summary: ${(l.enterprise_summary || '').slice(0, 120)}...`);
1599
+ console.log('');
1600
+ }
1601
+
1602
+ // 4. Confirm (or dry-run)
1603
+ if (opts.dryRun) {
1604
+ console.log('Dry run — no data sent. Remove --dry-run to promote.');
1605
+ return;
1606
+ }
1607
+
1608
+ // H-004: --auto-confirm (or -y/--yes alias) bypasses the y/N prompt for CI use.
1609
+ // The consent gate at line ~1183 (share_enterprise: true) is a SEPARATE check
1610
+ // that --auto-confirm does NOT bypass — operators must explicitly opt in.
1611
+ const skipConfirm = opts.autoConfirm || opts.yes;
1612
+ if (!skipConfirm) {
1613
+ const readline = await import('readline');
1614
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1615
+ const answer = await new Promise(resolve => rl.question(`Send ${eligible.length} learning(s) to Hone server? (y/N): `, resolve));
1616
+ rl.close();
1617
+
1618
+ if (!answer.match(/^[Yy]/)) {
1619
+ console.log('Cancelled.');
1620
+ return;
1621
+ }
1622
+ } else {
1623
+ console.log(`Auto-confirm: sending ${eligible.length} learning(s) without prompt (H-004).`);
1624
+ }
1625
+
1626
+ // 5. POST to /learnings/promote (enterprise_summary only — never local_summary)
1627
+ const payload = eligible.map(l => ({
1628
+ id: l.id || l._file,
1629
+ repo_name: l.repo_name,
1630
+ type: l.type,
1631
+ agent: l.agent,
1632
+ target_skill: l.target_skill,
1633
+ enterprise_summary: l.enterprise_summary,
1634
+ proposed_fix: l.proposed_fix || l.absorbed_into,
1635
+ }));
1636
+
1637
+ try {
1638
+ const r = await client.post('/learnings/promote', { learnings: payload });
1639
+ console.log(`\n✓ ${r.data.promoted} learning(s) promoted to Hone server`);
1640
+ console.log(' They will be reviewed by the Hone team and absorbed into enterprise skills.');
1641
+ } catch (e) {
1642
+ console.error(`\nPromotion failed: ${e.response?.data?.error || e.message}`);
1643
+ console.error('Local files unchanged — safe to retry.');
1644
+ return;
1645
+ }
1646
+
1647
+ // 6. Update local YAML: status → promoted (per-schema, idempotent).
1648
+ // Group by file so each file is read + written exactly once. H-035.
1649
+ const promotedByFile = new Map(); // _filePath → ids[]
1650
+ for (const l of eligible) {
1651
+ if (!promotedByFile.has(l._filePath)) promotedByFile.set(l._filePath, []);
1652
+ promotedByFile.get(l._filePath).push(l.id);
1653
+ }
1654
+ for (const [filePath, ids] of promotedByFile) {
1655
+ try {
1656
+ const schema = fileSchemas[filePath];
1657
+ let content = fs.readFileSync(filePath, 'utf8');
1658
+ content = writeBackPromoted(content, schema, ids);
1659
+ fs.writeFileSync(filePath, content);
1660
+ const fileName = path.basename(filePath);
1661
+ console.log(` ✓ ${fileName}: ${ids.length} learning(s) → promoted`);
1662
+ } catch (e) {
1663
+ console.warn(` ⚠ ${path.basename(filePath)}: failed to update local file — ${e.message}`);
1664
+ }
1665
+ }
1666
+
1667
+ console.log('\nDone. Commit the updated learnings files:');
1668
+ console.log(' git add .github/learnings/ && git commit -m "chore: promote learnings to enterprise"');
1669
+ });
1670
+
1671
+ // ── RETRACT command (H-023) ──────────────────────────────────────────────────
1672
+ // Two modes:
1673
+ // 1. Single retract: `hone retract <LEARNING-ID> --reason "..."`
1674
+ // 2. Batch from YAML: `hone retract --from-yaml`
1675
+ // Scans .github/learnings/*.yml for entries with status: retracted
1676
+ // and syncs each to the server.
1677
+ program
1678
+ .command('retract [learningId]')
1679
+ .description('Retract previously-promoted learnings on the Hone server')
1680
+ .option('--reason <reason>', 'Retraction reason (required when retracting a single ID)')
1681
+ .option('--from-yaml', 'Read all status: retracted entries from .github/learnings/*.yml')
1682
+ .option('--dry-run', 'Show what would be retracted without sending')
1683
+ .option('-y, --yes', 'Skip confirmation prompt')
1684
+ .action(async (learningId, opts) => {
1685
+ const config = getConfig();
1686
+ const client = api(config);
1687
+ const repoRoot = process.cwd();
1688
+ const repoName = path.basename(repoRoot);
1689
+
1690
+ console.log('Hone AI — Learning Retraction');
1691
+ console.log('================================');
1692
+
1693
+ const payload = [];
1694
+
1695
+ if (opts.fromYaml) {
1696
+ // Batch mode: scan .github/learnings/*.yml for retracted entries
1697
+ const learningsDir = path.join(repoRoot, '.github', 'learnings');
1698
+ if (!fs.existsSync(learningsDir)) {
1699
+ console.log('No .github/learnings/ directory found — nothing to retract.');
1700
+ return;
1701
+ }
1702
+ const yaml = require('js-yaml');
1703
+ const files = fs.readdirSync(learningsDir).filter(f => f.endsWith('.yml'));
1704
+ for (const file of files) {
1705
+ try {
1706
+ const parsed = yaml.load(fs.readFileSync(path.join(learningsDir, file), 'utf8'));
1707
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
1708
+ for (const entry of entries) {
1709
+ if (entry?.status === 'retracted' && entry.id) {
1710
+ payload.push({
1711
+ id: entry.id,
1712
+ repo_name: repoName,
1713
+ reason: entry.retraction_reason || 'Retracted (no reason provided in YAML)',
1714
+ });
1715
+ }
1716
+ }
1717
+ } catch (e) {
1718
+ console.warn(` ⚠ ${file}: parse error — ${e.message}`);
1719
+ }
1720
+ }
1721
+ if (payload.length === 0) {
1722
+ console.log('No retracted entries found in .github/learnings/*.yml.');
1723
+ console.log('To retract a learning, set status: retracted + retraction_reason in its YAML entry.');
1724
+ return;
1725
+ }
1726
+ } else if (learningId) {
1727
+ // Single mode: command-line ID + --reason
1728
+ if (!opts.reason) {
1729
+ console.error('Error: --reason "..." is required when retracting a single learning.');
1730
+ process.exit(1);
1731
+ }
1732
+ payload.push({
1733
+ id: learningId,
1734
+ repo_name: repoName,
1735
+ reason: opts.reason,
1736
+ });
1737
+ } else {
1738
+ console.error('Error: provide either a <LEARNING-ID> with --reason, or --from-yaml.');
1739
+ console.error('Examples:');
1740
+ console.error(' hone retract E18-A-L2 --reason "dup-of E20-A-L1 (canonical)"');
1741
+ console.error(' hone retract --from-yaml');
1742
+ process.exit(1);
1743
+ }
1744
+
1745
+ // Display
1746
+ console.log(`\n${payload.length} learning(s) to retract:\n`);
1747
+ for (const p of payload) {
1748
+ console.log(` ${p.id}`);
1749
+ console.log(` Reason: ${p.reason.slice(0, 120)}${p.reason.length > 120 ? '...' : ''}`);
1750
+ }
1751
+ console.log('');
1752
+
1753
+ if (opts.dryRun) {
1754
+ console.log('Dry run — no data sent. Remove --dry-run to retract.');
1755
+ return;
1756
+ }
1757
+
1758
+ // Confirm unless --yes
1759
+ if (!opts.yes) {
1760
+ const readline = await import('readline');
1761
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1762
+ const answer = await new Promise(resolve =>
1763
+ rl.question(`Retract ${payload.length} learning(s) on Hone server? (y/N): `, resolve)
1764
+ );
1765
+ rl.close();
1766
+ if (!answer.match(/^[Yy]/)) {
1767
+ console.log('Cancelled.');
1768
+ return;
1769
+ }
1770
+ }
1771
+
1772
+ // POST to /learnings/retract
1773
+ try {
1774
+ const r = await client.post('/learnings/retract', { learnings: payload });
1775
+ console.log(`\n✓ ${r.data.retracted} learning(s) retracted on Hone server.`);
1776
+ if (r.data.skipped && r.data.skipped.length) {
1777
+ console.log(`\n⚠ ${r.data.skipped.length} skipped:`);
1778
+ for (const s of r.data.skipped) {
1779
+ console.log(` - ${s.id || '<no id>'}: ${s.why}`);
1780
+ }
1781
+ }
1782
+ console.log(' Retracted entries will be excluded from the next enterprise skill rebuild.');
1783
+ } catch (e) {
1784
+ console.error(`\nRetraction failed: ${e.response?.data?.error || e.message}`);
1785
+ console.error('Local files unchanged — safe to retry.');
1786
+ process.exit(1);
1787
+ }
1788
+ });
1789
+
1790
+ // ── ADOPT command (all-in-one: setup + derive + sync) ────────────────────────
1791
+ program
1792
+ .command('adopt')
1793
+ .description('Full pipeline adoption: setup + derive + sync in one command')
1794
+ .option('--install-tests', 'Install unit test framework')
1795
+ .option('--e2e', 'Install Playwright E2E framework')
1796
+ .option('--no-e2e', 'Skip Playwright')
1797
+ .option('--non-interactive', 'Use defaults without prompting')
1798
+ .option('--max-files <n>', 'Max source files for derive (default: 15)', '15')
1799
+ .option('--skip-derive', 'Skip the derive step (setup + sync only)')
1800
+ .action(async (opts) => {
1801
+ const startTime = Date.now();
1802
+
1803
+ console.log('');
1804
+ console.log('╔══════════════════════════════════════════════╗');
1805
+ console.log('║ Hone AI — Full Pipeline Adoption ║');
1806
+ console.log('║ setup → derive → sync ║');
1807
+ console.log('╚══════════════════════════════════════════════╝');
1808
+ console.log('');
1809
+
1810
+ // ── Step 1: Setup ─────────────────────────────────────────
1811
+ console.log('━━━ Step 1/3: Pipeline Setup ━━━━━━━━━━━━━━━━━━');
1812
+ console.log('');
1813
+ try {
1814
+ // Execute setup command programmatically
1815
+ const setupArgs = ['setup'];
1816
+ if (opts.nonInteractive) setupArgs.push('--non-interactive');
1817
+ if (opts.installTests) setupArgs.push('--install-tests');
1818
+ if (opts.e2e !== false) setupArgs.push('--e2e');
1819
+ if (opts.noE2e) setupArgs.push('--no-e2e');
1820
+
1821
+ const { execSync: ex } = require('child_process');
1822
+ const cliPath = __filename;
1823
+ const setupCmd = `node "${cliPath}" ${setupArgs.join(' ')}`;
1824
+ ex(setupCmd, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
1825
+ } catch (e) {
1826
+ console.error('Setup failed:', e.message);
1827
+ process.exit(1);
1828
+ }
1829
+
1830
+ // ── Step 2: Derive domain skills ──────────────────────────
1831
+ if (!opts.skipDerive) {
1832
+ console.log('');
1833
+ console.log('━━━ Step 2/3: Derive Domain Skills ━━━━━━━━━━━━');
1834
+ console.log('');
1835
+ try {
1836
+ const { execSync: ex } = require('child_process');
1837
+ const cliPath = __filename;
1838
+ const deriveCmd = `node "${cliPath}" derive --max-files ${opts.maxFiles}`;
1839
+ ex(deriveCmd, { stdio: 'inherit', cwd: process.cwd(), env: process.env, timeout: 600_000 });
1840
+ } catch (e) {
1841
+ console.warn('');
1842
+ console.warn('⚠ Derive failed (non-fatal):', e.message?.slice(0, 100));
1843
+ console.warn(' You can run "hone derive" later to generate domain skills.');
1844
+ console.warn(' The pipeline works with enterprise baseline skills.');
1845
+ }
1846
+ } else {
1847
+ console.log('');
1848
+ console.log('━━━ Step 2/3: Derive — skipped (--skip-derive) ━━');
1849
+ }
1850
+
1851
+ // ── Step 3: Sync agent prompts + copilot-instructions ─────
1852
+ console.log('');
1853
+ console.log('━━━ Step 3/3: Sync Prompts ━━━━━━━━━━━━━━━━━━━━');
1854
+ console.log('');
1855
+ try {
1856
+ const { execSync: ex } = require('child_process');
1857
+ const cliPath = __filename;
1858
+ ex(`node "${cliPath}" sync`, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
1859
+ } catch (e) {
1860
+ console.warn('⚠ Sync failed:', e.message);
1861
+ }
1862
+
1863
+ // ── H-018: Post-adopt pattern detection summary ───────────
1864
+ // Tells the adopter what we detected vs what was written, so
1865
+ // mismatches surface immediately instead of silently breaking
1866
+ // Gate 3 / Pizza Tracker on the first PR.
1867
+ try {
1868
+ const { execSync: ex } = require('child_process');
1869
+ const cliPath = __filename;
1870
+ console.log('');
1871
+ console.log('━━━ Pattern detection (H-018) ━━━━━━━━━━━━━━━━━━');
1872
+ console.log('');
1873
+ ex(`node "${cliPath}" check-patterns`, { stdio: 'inherit', cwd: process.cwd(), env: process.env });
1874
+ } catch (e) {
1875
+ // Exit 1 from check-patterns means drift detected — the user
1876
+ // already saw the table, so just note that adoption succeeded
1877
+ // but config needs review. Don't fail adoption itself.
1878
+ console.warn('');
1879
+ console.warn(' ↑ Patterns above drift from detected conventions — review .pipeline-config.yml.');
1880
+ }
1881
+
1882
+ // ── Summary ───────────────────────────────────────────────
1883
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1884
+ console.log('');
1885
+ console.log('╔══════════════════════════════════════════════╗');
1886
+ console.log('║ ✅ Pipeline Adoption Complete ║');
1887
+ console.log(`║ Time: ${elapsed}s ║`);
1888
+ console.log('╚══════════════════════════════════════════════╝');
1889
+ console.log('');
1890
+ console.log(' Your repo now has:');
1891
+ console.log(' • 9 agents (.github/agents/ + .claude/agents/)');
1892
+ console.log(' • 11 enterprise skills + domain skills');
1893
+ console.log(' • CI workflow (ai-review.yml)');
1894
+ console.log(' • CLAUDE.md + copilot-instructions.md');
1895
+ console.log(' • Metrics + learnings + docs infrastructure');
1896
+ console.log('');
1897
+ console.log(' Next steps:');
1898
+ console.log(' 1. git add -A && git commit -m "feat: adopt Hone AI pipeline"');
1899
+ console.log(' 2. Open Claude Code or VS Code Copilot');
1900
+ console.log(' 3. Say: "Run the SDLC pipeline for: [feature description]"');
1901
+ console.log('');
1902
+ // H-012 (#21): surface `hone tests organize` so teams actually run it.
1903
+ // Discovery-only — the command itself has existed since H-020 era.
1904
+ console.log(' (Recommended) Curate your regression suite:');
1905
+ console.log(' hone tests organize # group test files by story-id; flag orphans');
1906
+ console.log('');
1907
+ });
1908
+
1909
+ // ── AUDIT command (H-020) ────────────────────────────────────────────────────
1910
+ program
1911
+ .command('audit')
1912
+ .description('Score every story under .github/pipeline/ for SDLC compliance')
1913
+ .option('--fail-if-incomplete', 'Exit 1 if any story is INCOMPLETE or EMPTY (use in CI)')
1914
+ .option('--format <fmt>', 'Output format: table | json', 'table')
1915
+ .option('--story <id>', 'Audit a single story (e.g. E20-A) instead of all')
1916
+ .action((opts) => {
1917
+ const { auditPipeline, renderTable } = require('./lib/audit');
1918
+ const repoRoot = process.cwd();
1919
+ const pipelineDir = path.join(repoRoot, '.github', 'pipeline');
1920
+
1921
+ if (!fs.existsSync(pipelineDir)) {
1922
+ console.error('No .github/pipeline/ directory found. Run `hone adopt` first.');
1923
+ process.exit(1);
1924
+ }
1925
+
1926
+ const stories = [];
1927
+ for (const entry of fs.readdirSync(pipelineDir, { withFileTypes: true })) {
1928
+ if (!entry.isDirectory()) continue;
1929
+ const storyDir = path.join(pipelineDir, entry.name);
1930
+ const files = fs
1931
+ .readdirSync(storyDir, { withFileTypes: true })
1932
+ .filter((f) => f.isFile())
1933
+ .map((f) => f.name);
1934
+ stories.push({ storyId: entry.name, files });
1935
+ }
1936
+ stories.sort((a, b) => a.storyId.localeCompare(b.storyId));
1937
+
1938
+ // Optional override of required steps via .pipeline-config.yml.
1939
+ let requiredSteps;
1940
+ const cfgPath = path.join(repoRoot, '.github', '.pipeline-config.yml');
1941
+ if (fs.existsSync(cfgPath)) {
1942
+ try {
1943
+ const yaml = require('js-yaml');
1944
+ const cfg = yaml.load(fs.readFileSync(cfgPath, 'utf8')) || {};
1945
+ if (Array.isArray(cfg.pipeline?.required_steps)) {
1946
+ requiredSteps = cfg.pipeline.required_steps;
1947
+ }
1948
+ } catch {
1949
+ // Bad config — fall back to defaults silently.
1950
+ }
1951
+ }
1952
+
1953
+ const audit = auditPipeline(stories, { story: opts.story, requiredSteps });
1954
+
1955
+ if (opts.format === 'json') {
1956
+ console.log(JSON.stringify(audit, null, 2));
1957
+ } else {
1958
+ console.log(renderTable(audit));
1959
+ }
1960
+
1961
+ const hasFailures = audit.summary.incomplete > 0 || audit.summary.empty > 0;
1962
+ if (opts.failIfIncomplete && hasFailures) {
1963
+ process.exit(1);
1964
+ }
1965
+ process.exit(0);
1966
+ });
1967
+
1968
+ // ── CHECK-PATTERNS command (H-018) ───────────────────────────────────────────
1969
+ // Detects whether the inlined story-ID + E2E-spec regexes in
1970
+ // .pipeline-config.yml match the conventions this repo actually uses.
1971
+ // Catches the silent-Gate-3 failure mode for non-TS-Playwright stacks.
1972
+ program
1973
+ .command('check-patterns')
1974
+ .description('Verify .pipeline-config.yml regexes match the repo\'s actual story-ID + E2E conventions (H-018)')
1975
+ .option('--json', 'Emit machine-readable JSON instead of pretty output')
1976
+ .action((opts) => {
1977
+ const { detectStoryIdShape, detectE2EConvention, checkPatternDrift } = require('./lib/auto-detect');
1978
+ const repoRoot = process.cwd();
1979
+
1980
+ // ── Collect filesystem signals ───────────────────────────────
1981
+ const branchSamples = [];
1982
+ try {
1983
+ const out = execSync('git for-each-ref --sort=-committerdate --count=30 --format=%(refname:short) refs/heads/ refs/remotes/', {
1984
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
1985
+ });
1986
+ branchSamples.push(...out.split('\n').filter(Boolean));
1987
+ } catch { /* not a git repo — skip */ }
1988
+
1989
+ const pipelineDir = path.join(repoRoot, '.github', 'pipeline');
1990
+ if (fs.existsSync(pipelineDir)) {
1991
+ try {
1992
+ for (const e of fs.readdirSync(pipelineDir, { withFileTypes: true })) {
1993
+ if (e.isDirectory()) branchSamples.push(e.name);
1994
+ }
1995
+ } catch { /* ignore */ }
1996
+ }
1997
+
1998
+ const hasPyproject = fs.existsSync(path.join(repoRoot, 'pyproject.toml'));
1999
+ const hasRequirements = fs.existsSync(path.join(repoRoot, 'requirements.txt'));
2000
+ let hasPlaywrightDep = false;
2001
+ const pkgJsonPath = path.join(repoRoot, 'package.json');
2002
+ if (fs.existsSync(pkgJsonPath)) {
2003
+ try {
2004
+ const pj = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
2005
+ const deps = { ...(pj.dependencies || {}), ...(pj.devDependencies || {}) };
2006
+ hasPlaywrightDep = '@playwright/test' in deps || 'playwright' in deps;
2007
+ } catch { /* ignore */ }
2008
+ }
2009
+ const testsDir = fs.existsSync(path.join(repoRoot, 'tests'));
2010
+ const testDir = fs.existsSync(path.join(repoRoot, 'test'));
2011
+
2012
+ const sampleE2EFiles = [];
2013
+ for (const d of ['tests/e2e', 'test/e2e']) {
2014
+ const full = path.join(repoRoot, d);
2015
+ if (fs.existsSync(full)) {
2016
+ try {
2017
+ for (const f of fs.readdirSync(full, { withFileTypes: true })) {
2018
+ if (f.isFile()) sampleE2EFiles.push(f.name);
2019
+ }
2020
+ } catch { /* ignore */ }
2021
+ }
2022
+ }
2023
+
2024
+ // ── Run detection ────────────────────────────────────────────
2025
+ const story = detectStoryIdShape(branchSamples);
2026
+ const e2e = detectE2EConvention({
2027
+ hasPyproject, hasRequirements, hasPlaywrightDep, testsDir, testDir, sampleE2EFiles,
2028
+ });
2029
+ const recommended = { story, e2e };
2030
+
2031
+ // ── Read configured patterns ─────────────────────────────────
2032
+ const cfgPath = path.join(repoRoot, '.github', '.pipeline-config.yml');
2033
+ let configured = null;
2034
+ if (fs.existsSync(cfgPath)) {
2035
+ try {
2036
+ const yaml = require('js-yaml');
2037
+ const cfg = yaml.load(fs.readFileSync(cfgPath, 'utf8')) || {};
2038
+ configured = {
2039
+ story_id_pattern: cfg.ci?.story_id_pattern,
2040
+ e2e_spec_pattern: cfg.ci?.e2e_spec_pattern,
2041
+ };
2042
+ } catch { /* malformed — leave configured null */ }
2043
+ }
2044
+
2045
+ const drift = checkPatternDrift({ configured, recommended });
2046
+ const result = { configured, recommended, drift };
2047
+
2048
+ if (opts.json) {
2049
+ console.log(JSON.stringify(result, null, 2));
2050
+ } else {
2051
+ console.log('Pattern detection:');
2052
+ console.log(` Story ID: ${story.shape} (${story.confidence} confidence) → ${story.pattern}`);
2053
+ if (story.samples.length) console.log(` samples: ${story.samples.join(', ')}`);
2054
+ console.log(` E2E spec: ${e2e.framework} under ${e2e.dir}/e2e/ (${e2e.confidence} confidence) → ${e2e.pattern}`);
2055
+ console.log('');
2056
+ if (!configured) {
2057
+ console.log('No .github/.pipeline-config.yml found — run `hone setup` first.');
2058
+ process.exit(2);
2059
+ }
2060
+ console.log('Configured (.pipeline-config.yml):');
2061
+ console.log(` story_id_pattern: ${configured.story_id_pattern || '(unset)'}`);
2062
+ console.log(` e2e_spec_pattern: ${configured.e2e_spec_pattern || '(unset)'}`);
2063
+ console.log('');
2064
+ if (drift.status === 'ok') {
2065
+ console.log('✓ Patterns match detected conventions.');
2066
+ } else {
2067
+ console.log(`✗ Drift: ${drift.reason}`);
2068
+ for (const f of drift.findings) {
2069
+ console.log(` • ${f.key}`);
2070
+ console.log(` configured: ${f.configured}`);
2071
+ console.log(` recommended: ${f.recommended}`);
2072
+ console.log(` reason: ${f.reason}`);
2073
+ }
2074
+ console.log('');
2075
+ console.log(` Suggested fix: ${drift.suggestedFix}`);
2076
+ }
2077
+ }
2078
+ process.exit(drift.status === 'drift' ? 1 : 0);
2079
+ });
2080
+
2081
+ // ── DOCTOR command (H-017) ───────────────────────────────────────────────────
2082
+ // Health checks for the adopter repo. First subcheck: docs freshness (catches
2083
+ // drifted README test-count badges). Future subchecks (H-018, H-021) plug into
2084
+ // the same `--check <name>` switch.
2085
+ program
2086
+ .command('doctor')
2087
+ .description('Run health checks on this repo (docs freshness, etc.)')
2088
+ .option('--check <name>', 'Run only one named check (default: all)', 'all')
2089
+ .option('--json', 'Emit machine-readable JSON instead of pretty output')
2090
+ .option('--lookback <n>', 'commits to scan for admin-merge check (HC-005-A; default: 50)', '50')
2091
+ .action(async (opts) => {
2092
+ const repoRoot = process.cwd();
2093
+ const results = [];
2094
+
2095
+ // Read .pipeline-config.yml to learn the stack
2096
+ let stack = 'unknown';
2097
+ try {
2098
+ const yaml = require('js-yaml');
2099
+ const cfgPath = path.join(repoRoot, '.github', '.pipeline-config.yml');
2100
+ if (fs.existsSync(cfgPath)) {
2101
+ const cfg = yaml.load(fs.readFileSync(cfgPath, 'utf8'));
2102
+ stack = cfg?.stack?.primary || cfg?.stack?.language || 'unknown';
2103
+ }
2104
+ } catch {}
2105
+
2106
+ if (opts.check === 'all' || opts.check === 'docs') {
2107
+ const {
2108
+ checkDocsFreshness,
2109
+ testCounterCommand,
2110
+ parseTestCount,
2111
+ } = require('./lib/doctor-docs');
2112
+
2113
+ const readmePath = path.join(repoRoot, 'README.md');
2114
+ const readmeText = fs.existsSync(readmePath)
2115
+ ? fs.readFileSync(readmePath, 'utf8')
2116
+ : null;
2117
+
2118
+ // Try to count tests for this stack
2119
+ let actualTestCount = null;
2120
+ const counterCmd = testCounterCommand(stack);
2121
+ if (counterCmd) {
2122
+ try {
2123
+ const stdout = execSync(counterCmd, {
2124
+ cwd: repoRoot,
2125
+ encoding: 'utf8',
2126
+ timeout: 60_000,
2127
+ stdio: ['ignore', 'pipe', 'pipe'],
2128
+ });
2129
+ actualTestCount = parseTestCount(stack, stdout);
2130
+ } catch {
2131
+ // Test runner failed — leave actualTestCount null; helper returns 'skip'
2132
+ }
2133
+ }
2134
+
2135
+ const result = checkDocsFreshness({ readmeText, actualTestCount });
2136
+ results.push({ name: 'docs', stack, ...result });
2137
+ }
2138
+
2139
+ // HC-002: admin-merge anti-pattern check (per E13-A-L3)
2140
+ // HC-005-A: --lookback CLI plumbing with explicit validation
2141
+ if (opts.check === 'all' || opts.check === 'admin-merge') {
2142
+ const { checkAdminMerge } = require('./lib/doctor-admin-merge');
2143
+ const parsedLookback = parseInt(opts.lookback, 10);
2144
+ if (!Number.isFinite(parsedLookback) || parsedLookback <= 0) {
2145
+ console.error(`✗ --lookback must be a positive integer; got "${opts.lookback}"`);
2146
+ process.exit(2);
2147
+ }
2148
+ results.push(checkAdminMerge({ repoRoot, lookback: parsedLookback }));
2149
+ }
2150
+
2151
+ // HC-002: bind-default check (per E13-A-L1; security-flagged per SC-006-L1)
2152
+ if (opts.check === 'all' || opts.check === 'bind-default') {
2153
+ const { checkBindDefault } = require('./lib/doctor-bind-default');
2154
+ results.push(checkBindDefault({ repoRoot }));
2155
+ }
2156
+
2157
+ // HC-002: unsubstituted placeholder check (per E21-A-L1)
2158
+ if (opts.check === 'all' || opts.check === 'placeholders') {
2159
+ const { checkPlaceholders } = require('./lib/doctor-placeholders');
2160
+ results.push(checkPlaceholders({ repoRoot }));
2161
+ }
2162
+
2163
+ // HC-002: skill staleness check (per E16-A-L4; mirrors hone check-refresh)
2164
+ if (opts.check === 'all' || opts.check === 'skill-staleness') {
2165
+ const { checkSkillStaleness } = require('./lib/doctor-skill-staleness');
2166
+ results.push(checkSkillStaleness({ repoRoot }));
2167
+ }
2168
+
2169
+ // Render
2170
+ if (opts.json) {
2171
+ console.log(JSON.stringify({ checks: results }, null, 2));
2172
+ } else {
2173
+ console.log('Hone Doctor — Repo Health');
2174
+ console.log('==========================');
2175
+ for (const r of results) {
2176
+ // HC-005-A: distinguish info (ℹ) from warn/skip (⚠)
2177
+ const icon = r.status === 'ok' ? '✓'
2178
+ : r.status === 'drift' ? '✗'
2179
+ : r.status === 'info' ? 'ℹ'
2180
+ : '⚠';
2181
+ const label = r.name === 'docs' ? 'Docs freshness' : r.name;
2182
+ console.log(`${icon} ${label} — ${r.reason}`);
2183
+ if (r.suggestedFix) {
2184
+ console.log(` Fix: ${r.suggestedFix}`);
2185
+ }
2186
+ }
2187
+ }
2188
+
2189
+ // Exit non-zero if any check is in 'drift' state — useful in CI.
2190
+ const failed = results.some((r) => r.status === 'drift');
2191
+ process.exit(failed ? 1 : 0);
2192
+ });
2193
+
2194
+ // ── TESTS ORGANIZE command ───────────────────────────────────────────────────
2195
+ // ── TESTS ORGANIZE command ───────────────────────────────────────────────────
2196
+ program
2197
+ .command('tests')
2198
+ .argument('<action>', 'Action: organize')
2199
+ .description('Organize existing test files into the regression suite structure')
2200
+ .option('--dry-run', 'Show what would be moved without moving')
2201
+ .action(async (action, opts) => {
2202
+ if (action !== 'organize') {
2203
+ console.error(`Unknown action: ${action}. Available: organize`);
2204
+ process.exit(1);
2205
+ }
2206
+
2207
+ const repoRoot = process.cwd();
2208
+ console.log('Hone AI — Test Suite Organizer');
2209
+ console.log('================================');
2210
+ console.log('');
2211
+
2212
+ // 1. Ensure regression dirs exist
2213
+ const e2eRoot = path.join(repoRoot, 'e2e');
2214
+ for (const dir of ['smoke', 'regression', 'api', 'story']) {
2215
+ fs.mkdirSync(path.join(e2eRoot, dir), { recursive: true });
2216
+ }
2217
+
2218
+ // 2. Scan for existing spec files across common locations
2219
+ const specFiles = [];
2220
+ const scanDirs = [
2221
+ 'e2e', 'test/e2e', 'tests/e2e', 'test', 'tests',
2222
+ 'src/__tests__/e2e', 'src/__tests__/integration',
2223
+ ];
2224
+
2225
+ for (const dir of scanDirs) {
2226
+ const fullDir = path.join(repoRoot, dir);
2227
+ if (!fs.existsSync(fullDir)) continue;
2228
+ scanForSpecs(fullDir, repoRoot, specFiles);
2229
+ }
2230
+
2231
+ // Filter out files already in organized dirs
2232
+ const organized = specFiles.filter(f =>
2233
+ !f.relPath.startsWith('e2e/smoke/') &&
2234
+ !f.relPath.startsWith('e2e/regression/') &&
2235
+ !f.relPath.startsWith('e2e/api/') &&
2236
+ !f.relPath.startsWith('e2e/story/')
2237
+ );
2238
+
2239
+ if (organized.length === 0) {
2240
+ console.log('No unorganized spec files found.');
2241
+ console.log('Spec files already in e2e/smoke/, e2e/regression/, e2e/api/, or e2e/story/ are considered organized.');
2242
+ return;
2243
+ }
2244
+
2245
+ // 3. Classify each file
2246
+ const classified = organized.map(f => classifySpec(f, repoRoot));
2247
+
2248
+ // 4. Display plan
2249
+ const browser = classified.filter(c => c.type === 'BROWSER');
2250
+ const apiTests = classified.filter(c => c.type === 'API');
2251
+ const hybrid = classified.filter(c => c.type === 'HYBRID');
2252
+ const unknown = classified.filter(c => c.type === 'UNKNOWN');
2253
+
2254
+ console.log(`Found ${classified.length} spec files to organize:\n`);
2255
+
2256
+ if (browser.length > 0) {
2257
+ console.log(` BROWSER tests (${browser.length}):`);
2258
+ for (const c of browser) {
2259
+ console.log(` ${c.relPath}`);
2260
+ console.log(` → ${c.destination}${c.smoke ? ' (+ @smoke)' : ''}`);
2261
+ }
2262
+ console.log('');
2263
+ }
2264
+
2265
+ if (apiTests.length > 0) {
2266
+ console.log(` API tests (${apiTests.length}):`);
2267
+ for (const c of apiTests) {
2268
+ console.log(` ${c.relPath}`);
2269
+ console.log(` → ${c.destination}`);
2270
+ }
2271
+ console.log('');
2272
+ }
2273
+
2274
+ if (hybrid.length > 0) {
2275
+ console.log(` HYBRID tests (${hybrid.length}):`);
2276
+ for (const c of hybrid) {
2277
+ console.log(` ${c.relPath}`);
2278
+ console.log(` → ${c.destination}`);
2279
+ }
2280
+ console.log('');
2281
+ }
2282
+
2283
+ if (unknown.length > 0) {
2284
+ console.log(` UNCLASSIFIED (${unknown.length}) — needs manual review:`);
2285
+ for (const c of unknown) {
2286
+ console.log(` ${c.relPath}`);
2287
+ console.log(` → ${c.destination} (best guess)`);
2288
+ }
2289
+ console.log('');
2290
+ }
2291
+
2292
+ if (opts.dryRun) {
2293
+ console.log('Dry run — no files moved. Remove --dry-run to organize.');
2294
+ return;
2295
+ }
2296
+
2297
+ // 5. Confirm
2298
+ const readline = await import('readline');
2299
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2300
+ const answer = await new Promise(resolve =>
2301
+ rl.question(`Move ${classified.length} file(s) to regression structure? (y/N): `, resolve)
2302
+ );
2303
+ rl.close();
2304
+
2305
+ if (!answer.match(/^[Yy]/)) {
2306
+ console.log('Cancelled.');
2307
+ return;
2308
+ }
2309
+
2310
+ // 6. Move files
2311
+ let moved = 0;
2312
+ const smokeSpecs = [];
2313
+ for (const c of classified) {
2314
+ const src = path.join(repoRoot, c.relPath);
2315
+ const dst = path.join(repoRoot, c.destination);
2316
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
2317
+ fs.renameSync(src, dst);
2318
+ console.log(` ✓ ${c.relPath} → ${c.destination}`);
2319
+ moved++;
2320
+ if (c.smoke) smokeSpecs.push(c.destination);
2321
+ }
2322
+
2323
+ // 7. Copy smoke candidates
2324
+ for (const spec of smokeSpecs) {
2325
+ const src = path.join(repoRoot, spec);
2326
+ const smokeDst = path.join(e2eRoot, 'smoke', path.basename(spec));
2327
+ if (!fs.existsSync(smokeDst)) {
2328
+ fs.copyFileSync(src, smokeDst);
2329
+ console.log(` ✓ ${path.basename(spec)} → e2e/smoke/ (@smoke)`);
2330
+ }
2331
+ }
2332
+
2333
+ // 8. Update regression manifest
2334
+ const manifestPath = path.join(e2eRoot, 'regression-manifest.yml');
2335
+ if (fs.existsSync(manifestPath)) {
2336
+ try {
2337
+ const yaml = require('js-yaml');
2338
+ const manifest = yaml.load(fs.readFileSync(manifestPath, 'utf8'));
2339
+ manifest.last_updated = new Date().toISOString();
2340
+ manifest.total_specs = (manifest.total_specs || 0) + moved;
2341
+ manifest.smoke_count = (manifest.smoke_count || 0) + smokeSpecs.length;
2342
+ if (!manifest.promotion_history) manifest.promotion_history = [];
2343
+ manifest.promotion_history.push({
2344
+ action: 'organize',
2345
+ files_moved: moved,
2346
+ smoke_added: smokeSpecs.length,
2347
+ organized_at: new Date().toISOString(),
2348
+ });
2349
+ fs.writeFileSync(manifestPath, yaml.dump(manifest, { flowLevel: 3, sortKeys: false }));
2350
+ console.log(` ✓ regression-manifest.yml updated (${moved} specs, ${smokeSpecs.length} smoke)`);
2351
+ } catch (e) {
2352
+ console.warn(` ⚠ manifest update failed: ${e.message}`);
2353
+ }
2354
+ }
2355
+
2356
+ console.log(`\nDone: ${moved} files organized.`);
2357
+ console.log(' git add e2e/ && git commit -m "chore: organize test suite into regression structure"');
2358
+ });
2359
+
2360
+ // ── Test organizer helpers ───────────────────────────────────────────────────
2361
+
2362
+ function scanForSpecs(dir, repoRoot, results) {
2363
+ const SPEC_PATTERN = /\.(spec|test)\.(js|jsx|ts|tsx)$/;
2364
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
2365
+
2366
+ try {
2367
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2368
+ for (const entry of entries) {
2369
+ if (SKIP.has(entry.name)) continue;
2370
+ const fullPath = path.join(dir, entry.name);
2371
+ if (entry.isDirectory()) {
2372
+ scanForSpecs(fullPath, repoRoot, results);
2373
+ } else if (SPEC_PATTERN.test(entry.name)) {
2374
+ results.push({
2375
+ fullPath,
2376
+ relPath: path.relative(repoRoot, fullPath),
2377
+ name: entry.name,
2378
+ });
2379
+ }
2380
+ }
2381
+ } catch {}
2382
+ }
2383
+
2384
+ function classifySpec(file, repoRoot) {
2385
+ let content = '';
2386
+ try { content = fs.readFileSync(file.fullPath, 'utf8'); } catch { }
2387
+
2388
+ const hasPage = /page\.(goto|click|locator|fill|waitFor|getBy)/.test(content);
2389
+ const hasRequest = /request\.(get|post|put|delete|patch)\(/.test(content);
2390
+ const hasExpectResponse = /response\.(status|json|ok|body)/.test(content) ||
2391
+ /expect\(resp/.test(content);
2392
+
2393
+ // Detect type
2394
+ let type = 'UNKNOWN';
2395
+ if (hasPage && (hasRequest || hasExpectResponse)) type = 'HYBRID';
2396
+ else if (hasPage) type = 'BROWSER';
2397
+ else if (hasRequest || hasExpectResponse) type = 'API';
2398
+ else if (/import.*playwright|from.*@playwright/.test(content)) type = 'BROWSER';
2399
+ else if (/import.*supertest|from.*supertest/.test(content)) type = 'API';
2400
+
2401
+ // Detect module from filename or content
2402
+ // Detect module — filename takes priority over content, most specific first
2403
+ let module = 'general';
2404
+ const name = file.name.toLowerCase();
2405
+ const contentLower = content.toLowerCase();
2406
+
2407
+ // Pass 1: check filename (strongest signal)
2408
+ if (/auth|login|signup|oauth|session/.test(name)) module = 'auth';
2409
+ else if (/trade|order|execution/.test(name)) module = 'trading';
2410
+ else if (/position|portfolio|pnl|p&l/.test(name)) module = 'portfolio';
2411
+ else if (/alert|notification/.test(name)) module = 'alerts';
2412
+ else if (/dashboard|widget|overview/.test(name)) module = 'dashboard';
2413
+ else if (/scanner|scan|market/.test(name)) module = 'market';
2414
+ else if (/setting|config|preference/.test(name)) module = 'settings';
2415
+
2416
+ // Pass 2: fallback to content (if filename didn't match)
2417
+ if (module === 'general') {
2418
+ if (/login|authenticate|sign.?in/.test(contentLower)) module = 'auth';
2419
+ else if (/trading|order|execute/.test(contentLower)) module = 'trading';
2420
+ else if (/portfolio/.test(contentLower)) module = 'portfolio';
2421
+ else if (/alert|notification/.test(contentLower)) module = 'alerts';
2422
+ else if (/dashboard/.test(contentLower)) module = 'dashboard';
2423
+ else if (/market|scanner/.test(contentLower)) module = 'market';
2424
+ }
2425
+
2426
+ // Determine destination
2427
+ let destination;
2428
+ if (type === 'API') {
2429
+ destination = `e2e/api/${file.name}`;
2430
+ } else {
2431
+ destination = `e2e/regression/${module}/${file.name}`;
2432
+ }
2433
+
2434
+ // Smoke candidate: login, auth, critical navigation, app loads
2435
+ const smoke = /login|auth|app.load|critical|smoke|health/.test(name) ||
2436
+ /login|authenticate/.test(contentLower);
2437
+
2438
+ return { ...file, type, module, destination, smoke };
2439
+ }
2440
+
2441
+ // ── Helpers ───────────────────────────────────────────────────────────────────
2442
+ function bundleDirectory(dir, root, files, maxFiles, currentCount) {
2443
+ if (currentCount >= maxFiles) return;
2444
+ const EXTENSIONS = /\.(js|ts|jsx|tsx|java|py|cs|cls|trigger|lwc|html|css|yml|yaml|json|xml|md)$/;
2445
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.github', 'dist', 'build', 'coverage', '__pycache__', '.venv', 'target']);
2446
+
2447
+ try {
2448
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2449
+ for (const entry of entries) {
2450
+ if (Object.keys(files).length >= maxFiles) break;
2451
+ if (SKIP_DIRS.has(entry.name)) continue;
2452
+ const fullPath = path.join(dir, entry.name);
2453
+ const relPath = path.relative(root, fullPath);
2454
+
2455
+ if (entry.isDirectory()) {
2456
+ bundleDirectory(fullPath, root, files, maxFiles, Object.keys(files).length);
2457
+ } else if (EXTENSIONS.test(entry.name)) {
2458
+ try {
2459
+ const content = fs.readFileSync(fullPath, 'utf8');
2460
+ if (content.length < 50_000) { // skip very large files
2461
+ files[relPath] = content;
2462
+ }
2463
+ } catch {} // skip unreadable files
2464
+ }
2465
+ }
2466
+ } catch {} // skip unreadable directories
2467
+ }
2468
+
2469
+ function sleep(ms) {
2470
+ return new Promise(resolve => setTimeout(resolve, ms));
2471
+ }
2472
+
2473
+ // ── STATUS command (H-009) ────────────────────────────────────────────────────
2474
+ // Show pipeline state for a story (or use --all for every in-flight story).
2475
+ // Reads .github/pipeline/<STORY-ID>/metadata.yml + filesystem; uses
2476
+ // filesystem as source of truth on metadata-vs-reality disagreement (AC-5).
2477
+ // Pure helpers in cli/lib/pipeline-status.js — this command is the I/O shell.
2478
+ program
2479
+ .command('status [storyId]')
2480
+ .description('Show pipeline state for a story (or use --all for every in-flight story)')
2481
+ .option('--all', 'List every in-flight story under .github/pipeline/')
2482
+ .action(async (storyIdArg, opts) => {
2483
+ const ps = require('./lib/pipeline-status');
2484
+ const repoRoot = process.cwd();
2485
+ const pipelineRoot = path.join(repoRoot, '.github', 'pipeline');
2486
+
2487
+ if (!fs.existsSync(pipelineRoot)) {
2488
+ console.log('No pipeline state — run `hone setup` first.');
2489
+ process.exit(0);
2490
+ }
2491
+
2492
+ if (opts.all) {
2493
+ const dirs = fs.readdirSync(pipelineRoot, { withFileTypes: true })
2494
+ .filter(d => d.isDirectory()).map(d => d.name);
2495
+ if (dirs.length === 0) {
2496
+ console.log('No in-flight stories under .github/pipeline/');
2497
+ process.exit(0);
2498
+ }
2499
+ const rows = [];
2500
+ for (const sid of dirs) {
2501
+ const metaPath = path.join(pipelineRoot, sid, 'metadata.yml');
2502
+ if (!fs.existsSync(metaPath)) {
2503
+ rows.push({ storyId: sid, branch: '(no metadata.yml)', summary: 'no metadata' });
2504
+ continue;
2505
+ }
2506
+ const parsed = ps.parseMetadata(fs.readFileSync(metaPath, 'utf8'));
2507
+ if (parsed.error) {
2508
+ rows.push({ storyId: sid, branch: '?', summary: 'metadata.yml malformed' });
2509
+ continue;
2510
+ }
2511
+ const presence = {};
2512
+ for (const [k, fname] of Object.entries(ps.STEP_FILE_MAP)) {
2513
+ presence[k] = fs.existsSync(path.join(pipelineRoot, sid, fname));
2514
+ }
2515
+ rows.push(ps.summarizeForAll(parsed, presence));
2516
+ }
2517
+ console.log('');
2518
+ console.log(`${rows.length} pipeline ${rows.length === 1 ? 'story' : 'stories'}:`);
2519
+ console.log('');
2520
+ for (const r of rows) {
2521
+ console.log(` ${(r.storyId || '?').padEnd(8)} ${(r.summary || '').padEnd(45)} ${r.branch || ''}`);
2522
+ }
2523
+ console.log('');
2524
+ process.exit(0);
2525
+ }
2526
+
2527
+ // Single-story mode — derive STORY-ID if not passed
2528
+ let storyId = storyIdArg;
2529
+ if (!storyId) {
2530
+ try {
2531
+ const branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
2532
+ storyId = ps.extractStoryIdFromBranch(branch);
2533
+ if (!storyId) {
2534
+ console.error(`Could not derive STORY-ID from branch '${branch}'.`);
2535
+ console.error('Pass STORY-ID explicitly or use `hone status --all`.');
2536
+ process.exit(1);
2537
+ }
2538
+ } catch (e) {
2539
+ console.error('Not a git repo or git unavailable. Pass STORY-ID explicitly.');
2540
+ process.exit(1);
2541
+ }
2542
+ }
2543
+
2544
+ const storyDir = path.join(pipelineRoot, storyId);
2545
+ if (!fs.existsSync(storyDir)) {
2546
+ console.error(`No story ${storyId} — checked ${storyDir}`);
2547
+ console.error('Start with Step 0 (Story Groomer) or check the STORY-ID.');
2548
+ process.exit(1);
2549
+ }
2550
+
2551
+ const metaPath = path.join(storyDir, 'metadata.yml');
2552
+ if (!fs.existsSync(metaPath)) {
2553
+ console.error(`No metadata.yml in ${storyDir} — run Step 0 (Story Groomer).`);
2554
+ process.exit(1);
2555
+ }
2556
+
2557
+ const parsed = ps.parseMetadata(fs.readFileSync(metaPath, 'utf8'));
2558
+ if (parsed.error) {
2559
+ console.error(`metadata.yml is malformed (${parsed.error}): ${parsed.message || ''}`);
2560
+ console.error('Fix the YAML and re-run.');
2561
+ process.exit(1);
2562
+ }
2563
+
2564
+ const presence = {};
2565
+ for (const [k, fname] of Object.entries(ps.STEP_FILE_MAP)) {
2566
+ presence[k] = fs.existsSync(path.join(storyDir, fname));
2567
+ }
2568
+
2569
+ const states = ps.computeStepStates(parsed, presence);
2570
+ const next = ps.findNextIncompleteStep(states);
2571
+
2572
+ console.log('');
2573
+ console.log(`Story: ${parsed.storyId} — ${parsed.title || '(no title)'}`);
2574
+ console.log(`Branch: ${parsed.branch || '(unknown)'}`);
2575
+ console.log(`Current step: ${next ? next.displayName : 'pipeline complete 🎉'}`);
2576
+ console.log('');
2577
+ console.log('Pipeline artifacts:');
2578
+ for (const s of states) {
2579
+ const pathStr = `(${path.join('.github/pipeline', storyId, s.artifactPath)})`;
2580
+ console.log(` ${s.icon} ${s.displayName.padEnd(28)} ${pathStr}`);
2581
+ }
2582
+
2583
+ // Optional sidecar files
2584
+ const learningsRel = path.join('.github/learnings', `${storyId}.yml`);
2585
+ const metricsRel = path.join('.github/metrics', `${storyId}.yml`);
2586
+ console.log('');
2587
+ console.log(`Learnings: ${fs.existsSync(path.join(repoRoot, learningsRel)) ? '✓' : '⬜'} (${learningsRel})`);
2588
+ console.log(`Metrics: ${fs.existsSync(path.join(repoRoot, metricsRel)) ? '✓' : '⬜'} (${metricsRel})`);
2589
+
2590
+ if (next) {
2591
+ console.log('');
2592
+ console.log(`Next action: write ${path.join('.github/pipeline', storyId, next.artifactPath)}`);
2593
+ console.log(`Suggested agent: ${next.agent}`);
2594
+ process.exit(1); // rsync-style: 1 = work remaining
2595
+ }
2596
+ console.log('');
2597
+ console.log('🎉 Pipeline complete. Run `hone status --all` for team sync.');
2598
+ process.exit(0);
2599
+ });
2600
+
2601
+ // ── NEXT command (H-009) ──────────────────────────────────────────────────────
2602
+ // Find current story's next incomplete step + suggested agent name.
2603
+ // Uses git branch to auto-derive STORY-ID; rsync-style exit codes.
2604
+ program
2605
+ .command('next')
2606
+ .description('Find current story\'s next incomplete step + suggested agent')
2607
+ .action(async () => {
2608
+ const ps = require('./lib/pipeline-status');
2609
+ const repoRoot = process.cwd();
2610
+ const pipelineRoot = path.join(repoRoot, '.github', 'pipeline');
2611
+
2612
+ let branch;
2613
+ try {
2614
+ branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
2615
+ } catch {
2616
+ console.error('Not a git repo. `hone next` requires a git branch.');
2617
+ process.exit(1);
2618
+ }
2619
+ const storyId = ps.extractStoryIdFromBranch(branch);
2620
+ if (!storyId) {
2621
+ console.error(`Could not derive STORY-ID from branch '${branch}'.`);
2622
+ process.exit(1);
2623
+ }
2624
+ const storyDir = path.join(pipelineRoot, storyId);
2625
+ const metaPath = path.join(storyDir, 'metadata.yml');
2626
+ if (!fs.existsSync(metaPath)) {
2627
+ console.error(`No metadata.yml at ${metaPath}. Start with Step 0 (Story Groomer).`);
2628
+ process.exit(1);
2629
+ }
2630
+ const parsed = ps.parseMetadata(fs.readFileSync(metaPath, 'utf8'));
2631
+ if (parsed.error) {
2632
+ console.error(`metadata.yml malformed: ${parsed.message || ''}`);
2633
+ process.exit(1);
2634
+ }
2635
+ const presence = {};
2636
+ for (const [k, fname] of Object.entries(ps.STEP_FILE_MAP)) {
2637
+ presence[k] = fs.existsSync(path.join(storyDir, fname));
2638
+ }
2639
+ const states = ps.computeStepStates(parsed, presence);
2640
+ const next = ps.findNextIncompleteStep(states);
2641
+ if (!next) {
2642
+ console.log(`🎉 Pipeline complete for ${storyId}. Open a PR or run \`hone status\`.`);
2643
+ process.exit(0);
2644
+ }
2645
+ console.log(`Story: ${parsed.storyId} — ${parsed.title || ''}`);
2646
+ console.log(`Branch: ${branch}`);
2647
+ console.log(`Next step: ${next.displayName}`);
2648
+ console.log(`Next action: write ${path.join('.github/pipeline', storyId, next.artifactPath)}`);
2649
+ console.log(`Suggested agent: ${next.agent}`);
2650
+ process.exit(1); // rsync-style
2651
+ });
2652
+
2653
+ // ── METRICS command group (H-008) ─────────────────────────────────────────────
2654
+ // `hone metrics collect [STORY-ID]` — bootstrap .github/metrics/<STORY-ID>.yml
2655
+ // from pipeline metadata + git + gh data. Pure transformer; no re-collection.
2656
+ // Sub-task (a) of issue #17. Sub-tasks (b) Code Reviewer wiring + (c) CI gate
2657
+ // are deferred to Phase 3.7+. Helper module: cli/lib/metrics-collect.js
2658
+ const metricsCmd = program.command('metrics').description('Pipeline metrics scorecards');
2659
+ metricsCmd
2660
+ .command('collect [storyId]')
2661
+ .description('Bootstrap .github/metrics/<STORY-ID>.yml from pipeline metadata + git + gh')
2662
+ .option('--force', 'Overwrite an existing scorecard (default: preserve)')
2663
+ .action(async (storyIdArg, opts) => {
2664
+ const ps = require('./lib/pipeline-status');
2665
+ const mc = require('./lib/metrics-collect');
2666
+ const repoRoot = process.cwd();
2667
+ const pipelineRoot = path.join(repoRoot, '.github', 'pipeline');
2668
+ const metricsRoot = path.join(repoRoot, '.github', 'metrics');
2669
+
2670
+ // Derive STORY-ID
2671
+ let storyId = storyIdArg;
2672
+ if (!storyId) {
2673
+ try {
2674
+ const branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
2675
+ storyId = ps.extractStoryIdFromBranch(branch);
2676
+ if (!storyId) {
2677
+ console.error(`Could not derive STORY-ID from branch '${branch}'.`);
2678
+ console.error('Pass STORY-ID explicitly.');
2679
+ process.exit(1);
2680
+ }
2681
+ } catch {
2682
+ console.error('Not a git repo or git unavailable. Pass STORY-ID explicitly.');
2683
+ process.exit(1);
2684
+ }
2685
+ }
2686
+
2687
+ const storyDir = path.join(pipelineRoot, storyId);
2688
+ const metaPath = path.join(storyDir, 'metadata.yml');
2689
+ if (!fs.existsSync(metaPath)) {
2690
+ console.error(`No pipeline metadata for ${storyId} — checked ${metaPath}`);
2691
+ console.error('Run `hone setup` and Step 0 (Story Groomer) first.');
2692
+ process.exit(1);
2693
+ }
2694
+
2695
+ const parsed = ps.parseMetadata(fs.readFileSync(metaPath, 'utf8'));
2696
+ if (parsed.error) {
2697
+ console.error(`metadata.yml malformed (${parsed.error}): ${parsed.message || ''}`);
2698
+ console.error('Fix the YAML and re-run.');
2699
+ process.exit(1);
2700
+ }
2701
+
2702
+ // Filesystem presence check
2703
+ const artifactPresence = {};
2704
+ for (const [k, fname] of Object.entries(ps.STEP_FILE_MAP)) {
2705
+ artifactPresence[k] = fs.existsSync(path.join(storyDir, fname));
2706
+ }
2707
+
2708
+ // Idempotency: preserve existing scorecard unless --force
2709
+ fs.mkdirSync(metricsRoot, { recursive: true });
2710
+ const outPath = path.join(metricsRoot, `${storyId}.yml`);
2711
+ if (fs.existsSync(outPath) && !opts.force) {
2712
+ console.error(`${outPath} already exists. Re-run with --force to overwrite.`);
2713
+ console.error('(Default behavior: preserve any hand-written or agent-appended fields.)');
2714
+ process.exit(1);
2715
+ }
2716
+
2717
+ // Git info — best-effort, non-fatal on failure
2718
+ let author;
2719
+ try {
2720
+ author = execSync(`git log -1 --format=%an HEAD`, { encoding: 'utf8' }).trim();
2721
+ } catch { /* leave undefined */ }
2722
+
2723
+ // Files changed — best-effort
2724
+ let filesChanged = [];
2725
+ try {
2726
+ const diff = execSync(`git diff --name-only ${parsed.base || 'develop'}...HEAD`, { encoding: 'utf8' });
2727
+ filesChanged = diff.split('\n').map(s => s.trim()).filter(Boolean);
2728
+ } catch { /* leave empty */ }
2729
+
2730
+ // PR info — best-effort, gh may be missing or no PR yet (E4/E5 graceful)
2731
+ let prInfo = {};
2732
+ try {
2733
+ const branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
2734
+ const pr = execSync(`gh pr list --head ${branch} --json number --jq '.[0].number' 2>/dev/null`, { encoding: 'utf8' }).trim();
2735
+ if (pr && /^\d+$/.test(pr)) prInfo = { pr_number: parseInt(pr, 10) };
2736
+ } catch { /* leave empty — graceful */ }
2737
+
2738
+ // Learnings count — files matching `${storyId}.yml` or `${storyId}-*.yml`
2739
+ let learningsCount = 0;
2740
+ try {
2741
+ const learningsRoot = path.join(repoRoot, '.github', 'learnings');
2742
+ if (fs.existsSync(learningsRoot)) {
2743
+ const re = new RegExp(`^${storyId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:-.*)?\\.ya?ml$`);
2744
+ learningsCount = fs.readdirSync(learningsRoot).filter(f => re.test(f)).length;
2745
+ }
2746
+ } catch { /* leave 0 */ }
2747
+
2748
+ const today = new Date().toISOString().slice(0, 10);
2749
+ const scorecard = mc.buildScorecard({
2750
+ parsedPipelineMetadata: parsed,
2751
+ artifactPresence,
2752
+ gitInfo: author ? { author } : {},
2753
+ prInfo,
2754
+ learningsCount,
2755
+ filesChanged,
2756
+ today,
2757
+ });
2758
+ const yamlText = mc.renderScorecardYaml(scorecard);
2759
+ fs.writeFileSync(outPath, yamlText);
2760
+ console.log(`✓ Wrote ${outPath}`);
2761
+ console.log(` steps_completed: ${scorecard.totals.steps_completed}`);
2762
+ console.log(` pipeline_result: ${scorecard.totals.pipeline_result}`);
2763
+ process.exit(0);
2764
+ });
2765
+
2766
+ // ── CLASSIFY-CI command (H-061 — Phase 4.1) ───────────────────────────────────
2767
+ // Classify CI failure logs into named categories with severity ranking.
2768
+ // Reads stdin or --log-file. Outputs JSON list of classifications.
2769
+ // Categories form a stable contract for downstream tools (#19 CI→skills,
2770
+ // #60 CI auto-fix). CLI exits 0 — downstream tools decide on severity.
2771
+ program
2772
+ .command('classify-ci')
2773
+ .description('Classify CI failure logs into named categories (security, lint, e2e_flake, ...)')
2774
+ .option('--log-file <path>', 'Read log from file (default: stdin)')
2775
+ .action((opts) => {
2776
+ const { classifyLogText } = require('./lib/ci-classifier');
2777
+ let text = '';
2778
+ try {
2779
+ if (opts.logFile) {
2780
+ text = fs.readFileSync(opts.logFile, 'utf8');
2781
+ } else {
2782
+ // Read all of stdin
2783
+ text = fs.readFileSync(0, 'utf8');
2784
+ }
2785
+ } catch (e) {
2786
+ console.error(`Failed to read log: ${e.message}`);
2787
+ process.exit(1);
2788
+ }
2789
+ const result = classifyLogText(text);
2790
+ console.log(JSON.stringify(result, null, 2));
2791
+ // CLI exits 0 — downstream tools decide on severity (per issue #61 spec).
2792
+ process.exit(0);
2793
+ });
2794
+
2795
+ // ── CI-FAILURES command group (H-010 — Phase 4.2) ─────────────────────────────
2796
+ // CI failures feedback loop. Append-only NDJSON at .github/learnings/
2797
+ // ci-failures.jsonl. Subcommands: record / record-from-classify / summary.
2798
+ // Closes #19 parts 1+2; auto-derive (part 3) and server round-trip (part 4)
2799
+ // are deferred. Helper module: cli/lib/ci-failures.js
2800
+ const ciFailuresCmd = program.command('ci-failures').description('CI failure feedback loop (H-010)');
2801
+
2802
+ function _ciFailuresPath() {
2803
+ return path.join(process.cwd(), '.github', 'learnings', 'ci-failures.jsonl');
2804
+ }
2805
+
2806
+ function _deriveStoryIdFromBranch() {
2807
+ try {
2808
+ const ps = require('./lib/pipeline-status');
2809
+ const branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
2810
+ return ps.extractStoryIdFromBranch(branch);
2811
+ } catch { return null; }
2812
+ }
2813
+
2814
+ ciFailuresCmd
2815
+ .command('record')
2816
+ .description('Append a CI failure entry to .github/learnings/ci-failures.jsonl')
2817
+ .requiredOption('--tool <name>', 'Tool name (e.g. bandit, eslint)')
2818
+ .requiredOption('--rule <id>', 'Rule ID (e.g. B104, no-unused-vars)')
2819
+ .option('--file <path>', 'Source file with the finding')
2820
+ .option('--line <n>', 'Line number')
2821
+ .option('--message <msg>', 'Finding message')
2822
+ .option('--story-id <id>', 'Override story ID (default: derived from branch)')
2823
+ .option('--severity <s>', 'Severity (HIGH|MEDIUM|LOW)')
2824
+ .option('--category <c>', 'Category from classifier (security|lint|...)')
2825
+ .action((opts) => {
2826
+ const cf = require('./lib/ci-failures');
2827
+ const jsonlPath = _ciFailuresPath();
2828
+ fs.mkdirSync(path.dirname(jsonlPath), { recursive: true });
2829
+ const existing = fs.existsSync(jsonlPath) ? fs.readFileSync(jsonlPath, 'utf8') : '';
2830
+ const entry = {
2831
+ story_id: opts.storyId || _deriveStoryIdFromBranch() || 'unknown',
2832
+ tool: opts.tool,
2833
+ rule: opts.rule,
2834
+ };
2835
+ if (opts.file) entry.file = opts.file;
2836
+ if (opts.line) entry.line = Number.parseInt(opts.line, 10);
2837
+ if (opts.message) entry.message = opts.message;
2838
+ if (opts.severity) entry.severity = opts.severity;
2839
+ if (opts.category) entry.category = opts.category;
2840
+ fs.writeFileSync(jsonlPath, cf.appendFailure(existing, entry));
2841
+ console.log(`✓ Recorded ${entry.tool}:${entry.rule} → ${jsonlPath}`);
2842
+ process.exit(0);
2843
+ });
2844
+
2845
+ ciFailuresCmd
2846
+ .command('record-from-classify')
2847
+ .description('Pipe `hone classify-ci` JSON output into the failures log')
2848
+ .option('--story-id <id>', 'Override story ID (default: derived from branch)')
2849
+ .action((opts) => {
2850
+ const cf = require('./lib/ci-failures');
2851
+ let stdin = '';
2852
+ try { stdin = fs.readFileSync(0, 'utf8'); }
2853
+ catch (e) {
2854
+ console.error(`Failed to read stdin: ${e.message}`);
2855
+ process.exit(1);
2856
+ }
2857
+ let matches;
2858
+ try {
2859
+ matches = JSON.parse(stdin);
2860
+ if (!Array.isArray(matches)) throw new Error('expected array of classifications');
2861
+ } catch (e) {
2862
+ console.error(`Invalid JSON input from classify-ci: ${e.message}`);
2863
+ process.exit(1);
2864
+ }
2865
+ const storyId = opts.storyId || _deriveStoryIdFromBranch() || 'unknown';
2866
+ const jsonlPath = _ciFailuresPath();
2867
+ fs.mkdirSync(path.dirname(jsonlPath), { recursive: true });
2868
+ let text = fs.existsSync(jsonlPath) ? fs.readFileSync(jsonlPath, 'utf8') : '';
2869
+ let added = 0;
2870
+ for (const m of matches) {
2871
+ // Skip 'unknown' classifications — they're noise in the recurring data
2872
+ if (m.category === 'unknown') continue;
2873
+ const entry = {
2874
+ story_id: storyId,
2875
+ tool: 'classifier',
2876
+ rule: m.category,
2877
+ category: m.category,
2878
+ severity: typeof m.severity === 'number' ? String(m.severity) : m.severity,
2879
+ message: m.evidence ? String(m.evidence).slice(0, 200) : undefined,
2880
+ };
2881
+ text = cf.appendFailure(text, entry);
2882
+ added++;
2883
+ }
2884
+ fs.writeFileSync(jsonlPath, text);
2885
+ console.log(`✓ Recorded ${added} classification(s) → ${jsonlPath}`);
2886
+ process.exit(0);
2887
+ });
2888
+
2889
+ ciFailuresCmd
2890
+ .command('summary')
2891
+ .description('Print grouped counts + recurring patterns')
2892
+ .option('--threshold <n>', 'Recurring threshold (default 3)', '3')
2893
+ .option('--since-days <n>', 'Lookback window (default 30)', '30')
2894
+ .action((opts) => {
2895
+ const cf = require('./lib/ci-failures');
2896
+ const jsonlPath = _ciFailuresPath();
2897
+ if (!fs.existsSync(jsonlPath)) {
2898
+ console.log(`No CI failures recorded yet. Path: ${jsonlPath}`);
2899
+ process.exit(0);
2900
+ }
2901
+ const text = fs.readFileSync(jsonlPath, 'utf8');
2902
+ const { failures, malformedCount } = cf.loadFailures(text);
2903
+ if (malformedCount > 0) {
2904
+ console.warn(`⚠ ${malformedCount} malformed line(s) skipped.`);
2905
+ }
2906
+ console.log(cf.summarize(failures, {
2907
+ threshold: Number.parseInt(opts.threshold, 10) || 3,
2908
+ sinceDays: Number.parseInt(opts.sinceDays, 10) || 30,
2909
+ }));
2910
+ process.exit(0);
2911
+ });
2912
+
2913
+ // ── CHECK-REFRESH command (H-015 — Phase 4.3) ─────────────────────────────────
2914
+ // Evaluate skill freshness against time-based + growth-based triggers.
2915
+ // Reads .pipeline-config.yml + walks source_dirs to count files.
2916
+ // Exit 0 if fresh, exit 1 if stale (with reasons printed).
2917
+ program
2918
+ .command('check-refresh')
2919
+ .description('Check if skill files are stale (time-based + growth-based triggers)')
2920
+ .option('--growth-pct <n>', 'Growth threshold percent (default 50)', '50')
2921
+ .action((opts) => {
2922
+ const yaml = require('js-yaml');
2923
+ const { evaluateRefresh } = require('./lib/refresh-check');
2924
+ const repoRoot = process.cwd();
2925
+ const configPath = path.join(repoRoot, '.github', '.pipeline-config.yml');
2926
+
2927
+ if (!fs.existsSync(configPath)) {
2928
+ console.error(`No .pipeline-config.yml at ${configPath}`);
2929
+ console.error('Run `hone setup` first.');
2930
+ process.exit(1);
2931
+ }
2932
+
2933
+ const config = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
2934
+ const sr = config.skill_refresh || {};
2935
+ const sourceDirs = config.source_dirs || ['src/'];
2936
+
2937
+ // Count source files in configured source_dirs (re-uses bundleDirectory
2938
+ // semantics from `hone derive`)
2939
+ let currentSrcFiles = 0;
2940
+ for (const dir of sourceDirs) {
2941
+ const dirPath = path.join(repoRoot, dir.replace(/"/g, ''));
2942
+ if (!fs.existsSync(dirPath)) continue;
2943
+ const stack = [dirPath];
2944
+ while (stack.length > 0) {
2945
+ const cur = stack.pop();
2946
+ let entries;
2947
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
2948
+ catch { continue; }
2949
+ for (const ent of entries) {
2950
+ const full = path.join(cur, ent.name);
2951
+ if (ent.isDirectory()) {
2952
+ if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
2953
+ stack.push(full);
2954
+ } else if (ent.isFile()) {
2955
+ currentSrcFiles++;
2956
+ }
2957
+ }
2958
+ }
2959
+ }
2960
+
2961
+ const result = evaluateRefresh({
2962
+ now: new Date(),
2963
+ lastDerived: sr.last_derived || null,
2964
+ lastDerivedSrcFiles: sr.last_derived_src_files == null ? null : Number(sr.last_derived_src_files),
2965
+ currentSrcFiles,
2966
+ refreshIntervalDays: typeof sr.refresh_interval_days === 'number' ? sr.refresh_interval_days : 90,
2967
+ growthPct: Number.parseFloat(opts.growthPct) || 50,
2968
+ });
2969
+
2970
+ console.log('');
2971
+ console.log(`Skill freshness check`);
2972
+ console.log(`=====================`);
2973
+ console.log(`Last derived: ${sr.last_derived || '(never)'}`);
2974
+ console.log(`Last derived count: ${sr.last_derived_src_files == null ? '(unknown)' : sr.last_derived_src_files}`);
2975
+ console.log(`Current src files: ${currentSrcFiles}`);
2976
+ console.log(`Refresh interval: ${sr.refresh_interval_days || 90} days`);
2977
+ console.log(`Growth threshold: ${result.reasons.length === 0 ? 'fresh' : '...'} ${Number.parseFloat(opts.growthPct) || 50}%`);
2978
+ console.log('');
2979
+ if (result.stale) {
2980
+ console.log(`⚠ Skills are STALE. Reasons: ${result.reasons.join(', ')}`);
2981
+ console.log(`Run \`hone derive\` to refresh.`);
2982
+ process.exit(1);
2983
+ }
2984
+ console.log(`✓ Skills are fresh.`);
2985
+ process.exit(0);
2986
+ });
2987
+
2988
+ // ── RATIFY command (H-027 — fast-track ratification at epic level) ────────────
2989
+ // Reads .github/EXECUTION_PLAN.yml; classifies fast_track:true stories
2990
+ // via isHighTouch; high-touch stories are auto-excluded from epic
2991
+ // ratification. Prompts user for accept/decline/per-story.
2992
+ // Sub-tasks (e) agent prompt wiring + (f) doc updates deferred to follow-up.
2993
+ program
2994
+ .command('ratify')
2995
+ .description('Ratify fast-track approvals at the epic level (H-027)')
2996
+ .option('--status', 'Print current ratification status + exit')
2997
+ .option('--per-story', 'Skip prompt; force per-story mode')
2998
+ .option('--auto-confirm', 'Accept without prompting (CI use)')
2999
+ .option('-y, --yes', 'Alias for --auto-confirm')
3000
+ .action(async (opts) => {
3001
+ const ratify = require('./lib/fast-track-ratify');
3002
+ const repoRoot = process.cwd();
3003
+ const planPath = path.join(repoRoot, '.github', 'EXECUTION_PLAN.yml');
3004
+
3005
+ if (!fs.existsSync(planPath)) {
3006
+ console.error(`No execution plan at ${planPath}`);
3007
+ console.error('Run the Delivery Architect agent first.');
3008
+ process.exit(1);
3009
+ }
3010
+
3011
+ const text = fs.readFileSync(planPath, 'utf8');
3012
+
3013
+ // --status: read + print + exit
3014
+ if (opts.status) {
3015
+ const status = ratify.getRatificationStatus(text);
3016
+ if (!status.ratified) {
3017
+ console.log('Fast-track ratification: NOT RATIFIED');
3018
+ console.log('Run `hone ratify` to ratify.');
3019
+ process.exit(0);
3020
+ }
3021
+ console.log('Fast-track ratification: RATIFIED');
3022
+ console.log(` By: ${status.by}`);
3023
+ console.log(` At: ${status.at}`);
3024
+ console.log(` Mode: ${status.mode}`);
3025
+ console.log(` High-touch excluded: ${status.highTouchExcluded.length > 0 ? status.highTouchExcluded.join(', ') : '(none)'}`);
3026
+ process.exit(0);
3027
+ }
3028
+
3029
+ // Else: classify candidates + prompt
3030
+ const parsed = ratify.parseExecutionPlan(text);
3031
+ if (parsed.error) {
3032
+ console.error(`EXECUTION_PLAN.yml ${parsed.error}: ${parsed.message || ''}`);
3033
+ process.exit(1);
3034
+ }
3035
+ const stories = Array.isArray(parsed.stories) ? parsed.stories : [];
3036
+ const candidates = stories.filter(s => s && s.fast_track === true);
3037
+ if (candidates.length === 0) {
3038
+ console.log('No fast_track candidates in EXECUTION_PLAN.yml. Nothing to ratify.');
3039
+ process.exit(0);
3040
+ }
3041
+
3042
+ // Classify
3043
+ const eligible = [];
3044
+ const highTouchExcluded = [];
3045
+ for (const s of candidates) {
3046
+ if (ratify.isHighTouch(s)) highTouchExcluded.push(s.id);
3047
+ else eligible.push(s.id);
3048
+ }
3049
+
3050
+ console.log('');
3051
+ console.log(`Delivery Architect proposed fast-track for ${candidates.length} stories:`);
3052
+ for (const s of candidates) {
3053
+ const tag = ratify.isHighTouch(s) ? '[HIGH-TOUCH — will prompt per-story]' : '[eligible]';
3054
+ const est = typeof s.estimate === 'number' ? `(${s.estimate}pt)` : '';
3055
+ console.log(` • ${s.id} — ${s.title || ''} ${est} ${tag}`);
3056
+ }
3057
+ console.log('');
3058
+ console.log(` Eligible: ${eligible.length}`);
3059
+ console.log(` High-touch excluded: ${highTouchExcluded.length}`);
3060
+ console.log('');
3061
+
3062
+ // Decide mode
3063
+ let mode = 'epic';
3064
+ let accept = false;
3065
+ if (opts.perStory) {
3066
+ mode = 'per-story';
3067
+ accept = true;
3068
+ console.log('Mode: per-story (--per-story flag)');
3069
+ } else if (opts.autoConfirm || opts.yes) {
3070
+ accept = true;
3071
+ console.log('Auto-confirmed (CI mode)');
3072
+ } else {
3073
+ // Interactive prompt via readline
3074
+ const readline = require('readline');
3075
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
3076
+ const ask = (q) => new Promise(res => rl.question(q, res));
3077
+ const answer = (await ask('Ratify all eligible? [y / n / per-story]: ')).trim().toLowerCase();
3078
+ rl.close();
3079
+ if (answer === 'y' || answer === 'yes') {
3080
+ accept = true;
3081
+ } else if (answer === 'p' || answer === 'per-story') {
3082
+ accept = true;
3083
+ mode = 'per-story';
3084
+ }
3085
+ }
3086
+
3087
+ if (!accept) {
3088
+ console.log('Declined. No changes written.');
3089
+ process.exit(1);
3090
+ }
3091
+
3092
+ // Write the block
3093
+ const author = process.env.USER || process.env.LOGNAME || 'unknown';
3094
+ const result = ratify.addRatifiedBlock(text, {
3095
+ by: author,
3096
+ at: new Date().toISOString(),
3097
+ mode,
3098
+ highTouchExcluded,
3099
+ });
3100
+ fs.writeFileSync(planPath, result.text);
3101
+ console.log('');
3102
+ console.log(`✓ ${path.relative(repoRoot, planPath)} updated — fast_track_ratified block added`);
3103
+ console.log(` Mode: ${mode}`);
3104
+ if (highTouchExcluded.length > 0) {
3105
+ console.log(` High-touch excluded (will prompt per-story): ${highTouchExcluded.join(', ')}`);
3106
+ }
3107
+ process.exit(0);
3108
+ });
3109
+
3110
+ // ── CHECK-COMPLIANCE command (H-011 — pre-commit pipeline compliance) ────────
3111
+ // Pre-commit-friendly check: ensures the current feature branch has a
3112
+ // step-0-grooming.md artifact for its derived STORY-ID. Off-pattern
3113
+ // branches and fresh repos pass gracefully (exit 0). Missing grooming
3114
+ // on a STORY-ID branch fails with exit 1.
3115
+ //
3116
+ // Wire as a pre-commit hook (see docs/sdlc/pre-commit-hook.md):
3117
+ // - id: hone-pipeline-compliance
3118
+ // entry: hone check-compliance
3119
+ // language: system
3120
+ // stages: [pre-push]
3121
+ program
3122
+ .command('check-compliance')
3123
+ .description('Verify pipeline artifacts exist for current branch (pre-commit-friendly)')
3124
+ .action(() => {
3125
+ const { evaluateCompliance } = require('./lib/compliance-check');
3126
+ const repoRoot = process.cwd();
3127
+ const pipelineRoot = path.join(repoRoot, '.github', 'pipeline');
3128
+
3129
+ let branch = '';
3130
+ try {
3131
+ branch = execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
3132
+ } catch {
3133
+ // Detached HEAD or not a git repo — graceful pass
3134
+ console.log('Not a git repo or detached HEAD — skipping pipeline compliance check.');
3135
+ process.exit(0);
3136
+ }
3137
+
3138
+ const result = evaluateCompliance({
3139
+ branch,
3140
+ pipelineRoot,
3141
+ fileExists: (p) => fs.existsSync(p),
3142
+ });
3143
+
3144
+ if (result.compliant) {
3145
+ switch (result.reason) {
3146
+ case 'no-story-id':
3147
+ console.log(`✓ Branch '${branch}' has no story-ID pattern — pipeline compliance not applicable`);
3148
+ break;
3149
+ case 'no-pipeline-dir':
3150
+ console.log(`✓ No .github/pipeline/ — pipeline compliance not yet applicable (run \`hone setup\`)`);
3151
+ break;
3152
+ case 'ok':
3153
+ console.log(`✓ Pipeline compliance OK: ${result.storyId}/step-0-grooming.md`);
3154
+ break;
3155
+ }
3156
+ process.exit(0);
3157
+ }
3158
+
3159
+ // missing-grooming
3160
+ console.error(`✗ Pipeline compliance: ${result.storyId} has no step-0-grooming.md`);
3161
+ console.error(` Expected: ${path.relative(repoRoot, result.expected)}`);
3162
+ console.error(` Run the Story Groomer agent before committing.`);
3163
+ process.exit(1);
3164
+ });
3165
+
3166
+ // H-086: synthetic SDLC pipeline run — generate fixture or verify captured output.
3167
+ program
3168
+ .command('synthetic-pipeline-run')
3169
+ .description('Generate synthetic story fixture or verify captured pipeline output against expected rule-applied markers. M scope: fixture lib + verifier; per-editor runtime drivers deferred.')
3170
+ .option('--fixture <name>', 'Print synthetic fixture by name (see SYNTHETIC_FIXTURES)')
3171
+ .option('--list', 'List available synthetic fixtures')
3172
+ .option('--verify <story-dir>', 'Verify captured outputs in story dir against fixture markers')
3173
+ .option('--scenario <name>', 'Scenario name to use with --verify (default: fix-with-regression-policy)')
3174
+ .action(async (opts) => {
3175
+ const sp = require('./lib/synthetic-pipeline');
3176
+
3177
+ if (opts.list) {
3178
+ console.log(JSON.stringify(Object.keys(sp.SYNTHETIC_FIXTURES), null, 2));
3179
+ return;
3180
+ }
3181
+
3182
+ if (opts.fixture) {
3183
+ const f = sp.generateFixture(opts.fixture);
3184
+ if (!f) {
3185
+ console.error(`Unknown fixture: ${opts.fixture}`);
3186
+ process.exit(1);
3187
+ }
3188
+ console.log(JSON.stringify(f, null, 2));
3189
+ return;
3190
+ }
3191
+
3192
+ if (opts.verify) {
3193
+ const scenarioName = opts.scenario || 'fix-with-regression-policy';
3194
+ const fixture = sp.generateFixture(scenarioName);
3195
+ if (!fixture) {
3196
+ console.error(`Unknown scenario: ${scenarioName}`);
3197
+ process.exit(1);
3198
+ }
3199
+ const storyDir = path.resolve(opts.verify);
3200
+ const capturedOutputs = {};
3201
+ const stepFileMap = {
3202
+ 'step-1-plan': 'step-1-plan.md',
3203
+ 'step-3-e2e-plan': 'step-3-e2e-plan.md',
3204
+ 'step-5-review': 'step-5-review.md',
3205
+ 'step-5c-ci': 'step-5c-ci.md', // HS-004: Step 5c artifact lives in its own file
3206
+ };
3207
+ for (const [stepName, fileName] of Object.entries(stepFileMap)) {
3208
+ const fp = path.join(storyDir, fileName);
3209
+ if (fs.existsSync(fp)) capturedOutputs[stepName] = fs.readFileSync(fp, 'utf8');
3210
+ }
3211
+ const result = sp.verifyMarkers({
3212
+ capturedOutputs,
3213
+ expectedMarkers: fixture.expectedMarkers,
3214
+ });
3215
+ console.log(JSON.stringify(result, null, 2));
3216
+ if (!result.passed) process.exit(1);
3217
+ return;
3218
+ }
3219
+
3220
+ console.error('Specify one of: --list, --fixture <name>, or --verify <story-dir>');
3221
+ process.exit(1);
3222
+ });
3223
+
3224
+ // H-084: merge adopter-side rule overlays onto canonical defaults.
3225
+ program
3226
+ .command('merge-overlays <step>')
3227
+ .description('Merge .hone-local/rule-overlays/<step>.*.md onto .hone/agents/<step>.agent.md. Prints merged result to stdout; diagnostics to stderr.')
3228
+ .option('--default <path>', 'Override default file path (default: .hone/agents/<step>.agent.md)')
3229
+ .option('--overlays-dir <path>', 'Override overlays directory (default: .hone-local/rule-overlays)')
3230
+ .action(async (step, opts) => {
3231
+ const { mergeOverlays, verifyOverlayMarkers } = require('./lib/overlay-merge');
3232
+ const repoRoot = process.cwd();
3233
+ const defaultPath = opts.default || path.join(repoRoot, '.hone/agents', `${step}.agent.md`);
3234
+ const overlaysDir = opts.overlaysDir || path.join(repoRoot, '.hone-local/rule-overlays');
3235
+
3236
+ if (!fs.existsSync(defaultPath)) {
3237
+ console.error(`Default file not found: ${defaultPath}`);
3238
+ process.exit(1);
3239
+ }
3240
+
3241
+ const defaultText = fs.readFileSync(defaultPath, 'utf8');
3242
+
3243
+ const overlays = [];
3244
+ if (fs.existsSync(overlaysDir)) {
3245
+ for (const f of fs.readdirSync(overlaysDir).sort()) {
3246
+ if (f.startsWith(`${step}.`) && f.endsWith('.md')) {
3247
+ overlays.push({
3248
+ path: path.join(overlaysDir, f),
3249
+ content: fs.readFileSync(path.join(overlaysDir, f), 'utf8'),
3250
+ });
3251
+ }
3252
+ }
3253
+ }
3254
+
3255
+ const result = mergeOverlays(defaultText, overlays);
3256
+ const verify = verifyOverlayMarkers(result.merged, result.applied);
3257
+
3258
+ process.stdout.write(result.merged);
3259
+ process.stderr.write(`\n[merge-overlays] ${result.applied.length} overlays applied, ${result.errors.length} errors\n`);
3260
+ for (const a of result.applied) {
3261
+ process.stderr.write(` ${a.ok ? '✓' : '✗'} ${a.ruleId} (${a.insertSpec})\n`);
3262
+ }
3263
+ for (const e of result.errors) {
3264
+ process.stderr.write(` ERROR: ${e}\n`);
3265
+ }
3266
+ if (!verify.allPresent) {
3267
+ process.stderr.write(` WARN: missing markers: ${verify.missing.join(', ')}\n`);
3268
+ }
3269
+ if (result.errors.length > 0 || !verify.allPresent) process.exit(1);
3270
+ });
3271
+
3272
+ // H-081: validate editor-aware SDLC pipeline scaffolding.
3273
+ program
3274
+ .command('validate-pipeline')
3275
+ .description('Validate editor-aware SDLC pipeline: detect editors, check discovery + rule presence + cross-editor drift. Prints JSON. Exits 1 on ERROR findings (use --strict for CI).')
3276
+ .option('--strict', 'Exit non-zero on any WARN finding too (CI grade)')
3277
+ .action(async (opts) => {
3278
+ const { detectEditors, EDITOR_PROJECTIONS } = require('./lib/editor-detect');
3279
+ const { validatePipeline } = require('./lib/pipeline-validate');
3280
+ const repoRoot = process.cwd();
3281
+ const fileExists = (p) => fs.existsSync(path.join(repoRoot, p));
3282
+ const readFile = (p) => fs.existsSync(path.join(repoRoot, p))
3283
+ ? fs.readFileSync(path.join(repoRoot, p), 'utf8')
3284
+ : null;
3285
+
3286
+ const editors = detectEditors({
3287
+ fileExists,
3288
+ envVar: process.env.HONE_EDITOR,
3289
+ readFile,
3290
+ });
3291
+
3292
+ const surfaceContents = new Map();
3293
+ for (const editor of editors) {
3294
+ const proj = EDITOR_PROJECTIONS[editor];
3295
+ if (proj?.project_doc) {
3296
+ const content = readFile(proj.project_doc);
3297
+ if (content) surfaceContents.set(proj.project_doc, content);
3298
+ }
3299
+ }
3300
+
3301
+ const skillNames = [];
3302
+ for (const skillsDir of [path.join(repoRoot, '.github/skills'), path.join(repoRoot, 'server/seeds/skills')]) {
3303
+ if (fs.existsSync(skillsDir)) {
3304
+ for (const e of fs.readdirSync(skillsDir)) {
3305
+ if (fs.existsSync(path.join(skillsDir, e, 'SKILL.md')) && !skillNames.includes(e)) {
3306
+ skillNames.push(e);
3307
+ }
3308
+ }
3309
+ break;
3310
+ }
3311
+ }
3312
+
3313
+ const result = validatePipeline({ editors, surfaceContents, fileExists, skillNames });
3314
+ console.log(JSON.stringify({
3315
+ detected_editors: editors,
3316
+ ...result,
3317
+ }, null, 2));
3318
+
3319
+ const exitCode = result.summary.error_count > 0
3320
+ || (opts.strict && result.summary.warn_count > 0)
3321
+ ? 1 : 0;
3322
+ if (exitCode > 0) process.exit(exitCode);
3323
+ });
3324
+
3325
+ // H-085: audit skills against codebase + learnings — detect drift.
3326
+ program
3327
+ .command('audit-skills')
3328
+ .description('Audit .github/skills/ for path drift, cross-ref drift, and new-skill candidates from learnings. Prints JSON.')
3329
+ .action(async () => {
3330
+ const { auditSkills } = require('./lib/skill-audit');
3331
+ const repoRoot = process.cwd();
3332
+
3333
+ // Collect skill files: prefer .github/skills/, fall back to server/seeds/skills/
3334
+ function collectSkillFiles(skillsDir) {
3335
+ const out = { names: [], contents: [] };
3336
+ if (!fs.existsSync(skillsDir)) return out;
3337
+ const entries = fs.readdirSync(skillsDir);
3338
+ for (const e of entries) {
3339
+ const skillPath = path.join(skillsDir, e, 'SKILL.md');
3340
+ if (fs.existsSync(skillPath)) {
3341
+ out.names.push(e);
3342
+ out.contents.push(fs.readFileSync(skillPath, 'utf8'));
3343
+ }
3344
+ }
3345
+ return out;
3346
+ }
3347
+
3348
+ let skills = collectSkillFiles(path.join(repoRoot, '.github/skills'));
3349
+ if (skills.names.length === 0) {
3350
+ skills = collectSkillFiles(path.join(repoRoot, 'server/seeds/skills'));
3351
+ }
3352
+
3353
+ // Collect learnings files
3354
+ const learningsDir = path.join(repoRoot, '.github/learnings');
3355
+ const learningsFiles = [];
3356
+ if (fs.existsSync(learningsDir)) {
3357
+ for (const f of fs.readdirSync(learningsDir).filter((f) => f.endsWith('.yml'))) {
3358
+ learningsFiles.push(fs.readFileSync(path.join(learningsDir, f), 'utf8'));
3359
+ }
3360
+ }
3361
+
3362
+ // AU-001 (#159): optional adopter-supplied allow-list at
3363
+ // .hone/audit-skills.ignore.yml. When present, paths in the
3364
+ // `ignore` array are never reported as PATH_BROKEN even if
3365
+ // other heuristics would have flagged them. Glob support: entries
3366
+ // containing * or ? are treated as globs.
3367
+ let ignoreList = [];
3368
+ const ignorePath = path.join(repoRoot, '.hone/audit-skills.ignore.yml');
3369
+ if (fs.existsSync(ignorePath)) {
3370
+ try {
3371
+ const yamlMod = require('js-yaml');
3372
+ const parsed = yamlMod.load(fs.readFileSync(ignorePath, 'utf8'));
3373
+ if (parsed && Array.isArray(parsed.ignore)) {
3374
+ ignoreList = parsed.ignore.filter(x => typeof x === 'string');
3375
+ }
3376
+ } catch (e) {
3377
+ console.error(`audit-skills: warning — could not parse ${ignorePath}: ${e.message}`);
3378
+ }
3379
+ }
3380
+
3381
+ const result = auditSkills({
3382
+ skillFiles: skills.contents,
3383
+ skillNames: skills.names,
3384
+ learningsFiles,
3385
+ fileExists: (p) => fs.existsSync(path.join(repoRoot, p)),
3386
+ ignoreList,
3387
+ });
3388
+
3389
+ console.log(JSON.stringify(result, null, 2));
3390
+ if (result.summary.error_count > 0) process.exit(1);
3391
+ });
3392
+
3393
+ // LC-003 (#58 G2 partial 3/3): retrospective audit of the learnings corpus.
3394
+ // Read-only — classifies each learning entry by freshness/incorporation/
3395
+ // contradiction status. Prints JSON.
3396
+ program
3397
+ .command('audit-learnings')
3398
+ .description('Retrospective audit of .github/learnings/. Classifies each entry by freshness, incorporation, and contradiction. Prints JSON.')
3399
+ .option('--filter <status>', 'show only entries with this status (fresh|aging|stale|incorporated|contradicted|unknown)')
3400
+ .action(async (cmdOpts) => {
3401
+ const { auditLearnings, STATUS } = require('./lib/learnings-audit');
3402
+ const { execSync } = require('node:child_process');
3403
+ const repoRoot = process.cwd();
3404
+
3405
+ // Collect learnings files
3406
+ const learningsDir = path.join(repoRoot, '.github/learnings');
3407
+ const learningsFiles = [];
3408
+ if (fs.existsSync(learningsDir)) {
3409
+ for (const f of fs.readdirSync(learningsDir).filter((f) => f.endsWith('.yml'))) {
3410
+ learningsFiles.push({
3411
+ fileLabel: f,
3412
+ content: fs.readFileSync(path.join(learningsDir, f), 'utf8'),
3413
+ });
3414
+ }
3415
+ }
3416
+
3417
+ // Collect skill names
3418
+ const skillsDir = path.join(repoRoot, '.github/skills');
3419
+ const skillNames = [];
3420
+ if (fs.existsSync(skillsDir)) {
3421
+ for (const e of fs.readdirSync(skillsDir)) {
3422
+ if (fs.existsSync(path.join(skillsDir, e, 'SKILL.md'))) skillNames.push(e);
3423
+ }
3424
+ }
3425
+
3426
+ // LC-004: shared git helpers (was inline; now cli/lib/git-helpers.js).
3427
+ const { getLastModifiedDays, getFirstCommitDateMs } = require('./lib/git-helpers');
3428
+ const todayMs = Date.now();
3429
+
3430
+ function getSkillLastModifiedDays(name) {
3431
+ return getLastModifiedDays(repoRoot, '.github/skills/' + name + '/SKILL.md', todayMs);
3432
+ }
3433
+ // LC-004 (#143): captured_at fallback for entries missing the field.
3434
+ // Query git for the file's first-commit timestamp.
3435
+ function getCapturedAtFromGit(fileLabel) {
3436
+ return getFirstCommitDateMs(repoRoot, '.github/learnings/' + fileLabel);
3437
+ }
3438
+
3439
+ const result = auditLearnings({
3440
+ learningsFiles,
3441
+ skillNames,
3442
+ getSkillLastModifiedDays,
3443
+ getCapturedAtFromGit,
3444
+ });
3445
+
3446
+ if (cmdOpts.filter) {
3447
+ const valid = Object.values(STATUS);
3448
+ if (!valid.includes(cmdOpts.filter)) {
3449
+ console.error(`error: --filter must be one of [${valid.join(', ')}], got "${cmdOpts.filter}"`);
3450
+ process.exit(2);
3451
+ }
3452
+ result.entries = result.entries.filter((e) => e.status === cmdOpts.filter);
3453
+ }
3454
+
3455
+ console.log(JSON.stringify(result, null, 2));
3456
+ // Exit 0 — this is read-only retrospective; non-zero only on parse errors
3457
+ if (result.summary.parse_errors > 0) process.exit(1);
3458
+ });
3459
+
3460
+ // SA-002 / #137 (architect plan, story 2/2): Step 5b runtime skill-application
3461
+ // SC-002 (Phase 2 of SC family): classify a story's recommended SDLC
3462
+ // track + agent invocation per `cli/lib/story-classifier.js`. CLI reads
3463
+ // GitHub issue via `gh issue view`, extracts 4 reliable inputs heuristically,
3464
+ // accepts the other 5 via --input-file or interactive prompt, prints the
3465
+ // full classifier result. Adopters use this for ad-hoc backlog triage.
3466
+ program
3467
+ .command('classify-story [issueId]')
3468
+ .description('Classify a GitHub issue by SDLC track. Extracts inputs from issue body/labels heuristically; reads other inputs from --input-file or interactive prompt. Outputs JSON.')
3469
+ .option('--input-file <path>', 'YAML file with all 9 classifyStory inputs (skips GitHub fetch + interactive)')
3470
+ .option('--interactive', 'prompt for the 5 judgment fields not heuristically extractable')
3471
+ .option('--pretty', 'human-readable output (default: --json)')
3472
+ .option('--repo-root <path>', 'repo root (default: process.cwd())')
3473
+ .option('--window-days <n>', 'recently-modified window in days (overrides .pipeline-config.yml; default 14)')
3474
+ .option('--threshold-estimate-full-sdlc <s>', 'override estimate threshold (XS|S|M|L|XL)')
3475
+ .action(async (issueId, cmdOpts) => {
3476
+ const { classifyStory } = require('./lib/story-classifier');
3477
+ const { readStoryClassifierConfig } = require('./lib/pipeline-config');
3478
+ const repoRoot = cmdOpts.repoRoot || process.cwd();
3479
+
3480
+ // SC-003 + SC-004: read adopter-set thresholds once, up front.
3481
+ // Used both for the extractor (window-days) and for classifyStory (estimate threshold).
3482
+ // Precedence for any given knob: framework default < config default < CLI flag.
3483
+ const configThresholds = readStoryClassifierConfig(repoRoot);
3484
+ const effectiveWindowDays = parseInt(
3485
+ cmdOpts.windowDays || configThresholds.recently_modified_window_days,
3486
+ 10,
3487
+ ) || 14;
3488
+
3489
+ let inputs = null;
3490
+ let issueMeta = null;
3491
+
3492
+ // Path A: --input-file (CI use, no GitHub fetch)
3493
+ if (cmdOpts.inputFile) {
3494
+ if (!fs.existsSync(cmdOpts.inputFile)) {
3495
+ console.error(`error: input file not found: ${cmdOpts.inputFile}`);
3496
+ process.exit(2);
3497
+ }
3498
+ try {
3499
+ const yamlMod = require('js-yaml');
3500
+ inputs = yamlMod.load(fs.readFileSync(cmdOpts.inputFile, 'utf8'));
3501
+ if (!inputs || typeof inputs !== 'object') {
3502
+ console.error(`error: input file is empty or not an object`);
3503
+ process.exit(2);
3504
+ }
3505
+ } catch (e) {
3506
+ console.error(`error: failed to parse ${cmdOpts.inputFile}: ${e.message}`);
3507
+ process.exit(2);
3508
+ }
3509
+ }
3510
+ // Path B: GitHub issue fetch via gh
3511
+ else if (issueId) {
3512
+ try {
3513
+ const out = execSync(
3514
+ `gh issue view ${issueId} --json title,body,labels,url`,
3515
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
3516
+ );
3517
+ issueMeta = JSON.parse(out);
3518
+ const labels = (issueMeta.labels || []).map(l => l.name);
3519
+ const {
3520
+ extractType, extractSecurityImplications,
3521
+ extractCrossRepoDependency, extractRecentlyModifiedOverlap,
3522
+ } = require('./lib/story-classifier-extract');
3523
+ inputs = {
3524
+ type: extractType(labels, issueMeta.body, issueMeta.title),
3525
+ has_security_implications: extractSecurityImplications(labels, issueMeta.body),
3526
+ cross_repo_dependency: extractCrossRepoDependency(issueMeta.body),
3527
+ recently_modified_overlap: extractRecentlyModifiedOverlap(repoRoot, [], effectiveWindowDays),
3528
+ // 5 fields below default to safe values; user supplies via --input-file or --interactive
3529
+ adopter_blast_radius: 'medium',
3530
+ surface_area: 'single-module',
3531
+ estimate: 'M',
3532
+ design_options: '1',
3533
+ has_test_matrix: 'simple',
3534
+ is_first_of_its_kind: false,
3535
+ };
3536
+ } catch (e) {
3537
+ console.error(`error: gh issue view ${issueId} failed: ${(e.stderr || '').toString().trim() || e.message}`);
3538
+ console.error(`hint: install GitHub CLI (cli.github.com), run \`gh auth login\`, or pass --input-file <path>`);
3539
+ process.exit(2);
3540
+ }
3541
+ } else {
3542
+ console.error('error: provide either an <issueId> argument or --input-file <path>');
3543
+ console.error('usage: hone classify-story 142');
3544
+ console.error(' hone classify-story --input-file inputs.yml');
3545
+ process.exit(2);
3546
+ }
3547
+
3548
+ // SC-003 + SC-004: build thresholds — config defaults UNDER explicit CLI flags.
3549
+ // Adopter sets defaults in .pipeline-config.yml; CLI flags win when both supplied.
3550
+ // Safety-critical rules (R1/R2/R3) ignore both — framework-fixed.
3551
+ // (configThresholds was read up front so the extractor could also use it.)
3552
+ const thresholds = { ...configThresholds };
3553
+ if (cmdOpts.thresholdEstimateFullSdlc) {
3554
+ thresholds.estimate_full_sdlc = cmdOpts.thresholdEstimateFullSdlc;
3555
+ }
3556
+
3557
+ const classification = classifyStory(inputs, thresholds);
3558
+ const result = {
3559
+ ...(issueMeta && { issue_id: issueMeta.url ? issueId : null, title: issueMeta.title, url: issueMeta.url }),
3560
+ inputs,
3561
+ classification,
3562
+ };
3563
+
3564
+ if (cmdOpts.pretty) {
3565
+ console.log(`Track: ${classification.track}`);
3566
+ console.log(`Architect: ${classification.architect.engage ? 'YES — ' + classification.architect.axes.join(', ') : 'no'}`);
3567
+ console.log(`Agents: ${classification.agents.join(', ')}`);
3568
+ console.log(`Artifacts: ${classification.artifacts.join(', ')}`);
3569
+ console.log(`Rationale: ${classification.rationale}`);
3570
+ } else {
3571
+ console.log(JSON.stringify(result, null, 2));
3572
+ }
3573
+ });
3574
+
3575
+ // SC-005 (Phase 5 of SC family): publish a framework-canonical learning
3576
+ // from `.github/learnings/<ID>.yml` to `server/seeds/learnings/<ID>.yml`,
3577
+ // flipping its status from `pending` → `promoted` and stamping a
3578
+ // `promoted_at` timestamp. The seed copy is what adopters receive at
3579
+ // install time. Eligibility: share_enterprise=true + enterprise_candidate=true.
3580
+ program
3581
+ .command('publish-learning <learningId>')
3582
+ .description('Publish an enterprise-candidate learning to server/seeds/learnings/ + flip status to promoted')
3583
+ .option('--force', 're-publish even if status is already promoted')
3584
+ .option('--repo-root <path>', 'repo root (default: process.cwd())')
3585
+ .action((learningId, opts) => {
3586
+ const { publishLearning } = require('./lib/publish-learning');
3587
+ const repoRoot = opts.repoRoot || process.cwd();
3588
+ const result = publishLearning({ repoRoot, learningId, force: opts.force });
3589
+
3590
+ switch (result.status) {
3591
+ case 'published':
3592
+ console.log(`✓ Published ${learningId} → ${result.copiedTo}`);
3593
+ console.log(` Source updated: status: promoted + promoted_at stamped.`);
3594
+ break;
3595
+ case 'already-promoted':
3596
+ console.log(`• ${learningId} is already promoted (idempotent no-op). Pass --force to re-publish.`);
3597
+ break;
3598
+ case 'not-eligible':
3599
+ console.error(`error: ${learningId} not eligible: ${result.error}`);
3600
+ process.exit(2);
3601
+ break;
3602
+ case 'not-found':
3603
+ console.error(`error: ${result.error}`);
3604
+ process.exit(2);
3605
+ break;
3606
+ case 'error':
3607
+ default:
3608
+ console.error(`error: ${result.error || 'unknown failure'}`);
3609
+ process.exit(1);
3610
+ }
3611
+ });
3612
+
3613
+ // HC-003: multi-channel orchestration wrapper for `hone sync` + `hone derive`
3614
+ // with backup-before-write + section-aware restore + halt-and-restore-on-failure.
3615
+ // First multi-channel wrapper the framework ships. Implements OptionsFlow
3616
+ // E22-E-L3 (deferred during SC-008 absorption). See pipeline-integrity §17.
3617
+ program
3618
+ .command('refresh-knowledge')
3619
+ .description('Multi-channel wrapper for sync + derive with backup, section-aware restore, and halt-on-failure (HC-003).')
3620
+ .option('--channels <list>', 'comma-separated channels to run (default: sync,derive)', 'sync,derive')
3621
+ .option('--no-preserve', 'Skip section-aware restore (alias for --force; HC-005-B)')
3622
+ .option('--backup-dir <path>', 'backup directory (default: .hone/backups)', '.hone/backups')
3623
+ .option('--dry-run', 'print plan; do not run channels or create backup')
3624
+ .option('--force', 'Skip section-aware restore (DANGEROUS — clobbers adopter customizations; alias for --no-preserve; HC-005-B)')
3625
+ .option('--repo-root <path>', 'repo root (default: process.cwd())')
3626
+ .option('--json', 'emit findings as JSON')
3627
+ .action((opts) => {
3628
+ const { refreshKnowledge } = require('./lib/refresh-knowledge');
3629
+ const repoRoot = opts.repoRoot || process.cwd();
3630
+ const channels = (opts.channels || 'sync,derive').split(',').map(s => s.trim()).filter(Boolean);
3631
+ const result = refreshKnowledge({
3632
+ repoRoot,
3633
+ channels,
3634
+ preserveUserSections: opts.preserve !== false,
3635
+ backupDir: opts.backupDir,
3636
+ dryRun: opts.dryRun,
3637
+ force: opts.force,
3638
+ });
3639
+
3640
+ if (opts.json) {
3641
+ console.log(JSON.stringify(result, null, 2));
3642
+ process.exit(result.summary.errors > 0 ? 1 : 0);
3643
+ return;
3644
+ }
3645
+
3646
+ console.log(`Hone refresh-knowledge (HC-003) — channels: ${channels.join(', ')}`);
3647
+ if (result.dryRun) {
3648
+ console.log('Dry-run plan (no side effects):');
3649
+ for (const f of result.findings) {
3650
+ console.log(` • [${f.code}] ${f.message}`);
3651
+ }
3652
+ console.log(`Would run channels in order: ${channels.join(' → ')}`);
3653
+ console.log(`Would back up to: ${path.join(repoRoot, opts.backupDir)}`);
3654
+ process.exit(0);
3655
+ return;
3656
+ }
3657
+
3658
+ if (result.backupPath) {
3659
+ console.log(`✓ Backup created: ${result.backupPath}`);
3660
+ }
3661
+ for (const cr of result.channelResults) {
3662
+ const icon = cr.ok ? '✓' : '✗';
3663
+ console.log(`${icon} channel ${cr.name}: exit ${cr.exitCode}`);
3664
+ }
3665
+ if (result.restored && result.restored.length > 0) {
3666
+ console.log(`✓ Restored user sections in ${result.restored.length} file(s):`);
3667
+ for (const f of result.restored) console.log(` - ${f}`);
3668
+ }
3669
+ if (result.summary.errors > 0) {
3670
+ console.error(`\n✗ ${result.summary.errors} error(s):`);
3671
+ for (const f of result.findings.filter(x => x.severity === 'ERROR')) {
3672
+ console.error(` [${f.code}] ${f.message}`);
3673
+ }
3674
+ // HC-005-B: distinguish restore-failed via exit(3); generic errors stay exit(1)
3675
+ const restoreFailed = result.findings.some(f => f.code === 'restore-failed');
3676
+ process.exit(restoreFailed ? 3 : 1);
3677
+ }
3678
+ if (result.summary.warnings > 0) {
3679
+ console.log(`\n⚠ ${result.summary.warnings} warning(s):`);
3680
+ for (const f of result.findings.filter(x => x.severity === 'WARN')) {
3681
+ console.log(` [${f.code}] ${f.message}`);
3682
+ }
3683
+ }
3684
+ });
3685
+
3686
+ // HC-004: scaffold a per-adopter <name>-domain/SKILL.md from the 7-section
3687
+ // template documented in pipeline-integrity §13 (per OptionsFlow E26-A-L1).
3688
+ // First per-adopter skill scaffolder. Marker discipline per HC-001-L1.
3689
+ program
3690
+ .command('derive-domain <name>')
3691
+ .description('Scaffold a per-adopter <name>-domain/SKILL.md from the 7-section template (HC-004). Idempotent on managed files; refuses to overwrite unmanaged unless --force.')
3692
+ .option('--force', 'overwrite unmanaged existing file (DANGEROUS — clobbers adopter customizations)')
3693
+ .option('--repo-root <path>', 'repo root (default: process.cwd())')
3694
+ .option('--json', 'emit findings as JSON')
3695
+ .action((name, opts) => {
3696
+ const { deriveDomain } = require('./lib/derive-domain');
3697
+ const repoRoot = opts.repoRoot || process.cwd();
3698
+ const result = deriveDomain({ repoRoot, domainName: name, force: opts.force });
3699
+
3700
+ if (opts.json) {
3701
+ console.log(JSON.stringify(result, null, 2));
3702
+ process.exit(result.summary.errors > 0 ? 1 : 0);
3703
+ return;
3704
+ }
3705
+
3706
+ console.log(`Hone derive-domain (HC-004) — domain: ${name}`);
3707
+ if (result.created.length > 0) {
3708
+ console.log(`✓ Created: ${result.created[0]}`);
3709
+ console.log(` Edit the 7 sections (Vocabulary / Architectural Contracts / Review Rules /`);
3710
+ console.log(` Anti-Hallucination / Calibration / Asymmetries / References) with your`);
3711
+ console.log(` domain's content. Set autoLoad: true in frontmatter when ready.`);
3712
+ }
3713
+ for (const s of result.skipped || []) {
3714
+ console.log(` • ${s.path}: ${s.reason}`);
3715
+ if (s.userAction) console.log(` → ${s.userAction}`);
3716
+ }
3717
+ if (result.summary.errors > 0) {
3718
+ console.error(`\n✗ ${result.summary.errors} error(s):`);
3719
+ for (const f of result.findings.filter(x => x.severity === 'ERROR')) {
3720
+ console.error(` [${f.code}] ${f.message}`);
3721
+ }
3722
+ process.exit(1);
3723
+ }
3724
+ });
3725
+
3726
+ // HC-001: install adopter-side git hooks (pre-commit + pre-push) that
3727
+ // validate metadata.yml against the schema (SC-011) and enforce step-
3728
+ // artifact existence per metadata.yml status. First git hook the framework
3729
+ // ships. See pipeline-integrity §17 for the convention.
3730
+ program
3731
+ .command('install-hooks')
3732
+ .description('Install pre-commit + pre-push git hooks (per HC-001). Idempotent; refuses to overwrite unmanaged hooks unless --force.')
3733
+ .option('--uninstall', 'remove managed hooks (only removes hooks carrying the managed-by:hone marker)')
3734
+ .option('--mode <mode>', 'install mode: auto (default) | native | skip-husky', 'auto')
3735
+ .option('--force', 'overwrite unmanaged existing hooks (DANGEROUS — clobbers adopter customizations)')
3736
+ .option('--repo-root <path>', 'repo root (default: process.cwd())')
3737
+ .option('--json', 'emit findings as JSON')
3738
+ .action((opts) => {
3739
+ const { installHooks, uninstallHooks } = require('./lib/install-hooks');
3740
+ const repoRoot = opts.repoRoot || process.cwd();
3741
+
3742
+ const result = opts.uninstall
3743
+ ? uninstallHooks({ repoRoot })
3744
+ : installHooks({ repoRoot, mode: opts.mode, force: opts.force });
3745
+
3746
+ if (opts.json) {
3747
+ console.log(JSON.stringify(result, null, 2));
3748
+ process.exit(result.summary.errors > 0 ? 1 : 0);
3749
+ return;
3750
+ }
3751
+
3752
+ if (opts.uninstall) {
3753
+ const removed = result.removed || [];
3754
+ console.log(`Hone install-hooks --uninstall (HC-001)`);
3755
+ if (removed.length > 0) {
3756
+ console.log(`✓ Removed ${removed.length} managed hook(s): ${removed.join(', ')}`);
3757
+ } else {
3758
+ console.log(`• No managed hooks to remove (none found with managed-by:hone marker)`);
3759
+ }
3760
+ for (const s of result.skipped || []) {
3761
+ console.log(` • ${s.hook}: ${s.reason}`);
3762
+ }
3763
+ } else {
3764
+ console.log(`Hone install-hooks (HC-001)`);
3765
+ const installed = result.installed || [];
3766
+ const skipped = result.skipped || [];
3767
+ if (installed.length > 0) {
3768
+ console.log(`✓ Installed ${installed.length} hook(s): ${installed.join(', ')}`);
3769
+ }
3770
+ for (const s of skipped) {
3771
+ console.log(` • ${s.hook}: ${s.reason}`);
3772
+ if (s.userAction) console.log(` → ${s.userAction}`);
3773
+ }
3774
+ if (installed.length === 0 && skipped.length === 0) {
3775
+ console.log(`• No hooks installed (check --help for options)`);
3776
+ }
3777
+ }
3778
+
3779
+ if (result.summary.errors > 0) {
3780
+ console.error(`\n✗ ${result.summary.errors} error(s):`);
3781
+ for (const f of result.findings.filter(x => x.severity === 'ERROR')) {
3782
+ console.error(` [${f.code}] ${f.message}`);
3783
+ }
3784
+ process.exit(1);
3785
+ }
3786
+ });
3787
+
3788
+ // SC-011 (Phase 11 of SC family): validate a story's metadata.yml against
3789
+ // the framework JSON schema. Implements what SC-010 §10 documented
3790
+ // (metadata.yml as wire protocol). Exit codes: 0 = pass, 1 = ERROR
3791
+ // findings, 2 = file or schema not found.
3792
+ program
3793
+ .command('validate-metadata [storyId]')
3794
+ .description('Validate .github/pipeline/<STORY-ID>/metadata.yml against the framework JSON schema. Implements SC-010 §10 (metadata.yml as wire protocol).')
3795
+ .option('--all', 'validate every metadata.yml in .github/pipeline/')
3796
+ .option('--repo-root <path>', 'repo root (default: process.cwd())')
3797
+ .option('--schema <path>', 'override schema path (default: enterprise-assets/.github/schema/metadata.schema.json)')
3798
+ .option('--json', 'emit findings as JSON')
3799
+ .action((storyId, opts) => {
3800
+ const { validateMetadata, validateAllMetadata } = require('./lib/validate-metadata');
3801
+ const repoRoot = opts.repoRoot || process.cwd();
3802
+ const schemaPath = opts.schema || require('node:path').join(repoRoot, 'enterprise-assets/.github/schema/metadata.schema.json');
3803
+
3804
+ if (opts.all) {
3805
+ const result = validateAllMetadata({ repoRoot, schemaPath });
3806
+ if (opts.json) {
3807
+ console.log(JSON.stringify(result, null, 2));
3808
+ } else {
3809
+ console.log(`Hone validate-metadata — ${result.summary.fileCount} files validated`);
3810
+ if (result.summary.errors === 0) {
3811
+ console.log(`✓ All ${result.summary.fileCount} files validated. No errors.`);
3812
+ } else {
3813
+ console.log(`✗ ${result.summary.errorFileCount} of ${result.summary.fileCount} files failed validation.`);
3814
+ for (const f of result.files) {
3815
+ if (f.summary.errors === 0) continue;
3816
+ console.log(`\n ${f.filePath}:`);
3817
+ for (const finding of f.findings.filter(x => x.severity === 'ERROR')) {
3818
+ console.log(` [${finding.code}] ${finding.message}`);
3819
+ }
3820
+ }
3821
+ }
3822
+ }
3823
+ process.exit(result.summary.errors > 0 ? 1 : 0);
3824
+ } else {
3825
+ if (!storyId) {
3826
+ console.error('error: provide a <STORY-ID> argument or pass --all');
3827
+ process.exit(2);
3828
+ }
3829
+ const filePath = require('node:path').join(repoRoot, '.github/pipeline', storyId, 'metadata.yml');
3830
+ const result = validateMetadata({ path: filePath, schemaPath });
3831
+ if (opts.json) {
3832
+ console.log(JSON.stringify(result, null, 2));
3833
+ } else if (result.summary.errors === 0) {
3834
+ console.log(`✓ ${storyId}: metadata.yml passes validation. No findings.`);
3835
+ } else {
3836
+ console.log(`✗ ${storyId}: ${result.summary.errors} validation error(s):`);
3837
+ for (const f of result.findings.filter(x => x.severity === 'ERROR')) {
3838
+ console.log(` [${f.code}] ${f.message}`);
3839
+ }
3840
+ }
3841
+ // Exit code 2 if file/schema missing; 1 if validation errors; 0 if pass.
3842
+ const hasMissingFile = result.findings.some(f => f.code === 'file-not-found' || f.code === 'schema-not-found');
3843
+ process.exit(hasMissingFile ? 2 : (result.summary.errors > 0 ? 1 : 0));
3844
+ }
3845
+ });
3846
+
3847
+ // audit runner. Shells out to `git diff origin/<base>...HEAD`, walks
3848
+ // `.github/skills/`, calls SA-001's `runAssertions()`, writes
3849
+ // `.github/pipeline/<STORY-ID>/step-5b-skill-audit.md`. Always exits 0;
3850
+ // gating deferred to OptionsFlow E29-G.
3851
+ program
3852
+ .command('step-5b')
3853
+ .description('Step 5b: runtime skill-application audit. Reads the story\'s referenced_skill cross-refs (LC-001), runs SA-001 assertion engine against PR diff, writes step-5b-skill-audit.md. Always exits 0 (informational; gating deferred to E29-G).')
3854
+ .option('--base <branch>', 'base branch to diff against', 'develop')
3855
+ .option('--story-id <id>', 'override story id (defaults to extraction from current branch name)')
3856
+ .action(async (cmdOpts) => {
3857
+ const { runAssertions } = require('./lib/skill-assertions');
3858
+ const { renderStep5bArtifact } = require('./lib/skill-audit-render');
3859
+ const { extractStoryIdFromBranch } = require('./lib/pipeline-status');
3860
+ const { parseLearningsFile } = require('./lib/learnings-parse');
3861
+ const { execSync } = require('node:child_process');
3862
+ const repoRoot = process.cwd();
3863
+
3864
+ // 1. Resolve story id (CLI flag wins; else parse branch name)
3865
+ let branchName = '';
3866
+ try {
3867
+ branchName = execSync('git rev-parse --abbrev-ref HEAD',
3868
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
3869
+ } catch { /* defensive */ }
3870
+ const storyId = cmdOpts.storyId || extractStoryIdFromBranch(branchName);
3871
+ if (!storyId) {
3872
+ console.error(`step-5b: cannot determine story id from branch "${branchName}". Pass --story-id explicitly. Skipping audit.`);
3873
+ process.exit(0); // exit 0 — informational
3874
+ }
3875
+
3876
+ // 2. Capture PR diff (defensive — empty diff → no findings)
3877
+ const baseBranch = cmdOpts.base || 'develop';
3878
+ let diff = '';
3879
+ try {
3880
+ diff = execSync(`git diff origin/${baseBranch}...HEAD`,
3881
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
3882
+ } catch {
3883
+ // Fallback: no remote tracking — try local base
3884
+ try {
3885
+ diff = execSync(`git diff ${baseBranch}...HEAD`,
3886
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 });
3887
+ } catch { /* leave empty */ }
3888
+ }
3889
+
3890
+ // 3. Walk skills. Adopters live at .github/skills/; the hone-server
3891
+ // framework dogfoods against server/seeds/skills/ (same fallback as
3892
+ // `hone audit-skills`).
3893
+ function collectSkillsFrom(dir) {
3894
+ const out = [];
3895
+ if (!fs.existsSync(dir)) return out;
3896
+ for (const name of fs.readdirSync(dir)) {
3897
+ const skillPath = path.join(dir, name, 'SKILL.md');
3898
+ if (fs.existsSync(skillPath)) {
3899
+ out.push({ id: name, content: fs.readFileSync(skillPath, 'utf8') });
3900
+ }
3901
+ }
3902
+ return out;
3903
+ }
3904
+ let skills = collectSkillsFrom(path.join(repoRoot, '.github/skills'));
3905
+ if (skills.length === 0) {
3906
+ skills = collectSkillsFrom(path.join(repoRoot, 'server/seeds/skills'));
3907
+ }
3908
+
3909
+ // 4. LC-001 cross-ref scoping: read story's learning file (if exists)
3910
+ // and pull every `referenced_skill` it declares.
3911
+ const referencedSkills = [];
3912
+ const learningPath = path.join(repoRoot, '.github/learnings', `${storyId}.yml`);
3913
+ if (fs.existsSync(learningPath)) {
3914
+ try {
3915
+ const parsed = parseLearningsFile(fs.readFileSync(learningPath, 'utf8'),
3916
+ { fileLabel: `${storyId}.yml` });
3917
+ for (const e of parsed.eligible || []) {
3918
+ if (e.referenced_skill) referencedSkills.push(e.referenced_skill);
3919
+ }
3920
+ } catch { /* defensive — empty referencedSkills means: evaluate all */ }
3921
+ }
3922
+
3923
+ // 5. Run engine
3924
+ const result = runAssertions({
3925
+ diff,
3926
+ skills,
3927
+ referencedSkills: referencedSkills.length > 0 ? referencedSkills : undefined,
3928
+ });
3929
+
3930
+ // 6. Render + write artifact
3931
+ const artifact = renderStep5bArtifact({
3932
+ result,
3933
+ storyId,
3934
+ branchName,
3935
+ baseBranch,
3936
+ referencedSkills,
3937
+ });
3938
+ const outDir = path.join(repoRoot, '.github/pipeline', storyId);
3939
+ fs.mkdirSync(outDir, { recursive: true });
3940
+ const outPath = path.join(outDir, 'step-5b-skill-audit.md');
3941
+ fs.writeFileSync(outPath, artifact);
3942
+
3943
+ // 7. Print compact JSON summary to stdout (artifact path is the substantive output)
3944
+ console.log(JSON.stringify({
3945
+ story_id: storyId,
3946
+ artifact: path.relative(repoRoot, outPath),
3947
+ base_branch: baseBranch,
3948
+ summary: {
3949
+ skills_scanned: result.scannedSkills.length,
3950
+ skills_skipped: result.skipped.length,
3951
+ findings: result.findings.length,
3952
+ warnings: (result.warnings || []).length,
3953
+ },
3954
+ // Convenience: per-severity counts
3955
+ severity_counts: result.findings.reduce((acc, f) => {
3956
+ const s = f.severity || 'INFO';
3957
+ acc[s] = (acc[s] || 0) + 1;
3958
+ return acc;
3959
+ }, { BLOCKER: 0, WARN: 0, INFO: 0 }),
3960
+ }, null, 2));
3961
+
3962
+ // Always exit 0 — Step 5b is informational. E29-G owns severity-to-gate mapping.
3963
+ process.exit(0);
3964
+ });
3965
+
3966
+ // H-028g: detect platform from fingerprints + adopter config_paths.
3967
+ // Exposes cli/lib/platform-detect.js + classifyPathsForRepo readback at
3968
+ // the CLI for adopters who want to verify what the framework sees.
3969
+ program
3970
+ .command('detect-platform')
3971
+ .description('Detect installed platforms from fingerprint files + read platform.config_paths from .pipeline-config.yml. Prints JSON.')
3972
+ .action(async () => {
3973
+ const { detectPlatforms, PLATFORM_FINGERPRINTS } = require('./lib/platform-detect');
3974
+ const { parsePlatformConfigPaths } = require('./lib/stack-paths');
3975
+ const repoRoot = process.cwd();
3976
+ const fileExists = (p) => fs.existsSync(path.join(repoRoot, p));
3977
+ const detected = detectPlatforms({ repoRoot, fileExists });
3978
+
3979
+ let configPaths = [];
3980
+ const cfgPath = path.join(repoRoot, '.pipeline-config.yml');
3981
+ if (fs.existsSync(cfgPath)) {
3982
+ try {
3983
+ configPaths = parsePlatformConfigPaths(fs.readFileSync(cfgPath, 'utf8'));
3984
+ } catch { /* defensive */ }
3985
+ }
3986
+
3987
+ const fingerprintsByPlatform = {};
3988
+ for (const [p, defn] of Object.entries(PLATFORM_FINGERPRINTS)) {
3989
+ fingerprintsByPlatform[p] = defn.files.filter(fileExists);
3990
+ }
3991
+
3992
+ console.log(JSON.stringify({
3993
+ detected,
3994
+ config_paths: configPaths,
3995
+ fingerprints_found: fingerprintsByPlatform,
3996
+ repo_root: repoRoot,
3997
+ }, null, 2));
3998
+ });
3999
+
4000
+ // ── CLI setup ─────────────────────────────────────────────────────────────────
4001
+ program
4002
+ .name('hone')
4003
+ .description('Hone AI — Enterprise SDLC Pipeline CLI')
4004
+ .version(pkg.version);
4005
+
4006
+ program.parse(process.argv);