@opengsd/gsd-core 1.6.0-rc.2 → 1.6.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 (157) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/agents/gsd-advisor-researcher.md +2 -0
  3. package/agents/gsd-ai-researcher.md +2 -0
  4. package/agents/gsd-assumptions-analyzer.md +2 -0
  5. package/agents/gsd-doc-classifier.md +2 -0
  6. package/agents/gsd-doc-synthesizer.md +2 -0
  7. package/agents/gsd-domain-researcher.md +2 -0
  8. package/agents/gsd-eval-auditor.md +6 -9
  9. package/agents/gsd-phase-researcher.md +2 -0
  10. package/agents/gsd-planner.md +8 -57
  11. package/agents/gsd-project-researcher.md +2 -0
  12. package/agents/gsd-research-synthesizer.md +2 -0
  13. package/agents/gsd-security-auditor.md +37 -18
  14. package/agents/gsd-ui-researcher.md +2 -0
  15. package/bin/install.js +370 -18
  16. package/gemini-extension.json +1 -1
  17. package/gsd-core/bin/gsd-tools.cjs +46 -4
  18. package/gsd-core/bin/lib/audit-command-router.cjs +52 -14
  19. package/gsd-core/bin/lib/capability-lifecycle.cjs +30 -7
  20. package/gsd-core/bin/lib/capability-registry.cjs +96 -83
  21. package/gsd-core/bin/lib/capability-validator.cjs +22 -0
  22. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +40 -2
  23. package/gsd-core/bin/lib/command-aliases.cjs +10 -1
  24. package/gsd-core/bin/lib/command-routing-hub.cjs +10 -3
  25. package/gsd-core/bin/lib/config-schema.cjs +1 -0
  26. package/gsd-core/bin/lib/config.cjs +67 -24
  27. package/gsd-core/bin/lib/coverage.cjs +464 -0
  28. package/gsd-core/bin/lib/decisions.cjs +27 -0
  29. package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
  30. package/gsd-core/bin/lib/eval.cjs +60 -0
  31. package/gsd-core/bin/lib/frontmatter.cjs +132 -13
  32. package/gsd-core/bin/lib/graphify-command-router.cjs +53 -36
  33. package/gsd-core/bin/lib/init.cjs +139 -30
  34. package/gsd-core/bin/lib/install-profiles.cjs +6 -3
  35. package/gsd-core/bin/lib/intel-command-router.cjs +79 -60
  36. package/gsd-core/bin/lib/io.cjs +1 -0
  37. package/gsd-core/bin/lib/phase.cjs +16 -2
  38. package/gsd-core/bin/lib/plan-scan.cjs +2 -2
  39. package/gsd-core/bin/lib/planning-workspace.cjs +157 -13
  40. package/gsd-core/bin/lib/profile-output.cjs +18 -6
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +53 -16
  42. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +21 -3
  43. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +15 -0
  44. package/gsd-core/bin/lib/runtime-name-policy.cjs +47 -2
  45. package/gsd-core/bin/lib/shell-command-projection.cjs +10 -0
  46. package/gsd-core/bin/lib/state.cjs +398 -60
  47. package/gsd-core/bin/lib/surface.cjs +42 -10
  48. package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
  49. package/gsd-core/bin/lib/update-context.cjs +2 -2
  50. package/gsd-core/bin/lib/verification.cjs +67 -6
  51. package/gsd-core/bin/lib/verify.cjs +8 -1
  52. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  53. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  54. package/gsd-core/references/planner-guidance.md +66 -0
  55. package/gsd-core/references/planning-config.md +2 -2
  56. package/gsd-core/references/security-asvs-levels.md +27 -0
  57. package/gsd-core/references/untrusted-input-boundary.md +13 -0
  58. package/gsd-core/templates/SECURITY.md +6 -4
  59. package/gsd-core/templates/summary-complex.md +4 -0
  60. package/gsd-core/templates/summary-minimal.md +3 -0
  61. package/gsd-core/templates/summary-standard.md +4 -0
  62. package/gsd-core/templates/summary.md +41 -0
  63. package/gsd-core/workflows/autonomous.md +53 -46
  64. package/gsd-core/workflows/complete-milestone.md +27 -8
  65. package/gsd-core/workflows/execute-phase.md +1 -1
  66. package/gsd-core/workflows/execute-plan.md +5 -0
  67. package/gsd-core/workflows/manager.md +17 -7
  68. package/gsd-core/workflows/new-project.md +82 -16
  69. package/gsd-core/workflows/plan-phase.md +15 -0
  70. package/gsd-core/workflows/profile-user.md +6 -2
  71. package/gsd-core/workflows/progress.md +37 -4
  72. package/gsd-core/workflows/quick.md +3 -1
  73. package/gsd-core/workflows/secure-phase.md +13 -7
  74. package/gsd-core/workflows/ship.md +3 -1
  75. package/gsd-core/workflows/spec-phase.md +3 -1
  76. package/gsd-core/workflows/transition.md +14 -12
  77. package/gsd-core/workflows/ui-review.md +2 -6
  78. package/gsd-core/workflows/verify-work.md +74 -1
  79. package/hooks/dist/gsd-read-injection-scanner.js +49 -25
  80. package/hooks/gsd-read-injection-scanner.js +49 -25
  81. package/hooks/hooks.json +1 -1
  82. package/package.json +4 -2
  83. package/scripts/check-alias-drift.cjs +5 -0
  84. package/scripts/gen-plugin-skills.cjs +117 -0
  85. package/scripts/lint-test-file-count.allowlist.json +2 -1
  86. package/scripts/prompt-injection-scan.sh +9 -0
  87. package/scripts/release-notes/conventional-title.cjs +88 -0
  88. package/scripts/release-notes/format-github-release-notes.cjs +4 -3
  89. package/skills/gsd-add-tests/SKILL.md +38 -0
  90. package/skills/gsd-ai-integration-phase/SKILL.md +37 -0
  91. package/skills/gsd-audit-fix/SKILL.md +33 -0
  92. package/skills/gsd-audit-milestone/SKILL.md +37 -0
  93. package/skills/gsd-audit-uat/SKILL.md +25 -0
  94. package/skills/gsd-autonomous/SKILL.md +51 -0
  95. package/skills/gsd-capture/SKILL.md +67 -0
  96. package/skills/gsd-cleanup/SKILL.md +24 -0
  97. package/skills/gsd-code-review/SKILL.md +59 -0
  98. package/skills/gsd-complete-milestone/SKILL.md +142 -0
  99. package/skills/gsd-config/SKILL.md +56 -0
  100. package/skills/gsd-debug/SKILL.md +53 -0
  101. package/skills/gsd-discuss-phase/SKILL.md +77 -0
  102. package/skills/gsd-docs-update/SKILL.md +49 -0
  103. package/skills/gsd-eval-review/SKILL.md +33 -0
  104. package/skills/gsd-execute-phase/SKILL.md +65 -0
  105. package/skills/gsd-explore/SKILL.md +28 -0
  106. package/skills/gsd-extract-learnings/SKILL.md +22 -0
  107. package/skills/gsd-fast/SKILL.md +31 -0
  108. package/skills/gsd-forensics/SKILL.md +56 -0
  109. package/skills/gsd-graphify/SKILL.md +204 -0
  110. package/skills/gsd-health/SKILL.md +31 -0
  111. package/skills/gsd-help/SKILL.md +29 -0
  112. package/skills/gsd-import/SKILL.md +46 -0
  113. package/skills/gsd-inbox/SKILL.md +39 -0
  114. package/skills/gsd-ingest-docs/SKILL.md +43 -0
  115. package/skills/gsd-manager/SKILL.md +45 -0
  116. package/skills/gsd-map-codebase/SKILL.md +83 -0
  117. package/skills/gsd-mempalace-capture/SKILL.md +71 -0
  118. package/skills/gsd-mempalace-recall/SKILL.md +102 -0
  119. package/skills/gsd-milestone-summary/SKILL.md +51 -0
  120. package/skills/gsd-mvp-phase/SKILL.md +45 -0
  121. package/skills/gsd-new-milestone/SKILL.md +45 -0
  122. package/skills/gsd-new-project/SKILL.md +47 -0
  123. package/skills/gsd-ns-context/SKILL.md +24 -0
  124. package/skills/gsd-ns-ideate/SKILL.md +23 -0
  125. package/skills/gsd-ns-manage/SKILL.md +35 -0
  126. package/skills/gsd-ns-project/SKILL.md +26 -0
  127. package/skills/gsd-ns-review/SKILL.md +28 -0
  128. package/skills/gsd-ns-workflow/SKILL.md +33 -0
  129. package/skills/gsd-pause-work/SKILL.md +43 -0
  130. package/skills/gsd-phase/SKILL.md +57 -0
  131. package/skills/gsd-plan-phase/SKILL.md +63 -0
  132. package/skills/gsd-plan-review-convergence/SKILL.md +60 -0
  133. package/skills/gsd-pr-branch/SKILL.md +26 -0
  134. package/skills/gsd-profile-user/SKILL.md +47 -0
  135. package/skills/gsd-progress/SKILL.md +49 -0
  136. package/skills/gsd-quick/SKILL.md +174 -0
  137. package/skills/gsd-resume-work/SKILL.md +31 -0
  138. package/skills/gsd-review/SKILL.md +42 -0
  139. package/skills/gsd-review-backlog/SKILL.md +63 -0
  140. package/skills/gsd-secure-phase/SKILL.md +36 -0
  141. package/skills/gsd-settings/SKILL.md +29 -0
  142. package/skills/gsd-ship/SKILL.md +24 -0
  143. package/skills/gsd-sketch/SKILL.md +60 -0
  144. package/skills/gsd-spec-phase/SKILL.md +63 -0
  145. package/skills/gsd-spike/SKILL.md +57 -0
  146. package/skills/gsd-stats/SKILL.md +20 -0
  147. package/skills/gsd-surface/SKILL.md +162 -0
  148. package/skills/gsd-thread/SKILL.md +24 -0
  149. package/skills/gsd-ui-phase/SKILL.md +35 -0
  150. package/skills/gsd-ui-review/SKILL.md +33 -0
  151. package/skills/gsd-ultraplan-phase/SKILL.md +34 -0
  152. package/skills/gsd-undo/SKILL.md +35 -0
  153. package/skills/gsd-update/SKILL.md +50 -0
  154. package/skills/gsd-validate-phase/SKILL.md +36 -0
  155. package/skills/gsd-verify-work/SKILL.md +39 -0
  156. package/skills/gsd-workspace/SKILL.md +53 -0
  157. package/skills/gsd-workstreams/SKILL.md +70 -0
@@ -0,0 +1,464 @@
1
+ "use strict";
2
+ /**
3
+ * Coverage metadata — deterministic UAT routing (#1602)
4
+ *
5
+ * Parses the optional `coverage:` block in a SUMMARY.md frontmatter, validates
6
+ * each deliverable entry against the coverage schema, and classifies each into
7
+ * `auto_passed` (deterministically covered — no human prompt) or `present`
8
+ * (a human UAT checkpoint is required).
9
+ *
10
+ * Design constraints (see issue #1602, plus the Postel/Goodhart/Hyrum analysis):
11
+ * - Lenient parse, strict auto-pass. The parser NEVER throws on malformed
12
+ * input; a structurally surprising entry degrades to `present` + an error.
13
+ * - Fail-safe asymmetry. Auto-pass is the narrow, fully-proven case
14
+ * (strict-boolean `human_judgment:false` AND non-empty all-`pass`
15
+ * verification AND zero validation errors). Everything else is presented to
16
+ * the human. A false-negative is a redundant prompt (the status quo); a
17
+ * false-positive ships a bug UAT existed to catch.
18
+ * - Absent block ≠ empty block. No `coverage:` key → `mode: legacy` so the
19
+ * caller falls through to today's prose-based extraction (byte-identical for
20
+ * un-migrated phases). `coverage: []` → `mode: coverage`, zero entries.
21
+ *
22
+ * The classifier is deterministic code, not a prompt heuristic — the issue's
23
+ * central thesis. Tests assert on the frozen typed-IR surface below, not prose.
24
+ */
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ const node_fs_1 = __importDefault(require("node:fs"));
29
+ const node_path_1 = __importDefault(require("node:path"));
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
31
+ const io = require("./io.cjs");
32
+ const { output, error } = io;
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ const coreUtils = require("./core-utils.cjs");
35
+ const { toPosixPath } = coreUtils;
36
+ const security_cjs_1 = require("./security.cjs");
37
+ // ─── Frozen typed-IR surface ────────────────────────────────────────────────
38
+ const MODE = Object.freeze({
39
+ COVERAGE: 'coverage',
40
+ LEGACY: 'legacy',
41
+ });
42
+ /** Why an entry was routed to the human path. Order of precedence below. */
43
+ const PRESENT_REASON = Object.freeze({
44
+ VALIDATION_FAILED: 'validation_failed',
45
+ HUMAN_JUDGMENT: 'human_judgment',
46
+ NO_VERIFICATION: 'no_verification',
47
+ VERIFICATION_NOT_PASSING: 'verification_not_passing',
48
+ });
49
+ /** Per-entry validation error codes. */
50
+ const ERROR_CODE = Object.freeze({
51
+ MISSING_ID: 'missing_id',
52
+ MISSING_DESCRIPTION: 'missing_description',
53
+ MISSING_HUMAN_JUDGMENT: 'missing_human_judgment',
54
+ INVALID_HUMAN_JUDGMENT: 'invalid_human_judgment',
55
+ MISSING_RATIONALE: 'missing_rationale',
56
+ DUPLICATE_ID: 'duplicate_id',
57
+ VERIFICATION_NOT_LIST: 'verification_not_list',
58
+ INVALID_KIND: 'invalid_kind',
59
+ INVALID_STATUS: 'invalid_status',
60
+ MISSING_REF: 'missing_ref',
61
+ MALFORMED_ENTRY: 'malformed_entry',
62
+ MALFORMED_BLOCK: 'malformed_block',
63
+ });
64
+ const VALID_KINDS = Object.freeze([
65
+ 'unit', 'integration', 'e2e', 'automated_ui', 'manual_procedural', 'other',
66
+ ]);
67
+ const VALID_STATUSES = Object.freeze(['pass', 'fail', 'unknown']);
68
+ // ─── YAML-subset block parser (scoped to the coverage schema) ────────────────
69
+ //
70
+ // `extractFrontmatter` (src/frontmatter.cts) flattens `- ` list items to
71
+ // scalars and cannot represent the coverage schema's list-of-maps-with-nested-
72
+ // list-of-maps. `parseMustHavesBlock` is the existing precedent for hand-rolling
73
+ // a focused parser for one schema; this is the same approach, one level deeper.
74
+ // We deliberately do NOT pull in a general YAML engine (no external deps in
75
+ // core; Greenspun's-tenth restraint).
76
+ function lineIndent(line) {
77
+ const m = /^( *)/.exec(line);
78
+ return m ? m[1].length : 0;
79
+ }
80
+ function isSignificant(line) {
81
+ return line.trim() !== '';
82
+ }
83
+ function parseScalar(raw) {
84
+ const t = raw.trim();
85
+ if (t === '')
86
+ return '';
87
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
88
+ return t.slice(1, -1);
89
+ }
90
+ if (t === 'true')
91
+ return true;
92
+ if (t === 'false')
93
+ return false;
94
+ if (t === 'null' || t === '~')
95
+ return null;
96
+ return t;
97
+ }
98
+ /** Parse a block of lines (all indented ≥ `indent`) into a value. */
99
+ function parseNode(lines, indent) {
100
+ const firstSig = lines.find(isSignificant);
101
+ if (firstSig === undefined)
102
+ return null;
103
+ if (lineIndent(firstSig) === indent && /^ *-(?: |$)/.test(firstSig)) {
104
+ return parseSequence(lines, indent);
105
+ }
106
+ return parseMapping(lines, indent);
107
+ }
108
+ function parseSequence(lines, indent) {
109
+ const items = [];
110
+ // Item-start lines: at exactly `indent`, beginning with a dash.
111
+ const starts = [];
112
+ for (let i = 0; i < lines.length; i++) {
113
+ if (!isSignificant(lines[i]))
114
+ continue;
115
+ if (lineIndent(lines[i]) === indent && /^ *-(?: |$)/.test(lines[i]))
116
+ starts.push(i);
117
+ }
118
+ for (let k = 0; k < starts.length; k++) {
119
+ const start = starts[k];
120
+ const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
121
+ const itemLines = lines.slice(start, end);
122
+ // Re-base the dash line: replace the `indent` + "- " prefix with spaces so
123
+ // the inline content aligns at `indent + 2` and parses as a normal node.
124
+ itemLines[0] = ' '.repeat(indent + 2) + itemLines[0].slice(indent + 2);
125
+ const itemFirst = itemLines.find(isSignificant);
126
+ const head = itemFirst ? itemFirst.trim() : '';
127
+ if (/^[\w-]+:(?: |$)/.test(head)) {
128
+ items.push(parseMapping(itemLines, indent + 2));
129
+ }
130
+ else if (head === '') {
131
+ items.push(null);
132
+ }
133
+ else {
134
+ items.push(parseScalar(head));
135
+ }
136
+ }
137
+ return items;
138
+ }
139
+ function parseMapping(lines, indent) {
140
+ const map = {};
141
+ let i = 0;
142
+ while (i < lines.length) {
143
+ const line = lines[i];
144
+ if (!isSignificant(line) || lineIndent(line) !== indent) {
145
+ i++;
146
+ continue;
147
+ }
148
+ const km = /^[\w-]+:\s*(.*)$/.exec(line.trim());
149
+ if (!km) {
150
+ i++;
151
+ continue;
152
+ }
153
+ const key = /^([\w-]+):/.exec(line.trim())[1];
154
+ const inlineVal = km[1];
155
+ if (inlineVal === '[]') {
156
+ setKey(map, key, []);
157
+ i++;
158
+ }
159
+ else if (inlineVal === '') {
160
+ // Nested block: following lines indented deeper than `indent`.
161
+ let j = i + 1;
162
+ while (j < lines.length && (!isSignificant(lines[j]) || lineIndent(lines[j]) > indent))
163
+ j++;
164
+ const block = lines.slice(i + 1, j);
165
+ const blockFirst = block.find(isSignificant);
166
+ if (blockFirst === undefined) {
167
+ setKey(map, key, null);
168
+ }
169
+ else {
170
+ setKey(map, key, parseNode(block, lineIndent(blockFirst)));
171
+ }
172
+ i = j;
173
+ }
174
+ else {
175
+ setKey(map, key, parseScalar(inlineVal));
176
+ i++;
177
+ }
178
+ }
179
+ return map;
180
+ }
181
+ // Prototype-pollution-safe assignment (CodeQL js/prototype-pollution-utility:
182
+ // inline literal key guard at the write site).
183
+ function setKey(obj, key, value) {
184
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
185
+ return;
186
+ obj[key] = value;
187
+ }
188
+ // ─── Frontmatter region helpers ──────────────────────────────────────────────
189
+ function getFrontmatterYaml(content) {
190
+ const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
191
+ if (headerEnd === -1)
192
+ return null;
193
+ const closingLineStart = content.indexOf('\n---', headerEnd);
194
+ if (closingLineStart === -1)
195
+ return null;
196
+ const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
197
+ return content.slice(headerEnd, yamlEnd);
198
+ }
199
+ /**
200
+ * Locate and parse the top-level `coverage:` block from a SUMMARY document.
201
+ * `malformed` is true when a `coverage:` key IS present with body content that
202
+ * does NOT parse into a non-empty sequence of entries — a distinct, fail-safe
203
+ * signal so a broken block can never masquerade as "all covered" (the caller
204
+ * falls back to prose extraction and surfaces the error). Distinct from
205
+ * `coverage: []` / an empty body, which is the legitimate zero-entry case.
206
+ */
207
+ function parseCoverage(content) {
208
+ const yaml = getFrontmatterYaml(content);
209
+ if (yaml === null)
210
+ return { found: false, entries: [], malformed: false };
211
+ const lines = yaml.split(/\r?\n/);
212
+ let covIdx = -1;
213
+ for (let i = 0; i < lines.length; i++) {
214
+ if (/^coverage:(?:\s|$)/.test(lines[i])) {
215
+ covIdx = i;
216
+ break;
217
+ }
218
+ }
219
+ if (covIdx === -1)
220
+ return { found: false, entries: [], malformed: false };
221
+ // Strip a trailing YAML comment from the header value. The `coverage:` header
222
+ // only ever carries `[]` or a comment — refs (which legitimately contain `#`)
223
+ // live in quoted scalars on deeper lines, never on this line.
224
+ const rawInline = /^coverage:\s*(.*)$/.exec(lines[covIdx])[1];
225
+ const inline = rawInline.replace(/\s*#.*$/, '').trim();
226
+ if (inline === '[]')
227
+ return { found: true, entries: [], malformed: false };
228
+ if (inline !== '') {
229
+ // A non-empty, non-`[]` inline scalar where a block was expected is malformed.
230
+ return { found: true, entries: [], malformed: true };
231
+ }
232
+ // Gather the block body: every line after the header up to the next top-level
233
+ // frontmatter key (a `key:` at column 0) or end of frontmatter. Mis-indented
234
+ // lines (tabs, wrong column) are INCLUDED so they surface as a malformed block
235
+ // rather than being silently excluded and the block read as falsely empty.
236
+ let j = covIdx + 1;
237
+ while (j < lines.length) {
238
+ const l = lines[j];
239
+ if (l.trim() === '') {
240
+ j++;
241
+ continue;
242
+ }
243
+ if (/^[A-Za-z0-9_-]+:(?:\s|$)/.test(l))
244
+ break; // next top-level key
245
+ j++;
246
+ }
247
+ const block = lines.slice(covIdx + 1, j);
248
+ const blockFirst = block.find(isSignificant);
249
+ if (blockFirst === undefined)
250
+ return { found: true, entries: [], malformed: false }; // empty body == coverage: []
251
+ const node = parseNode(block, lineIndent(blockFirst));
252
+ if (!Array.isArray(node) || node.length === 0) {
253
+ // Body had content but did not parse into a sequence of entries → malformed.
254
+ return { found: true, entries: [], malformed: true };
255
+ }
256
+ return { found: true, entries: node, malformed: false };
257
+ }
258
+ // ─── Validation ───────────────────────────────────────────────────────────────
259
+ function isPlainObject(v) {
260
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
261
+ }
262
+ function validateEntry(entry, index, seenIds) {
263
+ const errors = [];
264
+ // Object-check FIRST — before any property access — so a `null`/scalar
265
+ // sequence item (e.g. a bare `-` or `- "string"`) can never throw.
266
+ if (!isPlainObject(entry)) {
267
+ errors.push({ index, id: null, code: ERROR_CODE.MALFORMED_ENTRY, message: 'coverage entry is not a mapping' });
268
+ return errors;
269
+ }
270
+ const id = typeof entry.id === 'string' ? entry.id : null;
271
+ const push = (code, message, field) => {
272
+ errors.push({ index, id, code, field, message });
273
+ };
274
+ if (typeof entry.id !== 'string' || entry.id.trim() === '') {
275
+ push(ERROR_CODE.MISSING_ID, 'entry is missing a non-empty id', 'id');
276
+ }
277
+ else if (seenIds.has(entry.id)) {
278
+ push(ERROR_CODE.DUPLICATE_ID, `duplicate coverage id "${entry.id}"`, 'id');
279
+ }
280
+ else {
281
+ seenIds.add(entry.id);
282
+ }
283
+ if (typeof entry.description !== 'string' || entry.description.trim() === '') {
284
+ push(ERROR_CODE.MISSING_DESCRIPTION, 'entry is missing a non-empty description', 'description');
285
+ }
286
+ if (!('human_judgment' in entry)) {
287
+ push(ERROR_CODE.MISSING_HUMAN_JUDGMENT, 'entry is missing the required human_judgment flag', 'human_judgment');
288
+ }
289
+ else if (typeof entry.human_judgment !== 'boolean') {
290
+ push(ERROR_CODE.INVALID_HUMAN_JUDGMENT, 'human_judgment must be a boolean (true|false)', 'human_judgment');
291
+ }
292
+ if (entry.human_judgment === true && (typeof entry.rationale !== 'string' || entry.rationale.trim() === '')) {
293
+ push(ERROR_CODE.MISSING_RATIONALE, 'rationale is required when human_judgment is true', 'rationale');
294
+ }
295
+ const v = entry.verification;
296
+ if (v !== undefined && !Array.isArray(v)) {
297
+ push(ERROR_CODE.VERIFICATION_NOT_LIST, 'verification must be a list', 'verification');
298
+ }
299
+ else if (Array.isArray(v)) {
300
+ v.forEach((ve, vi) => {
301
+ if (!isPlainObject(ve)) {
302
+ push(ERROR_CODE.MALFORMED_ENTRY, 'verification item is not a mapping', `verification[${vi}]`);
303
+ return;
304
+ }
305
+ if (typeof ve.kind !== 'string' || !VALID_KINDS.includes(ve.kind)) {
306
+ push(ERROR_CODE.INVALID_KIND, `verification kind must be one of ${VALID_KINDS.join(', ')}`, `verification[${vi}].kind`);
307
+ }
308
+ if (typeof ve.status !== 'string' || !VALID_STATUSES.includes(ve.status)) {
309
+ push(ERROR_CODE.INVALID_STATUS, `verification status must be one of ${VALID_STATUSES.join(', ')}`, `verification[${vi}].status`);
310
+ }
311
+ if (typeof ve.ref !== 'string' || ve.ref.trim() === '') {
312
+ push(ERROR_CODE.MISSING_REF, 'verification entry is missing a non-empty ref', `verification[${vi}].ref`);
313
+ }
314
+ });
315
+ }
316
+ return errors;
317
+ }
318
+ // ─── Classification ───────────────────────────────────────────────────────────
319
+ function verificationList(entry) {
320
+ return Array.isArray(entry.verification) ? entry.verification : [];
321
+ }
322
+ /**
323
+ * Auto-pass is the narrow, fully-proven case:
324
+ * - zero validation errors, AND
325
+ * - human_judgment is the strict boolean `false`, AND
326
+ * - verification is a NON-EMPTY list, AND
327
+ * - every verification entry has status === 'pass'.
328
+ * The non-empty guard defeats the vacuous-`every` trap; the strict-boolean
329
+ * guard defeats a gamed string flag; the zero-errors guard means a malformed
330
+ * entry can never auto-pass.
331
+ */
332
+ function isAutoPass(entry, errors) {
333
+ if (errors.length > 0)
334
+ return false;
335
+ if (entry.human_judgment !== false)
336
+ return false;
337
+ const v = verificationList(entry);
338
+ if (v.length === 0)
339
+ return false;
340
+ return v.every((ve) => isPlainObject(ve) && ve.status === 'pass');
341
+ }
342
+ function presentReason(entry, errors) {
343
+ if (errors.length > 0)
344
+ return PRESENT_REASON.VALIDATION_FAILED;
345
+ if (entry.human_judgment === true)
346
+ return PRESENT_REASON.HUMAN_JUDGMENT;
347
+ const v = verificationList(entry);
348
+ if (v.length === 0)
349
+ return PRESENT_REASON.NO_VERIFICATION;
350
+ return PRESENT_REASON.VERIFICATION_NOT_PASSING;
351
+ }
352
+ function san(value) {
353
+ return typeof value === 'string' ? (0, security_cjs_1.sanitizeForDisplay)(value) : null;
354
+ }
355
+ function entryView(entry) {
356
+ // Null-safe: a malformed (non-object) entry still gets a minimal view so it
357
+ // can be presented to the human rather than dropped or throwing.
358
+ if (!isPlainObject(entry)) {
359
+ return { id: null, description: null, verification: [], human_judgment: null };
360
+ }
361
+ const verification = verificationList(entry).map((ve) => ({
362
+ kind: isPlainObject(ve) && typeof ve.kind === 'string' ? ve.kind : null,
363
+ ref: isPlainObject(ve) ? san(ve.ref) : null,
364
+ status: isPlainObject(ve) && typeof ve.status === 'string' ? ve.status : null,
365
+ }));
366
+ const view = {
367
+ id: san(entry.id),
368
+ description: san(entry.description),
369
+ verification,
370
+ human_judgment: typeof entry.human_judgment === 'boolean' ? entry.human_judgment : null,
371
+ };
372
+ if (typeof entry.requirement === 'string')
373
+ view.requirement = (0, security_cjs_1.sanitizeForDisplay)(entry.requirement);
374
+ if (typeof entry.rationale === 'string')
375
+ view.rationale = (0, security_cjs_1.sanitizeForDisplay)(entry.rationale);
376
+ return view;
377
+ }
378
+ function legacyResult(summaryFile, errors) {
379
+ return {
380
+ mode: MODE.LEGACY,
381
+ summary_file: summaryFile,
382
+ total: 0,
383
+ all_auto_covered: false,
384
+ auto_passed: [],
385
+ present: [],
386
+ errors,
387
+ };
388
+ }
389
+ /** Pure classification core — no I/O. Testable in isolation. */
390
+ function classifyContent(content, summaryFile) {
391
+ const { found, entries, malformed } = parseCoverage(content);
392
+ if (!found)
393
+ return legacyResult(summaryFile, []);
394
+ if (malformed) {
395
+ // A coverage block is present but unparseable. Fail-safe: fall back to the
396
+ // prose `## Accomplishments` path (the human still gets UAT) and surface the
397
+ // error so the author can fix the block. NEVER report all_auto_covered here.
398
+ return legacyResult(summaryFile, [{
399
+ index: -1,
400
+ id: null,
401
+ code: ERROR_CODE.MALFORMED_BLOCK,
402
+ message: 'coverage block is present but could not be parsed into entries; falling back to prose extraction',
403
+ }]);
404
+ }
405
+ const seenIds = new Set();
406
+ const autoPassed = [];
407
+ const present = [];
408
+ const allErrors = [];
409
+ entries.forEach((entry, index) => {
410
+ const errs = validateEntry(entry, index, seenIds);
411
+ allErrors.push(...errs);
412
+ const view = entryView(entry);
413
+ if (isAutoPass(entry, errs)) {
414
+ autoPassed.push({ ...view, source: 'automated' });
415
+ }
416
+ else {
417
+ present.push({ ...view, reason: presentReason(entry, errs) });
418
+ }
419
+ });
420
+ return {
421
+ mode: MODE.COVERAGE,
422
+ summary_file: summaryFile,
423
+ total: entries.length,
424
+ all_auto_covered: present.length === 0,
425
+ auto_passed: autoPassed,
426
+ present,
427
+ errors: allErrors,
428
+ };
429
+ }
430
+ // ─── CLI command ────────────────────────────────────────────────────────────
431
+ function cmdClassify(cwd, options = {}, raw) {
432
+ const filePath = options.summary || options.file;
433
+ if (!filePath) {
434
+ error('SUMMARY file required: use uat classify-coverage --summary <path>');
435
+ }
436
+ let resolvedPath;
437
+ try {
438
+ resolvedPath = (0, security_cjs_1.requireSafePath)(filePath, cwd, 'SUMMARY file', { allowAbsolute: true });
439
+ }
440
+ catch (e) {
441
+ // Emit a structured command error instead of leaking a raw stack trace.
442
+ error(`Invalid SUMMARY path: ${e instanceof Error ? e.message : 'unsafe path'}`);
443
+ return;
444
+ }
445
+ if (!node_fs_1.default.existsSync(resolvedPath)) {
446
+ error(`SUMMARY file not found: ${filePath}`);
447
+ }
448
+ const content = node_fs_1.default.readFileSync(resolvedPath, 'utf-8');
449
+ const result = classifyContent(content, toPosixPath(node_path_1.default.relative(cwd, resolvedPath)));
450
+ output(result, raw, undefined);
451
+ }
452
+ module.exports = {
453
+ cmdClassify,
454
+ classifyContent,
455
+ parseCoverage,
456
+ validateEntry,
457
+ isAutoPass,
458
+ presentReason,
459
+ MODE,
460
+ PRESENT_REASON,
461
+ ERROR_CODE,
462
+ VALID_KINDS,
463
+ VALID_STATUSES,
464
+ };
@@ -42,6 +42,18 @@ const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)
42
42
  * Accepts both U+2014 em-dash (—) and U+2013 en-dash (–) for robustness.
43
43
  */
44
44
  const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^*]*[—–][^*]*\*\*\s*(.*)$/;
45
+ /**
46
+ * Titled-colon form: `- **D-NN[ [tags]]: Title.** body`
47
+ * A title sits between the colon and the closing `**` (so the `:**` anchor of
48
+ * bulletColonRe fails, and there is no em-dash for bulletEmDashRe). This is a strict
49
+ * superset of the colon-immediate form, so it MUST be checked AFTER bulletColonRe and
50
+ * bulletEmDashRe — it only catches bullets those two miss. The title run is `[^:*]*` (no
51
+ * colon, no `*`) so a genuinely-malformed bullet with a colon in the pre-separator run
52
+ * (e.g. `D-07 ratio 3:1:**`) still fails the anchor and falls through to the parse-miss
53
+ * guard — matching bulletColonRe's `[^:*]*` discipline that the separator colon is the
54
+ * only colon permitted before `**`. (#1639)
55
+ */
56
+ const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:[^:*]*\*\*\s*(.*)$/;
45
57
  /**
46
58
  * Parse decision lines from a block of text (the inner text of a <decisions>
47
59
  * or markdown-header section body). Returns the extracted decisions and a count
@@ -110,6 +122,21 @@ function parseDecisionLines(block) {
110
122
  current = { id, text: emDashMatch[3] || '', category, tags, trackable };
111
123
  continue;
112
124
  }
125
+ // Titled-colon form: `- **D-NN[ [tags]]: Title.** body` (#1639). Checked LAST — it is
126
+ // a strict superset of bulletColonRe, so it only catches bullets the colon-immediate
127
+ // and em-dash forms missed (minimal blast radius). id + [tags] trackability honored;
128
+ // the body after the closing bold run is reported as text.
129
+ const titledColonMatch = line.match(bulletTitledColonRe);
130
+ if (titledColonMatch) {
131
+ flush();
132
+ const id = `D-${titledColonMatch[1]}`;
133
+ const tags = titledColonMatch[2]
134
+ ? titledColonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
135
+ : [];
136
+ const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
137
+ current = { id, text: titledColonMatch[3] || '', category, tags, trackable };
138
+ continue;
139
+ }
113
140
  // Parse-miss guard (FIX B + #1343): a line that looks like a `D-NN` decision
114
141
  // bullet but failed both patterns — flush, warn, and record the miss.
115
142
  // parseMisses > 0 forces could-not-parse even when other decisions parsed.
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ /**
3
+ * Manifest-backed eval subcommand router (#10).
4
+ */
5
+ const command_aliases_cjs_1 = require("./command-aliases.cjs");
6
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
7
+ const cjsCommandRouterAdapter = require("./cjs-command-router-adapter.cjs");
8
+ const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
9
+ function routeEvalCommand({ evalMod, args, cwd, raw, error }) {
10
+ routeCjsCommandFamily({
11
+ args,
12
+ subcommands: command_aliases_cjs_1.EVAL_SUBCOMMANDS,
13
+ unsupported: {},
14
+ error,
15
+ unknownMessage: (_s, available) => `Unknown eval subcommand. Available: ${available.join(', ')}`,
16
+ handlers: {
17
+ score: () => evalMod.cmdEvalScore(cwd, args, raw),
18
+ },
19
+ });
20
+ }
21
+ module.exports = { routeEvalCommand };
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ /**
3
+ * Deterministic eval scoring verb (#10).
4
+ * Moves coverage/infra/overall arithmetic out of the gsd-eval-auditor prompt
5
+ * into code, per the framework's code-delegation discipline.
6
+ */
7
+ function parseFlag(args, flag) {
8
+ const i = args.indexOf(flag);
9
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
10
+ }
11
+ const INFRA_VALUE = { ok: 1, partial: 0.5, missing: 0 };
12
+ const INFRA_TOKENS = new Set(Object.keys(INFRA_VALUE));
13
+ function computeEvalScore(covered, total, infra) {
14
+ const coverage = total > 0 ? (covered / total) * 100 : 0;
15
+ // unknown/typo tokens are treated as `missing` (score 0) by design — upstream agent only passes ok|partial|missing
16
+ const infraSum = infra.reduce((acc, s) => acc + (INFRA_VALUE[s.trim().toLowerCase()] ?? 0), 0);
17
+ const infraScore = (infraSum / 5) * 100;
18
+ const overall = coverage * 0.6 + infraScore * 0.4;
19
+ const round = (n) => Math.round(n * 100) / 100;
20
+ const o = round(overall);
21
+ const verdict = o >= 80 ? 'PRODUCTION READY' :
22
+ o >= 60 ? 'NEEDS WORK' :
23
+ o >= 40 ? 'SIGNIFICANT GAPS' : 'NOT IMPLEMENTED';
24
+ return { coverage_score: round(coverage), infra_score: round(infraScore), overall_score: o, verdict };
25
+ }
26
+ function cmdEvalScore(_cwd, args, raw) {
27
+ const coveredRaw = parseFlag(args, '--covered');
28
+ const totalRaw = parseFlag(args, '--total');
29
+ const infraRaw = parseFlag(args, '--infra') || '';
30
+ const infra = infraRaw ? infraRaw.split(',').map((s) => s.trim().toLowerCase()) : [];
31
+ const covered = Number(coveredRaw);
32
+ const total = Number(totalRaw);
33
+ if (coveredRaw === undefined || coveredRaw.trim() === '' ||
34
+ totalRaw === undefined || totalRaw.trim() === '' ||
35
+ !Number.isFinite(covered) || !Number.isFinite(total) ||
36
+ infra.length !== 5) {
37
+ process.stderr.write('Usage: gsd-tools query eval.score --covered N --total N --infra a,b,c,d,e (each ok|partial|missing)\n');
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ // Domain validation: this is a public CLI verb, so reject out-of-domain inputs
42
+ // rather than emit nonsense (covered>total -> coverage_score>100; negatives ->
43
+ // negative scores). Counts must be non-negative integers and covered cannot
44
+ // exceed total; infra tokens must match the documented ok|partial|missing set.
45
+ if (!Number.isInteger(covered) || !Number.isInteger(total) || covered < 0 || total < 0 || covered > total) {
46
+ process.stderr.write('Invalid eval.score domain: require integer counts with 0 <= covered <= total.\n');
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+ const invalidInfra = infra.find((s) => !INFRA_TOKENS.has(s));
51
+ if (invalidInfra !== undefined) {
52
+ process.stderr.write(`Invalid eval.score infra token: ${invalidInfra || '<empty>'}. Expected ok|partial|missing.\n`);
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ const result = computeEvalScore(covered, total, infra);
57
+ process.stdout.write(raw ? JSON.stringify(result) : JSON.stringify(result, null, 2));
58
+ process.stdout.write('\n');
59
+ }
60
+ module.exports = { cmdEvalScore, computeEvalScore };