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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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 <role>, 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
|
+
}
|