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,207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs-source-commit.mjs — `hs source commit`: path-based staging wrapper
4
+ * around the existing owner-directed commit gate
5
+ * (HS-EZNJ-CWPX, epic [[HS-Q0K8-CWPX-EPIC-spexsource-local-git-preflight-commit]]).
6
+ *
7
+ * This module NEVER runs `git add -A` / `git add -u` and NEVER runs a bare
8
+ * `git commit` — it stages exactly the paths named in the most recent
9
+ * `hs source preflight` stage plan (one `git add <path>` per path) and then
10
+ * shells out to the existing owner-directed commit path
11
+ * (`scripts/owner-commit.mjs`, i.e. `npm run commit:owner`) so
12
+ * `guard-agent-commit` and the pre-commit hooks still run unmodified. See
13
+ * the colocated test for the enforcement proof.
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import {
20
+ getTreeStatus,
21
+ readStagePlanFile,
22
+ computeDirtyFingerprint,
23
+ REPO_ROOT,
24
+ } from './hs-source-preflight.mjs';
25
+ import { createSessionBranch } from './session-branch.mjs';
26
+
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+ const OWNER_COMMIT_SCRIPT = path.join(REPO_ROOT, 'scripts', 'owner-commit.mjs');
29
+
30
+ /**
31
+ * Default subprocess runner — injectable so tests can spy without ever
32
+ * mutating real git state. Never throws; callers check `.status`.
33
+ * @param {string} cmd
34
+ * @param {string[]} args
35
+ * @param {object} [opts]
36
+ * @returns {{ status: number, error?: Error }}
37
+ */
38
+ export function defaultRun(cmd, args, opts = {}) {
39
+ const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
40
+ if (result.error) return { status: 1, error: result.error };
41
+ return { status: result.status ?? 1 };
42
+ }
43
+
44
+ /**
45
+ * Stages exactly the paths in `plan.included` (path-based `git add <path>`,
46
+ * one invocation per path — never `-A`/`-u`), then commits via the existing
47
+ * owner-directed gate. Refuses (no staging calls at all) when no plan is on
48
+ * disk or it has drifted from the current working tree.
49
+ *
50
+ * @param {{
51
+ * message: string,
52
+ * cwd?: string,
53
+ * run?: typeof defaultRun,
54
+ * getStatus?: typeof getTreeStatus,
55
+ * readPlan?: typeof readStagePlanFile,
56
+ * }} opts
57
+ * @returns {{ ok: boolean, code?: string, message?: string, staged?: string[] }}
58
+ */
59
+ export function commitFromPlan({
60
+ message,
61
+ cwd = process.cwd(),
62
+ run = defaultRun,
63
+ getStatus = getTreeStatus,
64
+ readPlan = readStagePlanFile,
65
+ } = {}) {
66
+ if (!message || typeof message !== 'string' || message.trim() === '') {
67
+ return {
68
+ ok: false,
69
+ code: 'NO_MESSAGE',
70
+ message:
71
+ 'hs source commit: a commit message is required (e.g. `hs source commit -m "feat: …"`).',
72
+ };
73
+ }
74
+
75
+ const plan = readPlan({ cwd });
76
+ if (!plan) {
77
+ return {
78
+ ok: false,
79
+ code: 'NO_PLAN',
80
+ message:
81
+ 'hs source commit: no stage plan found. Run `hs source preflight` first — it produces the plan this command consumes.',
82
+ };
83
+ }
84
+
85
+ const currentStatus = getStatus({ cwd, run });
86
+ const currentFingerprint = computeDirtyFingerprint(currentStatus.dirty);
87
+ if (currentFingerprint !== plan.dirtyFingerprint) {
88
+ return {
89
+ ok: false,
90
+ code: 'STALE_PLAN',
91
+ message:
92
+ 'hs source commit: the working tree has changed since the last `hs source preflight` — the stage plan is stale. Re-run `hs source preflight` and try again.',
93
+ };
94
+ }
95
+
96
+ if (!Array.isArray(plan.included) || plan.included.length === 0) {
97
+ return {
98
+ ok: false,
99
+ code: 'EMPTY_PLAN',
100
+ message:
101
+ 'hs source commit: the stage plan has nothing to stage (empty or fully denylisted). Nothing to commit.',
102
+ };
103
+ }
104
+
105
+ if (plan.ok === false) {
106
+ return {
107
+ ok: false,
108
+ code: 'PREFLIGHT_FAILED',
109
+ message:
110
+ 'hs source commit: the last `hs source preflight` reported a failing lint/format check or a secret-scan finding. Fix it and re-run preflight before committing.',
111
+ };
112
+ }
113
+
114
+ const staged = [];
115
+ for (const p of plan.included) {
116
+ const result = run('git', ['add', p], { cwd });
117
+ if (result.status !== 0) {
118
+ return {
119
+ ok: false,
120
+ code: 'STAGE_FAILED',
121
+ message: `hs source commit: \`git add ${p}\` failed — aborting before any commit was attempted.`,
122
+ staged,
123
+ };
124
+ }
125
+ staged.push(p);
126
+ }
127
+
128
+ const commitResult = run(process.execPath, [OWNER_COMMIT_SCRIPT, '-m', message], { cwd });
129
+ if (commitResult.status !== 0) {
130
+ return {
131
+ ok: false,
132
+ code: 'COMMIT_FAILED',
133
+ message: 'hs source commit: the owner-directed commit gate (owner-commit.mjs) failed — see its output above.',
134
+ staged,
135
+ };
136
+ }
137
+
138
+ return { ok: true, staged };
139
+ }
140
+
141
+ /**
142
+ * CLI entry for `hs source commit`. Parses `-m/--message <msg>` and an
143
+ * optional `--session <id>` from argv.
144
+ *
145
+ * When `--session <id>` is given, the session's branch is created/selected
146
+ * via `scripts/lib/session-branch.mjs` (HS-Z7VJ) BEFORE any staging or
147
+ * committing happens — session-branch.mjs is the only thing that ever runs
148
+ * `git checkout`/`git checkout -b`/`git config` on that path; this function
149
+ * never spawns git itself. Omitting `--session` leaves the existing
150
+ * no-flag path completely unchanged: `createSessionBranch` is never called,
151
+ * so there is no branch-creation/selection side effect at all (AC2).
152
+ *
153
+ * @param {string[]} argv (verb-stripped: just the flags/args after `commit`)
154
+ * @param {{
155
+ * cwd?: string,
156
+ * run?: (cmd: string, args: string[], opts?: object) => { status: number, stdout?: string, stderr?: string },
157
+ * createSessionBranchFn?: typeof createSessionBranch,
158
+ * }} [opts]
159
+ * @returns {number} process exit code
160
+ */
161
+ export function runCommitCommand(argv, { cwd = process.cwd(), run, createSessionBranchFn = createSessionBranch } = {}) {
162
+ let message = '';
163
+ let sessionId;
164
+ for (let i = 0; i < argv.length; i++) {
165
+ if ((argv[i] === '-m' || argv[i] === '--message') && argv[i + 1] !== undefined) {
166
+ message = argv[i + 1];
167
+ i++;
168
+ } else if (argv[i] === '--session' && argv[i + 1] !== undefined) {
169
+ sessionId = argv[i + 1];
170
+ i++;
171
+ }
172
+ }
173
+
174
+ if (sessionId !== undefined) {
175
+ const branchResult = createSessionBranchFn({ sessionId, cwd, run });
176
+ if (!branchResult.ok) {
177
+ console.error(branchResult.message);
178
+ return 1;
179
+ }
180
+ console.log(
181
+ `hs source commit: ${branchResult.reused ? 'reusing' : 'created'} session branch ${branchResult.branch}.`,
182
+ );
183
+ }
184
+
185
+ const result = commitFromPlan({ message, cwd, run });
186
+ if (!result.ok) {
187
+ console.error(result.message);
188
+ return 1;
189
+ }
190
+ console.log(`hs source commit: committed ${result.staged.length} path(s).`);
191
+ return 0;
192
+ }
193
+
194
+ // ---- direct-invocation entry --------------------------------------------
195
+
196
+ function invokedDirectly() {
197
+ try {
198
+ return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ if (invokedDirectly()) {
205
+ // argv: [node, script, "commit", ...flags] — drop the leading verb token.
206
+ process.exit(runCommitCommand(process.argv.slice(3)));
207
+ }
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hs-source-log.mjs — `hs source log <spec-id>`: commit-history retrieval
4
+ * for diagnosis (HS-I6AB, epic
5
+ * [[HS-VKD5-6JBX-EPIC-commit-history-retrieval-for-diagnosis]]).
6
+ *
7
+ * Reads the console's `GET /api/specs/:id/settlement` route
8
+ * (`spex-console/src/app/api/specs/[id]/settlement/route.ts`) to print the
9
+ * most recent commit-settlement row for a spec — repo, branch, commit sha,
10
+ * and when it settled — so diagnosis/triage can look up a spec's commit
11
+ * history from the console-known side, without a local git checkout
12
+ * (mirrors why `spec-settlement-info.tsx` exists in the console itself:
13
+ * under ADR-0022/ADR-0023 a browser-only cloud user has none).
14
+ *
15
+ * The console lookup is an injectable HTTP client (`fetchImpl`), never a
16
+ * hardcoded `fetch` call — tests inject a fake console and never touch the
17
+ * network. Console URL resolution reuses the ONE shared resolver
18
+ * (`console-url-resolver.mjs`) rather than a hand-rolled localhost guess.
19
+ *
20
+ * HS-CZ84 extends this module with `lookupCommitHistory` — a local
21
+ * `git -C <vaultRoot> log --grep <spec-id>` fallback (through the same
22
+ * injected-runner pattern `hs-source-commit.mjs`'s `defaultRun` uses) for
23
+ * when the console lookup finds nothing, so commit-history lookup still
24
+ * works with no console reachable. Provenance (`console` vs
25
+ * `local-git-log`) is always explicit on the result — see
26
+ * {@link formatSettlementResult}.
27
+ */
28
+ import { spawnSync } from 'node:child_process';
29
+ import { readFileSync } from 'node:fs';
30
+ import { homedir } from 'node:os';
31
+ import { join } from 'node:path';
32
+ import path from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+
35
+ import { resolveConsoleUrl } from './console-url-resolver.mjs';
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ export const REPO_ROOT = path.resolve(__dirname, '..', '..');
39
+
40
+ // ---- Bearer-header idiom -------------------------------------------------
41
+ // Mirrors `readPairedToken`/`bearerHeaderFor` in
42
+ // `spex-console/src/lib/cli/control-shared.ts` byte-for-byte in behavior:
43
+ // same paired `hsmcp_`/`hscmp_` token read from `~/.heyspex/credentials.yaml`,
44
+ // same loopback-skip rule (a local dev console never needed auth; a genuinely
45
+ // remote/paired console does — [[HS-PH7O-ILNC-DIAG-hs-check-report-cannot-authenticate]]).
46
+ // Duplicated here in plain Node ESM rather than imported from that TS module
47
+ // because `scripts/lib/*.mjs` runs standalone (bare `node`, no Next.js build
48
+ // step) — the same reasoning `console-url-resolver.mjs`'s own module doc
49
+ // gives for staying dependency-free.
50
+
51
+ /**
52
+ * Raw paired Bearer token from `~/.heyspex/credentials.yaml`, or `undefined`
53
+ * if absent/unreadable.
54
+ * @returns {string | undefined}
55
+ */
56
+ export function readPairedToken() {
57
+ try {
58
+ const raw = readFileSync(join(homedir(), '.heyspex', 'credentials.yaml'), 'utf-8');
59
+ const match = raw.match(/\b(hsmcp_[A-Za-z0-9_-]+|hscmp_[A-Za-z0-9_-]+)\b/);
60
+ return match?.[1];
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Bearer header for a non-loopback console; `{}` for loopback targets or
68
+ * when no token is paired.
69
+ * @param {string} consoleUrl
70
+ * @returns {Record<string, string>}
71
+ */
72
+ export function bearerHeaderFor(consoleUrl) {
73
+ try {
74
+ const host = new URL(consoleUrl).hostname;
75
+ if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1') {
76
+ return {};
77
+ }
78
+ const token = readPairedToken();
79
+ return token ? { authorization: `Bearer ${token}` } : {};
80
+ } catch {
81
+ return {};
82
+ }
83
+ }
84
+
85
+ // ---- console settlement lookup ------------------------------------------
86
+
87
+ /**
88
+ * The minimal HTTP-client surface this module needs — an injectable stand-in
89
+ * for `fetch`. Production uses the real global `fetch`; tests inject a fake
90
+ * console that never touches the network.
91
+ * @typedef {(url: string, init?: object) => Promise<{
92
+ * ok: boolean,
93
+ * status: number,
94
+ * json: () => Promise<any>,
95
+ * }>} HttpClient
96
+ */
97
+
98
+ /**
99
+ * One settlement row, shaped exactly like `SettlementRow` in
100
+ * `spex-console/src/lib/git-export/settlement-store.ts` — never reimplemented
101
+ * ad hoc.
102
+ * @typedef {{
103
+ * found: true,
104
+ * provenance: 'console',
105
+ * repo: string,
106
+ * branch: string,
107
+ * commitSha: string,
108
+ * settledAt: string,
109
+ * }} ConsoleSettlementFound
110
+ * @typedef {{
111
+ * found: false,
112
+ * provenance: 'console',
113
+ * reason: string,
114
+ * }} ConsoleSettlementNotFound
115
+ * @typedef {ConsoleSettlementFound | ConsoleSettlementNotFound} ConsoleSettlementResult
116
+ */
117
+
118
+ /**
119
+ * Look up the most recent settlement row for `specId` via the console's
120
+ * `GET /api/specs/:id/settlement` route. NEVER throws: a network error, a
121
+ * non-OK HTTP status, an unparsable body, or a `{ settled: false }` response
122
+ * all resolve to a typed `{ found: false, reason }` result rather than
123
+ * rejecting or crashing the caller.
124
+ *
125
+ * @param {{
126
+ * specId: string,
127
+ * consoleUrl?: string,
128
+ * fetchImpl?: HttpClient,
129
+ * repoRoot?: string,
130
+ * }} opts
131
+ * @returns {Promise<ConsoleSettlementResult>}
132
+ */
133
+ export async function lookupConsoleSettlement({
134
+ specId,
135
+ consoleUrl,
136
+ fetchImpl = fetch,
137
+ repoRoot,
138
+ } = {}) {
139
+ const resolvedUrl = consoleUrl ?? resolveConsoleUrl({ repoRoot });
140
+ const url = `${resolvedUrl}/api/specs/${encodeURIComponent(specId)}/settlement`;
141
+
142
+ let res;
143
+ try {
144
+ res = await fetchImpl(url, { headers: { ...bearerHeaderFor(resolvedUrl) } });
145
+ } catch (err) {
146
+ return {
147
+ found: false,
148
+ provenance: 'console',
149
+ reason: `no console reachable at ${resolvedUrl} (${err instanceof Error ? err.message : String(err)})`,
150
+ };
151
+ }
152
+
153
+ if (!res || !res.ok) {
154
+ return {
155
+ found: false,
156
+ provenance: 'console',
157
+ reason: `console returned ${res ? res.status : 'no response'} for ${url}`,
158
+ };
159
+ }
160
+
161
+ let body;
162
+ try {
163
+ body = await res.json();
164
+ } catch (err) {
165
+ return {
166
+ found: false,
167
+ provenance: 'console',
168
+ reason: `console response was not valid JSON (${err instanceof Error ? err.message : String(err)})`,
169
+ };
170
+ }
171
+
172
+ if (!body || body.settled !== true) {
173
+ return {
174
+ found: false,
175
+ provenance: 'console',
176
+ reason:
177
+ body && typeof body.reason === 'string'
178
+ ? `no settlement found (${body.reason})`
179
+ : 'no settlement found for this spec',
180
+ };
181
+ }
182
+
183
+ return {
184
+ found: true,
185
+ provenance: 'console',
186
+ repo: body.repo,
187
+ branch: body.branch,
188
+ commitSha: body.commit_sha,
189
+ settledAt: body.settled_at,
190
+ };
191
+ }
192
+
193
+ // ---- local git-log fallback (HS-CZ84) ------------------------------------
194
+
195
+ /**
196
+ * Default git-log runner — injectable so tests never touch a real repo,
197
+ * exactly like `defaultRun` in `hs-source-commit.mjs`. Unlike that sibling
198
+ * (which uses `stdio: 'inherit'` because it only cares about the exit code),
199
+ * this runner CAPTURES stdout — this module needs to parse and print the
200
+ * matched commits, not just observe whether the command succeeded.
201
+ * @param {string} cmd
202
+ * @param {string[]} args
203
+ * @param {object} [opts]
204
+ * @returns {{ status: number, stdout: string, error?: Error }}
205
+ */
206
+ export function defaultGitLogRun(cmd, args, opts = {}) {
207
+ const result = spawnSync(cmd, args, {
208
+ encoding: 'utf8',
209
+ stdio: ['ignore', 'pipe', 'pipe'],
210
+ ...opts,
211
+ });
212
+ if (result.error) return { status: 1, stdout: '', error: result.error };
213
+ return { status: result.status ?? 1, stdout: result.stdout ?? '' };
214
+ }
215
+
216
+ /**
217
+ * @typedef {{
218
+ * found: true,
219
+ * provenance: 'local-git-log',
220
+ * output: string,
221
+ * } | {
222
+ * found: false,
223
+ * provenance: 'local-git-log',
224
+ * reason: string,
225
+ * }} LocalGitLogResult
226
+ * @typedef {ConsoleSettlementResult | LocalGitLogResult} LookupResult
227
+ */
228
+
229
+ /**
230
+ * Local fallback: `git -C <vaultRoot> log --grep <spec-id>`, run ONLY
231
+ * through the injected `run` — this module never shells out to git any
232
+ * other way. Never throws: a failed spawn or a non-zero exit resolves to a
233
+ * typed `{ found: false, reason }` result, same contract as
234
+ * {@link lookupConsoleSettlement}.
235
+ *
236
+ * @param {{ specId: string, vaultRoot?: string, run?: typeof defaultGitLogRun }} opts
237
+ * @returns {LocalGitLogResult}
238
+ */
239
+ export function lookupLocalGitLog({ specId, vaultRoot = REPO_ROOT, run = defaultGitLogRun } = {}) {
240
+ const result = run('git', ['-C', vaultRoot, 'log', '--grep', specId]);
241
+
242
+ if (result.error) {
243
+ return {
244
+ found: false,
245
+ provenance: 'local-git-log',
246
+ reason: `git log failed to run (${result.error.message})`,
247
+ };
248
+ }
249
+ if (result.status !== 0) {
250
+ return {
251
+ found: false,
252
+ provenance: 'local-git-log',
253
+ reason: `git log exited ${result.status}`,
254
+ };
255
+ }
256
+
257
+ const output = (result.stdout ?? '').trim();
258
+ if (!output) {
259
+ return {
260
+ found: false,
261
+ provenance: 'local-git-log',
262
+ reason: `no local commits found matching --grep ${specId}`,
263
+ };
264
+ }
265
+
266
+ return { found: true, provenance: 'local-git-log', output };
267
+ }
268
+
269
+ /**
270
+ * Combined commit-history lookup (HS-CZ84): try the console settlement
271
+ * route first; when it finds nothing (unreachable OR no rows — both are
272
+ * "the console path came up empty"), fall back to the local
273
+ * `git log --grep <spec-id>` path. The returned result's `provenance` field
274
+ * always says which path actually produced it — the two result shapes are
275
+ * never silently merged (per this task's own do-not).
276
+ *
277
+ * @param {{
278
+ * specId: string,
279
+ * consoleUrl?: string,
280
+ * fetchImpl?: HttpClient,
281
+ * repoRoot?: string,
282
+ * vaultRoot?: string,
283
+ * gitRun?: typeof defaultGitLogRun,
284
+ * }} opts
285
+ * @returns {Promise<LookupResult>}
286
+ */
287
+ export async function lookupCommitHistory({
288
+ specId,
289
+ consoleUrl,
290
+ fetchImpl = fetch,
291
+ repoRoot,
292
+ vaultRoot,
293
+ gitRun = defaultGitLogRun,
294
+ } = {}) {
295
+ const consoleResult = await lookupConsoleSettlement({ specId, consoleUrl, fetchImpl, repoRoot });
296
+ if (consoleResult.found) return consoleResult;
297
+ return lookupLocalGitLog({ specId, vaultRoot: vaultRoot ?? repoRoot ?? REPO_ROOT, run: gitRun });
298
+ }
299
+
300
+ /**
301
+ * Render a lookup result as the CLI's printed output. Always labels
302
+ * provenance explicitly (`console` vs `local-git-log`) so a diagnosis
303
+ * reading the output is never misled about where the answer came from
304
+ * (AC2).
305
+ * @param {LookupResult} result
306
+ * @returns {string}
307
+ */
308
+ export function formatSettlementResult(result) {
309
+ if (result.found && result.provenance === 'console') {
310
+ return [
311
+ `hs source log: settled (via ${result.provenance})`,
312
+ ` repo: ${result.repo}`,
313
+ ` branch: ${result.branch}`,
314
+ ` commitSha: ${result.commitSha}`,
315
+ ` settledAt: ${result.settledAt}`,
316
+ ].join('\n');
317
+ }
318
+ if (result.found && result.provenance === 'local-git-log') {
319
+ return [`hs source log: found (via ${result.provenance} fallback)`, result.output].join('\n');
320
+ }
321
+ return `hs source log: not found (via ${result.provenance}) — ${result.reason}`;
322
+ }
323
+
324
+ /**
325
+ * CLI entry for `hs source log <spec-id>`. Tries the console settlement
326
+ * route first, then falls back to local `git log --grep` (HS-CZ84) when the
327
+ * console path comes up empty — see {@link lookupCommitHistory}.
328
+ * @param {string[]} argv (verb-stripped: args after `log`)
329
+ * @param {{
330
+ * cwd?: string,
331
+ * fetchImpl?: HttpClient,
332
+ * gitRun?: typeof defaultGitLogRun,
333
+ * }} [opts]
334
+ * @returns {Promise<number>} process exit code — 0 when found by either
335
+ * path, 1 when the command ran cleanly but found nothing (usage error
336
+ * included), never a thrown exception either way.
337
+ */
338
+ export async function runLogCommand(
339
+ argv,
340
+ { cwd = process.cwd(), fetchImpl = fetch, gitRun = defaultGitLogRun } = {},
341
+ ) {
342
+ const specId = argv[0];
343
+ if (!specId) {
344
+ console.error('hs source log: a spec id is required (e.g. `hs source log HS-TASK-XA40`).');
345
+ return 1;
346
+ }
347
+
348
+ const result = await lookupCommitHistory({ specId, fetchImpl, repoRoot: cwd, gitRun });
349
+ console.log(formatSettlementResult(result));
350
+ return result.found ? 0 : 1;
351
+ }
352
+
353
+ // ---- direct-invocation entry --------------------------------------------
354
+
355
+ function invokedDirectly() {
356
+ try {
357
+ return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '');
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
363
+ if (invokedDirectly()) {
364
+ // argv: [node, script, "log", ...flags] — drop the leading verb token.
365
+ runLogCommand(process.argv.slice(3)).then((code) => process.exit(code));
366
+ }