@hone-ai/cli 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hone.js +2 -0
- package/hone-cli.js +4006 -0
- package/lib/README.md +119 -0
- package/lib/adversarial-negative-lint.js +149 -0
- package/lib/audit.js +156 -0
- package/lib/auto-detect.js +213 -0
- package/lib/autofix-guardrails.js +124 -0
- package/lib/branch-protection.js +256 -0
- package/lib/ci-classifier.js +150 -0
- package/lib/ci-failures.js +173 -0
- package/lib/claude-md-tokens.js +71 -0
- package/lib/compliance-check.js +62 -0
- package/lib/config-augment.js +133 -0
- package/lib/config-update.js +70 -0
- package/lib/dependency-audit.js +108 -0
- package/lib/derive-domain.js +185 -0
- package/lib/doc-registry.js +63 -0
- package/lib/doctor-admin-merge.js +185 -0
- package/lib/doctor-bind-default.js +118 -0
- package/lib/doctor-docs.js +205 -0
- package/lib/doctor-placeholders.js +144 -0
- package/lib/doctor-skill-staleness.js +122 -0
- package/lib/domain-skill-template.md +114 -0
- package/lib/editor-detect.js +169 -0
- package/lib/fast-track-ratify.js +133 -0
- package/lib/git-helpers.js +109 -0
- package/lib/hook-templates/pre-commit.sh +54 -0
- package/lib/hook-templates/pre-push.sh +72 -0
- package/lib/install-hooks.js +205 -0
- package/lib/knowledge-graph.js +188 -0
- package/lib/learnings-audit.js +254 -0
- package/lib/learnings-parse.js +331 -0
- package/lib/learnings-sync.js +75 -0
- package/lib/mcp-detect.js +154 -0
- package/lib/metrics-collect.js +214 -0
- package/lib/overlay-merge.js +267 -0
- package/lib/performance-analyzer.js +142 -0
- package/lib/pipeline-config.js +83 -0
- package/lib/pipeline-status.js +207 -0
- package/lib/pipeline-validate.js +322 -0
- package/lib/platform-detect.js +86 -0
- package/lib/platform-discover.js +334 -0
- package/lib/publish-learning.js +160 -0
- package/lib/python-install.js +84 -0
- package/lib/refresh-check.js +67 -0
- package/lib/refresh-knowledge.js +360 -0
- package/lib/rule-resolver.js +146 -0
- package/lib/security-scanner.js +168 -0
- package/lib/setup-grounding.js +138 -0
- package/lib/skill-assertions.js +276 -0
- package/lib/skill-audit-render.js +158 -0
- package/lib/skill-audit.js +391 -0
- package/lib/stack-detect.js +170 -0
- package/lib/stack-paths.js +285 -0
- package/lib/story-classifier-extract.js +203 -0
- package/lib/story-classifier.js +282 -0
- package/lib/sync-overwrite.js +47 -0
- package/lib/synthetic-pipeline.js +299 -0
- package/lib/validate-metadata.js +175 -0
- package/package.json +41 -0
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);
|