@hone-ai/cli 1.18.0 → 1.20.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-mcp.js +9 -0
- package/hone-cli.js +2171 -82
- package/lib/agent-eval-judge.js +60 -0
- package/lib/agent-eval-probes-adversarial.js +45 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-faithfulness.js +59 -0
- package/lib/agent-eval-probes-safety.js +28 -0
- package/lib/agent-executor.js +139 -0
- package/lib/architect-config.js +121 -0
- package/lib/bundle-paths.js +141 -0
- package/lib/ci-gate-chooser.js +267 -0
- package/lib/doctor-admin-merge.js +3 -2
- package/lib/doctor-architecture.js +213 -0
- package/lib/emit-pr.js +167 -0
- package/lib/eval-evidence.js +213 -0
- package/lib/eval-graders.js +98 -1
- package/lib/git-env.js +94 -0
- package/lib/git-helpers.js +3 -2
- package/lib/judge-provider.js +63 -0
- package/lib/materialize-diff.js +345 -0
- package/lib/mcp-tools.js +310 -0
- package/lib/patch-apply.js +108 -0
- package/lib/pipeline-config.js +300 -0
- package/lib/pipeline-status.js +27 -2
- package/lib/refresh-knowledge.js +7 -0
- package/lib/release-review-cache.js +162 -0
- package/lib/release-review-config.js +4 -2
- package/lib/schedule-cron.js +141 -0
- package/lib/skill-eval-runner.js +667 -0
- package/lib/stack-paths.js +84 -6
- package/lib/story-classifier-extract.js +2 -1
- package/lib/verify-patch.js +78 -0
- package/lib/verify-pr.js +141 -0
- package/mcp-server.js +67 -0
- package/package.json +8 -5
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-RC-004: interactive ci.gate chooser for `hone setup`.
|
|
4
|
+
*
|
|
5
|
+
* Closes the adopter discoverability gap left by HC-101-followup-2/3.
|
|
6
|
+
* Before this story:
|
|
7
|
+
* - `hone setup` defaults to `github` (or auto-detects `local` when
|
|
8
|
+
* a Makefile with `ci:` target already exists at the repo root)
|
|
9
|
+
* - `hone setup-local-ci` flips to `local` — but only adopters who
|
|
10
|
+
* read the docs know this command exists
|
|
11
|
+
*
|
|
12
|
+
* After: `hone setup` presents the 4-mode choice WITH cost trade-offs at
|
|
13
|
+
* setup time so adopters who care about GitHub Actions minutes (almost all
|
|
14
|
+
* of them) discover the local-CI option without having to know its name.
|
|
15
|
+
*
|
|
16
|
+
* Modes:
|
|
17
|
+
* - github (default): GitHub Actions runs every gate, billed per minute
|
|
18
|
+
* - local: `make ci` runs every gate locally, $0 CI cost, requires
|
|
19
|
+
* HC-101 Makefile + compose stack (this helper scaffolds it on choice)
|
|
20
|
+
* - mixed: local for fast feedback, github for final gate (defense in
|
|
21
|
+
* depth, costs less than github-only)
|
|
22
|
+
* - none: no CI verification — NOT recommended; setup-local-ci's
|
|
23
|
+
* X-Hone-Recommendation nudge fires on every /orchestrate request
|
|
24
|
+
*
|
|
25
|
+
* Non-blocking guarantees:
|
|
26
|
+
* - Non-TTY (CI / piped / Claude Code) skips the prompt entirely —
|
|
27
|
+
* adopter CI scripts don't break
|
|
28
|
+
* - HONE_SETUP_NONINTERACTIVE=1 forces auto-detect path (env-var escape
|
|
29
|
+
* hatch for scripted setups)
|
|
30
|
+
* - --ci-gate=<mode> CLI flag overrides the prompt entirely
|
|
31
|
+
* - Any prompt failure (EOF, signal, error) falls back to auto-detect
|
|
32
|
+
* so a flaky terminal never blocks setup
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const readline = require('node:readline');
|
|
38
|
+
|
|
39
|
+
// Canonical mode names MUST match cli/lib/pipeline-config.js CI_GATE_MODES
|
|
40
|
+
// — that module's normalizeCIGate() rejects anything else with a stderr
|
|
41
|
+
// warning and silently downgrades to 'github'. HC-RC-004 pass-1 code
|
|
42
|
+
// review CRIT-1: pre-fix this list said 'mixed' (a marketing-friendly
|
|
43
|
+
// label) which downstream consumers (run-story, queue-stories, the
|
|
44
|
+
// setup-ai-pipeline.sh CI_GATE allowlist) all reject. Adopters picking
|
|
45
|
+
// option [3] silently got github-only mode. 'both' is the contract.
|
|
46
|
+
// 'mixed' / 'hybrid' are accepted as input aliases via normalizeMode.
|
|
47
|
+
const VALID_MODES = Object.freeze(['github', 'local', 'both', 'none']);
|
|
48
|
+
|
|
49
|
+
const MODE_DESCRIPTIONS = Object.freeze({
|
|
50
|
+
github: 'GitHub Actions runs every gate. Billed per CI minute. (default)',
|
|
51
|
+
local: 'Run `make ci` locally. $0 CI cost. Scaffolds HC-101 Makefile + compose.',
|
|
52
|
+
both: 'Local for fast feedback, GitHub for the final gate. Defense in depth.',
|
|
53
|
+
none: 'NO CI verification. NOT recommended — gates are advisory only.',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a raw mode string (from --ci-gate flag, env var, or prompt
|
|
58
|
+
* answer). Returns one of VALID_MODES or null on unrecognized.
|
|
59
|
+
*
|
|
60
|
+
* @param {string|undefined|null} raw
|
|
61
|
+
* @returns {string|null}
|
|
62
|
+
*/
|
|
63
|
+
function normalizeMode(raw) {
|
|
64
|
+
if (raw === undefined || raw === null) return null;
|
|
65
|
+
const v = String(raw).toLowerCase().trim();
|
|
66
|
+
if (VALID_MODES.includes(v)) return v;
|
|
67
|
+
// Aliases the adopter might type — map to the canonical names that
|
|
68
|
+
// downstream consumers (pipeline-config.js CI_GATE_MODES, the bash
|
|
69
|
+
// setup script's allowlist) actually understand.
|
|
70
|
+
if (v === 'gh') return 'github';
|
|
71
|
+
if (v === 'off' || v === 'disabled' || v === 'no') return 'none';
|
|
72
|
+
if (v === 'mixed' || v === 'hybrid') return 'both';
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Detect whether the auto-detect path should pick `local` (Makefile with
|
|
78
|
+
* `ci:` target already at repo root) or `github`.
|
|
79
|
+
*
|
|
80
|
+
* Mirrors the same check setup-ai-pipeline.sh's detected_ci_default logic
|
|
81
|
+
* does, but in JS so the CLI doesn't have to fork-and-grep. The
|
|
82
|
+
* `^ci:[\s$|^=]` shape rejects Make variable assignments (`ci:=`).
|
|
83
|
+
*
|
|
84
|
+
* @param {{repoRoot: string, readFileSync?: Function, existsSync?: Function}} opts
|
|
85
|
+
* @returns {string} 'local' | 'github'
|
|
86
|
+
*/
|
|
87
|
+
function detectAutoMode(opts = {}) {
|
|
88
|
+
const repoRoot = opts.repoRoot || process.cwd();
|
|
89
|
+
const exists = opts.existsSync || fs.existsSync;
|
|
90
|
+
const read = opts.readFileSync || fs.readFileSync;
|
|
91
|
+
const makefilePath = path.join(repoRoot, 'Makefile');
|
|
92
|
+
if (!exists(makefilePath)) return 'github';
|
|
93
|
+
try {
|
|
94
|
+
const content = read(makefilePath, 'utf8');
|
|
95
|
+
// Match a real `ci:` target — exclude Make variable assignments.
|
|
96
|
+
if (/^ci:(?:[ \t]|$|[^=])/m.test(content)) return 'local';
|
|
97
|
+
} catch { /* fall through to github */ }
|
|
98
|
+
return 'github';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Decide whether to skip the interactive prompt entirely.
|
|
103
|
+
*
|
|
104
|
+
* Returns true (skip prompt) when ANY of:
|
|
105
|
+
* - --ci-gate flag was passed
|
|
106
|
+
* - --non-interactive was passed
|
|
107
|
+
* - HONE_SETUP_NONINTERACTIVE env var is truthy
|
|
108
|
+
* - stdin is not a TTY (CI / piped / Claude Code)
|
|
109
|
+
* - stdout is not a TTY (piped output — prompt would garble)
|
|
110
|
+
*
|
|
111
|
+
* @param {{flagMode?: string, nonInteractive?: boolean, env?: object, stdin?: any, stdout?: any}} opts
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
function shouldSkipPrompt(opts = {}) {
|
|
115
|
+
const env = opts.env || process.env;
|
|
116
|
+
const stdin = opts.stdin || process.stdin;
|
|
117
|
+
const stdout = opts.stdout || process.stdout;
|
|
118
|
+
if (opts.flagMode && normalizeMode(opts.flagMode)) return true;
|
|
119
|
+
if (opts.nonInteractive) return true;
|
|
120
|
+
// HC-RC-004 pass-1 (LOW-1): tolerant truthy check so adopters who set
|
|
121
|
+
// HONE_SETUP_NONINTERACTIVE=yes / true / TRUE / 2 also get the
|
|
122
|
+
// documented escape hatch. Exact-string match silently fell through
|
|
123
|
+
// for every spelling but '1' and 'true'.
|
|
124
|
+
if (env.HONE_SETUP_NONINTERACTIVE && /^(1|true|yes|on)$/i.test(String(env.HONE_SETUP_NONINTERACTIVE))) return true;
|
|
125
|
+
if (stdin && stdin.isTTY === false) return true;
|
|
126
|
+
if (stdout && stdout.isTTY === false) return true;
|
|
127
|
+
// Defensive: missing isTTY at all (some test rigs) → treat as non-TTY
|
|
128
|
+
if (stdin && stdin.isTTY === undefined) return true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the mode WITHOUT prompting. Used when shouldSkipPrompt() returns
|
|
134
|
+
* true. Priority:
|
|
135
|
+
* 1. --ci-gate flag (already validated)
|
|
136
|
+
* 2. HONE_CI_GATE env var (alias for adopter CI scripts)
|
|
137
|
+
* 3. Auto-detect from Makefile presence
|
|
138
|
+
*
|
|
139
|
+
* @param {{flagMode?: string, env?: object, repoRoot?: string, existsSync?: Function, readFileSync?: Function}} opts
|
|
140
|
+
* @returns {{mode: string, source: string}}
|
|
141
|
+
*/
|
|
142
|
+
function resolveNonInteractive(opts = {}) {
|
|
143
|
+
const env = opts.env || process.env;
|
|
144
|
+
const flagMode = normalizeMode(opts.flagMode);
|
|
145
|
+
if (flagMode) return { mode: flagMode, source: 'flag' };
|
|
146
|
+
const envMode = normalizeMode(env.HONE_CI_GATE);
|
|
147
|
+
if (envMode) return { mode: envMode, source: 'env' };
|
|
148
|
+
return { mode: detectAutoMode(opts), source: 'auto-detect' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Render the prompt's banner. Pulled out so tests can pin the exact lines
|
|
153
|
+
* adopters see — accidental UX regressions (e.g. dropping the cost
|
|
154
|
+
* trade-off line that drives the choice) get caught at PR time.
|
|
155
|
+
*
|
|
156
|
+
* @returns {string[]} lines to print (caller does console.log per line)
|
|
157
|
+
*/
|
|
158
|
+
function renderPromptBanner() {
|
|
159
|
+
return [
|
|
160
|
+
'',
|
|
161
|
+
'HC-RC-004: How should CI run for this repo?',
|
|
162
|
+
'',
|
|
163
|
+
' [1] github — ' + MODE_DESCRIPTIONS.github,
|
|
164
|
+
' [2] local — ' + MODE_DESCRIPTIONS.local,
|
|
165
|
+
' [3] both — ' + MODE_DESCRIPTIONS.both,
|
|
166
|
+
' [4] none — ' + MODE_DESCRIPTIONS.none,
|
|
167
|
+
'',
|
|
168
|
+
'Default is `github`. Pick `local` if you want to save GitHub Actions minutes.',
|
|
169
|
+
'',
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run the interactive prompt. Returns the chosen mode or null on EOF /
|
|
175
|
+
* signal / any error.
|
|
176
|
+
*
|
|
177
|
+
* @param {{stdin?: any, stdout?: any, write?: Function, banner?: Function}=} opts
|
|
178
|
+
* @returns {Promise<string|null>}
|
|
179
|
+
*/
|
|
180
|
+
async function runPrompt(opts = {}) {
|
|
181
|
+
const stdin = opts.stdin || process.stdin;
|
|
182
|
+
const stdout = opts.stdout || process.stdout;
|
|
183
|
+
const write = opts.write || ((line) => stdout.write(line + '\n'));
|
|
184
|
+
const banner = opts.banner || ((lines) => lines.forEach(l => stdout.write(l + '\n')));
|
|
185
|
+
|
|
186
|
+
banner(renderPromptBanner());
|
|
187
|
+
|
|
188
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
189
|
+
// HC-RC-004 pass-1 (HIGH-3): the readline question() callback persists
|
|
190
|
+
// after the timeout fires; a late keypress would resolve a dead Promise
|
|
191
|
+
// and emit a 'line' event into a closed rl. Wrap with a single-shot
|
|
192
|
+
// settle guard + remove the question's line listener in the timeout
|
|
193
|
+
// branch so the rl can be closed cleanly.
|
|
194
|
+
let settled = false;
|
|
195
|
+
let questionTimer = null;
|
|
196
|
+
try {
|
|
197
|
+
const answer = await new Promise((resolve) => {
|
|
198
|
+
const settle = (v) => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
settled = true;
|
|
201
|
+
if (questionTimer) clearTimeout(questionTimer);
|
|
202
|
+
resolve(v);
|
|
203
|
+
};
|
|
204
|
+
rl.question('Pick [1-4] or mode name (default: github): ', (a) => settle(a));
|
|
205
|
+
// Defensive: timeout the prompt after 60s so a hung terminal doesn't
|
|
206
|
+
// wedge setup forever. Adopters who need >60s should set --ci-gate
|
|
207
|
+
// or HONE_SETUP_NONINTERACTIVE.
|
|
208
|
+
questionTimer = setTimeout(() => {
|
|
209
|
+
// Drop the pending line listener so a late keypress can't fire
|
|
210
|
+
// into the closed interface.
|
|
211
|
+
rl.removeAllListeners('line');
|
|
212
|
+
settle(null);
|
|
213
|
+
}, 60_000);
|
|
214
|
+
questionTimer.unref?.();
|
|
215
|
+
});
|
|
216
|
+
if (answer === null) {
|
|
217
|
+
write(' ⚠ prompt timed out after 60s — falling back to auto-detect');
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const trimmed = String(answer).trim();
|
|
221
|
+
if (trimmed.length === 0) return 'github';
|
|
222
|
+
// Numeric shortcut
|
|
223
|
+
const numeric = { '1': 'github', '2': 'local', '3': 'both', '4': 'none' };
|
|
224
|
+
if (numeric[trimmed]) return numeric[trimmed];
|
|
225
|
+
// Otherwise normalize as mode name
|
|
226
|
+
const norm = normalizeMode(trimmed);
|
|
227
|
+
if (norm) return norm;
|
|
228
|
+
write(` ⚠ unknown answer "${trimmed}" — falling back to auto-detect`);
|
|
229
|
+
return null;
|
|
230
|
+
} catch (e) {
|
|
231
|
+
write(` ⚠ prompt error (${e.message}) — falling back to auto-detect`);
|
|
232
|
+
return null;
|
|
233
|
+
} finally {
|
|
234
|
+
rl.close();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Top-level entry point. Decides whether to prompt, resolves the mode,
|
|
240
|
+
* returns it plus the source so the caller can log how it was chosen.
|
|
241
|
+
*
|
|
242
|
+
* @param {{flagMode?: string, nonInteractive?: boolean, env?: object, repoRoot?: string, stdin?: any, stdout?: any}=} opts
|
|
243
|
+
* @returns {Promise<{mode: string, source: string}>}
|
|
244
|
+
*/
|
|
245
|
+
async function chooseCIGate(opts = {}) {
|
|
246
|
+
if (shouldSkipPrompt(opts)) {
|
|
247
|
+
return resolveNonInteractive(opts);
|
|
248
|
+
}
|
|
249
|
+
const answer = await runPrompt(opts);
|
|
250
|
+
if (answer === null) {
|
|
251
|
+
// Prompt failed / timed out / unknown answer — fall back to auto-detect.
|
|
252
|
+
return resolveNonInteractive(opts);
|
|
253
|
+
}
|
|
254
|
+
return { mode: answer, source: 'prompt' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
chooseCIGate,
|
|
259
|
+
normalizeMode,
|
|
260
|
+
detectAutoMode,
|
|
261
|
+
shouldSkipPrompt,
|
|
262
|
+
resolveNonInteractive,
|
|
263
|
+
renderPromptBanner,
|
|
264
|
+
runPrompt,
|
|
265
|
+
VALID_MODES,
|
|
266
|
+
MODE_DESCRIPTIONS,
|
|
267
|
+
};
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
const fs = require('node:fs');
|
|
24
24
|
const path = require('node:path');
|
|
25
25
|
const { execSync } = require('node:child_process');
|
|
26
|
+
const { gitEnv } = require('./git-env');
|
|
26
27
|
|
|
27
28
|
// HC-005-A: ambiguous pattern named explicitly so the ambiguity-guard
|
|
28
29
|
// doesn't depend on array index ordering. Adding new patterns to
|
|
@@ -65,7 +66,7 @@ function checkAdminMerge(args) {
|
|
|
65
66
|
let log;
|
|
66
67
|
try {
|
|
67
68
|
log = execSync(`git log -n ${lookback} --pretty=format:'%H%x09%s%x09%b%x1e'`, {
|
|
68
|
-
cwd: repoRoot,
|
|
69
|
+
cwd: repoRoot, env: gitEnv(),
|
|
69
70
|
encoding: 'utf8',
|
|
70
71
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
72
|
});
|
|
@@ -166,7 +167,7 @@ function checkAdminMerge(args) {
|
|
|
166
167
|
function isShallowClone(repoRoot) {
|
|
167
168
|
try {
|
|
168
169
|
const out = execSync('git rev-parse --is-shallow-repository', {
|
|
169
|
-
cwd: repoRoot,
|
|
170
|
+
cwd: repoRoot, env: gitEnv(),
|
|
170
171
|
encoding: 'utf8',
|
|
171
172
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
172
173
|
}).trim();
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* doctor-architecture.js — HC-020e architecture-staleness check.
|
|
4
|
+
*
|
|
5
|
+
* Surfaces stale architecture docs in `hone doctor`. Mirrors the
|
|
6
|
+
* doctor-skill-staleness shape: returns a `{name, status, reason,
|
|
7
|
+
* suggestedFix}` object the hone-cli renderer consumes.
|
|
8
|
+
*
|
|
9
|
+
* What it checks:
|
|
10
|
+
* 1. Look for `docs/sdlc/ARCHITECTURE.md` (the canonical living-
|
|
11
|
+
* architecture summary file the CLI writes via `hone derive`).
|
|
12
|
+
* Memory: hone-cli.js:1438 confirms this is the target path.
|
|
13
|
+
* 2. If file is missing → `skip` (adopter hasn't run derive yet).
|
|
14
|
+
* 3. Try to parse `<!-- Generated by derive-domain-skills on
|
|
15
|
+
* YYYY-MM-DD -->` marker from the file body. This is the
|
|
16
|
+
* authoritative signal: git checkout resets file mtimes to
|
|
17
|
+
* checkout time, so mtime alone gives false-fresh results on
|
|
18
|
+
* every CI build.
|
|
19
|
+
* 4. If marker absent → `skip` with a note that the prompt should
|
|
20
|
+
* be emitting it (HC-020e's companion change updates the
|
|
21
|
+
* prompt's Living Architecture Summary section to do so).
|
|
22
|
+
* 5. If marker present + parses → compare against threshold
|
|
23
|
+
* (default 90 days, matching `refresh_interval_days`).
|
|
24
|
+
* > threshold → `drift` with `Run hone derive --refresh` fix.
|
|
25
|
+
* ≤ threshold → `ok`.
|
|
26
|
+
*
|
|
27
|
+
* Pure helper. No I/O outside `args.repoRoot`. Never throws.
|
|
28
|
+
*
|
|
29
|
+
* Why this ships in the same PR as the prompt change (defer-with-hooks
|
|
30
|
+
* pattern from HC-010d/e): pass-1 reviews on those stories established
|
|
31
|
+
* that "doctor check exists but its data source isn't emitted yet" is
|
|
32
|
+
* the same dead-data class as "schema with no runtime consumer." The
|
|
33
|
+
* companion prompt update closes the loop in the same PR.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('node:fs');
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
|
|
39
|
+
const DEFAULT_AGE_THRESHOLD_DAYS = 90;
|
|
40
|
+
const ARCHITECTURE_PATH = 'docs/sdlc/ARCHITECTURE.md';
|
|
41
|
+
// Pass-2 review LOW: cap file read size so a runaway ARCHITECTURE.md
|
|
42
|
+
// (or symlink to a huge log) can't OOM the doctor. 1MB matches the
|
|
43
|
+
// `bundleDirectory` defaults used elsewhere in the CLI. Files above
|
|
44
|
+
// the cap → skip with reason (no signal worse than wrong signal).
|
|
45
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
// Marker emitted by the derive prompt at the top of the Living
|
|
48
|
+
// Architecture Summary section. Matches `<!-- Generated by
|
|
49
|
+
// derive-domain-skills on 2026-06-07 -->` (or any 10-char ISO date).
|
|
50
|
+
// Capturing group #1 is the date string. Whitespace inside the comment
|
|
51
|
+
// delimiters is tolerated; the wording itself is exact-match.
|
|
52
|
+
const GENERATED_MARKER_RE =
|
|
53
|
+
/<!--\s*Generated by derive-domain-skills on (\d{4}-\d{2}-\d{2})\s*-->/;
|
|
54
|
+
|
|
55
|
+
// Anchored variant: matches the marker ONLY inside the Living
|
|
56
|
+
// Architecture Summary section (so an example marker pasted elsewhere
|
|
57
|
+
// in the file — e.g., inside a code fence or a docs reference — can't
|
|
58
|
+
// pollute the parse). Pass-1 review MED #3 caught this.
|
|
59
|
+
// Pass-2 review MED: the gap between the heading and the marker uses
|
|
60
|
+
// a negative-lookahead loop `(?:(?!\n##\s)[\s\S])*?` instead of the
|
|
61
|
+
// unbounded `[\s\S]*?`, so the inter-marker scan STOPS at the next
|
|
62
|
+
// `## ` heading. Otherwise a stray marker in a LATER section (e.g.
|
|
63
|
+
// "## Anti-Pattern Report" with a pasted example) would be falsely
|
|
64
|
+
// attributed to the Living Architecture Summary section.
|
|
65
|
+
const SECTION_ANCHORED_MARKER_RE =
|
|
66
|
+
/##\s+Living Architecture Summary(?:(?!\n##\s)[\s\S])*?<!--\s*Generated by derive-domain-skills on (\d{4}-\d{2}-\d{2})\s*-->/i;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {object} args
|
|
70
|
+
* @param {string} args.repoRoot
|
|
71
|
+
* @param {number} [args.ageThresholdDays=90]
|
|
72
|
+
* @param {Date|number} [args.now] — injected for tests
|
|
73
|
+
* @returns {{ name: 'architecture',
|
|
74
|
+
* status: 'ok'|'drift'|'skip',
|
|
75
|
+
* reason: string,
|
|
76
|
+
* suggestedFix?: string }}
|
|
77
|
+
*/
|
|
78
|
+
function checkArchitectureStaleness(args) {
|
|
79
|
+
const {
|
|
80
|
+
repoRoot,
|
|
81
|
+
ageThresholdDays = DEFAULT_AGE_THRESHOLD_DAYS,
|
|
82
|
+
now,
|
|
83
|
+
} = args || {};
|
|
84
|
+
const name = 'architecture';
|
|
85
|
+
|
|
86
|
+
if (!repoRoot || typeof repoRoot !== 'string') {
|
|
87
|
+
return { name, status: 'skip', reason: 'no repoRoot supplied' };
|
|
88
|
+
}
|
|
89
|
+
if (!Number.isFinite(ageThresholdDays) || ageThresholdDays <= 0) {
|
|
90
|
+
return { name, status: 'skip',
|
|
91
|
+
reason: `invalid ageThresholdDays: ${ageThresholdDays}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const archPath = path.join(repoRoot, ARCHITECTURE_PATH);
|
|
95
|
+
if (!fs.existsSync(archPath)) {
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
status: 'skip',
|
|
99
|
+
reason: `${ARCHITECTURE_PATH} not found (run \`hone derive\` to generate it)`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pass-2 review LOW: cap file size before read. A runaway
|
|
104
|
+
// ARCHITECTURE.md (or a symlink target) bigger than the cap would
|
|
105
|
+
// both waste memory and indicate the file isn't what the helper
|
|
106
|
+
// expects — skip with a clear reason rather than load it.
|
|
107
|
+
try {
|
|
108
|
+
const st = fs.statSync(archPath);
|
|
109
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
status: 'skip',
|
|
113
|
+
reason:
|
|
114
|
+
`${ARCHITECTURE_PATH} is ${st.size} bytes (> ${MAX_FILE_BYTES} cap); ` +
|
|
115
|
+
'refusing to load. Inspect the file or split it.',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return { name, status: 'skip',
|
|
120
|
+
reason: `failed to stat ${ARCHITECTURE_PATH}: ${e.message}` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let content;
|
|
124
|
+
try {
|
|
125
|
+
content = fs.readFileSync(archPath, 'utf8');
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return { name, status: 'skip',
|
|
128
|
+
reason: `failed to read ${ARCHITECTURE_PATH}: ${e.message}` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Prefer the section-anchored match — only counts a marker INSIDE
|
|
132
|
+
// the Living Architecture Summary section. Falls back to unanchored
|
|
133
|
+
// match for files that lack the section heading (legacy / pre-prompt
|
|
134
|
+
// ARCHITECTURE.md files) so existing adopter setups still get a
|
|
135
|
+
// staleness signal.
|
|
136
|
+
let m = content.match(SECTION_ANCHORED_MARKER_RE);
|
|
137
|
+
if (!m) m = content.match(GENERATED_MARKER_RE);
|
|
138
|
+
if (!m) {
|
|
139
|
+
// No marker — can't trust file mtime (git checkout resets it).
|
|
140
|
+
// Skip with action item.
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
status: 'skip',
|
|
144
|
+
reason:
|
|
145
|
+
`${ARCHITECTURE_PATH} has no \`<!-- Generated by derive-domain-skills on YYYY-MM-DD -->\` ` +
|
|
146
|
+
'marker; can\'t reliably measure staleness (git checkout resets file mtime). ' +
|
|
147
|
+
'Re-run `hone derive --refresh` so the new prompt instruction emits the marker (HC-020e).',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const dateStr = m[1];
|
|
152
|
+
const generatedAt = new Date(`${dateStr}T00:00:00Z`);
|
|
153
|
+
if (Number.isNaN(generatedAt.getTime())) {
|
|
154
|
+
return {
|
|
155
|
+
name,
|
|
156
|
+
status: 'skip',
|
|
157
|
+
reason: `invalid generated-on date: ${dateStr}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const nowMs = now instanceof Date ? now.getTime()
|
|
162
|
+
: typeof now === 'number' ? now
|
|
163
|
+
: Date.now();
|
|
164
|
+
const ageDays = Math.floor((nowMs - generatedAt.getTime()) / (1000 * 60 * 60 * 24));
|
|
165
|
+
|
|
166
|
+
// Pass-1 review LOW: if the LLM hallucinated a future date (model
|
|
167
|
+
// believes it's 2027), ageDays is negative and the staleness check
|
|
168
|
+
// silently reports "ok". Treat future dates as untrusted → skip with
|
|
169
|
+
// a clear reason so the operator notices the hallucination.
|
|
170
|
+
if (ageDays < 0) {
|
|
171
|
+
return {
|
|
172
|
+
name,
|
|
173
|
+
status: 'skip',
|
|
174
|
+
reason:
|
|
175
|
+
`${ARCHITECTURE_PATH} marker date ${dateStr} is in the future (now: ` +
|
|
176
|
+
`${new Date(nowMs).toISOString().slice(0, 10)}); treating as untrusted. ` +
|
|
177
|
+
'Re-run `hone derive` to regenerate with a correct date.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (ageDays > ageThresholdDays) {
|
|
182
|
+
return {
|
|
183
|
+
name,
|
|
184
|
+
// Pass-1 review HIGH #2: use 'warn' (matches doctor-skill-staleness
|
|
185
|
+
// precedent — same semantic of "regenerated artifact aged past
|
|
186
|
+
// threshold", same CI-pass-through exit code). The earlier
|
|
187
|
+
// 'drift' status would have silently flipped existing adopter CI
|
|
188
|
+
// builds red on first stale-arch detection.
|
|
189
|
+
status: 'warn',
|
|
190
|
+
reason:
|
|
191
|
+
`${ARCHITECTURE_PATH} is ${ageDays} days old ` +
|
|
192
|
+
`(generated ${dateStr}; threshold ${ageThresholdDays} days). ` +
|
|
193
|
+
'Architecture may have drifted from the canonical view.',
|
|
194
|
+
suggestedFix: 'Run `hone derive --refresh` to regenerate from the current codebase.',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
name,
|
|
199
|
+
status: 'ok',
|
|
200
|
+
reason:
|
|
201
|
+
`${ARCHITECTURE_PATH} is ${ageDays} days old ` +
|
|
202
|
+
`(generated ${dateStr}; threshold ${ageThresholdDays} days).`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
checkArchitectureStaleness,
|
|
208
|
+
DEFAULT_AGE_THRESHOLD_DAYS,
|
|
209
|
+
ARCHITECTURE_PATH,
|
|
210
|
+
MAX_FILE_BYTES,
|
|
211
|
+
GENERATED_MARKER_RE,
|
|
212
|
+
SECTION_ANCHORED_MARKER_RE,
|
|
213
|
+
};
|
package/lib/emit-pr.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* emit-pr.js — HC-019n-followup-25 (pipeline-recovery condition 7).
|
|
4
|
+
*
|
|
5
|
+
* Condition 6 proved step_4's diff APPLIES and its tests PASS in isolation.
|
|
6
|
+
* Condition 7 turns that verified diff into the reviewable artifact: a branch
|
|
7
|
+
* and (opt-in) a draft PR a human can review. Today the pipeline's terminal
|
|
8
|
+
* output is a markdown file; this is the first step that produces something a
|
|
9
|
+
* reviewer opens.
|
|
10
|
+
*
|
|
11
|
+
* This module is the PURE half — branch-name / commit-message / PR-body
|
|
12
|
+
* construction, the idempotency decision, and the exit-code map. The worktree
|
|
13
|
+
* lifecycle, git writes, push, and `gh pr create` live in the CLI command and
|
|
14
|
+
* call these builders. Keeping it I/O-free makes the naming + honest-limit
|
|
15
|
+
* wording unit-testable without a repo or a network.
|
|
16
|
+
*
|
|
17
|
+
* See .github/pipeline/HC-019n-followup-25/architect.md. Publishing is opt-in
|
|
18
|
+
* and staged (tier 1 dry-run by default; --push, then --open-pr). NOTE the
|
|
19
|
+
* honest limit, carried verbatim into every PR body: a green verify means the
|
|
20
|
+
* change "does not regress the existing suite", NOT that the story is correctly
|
|
21
|
+
* implemented.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Exit codes. 0–4 are byte-for-byte verify-patch's EXIT map, so a caller that
|
|
26
|
+
* already branches on verify-patch keeps working; 5 and 6 are the new
|
|
27
|
+
* outward-facing failures (branch conflict, publish failure).
|
|
28
|
+
*/
|
|
29
|
+
const EXIT = {
|
|
30
|
+
PASS: 0, // plan built (dry run) | branch pushed | PR opened | PR already open
|
|
31
|
+
NO_DIFF: 1, // step_4 produced no changes / fetch failed
|
|
32
|
+
DOES_NOT_APPLY: 2, // diff did not apply in the worktree
|
|
33
|
+
TESTS_FAILED: 3, // --verify ran and tests failed (no commit made)
|
|
34
|
+
TIMEOUT: 4, // --verify test command timed out
|
|
35
|
+
BRANCH_CONFLICT: 5, // local branch already exists (not overwritten)
|
|
36
|
+
PUBLISH_FAILED: 6, // push or gh pr create failed (no remote / no auth / rejected)
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sanitize an arbitrary string into a valid git ref path segment.
|
|
41
|
+
* Keeps [A-Za-z0-9._-], turns everything else into '-', collapses runs, and
|
|
42
|
+
* trims leading/trailing separators (git rejects a segment that starts with a
|
|
43
|
+
* dot or ends with '.lock', and a ref may not contain '..' or '@{').
|
|
44
|
+
*/
|
|
45
|
+
function slugifyRef(s) {
|
|
46
|
+
return String(s == null ? '' : s)
|
|
47
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
48
|
+
.replace(/\.+/g, '.') // no '..' (git forbids it in refs)
|
|
49
|
+
.replace(/-+/g, '-')
|
|
50
|
+
.replace(/^[-.]+|[-.]+$/g, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** First 8 chars of a workflow/run id, for a short, traceable branch suffix. */
|
|
54
|
+
function short8(workflowId) {
|
|
55
|
+
return String(workflowId == null ? '' : workflowId).replace(/[^A-Za-z0-9]/g, '').slice(0, 8);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Branch name for an emitted PR: `hone/<storyId>-<short8(workflowId)>`.
|
|
60
|
+
* An explicit override wins (still slugified, so a caller can't inject a bad
|
|
61
|
+
* ref). The workflowId suffix keeps re-runs of the same story from colliding.
|
|
62
|
+
*/
|
|
63
|
+
function buildBranchName({ storyId, workflowId, override }) {
|
|
64
|
+
if (override && override.trim()) {
|
|
65
|
+
// Preserve intentional path structure (`hone/foo`) in an override.
|
|
66
|
+
const cleaned = override.trim().split('/').map(slugifyRef).filter(Boolean).join('/');
|
|
67
|
+
if (cleaned) return cleaned;
|
|
68
|
+
}
|
|
69
|
+
const story = slugifyRef(storyId) || 'story';
|
|
70
|
+
const suffix = short8(workflowId) || 'run';
|
|
71
|
+
return `hone/${story}-${suffix}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Commit message. Provenance lives in the body/trailer — authorship is NOT
|
|
76
|
+
* spoofed (the commit is authored by the adopter's own git identity).
|
|
77
|
+
*
|
|
78
|
+
* @param {{storyId:string, summary?:string, workflowId:string, headSha:string,
|
|
79
|
+
* verifyState:'pass'|'skipped'}} o
|
|
80
|
+
*/
|
|
81
|
+
function buildCommitMessage({ storyId, summary, workflowId, headSha, verifyState }) {
|
|
82
|
+
const subjectStory = storyId || 'story';
|
|
83
|
+
const subjectText = (summary && summary.trim().split('\n')[0].trim()) || 'apply generated changes';
|
|
84
|
+
const verifyLine = verifyState === 'pass'
|
|
85
|
+
? `Verified by \`hone verify-patch\` against HEAD ${headSha}: pass.`
|
|
86
|
+
: `Diff applies against HEAD ${headSha}; verify skipped.`;
|
|
87
|
+
return [
|
|
88
|
+
`${subjectStory}: ${subjectText}`,
|
|
89
|
+
'',
|
|
90
|
+
`Generated by Hone step_4 (code-builder), workflow ${workflowId}.`,
|
|
91
|
+
'Diff materialized by `hone` from ## Changed Files (git-authored hunks).',
|
|
92
|
+
verifyLine,
|
|
93
|
+
'',
|
|
94
|
+
'Co-Authored-By: Hone Pipeline <noreply@hone.ai>',
|
|
95
|
+
'',
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The mandatory honest-limit paragraph. Exported so a test can assert it is
|
|
101
|
+
* present in every PR body — it is the "must not overclaim" rule made concrete.
|
|
102
|
+
*/
|
|
103
|
+
const HONEST_LIMIT = [
|
|
104
|
+
'## Honest limit',
|
|
105
|
+
'`verify-patch` proves this change **does not regress the existing test suite**.',
|
|
106
|
+
'It does **not** prove the story is correctly or completely implemented — the',
|
|
107
|
+
'existing tests may not cover the new behavior. Review accordingly.',
|
|
108
|
+
].join('\n');
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* PR body. Always a draft's body; always carries HONEST_LIMIT verbatim.
|
|
112
|
+
*
|
|
113
|
+
* @param {{storyId:string, workflowId:string, headSha:string,
|
|
114
|
+
* verifyState:'pass'|'skipped', command?:string, runUrl?:string}} o
|
|
115
|
+
*/
|
|
116
|
+
function buildPrBody({ storyId, workflowId, headSha, verifyState, command, runUrl }) {
|
|
117
|
+
const verification = verifyState === 'pass'
|
|
118
|
+
? `- Verification: \`hone verify-patch\` — tests PASSED against HEAD \`${headSha}\`` +
|
|
119
|
+
(command ? ` using \`${command}\`` : '')
|
|
120
|
+
: `- Verification: SKIPPED — diff applies against HEAD \`${headSha}\`, tests not run at emit time`;
|
|
121
|
+
const lines = [
|
|
122
|
+
'## Summary',
|
|
123
|
+
`Automated change for ${storyId || 'a story'}, generated by the Hone SDLC pipeline.`,
|
|
124
|
+
'',
|
|
125
|
+
'## Provenance',
|
|
126
|
+
`- Source: step_4 (code-builder) output of workflow \`${workflowId}\``,
|
|
127
|
+
'- Diff: materialized CLI-side from `## Changed Files` (hunks computed by `git diff`, applicable by construction)',
|
|
128
|
+
verification,
|
|
129
|
+
'',
|
|
130
|
+
HONEST_LIMIT,
|
|
131
|
+
];
|
|
132
|
+
if (runUrl) {
|
|
133
|
+
lines.push('', `Run: ${runUrl}`);
|
|
134
|
+
}
|
|
135
|
+
return lines.join('\n') + '\n';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Idempotency decision, given the observed remote/local state. Pure: the CLI
|
|
140
|
+
* does the git/gh probes and hands the results here.
|
|
141
|
+
*
|
|
142
|
+
* @param {{branchExists:boolean, openPrUrl:string|null}} state
|
|
143
|
+
* @returns {{action:'branch_conflict'|'already_open'|'proceed', exitCode:number, prUrl?:string}}
|
|
144
|
+
*/
|
|
145
|
+
function decidePublish({ branchExists, openPrUrl }) {
|
|
146
|
+
// An already-open PR for this head is idempotent success — never open a
|
|
147
|
+
// duplicate. Checked before the branch-conflict guard: if the PR is already
|
|
148
|
+
// up, the branch necessarily exists, and reporting the URL is the useful move.
|
|
149
|
+
if (openPrUrl) {
|
|
150
|
+
return { action: 'already_open', exitCode: EXIT.PASS, prUrl: openPrUrl };
|
|
151
|
+
}
|
|
152
|
+
if (branchExists) {
|
|
153
|
+
return { action: 'branch_conflict', exitCode: EXIT.BRANCH_CONFLICT };
|
|
154
|
+
}
|
|
155
|
+
return { action: 'proceed', exitCode: EXIT.PASS };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
EXIT,
|
|
160
|
+
HONEST_LIMIT,
|
|
161
|
+
slugifyRef,
|
|
162
|
+
short8,
|
|
163
|
+
buildBranchName,
|
|
164
|
+
buildCommitMessage,
|
|
165
|
+
buildPrBody,
|
|
166
|
+
decidePublish,
|
|
167
|
+
};
|