heyspex 0.1.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.
Files changed (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs-source-preflight.mjs — read-only tree status + path-scoped stage plan
4
+ * for `hs source status` / `hs source preflight`
5
+ * (HS-QKO3-CWPX, epic [[HS-Q0K8-CWPX-EPIC-spexsource-local-git-preflight-commit]]).
6
+ *
7
+ * Both exported entry points below are STRICTLY READ-ONLY: this module must
8
+ * never shell out to `git add`, `git commit`, or `git push`. That is proven
9
+ * by the colocated test, which spies on the injected command runner and
10
+ * asserts no invocation's argv ever contains those verbs.
11
+ *
12
+ * `hs source commit` (sibling task HS-EZNJ-CWPX) consumes the stage plan
13
+ * this module produces; it is the ONLY module in this epic allowed to stage.
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+ import path from 'node:path';
17
+ import fs from 'node:fs';
18
+ import crypto from 'node:crypto';
19
+ import { createRequire } from 'node:module';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ import { scanPaths as defaultScanPaths } from './hs-source-secret-scan.mjs';
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ export const REPO_ROOT = path.resolve(__dirname, '..', '..');
26
+
27
+ // Where `hs source preflight` persists its stage plan so `hs source commit`
28
+ // (sibling task HS-EZNJ-CWPX) can consume it and refuse on drift/staleness.
29
+ // Lives under `.git/` (like `.git/MERGE_MSG`, `.git/COMMIT_EDITMSG`) rather
30
+ // than anywhere in the tracked working tree: `.git/` is never reported by
31
+ // `git status`, so persisting the plan can never itself perturb the dirty
32
+ // snapshot the plan's own freshness check re-reads a moment later. Writing
33
+ // a plain file here is a filesystem write, not a git mutation — it never
34
+ // touches refs/branches/commits.
35
+ export const STAGE_PLAN_FILENAME = 'hs-source-stage-plan.json';
36
+
37
+ // ---- staging denylist -------------------------------------------------
38
+ // Machine-state junk that must never be swept into a commit by this tool —
39
+ // per the originating incident and capture
40
+ // [[HS-G7NR-V4RB-CAP-spexsource-a-heyspex-source-control-syst]].
41
+ export const DENYLIST_PATTERNS = [
42
+ /(^|\/)\.heyspex\/[^/]*-history\.jsonl$/i,
43
+ /(^|\/)scheduler-cache\.json$/i,
44
+ /(^|\/)terminal-close-debug\.jsonl$/i,
45
+ /^spex-console\/\.next-prod\//i,
46
+ ];
47
+
48
+ /** @param {string} relPath @returns {boolean} */
49
+ export function isDenylisted(relPath) {
50
+ const norm = String(relPath).split(path.sep).join('/');
51
+ return DENYLIST_PATTERNS.some((re) => re.test(norm));
52
+ }
53
+
54
+ /**
55
+ * Default subprocess runner — injectable so tests can spy without ever
56
+ * touching real git state. Never throws on a non-zero exit; callers check
57
+ * `.status`.
58
+ * @param {string} cmd
59
+ * @param {string[]} args
60
+ * @param {object} [opts]
61
+ * @returns {{ status: number, stdout: string, stderr: string }}
62
+ */
63
+ export function defaultRun(cmd, args, opts = {}) {
64
+ try {
65
+ const stdout = execFileSync(cmd, args, {
66
+ encoding: 'utf8',
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ ...opts,
69
+ });
70
+ return { status: 0, stdout, stderr: '' };
71
+ } catch (err) {
72
+ return {
73
+ status: typeof err.status === 'number' ? err.status : 1,
74
+ stdout: err.stdout ? String(err.stdout) : '',
75
+ stderr: err.stderr ? String(err.stderr) : String(err.message ?? ''),
76
+ };
77
+ }
78
+ }
79
+
80
+ /** @param {string} raw @returns {{ code: string, path: string }[]} */
81
+ function parsePorcelain(raw) {
82
+ return String(raw ?? '')
83
+ .split(/\r?\n/)
84
+ .map((l) => l.replace(/\r$/, ''))
85
+ .filter((l) => l.length > 0)
86
+ .map((line) => {
87
+ const code = line.slice(0, 2);
88
+ let rest = line.slice(3);
89
+ const arrow = rest.indexOf(' -> ');
90
+ if (arrow !== -1) rest = rest.slice(arrow + 4);
91
+ if (rest.startsWith('"') && rest.endsWith('"')) rest = rest.slice(1, -1);
92
+ return { code, path: rest };
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Bare, read-only working-tree report: dirty paths + `gh auth status`.
98
+ * Performs ZERO git mutations.
99
+ * @param {{ cwd?: string, run?: typeof defaultRun }} [opts]
100
+ */
101
+ export function getTreeStatus({ cwd = process.cwd(), run = defaultRun } = {}) {
102
+ const statusResult = run('git', ['status', '--porcelain=v1'], { cwd });
103
+ const dirty = statusResult.status === 0 ? parsePorcelain(statusResult.stdout) : [];
104
+
105
+ const ghResult = run('gh', ['auth', 'status'], { cwd });
106
+ const ghAuth = {
107
+ ok: ghResult.status === 0,
108
+ output: `${ghResult.stdout}${ghResult.stderr}`.trim(),
109
+ };
110
+
111
+ return { dirty, ghAuth };
112
+ }
113
+
114
+ // ---- lint/format scoping -----------------------------------------------
115
+ // Mirrors the technique in scripts/run-pre-commit-checks.mjs
116
+ // (stagedConsoleEslintPaths / stagedConsolePrettierPaths / resolveConsoleBin):
117
+ // scope strictly to touched files under spex-console/ (the only place in
118
+ // this repo with an eslint/prettier config today), resolve the bin via the
119
+ // package's own package.json#bin rather than a subpath import, and never run
120
+ // a bare `eslint .` / whole-workspace invocation — applied here to the
121
+ // WORKING TREE (`git status`), not the staged index, since preflight runs
122
+ // before anything is staged.
123
+ const CONSOLE_ESLINT_RE = /^spex-console\/.+\.(ts|tsx|js|jsx|mjs|cjs)$/i;
124
+ const CONSOLE_PRETTIER_RE = /^spex-console\/.+\.(ts|tsx|js|jsx|mjs|cjs|css|json|md|yml|yaml)$/i;
125
+ const CONSOLE_EXCLUDE_RE =
126
+ /^spex-console\/(\.next|\.next-prod|node_modules|dist|coverage|public\/design-(previews|system)|figma-connect)\//i;
127
+
128
+ /** @param {string} pkgName @returns {string | null} */
129
+ function resolveConsoleBin(pkgName) {
130
+ try {
131
+ const req = createRequire(path.join(REPO_ROOT, 'spex-console', 'package.json'));
132
+ const pkgJsonPath = req.resolve(`${pkgName}/package.json`);
133
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
134
+ const binEntry = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[pkgName];
135
+ if (!binEntry) return null;
136
+ return path.join(path.dirname(pkgJsonPath), binEntry);
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Builds the path-scoped stage plan: touched paths minus the denylist, plus
144
+ * a scoped eslint/prettier pass and a secret scan limited to those paths.
145
+ * Still READ-ONLY — lint/format run in check mode only (`--check` for
146
+ * prettier; eslint's default is already non-mutating), the secret scan only
147
+ * reads file contents, and no `git add`/`commit`/`push` is ever invoked.
148
+ *
149
+ * The secret scan (HS-5NXX-CWPX) runs over `included` — the same paths the
150
+ * stage plan would `git add` — and a finding blocks the plan (`ok: false`)
151
+ * exactly like a failing lint/format check.
152
+ * @param {{ cwd?: string, run?: typeof defaultRun, scanPaths?: typeof defaultScanPaths }} [opts]
153
+ */
154
+ export function buildStagePlan({ cwd = process.cwd(), run = defaultRun, scanPaths = defaultScanPaths } = {}) {
155
+ const { dirty, ghAuth } = getTreeStatus({ cwd, run });
156
+
157
+ const included = [];
158
+ const excluded = [];
159
+ for (const { path: p } of dirty) {
160
+ if (isDenylisted(p)) excluded.push(p);
161
+ else included.push(p);
162
+ }
163
+
164
+ const eslintTargets = included.filter(
165
+ (p) => CONSOLE_ESLINT_RE.test(p) && !CONSOLE_EXCLUDE_RE.test(p),
166
+ );
167
+ const prettierTargets = included.filter(
168
+ (p) => CONSOLE_PRETTIER_RE.test(p) && !CONSOLE_EXCLUDE_RE.test(p),
169
+ );
170
+
171
+ const lint = { ran: false, tool: /** @type {string|null} */ (null), targets: eslintTargets, ok: true, output: '' };
172
+ const format = { ran: false, tool: /** @type {string|null} */ (null), targets: prettierTargets, ok: true, output: '' };
173
+
174
+ if (eslintTargets.length > 0) {
175
+ const bin = resolveConsoleBin('eslint');
176
+ if (bin) {
177
+ const relTargets = eslintTargets.map((p) => p.replace(/^spex-console\//, ''));
178
+ const result = run(process.execPath, [bin, ...relTargets], {
179
+ cwd: path.join(cwd, 'spex-console'),
180
+ });
181
+ lint.ran = true;
182
+ lint.tool = bin;
183
+ lint.ok = result.status === 0;
184
+ lint.output = `${result.stdout}${result.stderr}`.trim();
185
+ }
186
+ }
187
+
188
+ if (prettierTargets.length > 0) {
189
+ const bin = resolveConsoleBin('prettier');
190
+ if (bin) {
191
+ const relTargets = prettierTargets.map((p) => p.replace(/^spex-console\//, ''));
192
+ const result = run(process.execPath, [bin, '--check', ...relTargets], {
193
+ cwd: path.join(cwd, 'spex-console'),
194
+ });
195
+ format.ran = true;
196
+ format.tool = bin;
197
+ format.ok = result.status === 0;
198
+ format.output = `${result.stdout}${result.stderr}`.trim();
199
+ }
200
+ }
201
+
202
+ const secretScanFindings = included.length > 0 ? scanPaths(included, { cwd }) : [];
203
+ const secretScan = {
204
+ ran: included.length > 0,
205
+ engine: secretScanFindings.engine ?? null,
206
+ findings: Array.from(secretScanFindings),
207
+ };
208
+
209
+ return {
210
+ ghAuth,
211
+ dirty,
212
+ included,
213
+ excluded,
214
+ lint,
215
+ format,
216
+ secretScan,
217
+ ok: lint.ok && format.ok && secretScan.findings.length === 0,
218
+ };
219
+ }
220
+
221
+ // ---- stage-plan persistence --------------------------------------------
222
+ // `hs source preflight` writes the plan here; `hs source commit` (sibling
223
+ // task HS-EZNJ-CWPX) reads it back and refuses to stage when the working
224
+ // tree has drifted since the plan was produced.
225
+
226
+ /**
227
+ * Deterministic fingerprint of a working tree's dirty-path snapshot — used
228
+ * to detect drift between "the plan was built" and "the plan is consumed".
229
+ * @param {{ code: string, path: string }[]} dirty
230
+ * @returns {string}
231
+ */
232
+ export function computeDirtyFingerprint(dirty) {
233
+ const normalized = [...dirty]
234
+ .map((d) => `${d.code} ${d.path}`)
235
+ .sort()
236
+ .join('\n');
237
+ return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex');
238
+ }
239
+
240
+ /** @param {string} cwd @returns {string} */
241
+ export function stagePlanFilePath(cwd = REPO_ROOT) {
242
+ return path.join(cwd, '.git', STAGE_PLAN_FILENAME);
243
+ }
244
+
245
+ /**
246
+ * Persists a stage plan (as produced by buildStagePlan) to disk so a later
247
+ * `hs source commit` invocation can consume it. Best-effort: swallows write
248
+ * errors (e.g. `.git/` missing — not actually a git repo) rather than
249
+ * failing the read-only preflight command itself — the commit step will
250
+ * simply report "no plan".
251
+ * @param {ReturnType<typeof buildStagePlan>} plan
252
+ * @param {{ cwd?: string }} [opts]
253
+ */
254
+ export function writeStagePlanFile(plan, { cwd = process.cwd() } = {}) {
255
+ try {
256
+ const dir = path.join(cwd, '.git');
257
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
258
+ const record = {
259
+ createdAt: new Date().toISOString(),
260
+ cwd,
261
+ included: plan.included,
262
+ excluded: plan.excluded,
263
+ ok: plan.ok,
264
+ dirtyFingerprint: computeDirtyFingerprint(plan.dirty),
265
+ };
266
+ fs.writeFileSync(stagePlanFilePath(cwd), JSON.stringify(record, null, 2), 'utf8');
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Reads back a persisted stage plan. Returns `null` when no plan file
275
+ * exists or it cannot be parsed — callers treat that as "no plan".
276
+ * @param {{ cwd?: string }} [opts]
277
+ */
278
+ export function readStagePlanFile({ cwd = process.cwd() } = {}) {
279
+ try {
280
+ const raw = fs.readFileSync(stagePlanFilePath(cwd), 'utf8');
281
+ const record = JSON.parse(raw);
282
+ if (!record || typeof record !== 'object' || !Array.isArray(record.included)) return null;
283
+ return record;
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ // ---- CLI report formatting ---------------------------------------------
290
+
291
+ /** @param {ReturnType<typeof getTreeStatus>} status */
292
+ export function formatStatusReport(status) {
293
+ const lines = [];
294
+ lines.push('hs source status');
295
+ lines.push('');
296
+ if (status.dirty.length === 0) {
297
+ lines.push('Working tree clean.');
298
+ } else {
299
+ lines.push(`Dirty paths (${status.dirty.length}):`);
300
+ for (const d of status.dirty) lines.push(` ${d.code} ${d.path}`);
301
+ }
302
+ lines.push('');
303
+ lines.push(`gh auth status: ${status.ghAuth.ok ? 'OK' : 'NOT LOGGED IN / ERROR'}`);
304
+ if (status.ghAuth.output) {
305
+ for (const l of status.ghAuth.output.split(/\r?\n/)) lines.push(` ${l}`);
306
+ }
307
+ lines.push('');
308
+ lines.push('(read-only — nothing was staged, committed, or pushed)');
309
+ return lines.join('\n');
310
+ }
311
+
312
+ /** @param {ReturnType<typeof buildStagePlan>} plan */
313
+ export function formatPreflightReport(plan) {
314
+ const lines = [];
315
+ lines.push('hs source preflight');
316
+ lines.push('');
317
+ lines.push(`gh auth status: ${plan.ghAuth.ok ? 'OK' : 'NOT LOGGED IN / ERROR'}`);
318
+ lines.push('');
319
+ if (plan.included.length === 0) {
320
+ lines.push('Stage plan: (empty — nothing to stage)');
321
+ } else {
322
+ lines.push(`Stage plan (${plan.included.length} path(s) — what \`hs source commit\` would \`git add\`):`);
323
+ for (const p of plan.included) lines.push(` + ${p}`);
324
+ }
325
+ if (plan.excluded.length > 0) {
326
+ lines.push('');
327
+ lines.push(`Excluded (denylisted machine-state paths, ${plan.excluded.length}):`);
328
+ for (const p of plan.excluded) lines.push(` - ${p}`);
329
+ }
330
+ lines.push('');
331
+ if (plan.lint.ran) {
332
+ lines.push(`ESLint (${plan.lint.targets.length} file(s)): ${plan.lint.ok ? 'PASS' : 'FAIL'}`);
333
+ if (!plan.lint.ok && plan.lint.output) lines.push(plan.lint.output);
334
+ } else if (plan.lint.targets.length > 0) {
335
+ lines.push(`ESLint: skipped (eslint binary not resolvable) for ${plan.lint.targets.length} file(s)`);
336
+ } else {
337
+ lines.push('ESLint: no touched spex-console/ files to scope to');
338
+ }
339
+ if (plan.format.ran) {
340
+ lines.push(`Prettier --check (${plan.format.targets.length} file(s)): ${plan.format.ok ? 'PASS' : 'FAIL'}`);
341
+ if (!plan.format.ok && plan.format.output) lines.push(plan.format.output);
342
+ } else if (plan.format.targets.length > 0) {
343
+ lines.push(`Prettier: skipped (prettier binary not resolvable) for ${plan.format.targets.length} file(s)`);
344
+ } else {
345
+ lines.push('Prettier: no touched spex-console/ files to scope to');
346
+ }
347
+ if (plan.secretScan) {
348
+ const findings = plan.secretScan.findings ?? [];
349
+ if (plan.secretScan.ran) {
350
+ lines.push(`Secret scan (engine: ${plan.secretScan.engine ?? 'unknown'}): ${findings.length === 0 ? 'PASS' : 'BLOCKED'}`);
351
+ for (const f of findings) lines.push(` ! ${f.path} (${f.rule})`);
352
+ } else {
353
+ lines.push('Secret scan: nothing to stage, skipped');
354
+ }
355
+ }
356
+ lines.push('');
357
+ lines.push(plan.ok ? 'Preflight: PASS' : 'Preflight: FAIL — fix the issues above before `hs source commit`');
358
+ lines.push('(read-only — nothing was staged, committed, or pushed)');
359
+ return lines.join('\n');
360
+ }
361
+
362
+ /**
363
+ * CLI entry for `hs source status`. Returns a process exit code; always 0
364
+ * (read-only report, never fails the invocation itself).
365
+ * @param {{ cwd?: string }} [opts]
366
+ */
367
+ export function runStatusCommand({ cwd = process.cwd() } = {}) {
368
+ const status = getTreeStatus({ cwd });
369
+ console.log(formatStatusReport(status));
370
+ return 0;
371
+ }
372
+
373
+ /**
374
+ * CLI entry for `hs source preflight`. Returns 0 when the stage plan's lint
375
+ * and format checks both pass, 1 otherwise — so a caller/script can gate on
376
+ * the exit code without parsing the printed report.
377
+ * @param {{ cwd?: string }} [opts]
378
+ */
379
+ export function runPreflightCommand({ cwd = process.cwd() } = {}) {
380
+ const plan = buildStagePlan({ cwd });
381
+ writeStagePlanFile(plan, { cwd });
382
+ console.log(formatPreflightReport(plan));
383
+ console.log(`\nStage plan written to ${path.relative(cwd, stagePlanFilePath(cwd))} for \`hs source commit\`.`);
384
+ return plan.ok ? 0 : 1;
385
+ }
386
+
387
+ // ---- direct-invocation entry --------------------------------------------
388
+
389
+ function invokedDirectly() {
390
+ try {
391
+ return import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href
392
+ || path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ if (invokedDirectly()) {
399
+ const verb = process.argv[2];
400
+ if (verb === 'preflight') process.exit(runPreflightCommand());
401
+ else process.exit(runStatusCommand());
402
+ }