@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.
@@ -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
+ };