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