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,441 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sufficiency-score.mjs — score a capture or research doc against the
4
+ * evidence-backed "minimal-sufficient" element set before an autonomous run
5
+ * trusts it as input. [[HS-9YK8-TASK-DB3O]] · [[HS-DOU7-3EFV-EPIC-capture-and-research-quality-gate-score]]
6
+ *
7
+ * This is the pure JUDGMENT core of the quality gate — it does not wire into
8
+ * Phase-1 pipeline entry or the capture-promotion flow (separate follow-on
9
+ * tasks), does not do retry-or-quorum over stochastic verdicts, and does not
10
+ * calibrate/version the pass bar from the ablation harnesses. It just scores
11
+ * one document, once, deterministically, from its frontmatter + body text.
12
+ *
13
+ * Element set + weights come straight from [[HS-B3KK-1L9W-RES-minimal-sufficient-capture-shape-for-age]]
14
+ * (cross-checked against dream-loop ablation iterations 7-14, dev-factory/DREAM-LOOP.md):
15
+ * - `example` (0.30) — the PROVEN load-bearing element (iteration 14: a
16
+ * real example's presence/absence measurably changed what
17
+ * survived into L3 research). Highest weight.
18
+ * - `core-idea` (0.30) — the foundational goal/problem statement; machine
19
+ * stages pass on core idea alone (iterations 8-9, 11).
20
+ * - `done-criteria`(0.20) — the one element every surveyed external spec
21
+ * format requires in some notation (EARS / Given-When-Then
22
+ * / Verification / our own "Acceptance criteria (binary)");
23
+ * not yet a coded capture section, but present on tasks/epics.
24
+ * - `kind` (0.10) — capture routing metadata (still the default value
25
+ * when unset — mirrors capture-vault.ts's CAPTURE_STATUS
26
+ * default logic, `fm.kind ?? "idea"`).
27
+ * - `user-stories` (0.10) — OPTIONAL formalization (research: no measured
28
+ * machine-stage effect; Kiro/Spec-Kit require it, OpenAI/
29
+ * Anthropic guidance does not). Only scored when the doc
30
+ * is feature-shaped; excluded (weight redistributed) for
31
+ * research docs and non-feature capture kinds.
32
+ *
33
+ * Weights of the elements that DO apply to a given doc are renormalized to
34
+ * sum to 1, so `score` always lands in [0, 1] regardless of which elements
35
+ * were applicable. This is the scoring RUBRIC only — the pass bar
36
+ * (`SUFFICIENCY_THRESHOLD`) is a single overridable constant, not the
37
+ * versioned/calibrated bar the epic describes; that calibration is a
38
+ * separate follow-on task.
39
+ *
40
+ * Pure string/object logic, no network, no fs. `--self-test` covers every
41
+ * branch + a sufficient/insufficient/malformed fixture (run:
42
+ * `node dev-factory/lib/sufficiency-score.mjs --self-test`).
43
+ */
44
+
45
+ import { fileURLToPath, pathToFileURL } from "node:url";
46
+ import { readFileSync, existsSync } from "node:fs";
47
+ import { join, dirname } from "node:path";
48
+
49
+ const HERE = dirname(fileURLToPath(import.meta.url));
50
+ const THRESHOLD_PATH = join(HERE, "sufficiency-threshold.json");
51
+
52
+ /**
53
+ * Read the versioned threshold from disk if it exists, else fallback to 0.7.
54
+ */
55
+ function loadThreshold() {
56
+ try {
57
+ if (existsSync(THRESHOLD_PATH)) {
58
+ const raw = readFileSync(THRESHOLD_PATH, "utf8");
59
+ const parsed = JSON.parse(raw);
60
+ if (
61
+ parsed &&
62
+ typeof parsed.threshold === "number" &&
63
+ parsed.threshold >= 0 &&
64
+ parsed.threshold <= 1
65
+ ) {
66
+ return parsed.threshold;
67
+ }
68
+ }
69
+ } catch {
70
+ // ignore
71
+ }
72
+ return 0.7; // fallback
73
+ }
74
+
75
+ /** Overridable pass bar (may be read from versioned file). */
76
+ export const SUFFICIENCY_THRESHOLD = loadThreshold();
77
+
78
+ /** capture `kind:` default at creation — mirrors capture-vault.ts's `CAPTURE_KIND_DEFAULT` (kept in sync by hand; no cross-import across the .mjs/.ts boundary — see task report). */
79
+ const CAPTURE_KIND_DEFAULT_VALUE = "idea";
80
+
81
+ /** Capture `kind:` values that are explicitly NOT feature-shaped work (per entity-model.md capture vocabulary). */
82
+ const NON_FEATURE_CAPTURE_KINDS = new Set([
83
+ "bug-fix",
84
+ "clip",
85
+ "reference",
86
+ "question",
87
+ "spec-import",
88
+ ]);
89
+
90
+ /** Body text that signals a specific non-idea capture kind (bug-fix / clip / reference / question). */
91
+ // Content → non-idea-kind signals, MIRRORED from vendor/heyspex-mcp/
92
+ // capture-kind-suggest.mjs (guessCaptureKindFromText) — kept dependency-free
93
+ // here (and in the hand-synced sufficiency-score.ts) rather than imported, per
94
+ // the HS-3J5A builder brief. Only kinds with a reliable lexical tell are
95
+ // signaled (bug-fix strong/weak+code-shape, clip = URL-only body, question =
96
+ // single short question); reference has no reliable tell and is never flagged.
97
+ const KIND_SIGNAL_BUG_FIX_RE =
98
+ /\b(stack ?trace|traceback|regression|silently (?:fails?|broke|breaks?)|doesn'?t work|does not work|no longer works|fails? to|null pointer|undefined is not a function|cannot read propert(?:y|ies) of (?:null|undefined)|this is (?:itself )?a bug|reproduc(?:e|es|ed|ible)|(?:type|reference|syntax)error\b|unhandled exception|threw an exception)\b/i;
99
+ const KIND_SIGNAL_BUG_FIX_WEAK_RE = /\b(error|exception|broken|crash(?:es|ed|ing)?|bug)\b/i;
100
+ const KIND_SIGNAL_CODE_SHAPE_RE =
101
+ /(\bat\s+\S+\(.*:\d+:\d+\)|\.(?:mjs|ts|tsx|js|jsx|cs|py):\d+|Traceback \(most recent call last\)|Exception in thread)/;
102
+ const KIND_SIGNAL_URL_ONLY_RE = /^\s*<?(https?:\/\/\S+)>?\s*$/i;
103
+ const KIND_SIGNAL_QUESTION_ONLY_RE =
104
+ /^\s*(?:why|how|what|when|where|who|which|should|is|are|does|do|can)\b[^\n]{0,240}\?\s*$/i;
105
+
106
+ const ELEMENT_WEIGHTS = Object.freeze({
107
+ example: 0.3,
108
+ "core-idea": 0.3,
109
+ "done-criteria": 0.2,
110
+ kind: 0.1,
111
+ "user-stories": 0.1,
112
+ });
113
+
114
+ // Section-content detectors. Generic markdown-heading matches so the same
115
+ // scorer works on a capture body (`# Core Idea`, `## Example`, `## User
116
+ // Stories`) and a research doc body (`## Executive Summary`, `## Sources`).
117
+ const CORE_IDEA_RE = /#[ \t]*Core[ \t]+Idea[ \t]*\n([\s\S]*?)(?=\n##?\s|$)/i;
118
+ const EXEC_SUMMARY_RE =
119
+ /##[ \t]*Executive[ \t]+Summary[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i;
120
+ const EXAMPLE_RE = /##[ \t]*Example[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i;
121
+ const USER_STORIES_RE =
122
+ /##[ \t]*User[ \t]+Stories[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i;
123
+ // "Done When" is the research-recommended candidate heading; "Acceptance
124
+ // criteria" / "Definition of Done" are the headings already live on tasks/epics.
125
+ const DONE_CRITERIA_RE =
126
+ /##[ \t]*(?:Done[ \t]+When|Acceptance[ \t]+Criteria(?:\s*\([^)\n]*\))?|Definition[ \t]+of[ \t]+Done)[ \t]*\n([\s\S]*?)(?=\n##\s|$)/i;
127
+
128
+ /** "As a &lt;role&gt;, I want …" — a user story with a real, named role. */
129
+ const USER_STORY_WITH_ROLE_RE = /\bas\s+an?\s+\S+/i;
130
+ /**
131
+ * A real (not AI-illustrative) Example is labeled by provenance
132
+ * (capture-guardrails §Example). Tolerates markdown emphasis around the label
133
+ * (`**Real example:**`, `_Real…_`, `**_Real…_**`) — kept in sync with
134
+ * capture-vault.ts / sufficiency-score.ts; part of the
135
+ * [[HS-CAIO-NR43-DIAG-promote-sufficiency-gate-demands-kind]] fix (a caller
136
+ * following the gate's own instruction to label real examples must not be
137
+ * marked insufficient for bolding it).
138
+ */
139
+ const REAL_EXAMPLE_LABEL_RE = /^\s*[*_]*\s*real\b/i;
140
+ /** At least one binary/checklist criterion — `- [ ]` / `- [x]` — not just a bare header. */
141
+ const CHECKLIST_ITEM_RE = /^[ \t]*[-*][ \t]+\[[ xX]\]/m;
142
+
143
+ function asString(value) {
144
+ return typeof value === "string" ? value : "";
145
+ }
146
+
147
+ function asRecord(value) {
148
+ return value && typeof value === "object" && !Array.isArray(value)
149
+ ? value
150
+ : {};
151
+ }
152
+
153
+ function matchSection(body, re) {
154
+ const m = asString(body).replace(/\r\n/g, "\n").match(re);
155
+ return m ? m[1].trim() : "";
156
+ }
157
+
158
+ /** Clear core idea: a `# Core Idea` section (capture) or `## Executive Summary` (research doc) with real content. */
159
+ function hasClearCoreIdea(body) {
160
+ const core = matchSection(body, CORE_IDEA_RE);
161
+ if (core.length >= 3) return true;
162
+ const summary = matchSection(body, EXEC_SUMMARY_RE);
163
+ return summary.length >= 3;
164
+ }
165
+
166
+ /** Real example: an `## Example` section whose content carries the "Real …" provenance label. */
167
+ function hasRealExample(body) {
168
+ const example = matchSection(body, EXAMPLE_RE);
169
+ return example.length > 0 && REAL_EXAMPLE_LABEL_RE.test(example);
170
+ }
171
+
172
+ /**
173
+ * Kind set: frontmatter.kind is present and not contradicted by the body.
174
+ * Explicitly storing `idea` is sufficient unless the content signals a
175
+ * specific non-idea kind (bug-fix / clip / reference / question) — in that
176
+ * case the kind metadata is inadequate and the element is marked missing.
177
+ */
178
+ function hasAdequateKind(frontmatter, body) {
179
+ const kind = frontmatter.kind;
180
+ if (typeof kind !== "string" || kind.trim().length === 0) return false;
181
+ const normalized = kind.trim();
182
+ if (normalized === CAPTURE_KIND_DEFAULT_VALUE) {
183
+ return !signalsNonIdeaKind(body);
184
+ }
185
+ return true;
186
+ }
187
+
188
+ /** Does the body text signal a specific non-idea capture kind? Mirrors
189
+ * guessCaptureKindFromText in capture-kind-suggest.mjs. */
190
+ function signalsNonIdeaKind(body) {
191
+ const text = asString(body).trim();
192
+ if (!text) return false;
193
+ if (KIND_SIGNAL_BUG_FIX_RE.test(text)) return true;
194
+ if (KIND_SIGNAL_BUG_FIX_WEAK_RE.test(text) && KIND_SIGNAL_CODE_SHAPE_RE.test(text)) return true;
195
+ if (KIND_SIGNAL_URL_ONLY_RE.test(text)) return true;
196
+ if (KIND_SIGNAL_QUESTION_ONLY_RE.test(text) && !text.includes("\n\n")) return true;
197
+ return false;
198
+ }
199
+
200
+ /** Done-criteria: a Done-When/Acceptance-Criteria/Definition-of-Done section with at least one checklist item. */
201
+ function hasDoneCriteria(body) {
202
+ const section = matchSection(body, DONE_CRITERIA_RE);
203
+ if (!section) return false;
204
+ return CHECKLIST_ITEM_RE.test(section) || section.length >= 10;
205
+ }
206
+
207
+ /** User stories with a real role: an `## User Stories` section naming at least one "As a …" actor. */
208
+ function hasUserStoriesWithRole(body) {
209
+ const stories = matchSection(body, USER_STORIES_RE);
210
+ return stories.length > 0 && USER_STORY_WITH_ROLE_RE.test(stories);
211
+ }
212
+
213
+ /**
214
+ * Is this doc feature-shaped (does "user stories" even apply)? Research docs
215
+ * report findings, not a feature to build, so they're excluded. Capture kinds
216
+ * that are explicitly non-feature (bug-fix / clip / reference / question /
217
+ * spec-import) are excluded too. Everything else (default `idea`, tasks,
218
+ * epics, unset) is treated as feature-shaped by default.
219
+ */
220
+ function isFeatureShaped(frontmatter) {
221
+ const type = asString(frontmatter.type);
222
+ if (type === "research") return false;
223
+ const kind = asString(frontmatter.kind).trim();
224
+ if (NON_FEATURE_CAPTURE_KINDS.has(kind)) return false;
225
+ return true;
226
+ }
227
+
228
+ /**
229
+ * Score a capture or research doc against the evidence-backed minimal-
230
+ * sufficient element set.
231
+ *
232
+ * @param {{ frontmatter?: Record<string, unknown>, body?: string }} doc
233
+ * @param {number} [threshold] overridable pass bar — defaults to SUFFICIENCY_THRESHOLD.
234
+ * @returns {{ score: number, missing: string[], pass: boolean }}
235
+ */
236
+ export function scoreSufficiency(doc, threshold = SUFFICIENCY_THRESHOLD) {
237
+ const frontmatter = asRecord(doc && doc.frontmatter);
238
+ const body = asString(doc && doc.body);
239
+
240
+ const checks = {
241
+ "core-idea": hasClearCoreIdea(body),
242
+ example: hasRealExample(body),
243
+ kind: hasAdequateKind(frontmatter, body),
244
+ "done-criteria": hasDoneCriteria(body),
245
+ };
246
+ if (isFeatureShaped(frontmatter)) {
247
+ checks["user-stories"] = hasUserStoriesWithRole(body);
248
+ }
249
+
250
+ const applicable = Object.keys(checks);
251
+ const totalWeight =
252
+ applicable.reduce((sum, key) => sum + (ELEMENT_WEIGHTS[key] || 0), 0) || 1;
253
+
254
+ const missing = [];
255
+ let earned = 0;
256
+ for (const key of applicable) {
257
+ const normalizedWeight = (ELEMENT_WEIGHTS[key] || 0) / totalWeight;
258
+ if (checks[key]) {
259
+ earned += normalizedWeight;
260
+ } else {
261
+ missing.push(key);
262
+ }
263
+ }
264
+
265
+ const score = Math.max(0, Math.min(1, earned));
266
+ return { score, missing, pass: score >= threshold };
267
+ }
268
+
269
+ // ── Self-test (pure) ─────────────────────────────────────────────────────────────
270
+ function runSelfTest() {
271
+ let ok = true;
272
+ const check = (name, cond) => {
273
+ console.log(` ${cond ? "✓" : "✗"} ${name}`);
274
+ if (!cond) ok = false;
275
+ };
276
+ console.log("== lib/sufficiency-score.mjs self-test ==\n");
277
+
278
+ // AC1: exports scoreSufficiency.
279
+ check(
280
+ "exports scoreSufficiency as a function",
281
+ typeof scoreSufficiency === "function",
282
+ );
283
+
284
+ // Sufficient fixture: a capture with every element present.
285
+ const sufficientCapture = {
286
+ frontmatter: { type: "capture", kind: "bug-fix" },
287
+ body: [
288
+ "# Core Idea",
289
+ "",
290
+ "The export button silently no-ops on the second rapid click.",
291
+ "",
292
+ "## Example",
293
+ "",
294
+ "Real example: click Export twice fast on a 200-row report; the second click does nothing, no error, no toast.",
295
+ "",
296
+ "## User Stories",
297
+ "",
298
+ "As a support agent, I want duplicate exports blocked, so that tickets don't double-file.",
299
+ "",
300
+ "## Acceptance criteria (binary)",
301
+ "",
302
+ "- [ ] AC1: A second click within 500ms is a no-op.",
303
+ "- [ ] AC2: The button re-enables after the first export completes.",
304
+ ].join("\n"),
305
+ };
306
+ const sufficientResult = scoreSufficiency(sufficientCapture);
307
+ // AC2: score in [0,1] + missing array.
308
+ check(
309
+ "AC2: sufficient fixture score is in [0,1]",
310
+ sufficientResult.score >= 0 && sufficientResult.score <= 1,
311
+ );
312
+ check(
313
+ "AC2: sufficient fixture returns a missing array",
314
+ Array.isArray(sufficientResult.missing),
315
+ );
316
+ // AC3: fully-present doc passes with nothing missing.
317
+ check(
318
+ "AC3: sufficient fixture has score >= threshold",
319
+ sufficientResult.score >= SUFFICIENCY_THRESHOLD,
320
+ );
321
+ check(
322
+ "AC3: sufficient fixture pass === true",
323
+ sufficientResult.pass === true,
324
+ );
325
+ check(
326
+ "AC3: sufficient fixture has no missing elements",
327
+ sufficientResult.missing.length === 0,
328
+ );
329
+
330
+ // Insufficient fixture: default kind, no example, no user stories, no done-criteria.
331
+ const insufficientCapture = {
332
+ frontmatter: { type: "capture", kind: "idea" },
333
+ body: "# Core Idea\n\nSomething should be better.\n",
334
+ };
335
+ const insufficientResult = scoreSufficiency(insufficientCapture);
336
+ check(
337
+ "AC3: insufficient fixture pass === false",
338
+ insufficientResult.pass === false,
339
+ );
340
+ check(
341
+ "AC1: explicit idea kind is not missing when content doesn't signal another kind",
342
+ !insufficientResult.missing.includes("kind"),
343
+ );
344
+ check(
345
+ "AC3: insufficient fixture lists the missing elements",
346
+ ["example", "done-criteria", "user-stories"].every((k) =>
347
+ insufficientResult.missing.includes(k),
348
+ ),
349
+ );
350
+ check(
351
+ "AC3: insufficient fixture does NOT list core-idea (it's present)",
352
+ !insufficientResult.missing.includes("core-idea"),
353
+ );
354
+
355
+ // AC2: content signaling a non-idea kind while stored kind is idea fails the kind element.
356
+ const contradictoryKindCapture = {
357
+ frontmatter: { type: "capture", kind: "idea" },
358
+ body: "# Core Idea\n\nThe bug-fix needs a regression test.\n",
359
+ };
360
+ const contradictoryKindResult = scoreSufficiency(contradictoryKindCapture);
361
+ check(
362
+ "AC2: content signaling bug-fix with stored idea fails kind",
363
+ contradictoryKindResult.missing.includes("kind"),
364
+ );
365
+
366
+ // AC4: malformed / missing fields never throw.
367
+ let threwOnMalformed = false;
368
+ const malformedInputs = [
369
+ undefined,
370
+ null,
371
+ {},
372
+ { frontmatter: null, body: null },
373
+ { frontmatter: "not-an-object", body: 42 },
374
+ { frontmatter: [], body: [] },
375
+ ];
376
+ for (const input of malformedInputs) {
377
+ try {
378
+ const r = scoreSufficiency(input);
379
+ if (
380
+ typeof r.score !== "number" ||
381
+ !Array.isArray(r.missing) ||
382
+ typeof r.pass !== "boolean"
383
+ ) {
384
+ threwOnMalformed = true;
385
+ }
386
+ } catch {
387
+ threwOnMalformed = true;
388
+ }
389
+ }
390
+ check(
391
+ "AC4: malformed/missing fields never throw and still return a well-shaped result",
392
+ !threwOnMalformed,
393
+ );
394
+ check(
395
+ "AC4: a fully-empty doc is scored as insufficient, not crashed",
396
+ scoreSufficiency({}).pass === false,
397
+ );
398
+
399
+ // Research-doc shape: user-stories excluded (not feature-shaped), example/done-criteria still apply.
400
+ const researchDoc = {
401
+ frontmatter: { type: "research" },
402
+ body: [
403
+ "## Executive Summary",
404
+ "",
405
+ "External evidence and our own ablations converge on the same five-element answer.",
406
+ "",
407
+ "## Sources",
408
+ "",
409
+ "- [Some source](https://example.com)",
410
+ ].join("\n"),
411
+ };
412
+ const researchResult = scoreSufficiency(researchDoc);
413
+ check(
414
+ "research doc: user-stories excluded from missing (not feature-shaped)",
415
+ !researchResult.missing.includes("user-stories"),
416
+ );
417
+ check(
418
+ "research doc: core-idea satisfied via Executive Summary",
419
+ !researchResult.missing.includes("core-idea"),
420
+ );
421
+
422
+ // Threshold is a single named, overridable constant.
423
+ check(
424
+ "threshold is overridable via the second argument",
425
+ scoreSufficiency(insufficientCapture, 0).pass === true,
426
+ );
427
+ check(
428
+ "SUFFICIENCY_THRESHOLD is exported and in (0,1]",
429
+ SUFFICIENCY_THRESHOLD > 0 && SUFFICIENCY_THRESHOLD <= 1,
430
+ );
431
+
432
+ console.log(`\n${ok ? "✓ self-test PASS" : "✗ self-test FAIL"}`);
433
+ return ok ? 0 : 1;
434
+ }
435
+
436
+ if (
437
+ process.argv[1] &&
438
+ pathToFileURL(process.argv[1]).href === import.meta.url
439
+ ) {
440
+ if (process.argv.includes("--self-test")) process.exit(runSelfTest());
441
+ }