hstack 0.16.0 → 0.18.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 (134) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +76 -163
  3. package/VERSION +1 -1
  4. package/dist/commands/doctor.js +18 -8
  5. package/dist/commands/doctor.js.map +1 -1
  6. package/dist/commands/init.js +7 -3
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/update.js +9 -2
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/lib/descriptions.js +10 -17
  11. package/dist/lib/descriptions.js.map +1 -1
  12. package/dist/lib/wire.js +166 -138
  13. package/dist/lib/wire.js.map +1 -1
  14. package/dist/manifest.js +41 -29
  15. package/dist/manifest.js.map +1 -1
  16. package/package.json +3 -4
  17. package/template/.claude/agents/adversarial-reviewer.md +44 -50
  18. package/template/.claude/agents/app-architect.md +44 -96
  19. package/template/.claude/agents/data-architect.md +46 -89
  20. package/template/.claude/agents/test-strategist.md +39 -68
  21. package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -73
  22. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +46 -152
  23. package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +89 -103
  24. package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -68
  25. package/template/.claude/skills/hstack-commit/SKILL.md +27 -68
  26. package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -56
  27. package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
  28. package/template/.claude/skills/hstack-story/SKILL.md +58 -0
  29. package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
  30. package/template/.claude/skills/hstack-wrap/SKILL.md +50 -0
  31. package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
  32. package/template/KERNEL.md +75 -318
  33. package/template/templates/adr.md +2 -18
  34. package/template/templates/app-architecture.md +38 -50
  35. package/template/templates/code-standards.md +107 -0
  36. package/template/templates/data-architecture.md +38 -89
  37. package/template/templates/eslint-clean-code.mjs +322 -0
  38. package/template/templates/infrastructure.md +54 -148
  39. package/template/templates/roadmap.md +6 -12
  40. package/template/templates/story.md +7 -13
  41. package/template/templates/tech-debt.md +2 -37
  42. package/template/templates/tech-stack.md +2 -12
  43. package/template/.claude/agents/data-specialist.md +0 -81
  44. package/template/.claude/agents/implementer.md +0 -97
  45. package/template/.claude/agents/kernel-fit-analyst.md +0 -129
  46. package/template/.claude/agents/planner.md +0 -75
  47. package/template/.claude/agents/product-discovery.md +0 -129
  48. package/template/.claude/agents/product-manager.md +0 -76
  49. package/template/.claude/agents/researcher.md +0 -87
  50. package/template/.claude/agents/security-reviewer.md +0 -88
  51. package/template/.claude/agents/spec-author.md +0 -89
  52. package/template/.claude/agents/stack-architect.md +0 -108
  53. package/template/.claude/agents/ui-ux-briefer.md +0 -75
  54. package/template/.claude/agents/verifier.md +0 -81
  55. package/template/.claude/skills/hstack-branch/SKILL.md +0 -76
  56. package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -131
  57. package/template/.claude/skills/hstack-change-new/SKILL.md +0 -84
  58. package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -108
  59. package/template/.claude/skills/hstack-configure/SKILL.md +0 -99
  60. package/template/.claude/skills/hstack-coord/SKILL.md +0 -95
  61. package/template/.claude/skills/hstack-data-review/SKILL.md +0 -93
  62. package/template/.claude/skills/hstack-finalize/SKILL.md +0 -140
  63. package/template/.claude/skills/hstack-flag/SKILL.md +0 -99
  64. package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -148
  65. package/template/.claude/skills/hstack-help/SKILL.md +0 -153
  66. package/template/.claude/skills/hstack-implement/SKILL.md +0 -155
  67. package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -125
  68. package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -142
  69. package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +0 -42
  70. package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -121
  71. package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -78
  72. package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -75
  73. package/template/.claude/skills/hstack-research/SKILL.md +0 -115
  74. package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -113
  75. package/template/.claude/skills/hstack-security-review/SKILL.md +0 -95
  76. package/template/.claude/skills/hstack-ship/SKILL.md +0 -119
  77. package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -81
  78. package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -92
  79. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -95
  80. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -123
  81. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -86
  82. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -77
  83. package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -73
  84. package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -159
  85. package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -81
  86. package/template/.claude/skills/hstack-verify/SKILL.md +0 -128
  87. package/template/scripts/compute-merge-readiness.mjs +0 -780
  88. package/template/scripts/coord/coord_scan.py +0 -681
  89. package/template/scripts/run-gates.sh +0 -388
  90. package/template/scripts/telemetry/__init__.py +0 -6
  91. package/template/scripts/telemetry/insights/__init__.py +0 -0
  92. package/template/scripts/telemetry/insights/contract_drift.py +0 -137
  93. package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
  94. package/template/scripts/telemetry/insights/overengineering.py +0 -115
  95. package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
  96. package/template/scripts/telemetry/insights/token_economics.py +0 -302
  97. package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
  98. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  99. package/template/scripts/telemetry/parsers/bodies.py +0 -87
  100. package/template/scripts/telemetry/parsers/commits.py +0 -219
  101. package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
  102. package/template/scripts/telemetry/parsers/sidecars.py +0 -61
  103. package/template/scripts/telemetry/parsers/transcripts.py +0 -294
  104. package/template/scripts/telemetry/render.py +0 -460
  105. package/template/scripts/telemetry/report.py +0 -150
  106. package/template/scripts/telemetry/run_kernel_fit.py +0 -95
  107. package/template/scripts/telemetry/session_id.py +0 -139
  108. package/template/scripts/validate-spec.mjs +0 -3303
  109. package/template/templates/adversarial-review.md +0 -54
  110. package/template/templates/bootstrap.md +0 -105
  111. package/template/templates/change-spec.md +0 -82
  112. package/template/templates/ci-cd.md +0 -41
  113. package/template/templates/coord-message.md +0 -56
  114. package/template/templates/data-review.md +0 -54
  115. package/template/templates/discovery/brainstorm.md +0 -69
  116. package/template/templates/discovery/forcing-questions.md +0 -76
  117. package/template/templates/discovery/project-brief.md +0 -53
  118. package/template/templates/figma-handoff.md +0 -38
  119. package/template/templates/glossary.md +0 -20
  120. package/template/templates/hardening-checklist.md +0 -73
  121. package/template/templates/incident-runbook.md +0 -57
  122. package/template/templates/kernel-fit-finding.md +0 -63
  123. package/template/templates/kernel-fit-flag.md +0 -52
  124. package/template/templates/module-spec.md +0 -49
  125. package/template/templates/persona.md +0 -38
  126. package/template/templates/plan.md +0 -53
  127. package/template/templates/product-brief.md +0 -56
  128. package/template/templates/security-review.md +0 -63
  129. package/template/templates/telemetry-sidecar.md +0 -227
  130. package/template/templates/test-plan.md +0 -119
  131. package/template/templates/threat-model.md +0 -54
  132. package/template/templates/ui-brief.md +0 -49
  133. package/template/templates/verification.md +0 -63
  134. package/template/templates/vision.md +0 -34
@@ -1,780 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * hstack merge-readiness scorecard — the twelve gates `/hstack:ship` reports,
4
- * and the thing `{{TODO-SCRIPT: compute-merge-readiness.ts}}` stood in for.
5
- *
6
- * node hstack/scripts/compute-merge-readiness.mjs <change-id>
7
- * node hstack/scripts/compute-merge-readiness.mjs <change-id> --json
8
- * node hstack/scripts/compute-merge-readiness.mjs <change-id> --base origin/main
9
- * node hstack/scripts/compute-merge-readiness.mjs --gates # the registry
10
- *
11
- * Exit codes: 0 every applicable gate passes, 1 at least one gate blocks,
12
- * 2 usage / environment error (no change folder, no change-spec, …).
13
- *
14
- * ## Why .mjs and not .ts
15
- *
16
- * The name declared in `hstack-ship` was `compute-merge-readiness.ts`. Same
17
- * decision as ADR-0001 and `validate-spec.mjs`: consuming repos have no
18
- * `node_modules` for hstack, so a `.ts` entrypoint would impose a runtime
19
- * (node >= 22.6 with `--experimental-strip-types`, or `npx tsx`) on every
20
- * consumer. Plain ESM runs on the node the consumer already has and imports
21
- * `validate-spec.mjs` — the frontmatter parser, the section parser and the
22
- * glob matcher are already there and are not written twice.
23
- *
24
- * ## The registry is the inventory
25
- *
26
- * `GATES` carries one entry per gate id named in `hstack-ship`, and
27
- * `DEFERRED_GATES` names the ones that are NOT computable together with the
28
- * reason. Nothing disappears: a gate is either computed here or listed as
29
- * deferred with a stated cause. The Skill states each gate as a one-line
30
- * contract; this file is where the arithmetic lives, so the two cannot drift
31
- * into two different answers.
32
- *
33
- * ## Verdicts
34
- *
35
- * pass the gate's condition holds
36
- * fail the gate's condition is violated — blocks
37
- * unknown the gate could not be evaluated (no git, lints not run) — blocks
38
- * not-applicable the gate's precondition does not hold for this change
39
- * deferred the gate's content is not defined anywhere in the repo
40
- *
41
- * `fail` and `unknown` block ready-to-ship. "Not evaluated" is not "passed" —
42
- * the same rule V-05 applies to a suite that executed zero tests.
43
- */
44
-
45
- import { execFileSync } from "node:child_process";
46
- import { existsSync, readFileSync, readdirSync } from "node:fs";
47
- import { join, resolve, sep } from "node:path";
48
-
49
- import {
50
- loadArtifact,
51
- buildWorld,
52
- findHstackRoot,
53
- globMatches,
54
- substantiveLines,
55
- } from "./validate-spec.mjs";
56
-
57
- // ---------------------------------------------------------------------------
58
- // 1. Verdict helpers
59
- // ---------------------------------------------------------------------------
60
-
61
- const PASS = (detail) => ({ verdict: "pass", detail: detail ?? null });
62
- const FAIL = (detail) => ({ verdict: "fail", detail });
63
- const UNKNOWN = (detail) => ({ verdict: "unknown", detail });
64
- const NA = (detail) => ({ verdict: "not-applicable", detail });
65
- const DEFERRED = (detail) => ({ verdict: "deferred", detail });
66
-
67
- /** Verdicts that stop a change from being ready to ship. */
68
- const BLOCKING = new Set(["fail", "unknown"]);
69
-
70
- const present = (v) => v !== null && v !== undefined && String(v).trim() !== "";
71
-
72
- const arr = (v) => (Array.isArray(v) ? v : v === null || v === undefined ? [] : [v]);
73
-
74
- const list = (xs, max = 6) =>
75
- xs.length <= max ? xs.join(", ") : `${xs.slice(0, max).join(", ")}, … (+${xs.length - max})`;
76
-
77
- /** Status of an artifact that may be absent. */
78
- const statusOf = (a) => (a && a.fm ? String(a.fm.status) : null);
79
-
80
- // ---------------------------------------------------------------------------
81
- // 2. Gate registry
82
- // ---------------------------------------------------------------------------
83
- //
84
- // Each entry: { id, title, description, check(ctx) }.
85
- // `check` returns one of the verdict helpers above.
86
- //
87
- // `ctx` is built by `buildContext` below and carries:
88
- // spec the change-spec artifact record
89
- // art { plan, testPlan, securityReview, dataReview, uiBrief,
90
- // figmaHandoff, verification, adversarialReview } — null when absent
91
- // world the whole-tree artifact index (validate-spec's buildWorld)
92
- // git { available, branch, base, mergeBase, files, onBase, error }
93
- // lints repo-relative paths of hstack/lints/*.yaml
94
- // gatesExit exit code of run-gates.sh, or null when it was not run
95
-
96
- /**
97
- * Paths hstack itself writes on the change branch by construction — every
98
- * Skill's auto-commit lands in one of them. GT-02 defends the *implementer's*
99
- * write boundary (kernel § Scope rules: "CI enforces the write boundary at PR
100
- * time"); the framework's own audit trail is not what it is defending, and a
101
- * gate that failed on the artifacts the workflow just wrote would be switched
102
- * off within a week. Named here rather than inferred, so the carve-out is
103
- * reviewable.
104
- *
105
- * `<id>` is substituted with the change id — a change may write its OWN
106
- * artifact folder, never a sibling change's.
107
- */
108
- const WORKFLOW_OWNED = [
109
- "hstack/specs/changes/<id>/**",
110
- "hstack/adr/**",
111
- "hstack/tech-debt/**",
112
- "hstack/kernel-fit/**",
113
- "hstack/coord/**",
114
- "hstack/research/**",
115
- "hstack/telemetry/reports/**",
116
- ];
117
-
118
- /** Terminal statuses per artifact type, as `hstack-ship` states them. */
119
- const REVIEW_TERMINAL = ["passed", "concerns-acknowledged"];
120
-
121
- export const GATES = [
122
- {
123
- id: "GT-01",
124
- title: "spec presence",
125
- description:
126
- "The change folder exists with a change-spec past `draft`, or the change carries `trivial: true`. A draft spec is not a contract anything can be scored against.",
127
- check(ctx) {
128
- if (ctx.spec.fm.trivial === true) {
129
- return PASS("`trivial: true` — spec-presence is bypassed per the kernel's trivial carve-out");
130
- }
131
- const s = statusOf(ctx.spec);
132
- if (s === "draft" || s === "archived") {
133
- return FAIL(`change-spec is at \`${s}\`; a shippable spec is past draft and not archived`);
134
- }
135
- return PASS(`change-spec at \`${s}\``);
136
- },
137
- },
138
- {
139
- id: "GT-02",
140
- title: "diff within scope",
141
- description:
142
- "Every file in the PR diff (against the merge target) is a subset of `change-spec.in-scope`, plus the named workflow-owned carve-out. Mandatory even for `trivial: true`.",
143
- check(ctx) {
144
- if (!ctx.git.available) {
145
- return UNKNOWN(`the diff could not be read: ${ctx.git.error}`);
146
- }
147
- if (ctx.git.onBase) {
148
- return UNKNOWN(
149
- `HEAD is \`${ctx.git.branch}\`, which is the merge target — there is no PR diff to score. ` +
150
- `Check out \`change/${ctx.changeId}\` (\`/hstack:branch ${ctx.changeId}\`) and re-run.`,
151
- );
152
- }
153
- const inScope = arr(ctx.spec.fm["in-scope"]).filter(present).map(String);
154
- if (inScope.length === 0) {
155
- return FAIL("`in-scope` is empty — every changed file is out of scope by definition");
156
- }
157
- const owned = WORKFLOW_OWNED.map((g) => g.replace("<id>", ctx.changeId));
158
- const offenders = ctx.git.files.filter(
159
- (f) => !inScope.some((g) => globMatches(g, f)) && !owned.some((g) => globMatches(g, f)),
160
- );
161
- if (offenders.length > 0) {
162
- return FAIL(
163
- `${offenders.length} of ${ctx.git.files.length} changed file(s) outside \`in-scope\`: ${list(offenders)}`,
164
- );
165
- }
166
- return PASS(`${ctx.git.files.length} changed file(s), all within \`in-scope\``);
167
- },
168
- },
169
- {
170
- id: "GT-03",
171
- title: "pattern lints",
172
- description:
173
- "Every rule under `hstack/lints/*.yaml` passes. Computed from the exit code of `hstack/scripts/run-gates.sh`, passed in with `--gates-exit`.",
174
- check(ctx) {
175
- if (ctx.lints.length === 0) {
176
- return NA("no `hstack/lints/*.yaml` rules are declared in this repo");
177
- }
178
- if (ctx.gatesExit === null) {
179
- return UNKNOWN(
180
- `${ctx.lints.length} lint rule file(s) declared but the gate runner was not run — ` +
181
- "run `hstack/scripts/run-gates.sh` and re-run with `--gates-exit <code>`",
182
- );
183
- }
184
- return ctx.gatesExit === 0
185
- ? PASS(`run-gates.sh exited 0 against ${ctx.lints.length} rule file(s)`)
186
- : FAIL(`run-gates.sh exited ${ctx.gatesExit} — read the captured output for the failing rule`);
187
- },
188
- },
189
- {
190
- id: "GT-04",
191
- title: "adversarial-review resolved",
192
- description: "`adversarial-review.md` is at `findings-resolved`.",
193
- check(ctx) {
194
- return terminalGate(ctx.art.adversarialReview, "adversarial-review.md", ["findings-resolved"]);
195
- },
196
- },
197
- {
198
- id: "GT-05",
199
- title: "security-review terminal",
200
- description: "`security-review.md` is at `passed` or `concerns-acknowledged`.",
201
- check(ctx) {
202
- return terminalGate(ctx.art.securityReview, "security-review.md", REVIEW_TERMINAL);
203
- },
204
- },
205
- {
206
- id: "GT-06",
207
- title: "data-review terminal",
208
- description:
209
- "`data-review.md` is at `passed` or `concerns-acknowledged`, when `surfaces` includes `db`.",
210
- check(ctx) {
211
- if (!ctx.surfaces.includes("db")) {
212
- return NA("`surfaces` does not include `db`");
213
- }
214
- return terminalGate(ctx.art.dataReview, "data-review.md", REVIEW_TERMINAL);
215
- },
216
- },
217
- {
218
- id: "GT-07",
219
- title: "ui artifacts terminal",
220
- description:
221
- "`ui-brief.md` is at `drafted` and `figma-handoff.md` is at `ready`, when `surfaces` includes `ui`.",
222
- check(ctx) {
223
- if (!ctx.surfaces.includes("ui")) {
224
- return NA("`surfaces` does not include `ui`");
225
- }
226
- const parts = [
227
- terminalGate(ctx.art.uiBrief, "ui-brief.md", ["drafted"]),
228
- terminalGate(ctx.art.figmaHandoff, "figma-handoff.md", ["ready"]),
229
- ];
230
- const bad = parts.filter((p) => p.verdict !== "pass");
231
- return bad.length === 0
232
- ? PASS(parts.map((p) => p.detail).join("; "))
233
- : FAIL(bad.map((p) => p.detail).join("; "));
234
- },
235
- },
236
- {
237
- id: "GT-08",
238
- title: "user value declared",
239
- description:
240
- "`user-stories` is non-empty, UNLESS exactly one no-story carve-out is declared: Category A `internal-tooling: true`, Category B non-empty `enables`, Category C `area: bootstrap`. Restates SP-09; GT-12 owns the mutual exclusion.",
241
- check(ctx) {
242
- const stories = arr(ctx.spec.fm["user-stories"]).filter(present);
243
- if (stories.length > 0) return PASS(`${stories.length} user story reference(s)`);
244
- if (ctx.spec.fm["internal-tooling"] === true) {
245
- return PASS("Category A — `internal-tooling: true`, never on a user path");
246
- }
247
- const enables = arr(ctx.spec.fm.enables).filter(present).map(String);
248
- if (enables.length > 0) {
249
- // The audit-chain assumption, stated so it is not mistaken for a bug:
250
- // a Category-B spec's user value lives in one of the specs it enables.
251
- // This gate does not transitively verify that downstream spec has
252
- // `user-stories` non-empty — that is the downstream's own GT-08, run at
253
- // its own ship time. Category C terminates the chain by construction.
254
- return PASS(
255
- `Category B — user value is realized downstream by ${list(enables)} ` +
256
- "(not transitively verified here; that is the downstream's own GT-08)",
257
- );
258
- }
259
- if (String(ctx.spec.fm.area) === "bootstrap") {
260
- return PASS("Category C — `area: bootstrap`");
261
- }
262
- return FAIL(
263
- "`user-stories` is empty and no carve-out is declared — set `internal-tooling: true` (A), " +
264
- "a non-empty `enables` (B), or `area: bootstrap` (C)",
265
- );
266
- },
267
- },
268
- {
269
- id: "GT-09",
270
- title: "cross-reference rules",
271
- description:
272
- "Every cross-reference rule CG-01..CG-04 passes. The range is named by `hstack-ship`; no repo source states what the four rules are.",
273
- check() {
274
- return DEFERRED(
275
- "CG-01..CG-04 are named as a range but stated nowhere — implementing them would mean " +
276
- "inventing them. Assigning the four statements is a kernel change, not a script change. " +
277
- "The reciprocity and presence rules the range would plausibly cover are already enforced " +
278
- "by `validate-spec.mjs` (SP-14, TD-01, TD-04, TD-05, KF-04) and by GT-12.",
279
- );
280
- },
281
- },
282
- {
283
- id: "GT-10",
284
- title: "test coverage honoured",
285
- description:
286
- "`test-plan.md` is at `passed` or `concerns-acknowledged`, and `verification.test-plan-coverage` shows no missing tenant-isolation tests and no out-of-budget performance assertions.",
287
- check(ctx) {
288
- const tp = terminalGate(ctx.art.testPlan, "test-plan.md", REVIEW_TERMINAL);
289
- if (tp.verdict !== "pass") return tp;
290
-
291
- const v = ctx.art.verification;
292
- if (!v) return FAIL("verification.md is missing — coverage cannot be scored");
293
- const cov = v.fm["test-plan-coverage"];
294
- if (!cov || typeof cov !== "object") {
295
- return FAIL("verification.md carries no `test-plan-coverage` map");
296
- }
297
- const problems = [];
298
- const tenant = String(cov["tenant-isolation"]);
299
- if (!["all-observed", "not-applicable"].includes(tenant)) {
300
- problems.push(`tenant-isolation is \`${tenant}\` (must be all-observed or not-applicable)`);
301
- }
302
- const perf = String(cov["performance-budgets"]);
303
- if (!["all-within-budget", "not-applicable"].includes(perf)) {
304
- problems.push(
305
- `performance-budgets is \`${perf}\` (must be all-within-budget or not-applicable)`,
306
- );
307
- }
308
- return problems.length === 0
309
- ? PASS(`${tp.detail}; tenant-isolation \`${tenant}\`, performance-budgets \`${perf}\``)
310
- : FAIL(problems.join("; "));
311
- },
312
- },
313
- {
314
- id: "GT-11",
315
- title: "tech-debt resolution honest",
316
- description:
317
- "When `resolves-tech-debt` is non-empty: every referenced tech-debt exists at `status: in-progress` with `resolution-attempted-at` set, none already carries a `resolved-by`, and the adversarial-review carries its AR-07 Acceptance-satisfied confirmation.",
318
- check(ctx) {
319
- const tds = arr(ctx.spec.fm["resolves-tech-debt"]).filter(present).map(String);
320
- if (tds.length === 0) return NA("`resolves-tech-debt` is empty");
321
-
322
- const problems = [];
323
- for (const id of tds) {
324
- const td = ctx.world.byId.get(id);
325
- if (!td || td.type !== "tech-debt") {
326
- problems.push(`${id} names no tech-debt on disk`);
327
- continue;
328
- }
329
- const s = statusOf(td);
330
- if (s !== "in-progress") {
331
- problems.push(
332
- `${id} is at \`${s}\`; a debt under active resolution is at \`in-progress\` ` +
333
- "(`/hstack:tech-debt-resolve` flips it, `/hstack:finalize` closes it post-merge)",
334
- );
335
- }
336
- if (!present(td.fm["resolution-attempted-at"])) {
337
- problems.push(`${id} has no \`resolution-attempted-at\``);
338
- }
339
- if (present(td.fm["resolved-by"])) {
340
- problems.push(
341
- `${id} already carries \`resolved-by: ${td.fm["resolved-by"]}\` — double resolution`,
342
- );
343
- }
344
- }
345
-
346
- // (b) AR-07: presence of the Acceptance-satisfied confirmation. This is
347
- // the one place ship reads a body section rather than frontmatter — the
348
- // confirmation has no frontmatter representation.
349
- const ar = ctx.art.adversarialReview;
350
- if (!ar) {
351
- problems.push("adversarial-review.md is missing, so the AR-07 confirmation cannot be present");
352
- } else {
353
- const hit = ar.sections.find((s) => /acceptance[- ]satisfied/i.test(s.title));
354
- if (!hit) {
355
- problems.push('adversarial-review.md carries no "Acceptance Satisfied" subsection (AR-07)');
356
- } else if (substantiveLines(hit).length === 0) {
357
- problems.push('the adversarial-review\'s "Acceptance Satisfied" subsection is empty (AR-07)');
358
- }
359
- }
360
-
361
- return problems.length === 0
362
- ? PASS(`${tds.length} tech-debt item(s) at \`in-progress\` with the AR-07 confirmation present`)
363
- : FAIL(problems.join("; "));
364
- },
365
- },
366
- {
367
- id: "GT-12",
368
- title: "category exclusivity and reciprocity",
369
- description:
370
- "SP-13: `internal-tooling: true` AND non-empty `enables` is forbidden. SP-14: every id in `enables` names a change-spec on disk that lists this change-id in its `enabled-by`, and every `enabled-by` entry reciprocates. Both directions are FAIL.",
371
- check(ctx) {
372
- const problems = [];
373
- const id = String(ctx.spec.fm.id);
374
- const enables = arr(ctx.spec.fm.enables).filter(present).map(String);
375
-
376
- // SP-13 — hard FAIL, and the reason the two halves are one gate: a spec
377
- // that is both A and B has no coherent user-value story to audit.
378
- const declared = [];
379
- if (ctx.spec.fm["internal-tooling"] === true) declared.push("`internal-tooling: true` (A)");
380
- if (enables.length > 0) declared.push("non-empty `enables` (B)");
381
- if (String(ctx.spec.fm.area) === "bootstrap") declared.push("`area: bootstrap` (C)");
382
- if (declared.length > 1) {
383
- problems.push(
384
- `the no-story categories are mutually exclusive but this spec declares ${declared.length}: ${declared.join(", ")}`,
385
- );
386
- }
387
-
388
- // SP-14 — reciprocity, both directions. By ship time a forward reference
389
- // must have been scaffolded; only authoring time tolerates a dangling id.
390
- for (const target of enables) {
391
- const other = ctx.world.byId.get(target);
392
- if (!other || other.type !== "change-spec") {
393
- problems.push(
394
- `enables \`${target}\` names no change-spec on disk — by ship time the downstream must be scaffolded`,
395
- );
396
- continue;
397
- }
398
- if (!arr(other.fm["enabled-by"]).map(String).includes(id)) {
399
- problems.push(`enables \`${target}\` but ${other.relpath} does not list \`${id}\` in \`enabled-by\``);
400
- }
401
- }
402
- for (const source of arr(ctx.spec.fm["enabled-by"]).filter(present).map(String)) {
403
- const other = ctx.world.byId.get(source);
404
- if (!other || other.type !== "change-spec") {
405
- problems.push(`enabled-by \`${source}\` names no change-spec on disk`);
406
- continue;
407
- }
408
- if (!arr(other.fm.enables).map(String).includes(id)) {
409
- problems.push(`enabled-by \`${source}\` but ${other.relpath} does not list \`${id}\` in \`enables\``);
410
- }
411
- }
412
-
413
- return problems.length === 0 ? PASS("categories exclusive, reciprocity holds") : FAIL(problems.join("; "));
414
- },
415
- },
416
- ];
417
-
418
- /**
419
- * Gates named by `hstack-ship` that this script does NOT compute, with the
420
- * reason. Printed by `--gates` and surfaced in `--json`, mirroring
421
- * `validate-spec.mjs`'s `DEFERRED_RULES`, so nothing quietly disappears
422
- * between "documented" and "enforced".
423
- */
424
- export const DEFERRED_GATES = [
425
- {
426
- id: "CG-01..CG-04",
427
- gate: "GT-09",
428
- reason:
429
- "The repo names the range (`GT-09: every cross-reference rule (CG-01..CG-04) passes`) but no source states what the four rules are. Implementing them means inventing them. Defining the four statements is a kernel change — a kernel-fit candidate, not a TODO in this file.",
430
- },
431
- ];
432
-
433
- /** Shared shape for the "artifact X is at one of these statuses" gates. */
434
- function terminalGate(a, filename, terminals) {
435
- if (!a) return FAIL(`${filename} is missing`);
436
- const s = statusOf(a);
437
- return terminals.includes(s)
438
- ? PASS(`${filename} at \`${s}\``)
439
- : FAIL(`${filename} is at \`${s}\`; required: ${terminals.map((t) => `\`${t}\``).join(" | ")}`);
440
- }
441
-
442
- // ---------------------------------------------------------------------------
443
- // 3. Git facts
444
- // ---------------------------------------------------------------------------
445
-
446
- function git(repoRoot, args) {
447
- try {
448
- const out = execFileSync("git", ["-C", repoRoot, ...args], {
449
- encoding: "utf8",
450
- stdio: ["ignore", "pipe", "pipe"],
451
- });
452
- return { ok: true, out: out.trim() };
453
- } catch (err) {
454
- const msg = (err && (err.stderr || err.message)) || String(err);
455
- return { ok: false, out: "", error: String(msg).trim().split("\n")[0] };
456
- }
457
- }
458
-
459
- const refExists = (repoRoot, ref) => git(repoRoot, ["rev-parse", "--verify", "--quiet", ref]).ok;
460
-
461
- /**
462
- * The merge target. `--base` wins; otherwise the default branch from
463
- * `hstack/config.yaml` (fallback `main`), preferring its remote-tracking ref
464
- * because that is what the PR will actually be diffed against.
465
- */
466
- function resolveBase(repoRoot, baseOpt, defaultBranch) {
467
- if (baseOpt) return baseOpt;
468
- for (const candidate of [`origin/${defaultBranch}`, defaultBranch]) {
469
- if (refExists(repoRoot, candidate)) return candidate;
470
- }
471
- return defaultBranch;
472
- }
473
-
474
- /**
475
- * The PR diff. `git diff --name-only <merge-base>` rather than `<base>...HEAD`
476
- * so uncommitted work counts too — ship runs *before* the PR exists, and a
477
- * scorecard that scored only committed files would pass a change whose
478
- * out-of-scope edit is still in the working tree. Untracked files are added
479
- * separately because `git diff` cannot see them.
480
- */
481
- export function gitContext(repoRoot, baseOpt, defaultBranch) {
482
- const head = git(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
483
- if (!head.ok) {
484
- return { available: false, branch: null, base: null, files: [], error: head.error };
485
- }
486
- const branch = head.out;
487
- const base = resolveBase(repoRoot, baseOpt, defaultBranch);
488
- if (!refExists(repoRoot, base)) {
489
- return {
490
- available: false,
491
- branch,
492
- base,
493
- files: [],
494
- error: `merge target \`${base}\` does not resolve — pass \`--base <ref>\``,
495
- };
496
- }
497
- const mb = git(repoRoot, ["merge-base", base, "HEAD"]);
498
- if (!mb.ok) {
499
- return { available: false, branch, base, files: [], error: `no merge-base with \`${base}\`: ${mb.error}` };
500
- }
501
- const changed = git(repoRoot, ["diff", "--name-only", mb.out]);
502
- const untracked = git(repoRoot, ["ls-files", "--others", "--exclude-standard"]);
503
- const files = [
504
- ...new Set([...changed.out.split("\n"), ...untracked.out.split("\n")].map((s) => s.trim()).filter(Boolean)),
505
- ].sort();
506
-
507
- // HEAD is the merge target itself: the diff is empty by construction, and an
508
- // empty diff would make GT-02 pass without having looked at anything.
509
- const baseBranch = base.replace(/^[^/]+\//, "");
510
- const onBase = branch === base || branch === baseBranch;
511
-
512
- return { available: true, branch, base, mergeBase: mb.out, files, onBase, error: null };
513
- }
514
-
515
- // ---------------------------------------------------------------------------
516
- // 4. Context assembly
517
- // ---------------------------------------------------------------------------
518
-
519
- /** Per-change artifact filenames, in the order the scorecard reports them. */
520
- const CHANGE_ARTIFACTS = {
521
- plan: "plan.md",
522
- testPlan: "test-plan.md",
523
- securityReview: "security-review.md",
524
- dataReview: "data-review.md",
525
- uiBrief: "ui-brief.md",
526
- figmaHandoff: "figma-handoff.md",
527
- verification: "verification.md",
528
- adversarialReview: "adversarial-review.md",
529
- };
530
-
531
- /** Best-effort read of `default-branch` from hstack/config.yaml. */
532
- export function readDefaultBranch(hstackRoot) {
533
- const cfg = join(hstackRoot, "config.yaml");
534
- if (!existsSync(cfg)) return "main";
535
- try {
536
- const m = /^\s*default-branch:\s*(\S+)\s*$/m.exec(readFileSync(cfg, "utf8"));
537
- return m ? m[1].replace(/^["']|["']$/g, "") : "main";
538
- } catch {
539
- return "main";
540
- }
541
- }
542
-
543
- function readLintRules(hstackRoot) {
544
- const dir = join(hstackRoot, "lints");
545
- try {
546
- return readdirSync(dir)
547
- .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
548
- .sort()
549
- .map((f) => `hstack/lints/${f}`);
550
- } catch {
551
- return [];
552
- }
553
- }
554
-
555
- /**
556
- * Assemble everything the gates read. Separated from `main` so the tests can
557
- * hand-build a context — including a synthetic `git` — and score it without a
558
- * repository.
559
- *
560
- * Returns `{ error }` for the two stop conditions `hstack-ship` names: the
561
- * change folder is missing, or the change-spec is missing / unparseable.
562
- */
563
- export function buildContext(repoRoot, hstackRoot, changeId, opts = {}) {
564
- const changeDir = join(hstackRoot, "specs", "changes", changeId);
565
- if (!existsSync(changeDir)) {
566
- return { error: `no change folder at hstack/specs/changes/${changeId}/` };
567
- }
568
- const specPath = join(changeDir, "spec.md");
569
- if (!existsSync(specPath)) {
570
- return { error: `no change-spec at hstack/specs/changes/${changeId}/spec.md` };
571
- }
572
- const spec = loadArtifact(specPath, repoRoot);
573
- if (spec.noFrontmatter || spec.unreadable) {
574
- return { error: `hstack/specs/changes/${changeId}/spec.md: ${spec.parseError ?? spec.unreadable}` };
575
- }
576
- if (spec.type !== "change-spec") {
577
- return { error: `hstack/specs/changes/${changeId}/spec.md carries \`type: ${spec.type}\`, not change-spec` };
578
- }
579
-
580
- const art = {};
581
- const missing = [];
582
- for (const [key, filename] of Object.entries(CHANGE_ARTIFACTS)) {
583
- const p = join(changeDir, filename);
584
- if (!existsSync(p)) {
585
- art[key] = null;
586
- continue;
587
- }
588
- const a = loadArtifact(p, repoRoot);
589
- if (a.noFrontmatter || a.unreadable) {
590
- // A present-but-unreadable artifact is a halt, not a FAIL: the scorecard
591
- // would be scoring a file it could not parse.
592
- missing.push(`${filename}: ${a.parseError ?? a.unreadable}`);
593
- art[key] = null;
594
- continue;
595
- }
596
- art[key] = a;
597
- }
598
- if (missing.length > 0) return { error: `unparseable frontmatter — ${missing.join("; ")}` };
599
-
600
- const defaultBranch = readDefaultBranch(hstackRoot);
601
- return {
602
- changeId,
603
- repoRoot,
604
- hstackRoot,
605
- spec,
606
- art,
607
- surfaces: arr(spec.fm.surfaces).filter(present).map(String),
608
- world: buildWorld(repoRoot, hstackRoot),
609
- defaultBranch,
610
- lints: readLintRules(hstackRoot),
611
- gatesExit: opts.gatesExit ?? null,
612
- git: opts.git ?? gitContext(repoRoot, opts.base ?? null, defaultBranch),
613
- };
614
- }
615
-
616
- // ---------------------------------------------------------------------------
617
- // 5. Runner
618
- // ---------------------------------------------------------------------------
619
-
620
- export function score(ctx) {
621
- const results = [];
622
- for (const gate of GATES) {
623
- let r;
624
- try {
625
- r = gate.check(ctx);
626
- } catch (err) {
627
- r = UNKNOWN(`scorecard error while computing this gate: ${err && err.message ? err.message : err}`);
628
- }
629
- results.push({ id: gate.id, title: gate.title, verdict: r.verdict, detail: r.detail ?? null });
630
- }
631
- const counts = { pass: 0, fail: 0, unknown: 0, "not-applicable": 0, deferred: 0 };
632
- for (const r of results) counts[r.verdict]++;
633
- return { gates: results, counts, ready: results.every((r) => !BLOCKING.has(r.verdict)) };
634
- }
635
-
636
- // ---------------------------------------------------------------------------
637
- // 6. CLI
638
- // ---------------------------------------------------------------------------
639
-
640
- function usage() {
641
- return `hstack compute-merge-readiness — the twelve-gate merge-readiness scorecard
642
-
643
- node hstack/scripts/compute-merge-readiness.mjs <change-id> [options]
644
-
645
- Options
646
- --json emit the scorecard as JSON on stdout
647
- --base REF merge target to diff against (default: origin/<default-branch>)
648
- --gates-exit N exit code from hstack/scripts/run-gates.sh, for GT-03
649
- --gates print the gate registry (computed + deferred) and exit
650
- --root DIR repo root to resolve hstack/ from (default: search upward)
651
- -h, --help this text
652
-
653
- Exit codes: 0 every applicable gate passes, 1 at least one gate blocks,
654
- 2 usage or environment error.`;
655
- }
656
-
657
- function printGates() {
658
- console.log(
659
- `# hstack merge-readiness registry — ${GATES.length} gates, ${DEFERRED_GATES.length} deferred entries\n`,
660
- );
661
- console.log("## Computed\n");
662
- for (const g of GATES) console.log(`- ${g.id} ${g.title}\n ${g.description}`);
663
- console.log("\n## Deferred — named, not computable\n");
664
- for (const d of DEFERRED_GATES) console.log(`- ${d.id} [${d.gate}]\n ${d.reason}`);
665
- }
666
-
667
- const TAG = {
668
- pass: "pass",
669
- fail: "FAIL",
670
- unknown: "? ",
671
- "not-applicable": "n/a ",
672
- deferred: "def ",
673
- };
674
-
675
- function render(ctx, result) {
676
- const g = ctx.git;
677
- console.log(`hstack merge-readiness — ${ctx.changeId}\n`);
678
- console.log(` branch: ${g.branch ?? "(unknown)"}`);
679
- console.log(` base: ${g.base ?? "(unresolved)"}${g.mergeBase ? ` (merge-base ${g.mergeBase.slice(0, 8)})` : ""}`);
680
- console.log(` diff: ${g.available ? `${g.files.length} file(s)` : `unavailable — ${g.error}`}`);
681
- console.log("");
682
- for (const r of result.gates) {
683
- console.log(` ${r.id} ${TAG[r.verdict]} ${r.title}`);
684
- if (r.detail) console.log(` ${r.detail}`);
685
- }
686
- console.log("");
687
- const c = result.counts;
688
- console.log(
689
- `merge-readiness: ${c.pass} pass, ${c.fail} fail, ${c.unknown} unknown, ` +
690
- `${c["not-applicable"]} n/a, ${c.deferred} deferred — ` +
691
- (result.ready ? "READY (advance to `ready-to-ship`)" : "NOT READY"),
692
- );
693
- if (!result.ready) {
694
- const blocking = result.gates.filter((r) => BLOCKING.has(r.verdict)).map((r) => r.id);
695
- console.log(` blocking: ${blocking.join(", ")}`);
696
- }
697
- }
698
-
699
- function main(argv) {
700
- const args = argv.slice(2);
701
- const opts = { json: false, gates: false, base: null, gatesExit: null, root: null, positional: [] };
702
- for (let i = 0; i < args.length; i++) {
703
- const a = args[i];
704
- if (a === "--json") opts.json = true;
705
- else if (a === "--gates") opts.gates = true;
706
- else if (a === "--base") opts.base = args[++i];
707
- else if (a === "--gates-exit") opts.gatesExit = Number(args[++i]);
708
- else if (a === "--root") opts.root = args[++i];
709
- else if (a === "-h" || a === "--help") {
710
- console.log(usage());
711
- return 0;
712
- } else if (a.startsWith("-")) {
713
- console.error(`compute-merge-readiness: unknown option ${a}\n\n${usage()}`);
714
- return 2;
715
- } else opts.positional.push(a);
716
- }
717
-
718
- if (opts.gates) {
719
- printGates();
720
- return 0;
721
- }
722
- if (opts.positional.length !== 1) {
723
- console.error(`compute-merge-readiness: expected exactly one <change-id>\n\n${usage()}`);
724
- return 2;
725
- }
726
- if (opts.gatesExit !== null && !Number.isInteger(opts.gatesExit)) {
727
- console.error("compute-merge-readiness: --gates-exit takes an integer exit code");
728
- return 2;
729
- }
730
-
731
- const found = findHstackRoot(opts.root ?? process.cwd());
732
- if (!found) {
733
- console.error(
734
- "compute-merge-readiness: no hstack/ tree found (looked for hstack/KERNEL.md upward from " +
735
- (opts.root ?? process.cwd()) +
736
- "). Pass --root <repo>.",
737
- );
738
- return 2;
739
- }
740
-
741
- const ctx = buildContext(found.repoRoot, found.hstackRoot, opts.positional[0], {
742
- base: opts.base,
743
- gatesExit: opts.gatesExit,
744
- });
745
- if (ctx.error) {
746
- console.error(`compute-merge-readiness: ${ctx.error}`);
747
- return 2;
748
- }
749
-
750
- const result = score(ctx);
751
- if (opts.json) {
752
- console.log(
753
- JSON.stringify(
754
- {
755
- ok: result.ready,
756
- changeId: ctx.changeId,
757
- branch: ctx.git.branch,
758
- base: ctx.git.base,
759
- mergeBase: ctx.git.mergeBase ?? null,
760
- changedFiles: ctx.git.files,
761
- gates: result.gates,
762
- counts: result.counts,
763
- deferred: DEFERRED_GATES,
764
- },
765
- null,
766
- 2,
767
- ),
768
- );
769
- } else {
770
- render(ctx, result);
771
- }
772
- return result.ready ? 0 : 1;
773
- }
774
-
775
- // Only run the CLI when invoked directly, so the Skill and the tests can import.
776
- const invokedDirectly =
777
- process.argv[1] && resolve(process.argv[1]).endsWith(`${sep}compute-merge-readiness.mjs`);
778
- if (invokedDirectly) {
779
- process.exit(main(process.argv));
780
- }