@hone-ai/cli 1.18.0 → 1.19.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/hone-cli.js +975 -34
- package/lib/architect-config.js +121 -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/eval-evidence.js +213 -0
- package/lib/git-env.js +94 -0
- package/lib/git-helpers.js +3 -2
- package/lib/pipeline-config.js +80 -0
- package/lib/pipeline-status.js +12 -1
- 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/package.json +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* architect-config.js — HC-019b: read per-story architect flags from
|
|
4
|
+
* `.github/EXECUTION_PLAN.yml` so the CLI can plumb them into
|
|
5
|
+
* `workflow_runs.config` on POST /orchestrate (and POST /orchestrate/batch).
|
|
6
|
+
*
|
|
7
|
+
* The architect prompt (HC-019a) instructs the LLM to emit, per story:
|
|
8
|
+
*
|
|
9
|
+
* stories:
|
|
10
|
+
* - id: <STORY-ID>
|
|
11
|
+
* config:
|
|
12
|
+
* architect_consulted: true|false
|
|
13
|
+
* checklist_b_completed: true|false
|
|
14
|
+
*
|
|
15
|
+
* The orchestrator's `validateStepPreConditions`
|
|
16
|
+
* (server/src/services/workflow-dag.js:340) BLOCKS step_1 when
|
|
17
|
+
* `architect_consulted=true` but `checklist_b_completed=false` — so
|
|
18
|
+
* without this plumbing, the HC-019a flag never reaches the server and
|
|
19
|
+
* every architect-engaged story would deadlock at step_1.
|
|
20
|
+
*
|
|
21
|
+
* Pure helper — caller wraps fs.readFileSync + path resolution so the
|
|
22
|
+
* helper is unit-testable with stubs.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const { parseExecutionPlan } = require('./fast-track-ratify');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Default flag values when the EXECUTION_PLAN.yml file is absent, the
|
|
31
|
+
* story id is not found in it, or the per-story `config:` block is
|
|
32
|
+
* absent. False/false matches the behavior an operator would get from
|
|
33
|
+
* a story that bypassed the architect — `validateStepPreConditions`
|
|
34
|
+
* only blocks when `architect_consulted=true`, so the safer default
|
|
35
|
+
* is "architect was not consulted" (lets the story flow through; the
|
|
36
|
+
* operator's classifier decision determines whether engagement is
|
|
37
|
+
* required, not this helper).
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULTS = Object.freeze({
|
|
40
|
+
architect_consulted: false,
|
|
41
|
+
checklist_b_completed: false,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the per-story architect flags from `.github/EXECUTION_PLAN.yml`.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} repoRoot — absolute path to the repo root
|
|
48
|
+
* @param {string} storyId — the story id to look up
|
|
49
|
+
* @returns {{ architect_consulted: boolean, checklist_b_completed: boolean }}
|
|
50
|
+
*/
|
|
51
|
+
function readArchitectConfig(repoRoot, storyId) {
|
|
52
|
+
if (typeof repoRoot !== 'string' || !repoRoot) return { ...DEFAULTS };
|
|
53
|
+
if (typeof storyId !== 'string' || !storyId) return { ...DEFAULTS };
|
|
54
|
+
|
|
55
|
+
const planPath = path.join(repoRoot, '.github', 'EXECUTION_PLAN.yml');
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(planPath)) return { ...DEFAULTS };
|
|
59
|
+
text = fs.readFileSync(planPath, 'utf8');
|
|
60
|
+
} catch {
|
|
61
|
+
return { ...DEFAULTS };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return readArchitectConfigFromText(text, storyId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Same as `readArchitectConfig` but takes the YAML text directly
|
|
69
|
+
* (so tests don't need to write to disk).
|
|
70
|
+
*
|
|
71
|
+
* The optional second-return value is a `diagnostic` string that surfaces
|
|
72
|
+
* silent-bypass conditions the operator should know about (code-review
|
|
73
|
+
* F2/F3 — without these, malformed plan files or missing story entries
|
|
74
|
+
* silently disable the architect contract for every affected story).
|
|
75
|
+
* Callers can log it via `console.warn(diagnostic)` or ignore it.
|
|
76
|
+
*
|
|
77
|
+
* Returns: { architect_consulted, checklist_b_completed, diagnostic? }
|
|
78
|
+
* - diagnostic === undefined → clean read, flags reflect reality
|
|
79
|
+
* - diagnostic === string → defaults returned for the reason given
|
|
80
|
+
*/
|
|
81
|
+
function readArchitectConfigFromText(text, storyId) {
|
|
82
|
+
if (typeof storyId !== 'string' || !storyId) return { ...DEFAULTS };
|
|
83
|
+
|
|
84
|
+
// Empty text is a legitimate "no plan file" path (most repos before
|
|
85
|
+
// architect adoption). NOT a diagnostic — defaults are intended.
|
|
86
|
+
if (typeof text !== 'string' || text.length === 0) return { ...DEFAULTS };
|
|
87
|
+
|
|
88
|
+
const parsed = parseExecutionPlan(text);
|
|
89
|
+
if (!parsed || parsed.error) {
|
|
90
|
+
// Code-review F2: malformed YAML silently disabled the contract for
|
|
91
|
+
// every story. Now we surface a diagnostic so the CLI can warn.
|
|
92
|
+
return {
|
|
93
|
+
...DEFAULTS,
|
|
94
|
+
diagnostic: `EXECUTION_PLAN.yml ${parsed?.error || 'parse error'}: ${parsed?.message || ''} — defaulting to {false, false} for all stories`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const stories = Array.isArray(parsed.stories) ? parsed.stories : [];
|
|
99
|
+
const story = stories.find(s => s && s.id === storyId);
|
|
100
|
+
if (!story) {
|
|
101
|
+
// Code-review F3: story-not-in-plan was silent. If the operator added
|
|
102
|
+
// the story but forgot to add an entry to EXECUTION_PLAN.yml, the
|
|
103
|
+
// architect contract is silently bypassed. Surface as a diagnostic.
|
|
104
|
+
return {
|
|
105
|
+
...DEFAULTS,
|
|
106
|
+
diagnostic: `story id '${storyId}' not found in EXECUTION_PLAN.yml — defaulting to {false, false}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cfg = story.config || {};
|
|
111
|
+
return {
|
|
112
|
+
architect_consulted: cfg.architect_consulted === true,
|
|
113
|
+
checklist_b_completed: cfg.checklist_b_completed === true,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
DEFAULTS,
|
|
119
|
+
readArchitectConfig,
|
|
120
|
+
readArchitectConfigFromText,
|
|
121
|
+
};
|
|
@@ -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
|
+
};
|