@opengsd/gsd-core 1.6.0-rc.2 → 1.6.0-rc.3

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 (116) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/agents/gsd-planner.md +8 -57
  3. package/agents/gsd-security-auditor.md +37 -18
  4. package/bin/install.js +151 -13
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +35 -3
  7. package/gsd-core/bin/lib/audit-command-router.cjs +52 -14
  8. package/gsd-core/bin/lib/capability-lifecycle.cjs +30 -7
  9. package/gsd-core/bin/lib/capability-registry.cjs +96 -83
  10. package/gsd-core/bin/lib/capability-validator.cjs +22 -0
  11. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +40 -2
  12. package/gsd-core/bin/lib/command-routing-hub.cjs +10 -3
  13. package/gsd-core/bin/lib/config-schema.cjs +1 -0
  14. package/gsd-core/bin/lib/config.cjs +67 -24
  15. package/gsd-core/bin/lib/coverage.cjs +464 -0
  16. package/gsd-core/bin/lib/graphify-command-router.cjs +53 -36
  17. package/gsd-core/bin/lib/init.cjs +8 -4
  18. package/gsd-core/bin/lib/install-profiles.cjs +6 -3
  19. package/gsd-core/bin/lib/intel-command-router.cjs +79 -60
  20. package/gsd-core/bin/lib/planning-workspace.cjs +157 -13
  21. package/gsd-core/bin/lib/profile-output.cjs +18 -6
  22. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +53 -16
  23. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +21 -3
  24. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +15 -0
  25. package/gsd-core/bin/lib/runtime-name-policy.cjs +47 -2
  26. package/gsd-core/bin/lib/state.cjs +364 -52
  27. package/gsd-core/bin/lib/surface.cjs +42 -10
  28. package/gsd-core/bin/lib/update-context.cjs +2 -2
  29. package/gsd-core/references/planner-guidance.md +66 -0
  30. package/gsd-core/references/planning-config.md +2 -2
  31. package/gsd-core/references/security-asvs-levels.md +27 -0
  32. package/gsd-core/templates/SECURITY.md +6 -4
  33. package/gsd-core/templates/summary-complex.md +4 -0
  34. package/gsd-core/templates/summary-minimal.md +3 -0
  35. package/gsd-core/templates/summary-standard.md +4 -0
  36. package/gsd-core/templates/summary.md +41 -0
  37. package/gsd-core/workflows/execute-plan.md +5 -0
  38. package/gsd-core/workflows/new-project.md +4 -4
  39. package/gsd-core/workflows/plan-phase.md +15 -0
  40. package/gsd-core/workflows/secure-phase.md +13 -7
  41. package/gsd-core/workflows/verify-work.md +30 -1
  42. package/package.json +4 -2
  43. package/scripts/gen-plugin-skills.cjs +117 -0
  44. package/scripts/lint-test-file-count.allowlist.json +2 -1
  45. package/scripts/prompt-injection-scan.sh +4 -0
  46. package/scripts/release-notes/conventional-title.cjs +88 -0
  47. package/scripts/release-notes/format-github-release-notes.cjs +4 -3
  48. package/skills/gsd-add-tests/SKILL.md +38 -0
  49. package/skills/gsd-ai-integration-phase/SKILL.md +37 -0
  50. package/skills/gsd-audit-fix/SKILL.md +33 -0
  51. package/skills/gsd-audit-milestone/SKILL.md +37 -0
  52. package/skills/gsd-audit-uat/SKILL.md +25 -0
  53. package/skills/gsd-autonomous/SKILL.md +51 -0
  54. package/skills/gsd-capture/SKILL.md +67 -0
  55. package/skills/gsd-cleanup/SKILL.md +24 -0
  56. package/skills/gsd-code-review/SKILL.md +59 -0
  57. package/skills/gsd-complete-milestone/SKILL.md +142 -0
  58. package/skills/gsd-config/SKILL.md +56 -0
  59. package/skills/gsd-debug/SKILL.md +53 -0
  60. package/skills/gsd-discuss-phase/SKILL.md +77 -0
  61. package/skills/gsd-docs-update/SKILL.md +49 -0
  62. package/skills/gsd-eval-review/SKILL.md +33 -0
  63. package/skills/gsd-execute-phase/SKILL.md +65 -0
  64. package/skills/gsd-explore/SKILL.md +28 -0
  65. package/skills/gsd-extract-learnings/SKILL.md +22 -0
  66. package/skills/gsd-fast/SKILL.md +31 -0
  67. package/skills/gsd-forensics/SKILL.md +56 -0
  68. package/skills/gsd-graphify/SKILL.md +204 -0
  69. package/skills/gsd-health/SKILL.md +31 -0
  70. package/skills/gsd-help/SKILL.md +29 -0
  71. package/skills/gsd-import/SKILL.md +46 -0
  72. package/skills/gsd-inbox/SKILL.md +39 -0
  73. package/skills/gsd-ingest-docs/SKILL.md +43 -0
  74. package/skills/gsd-manager/SKILL.md +45 -0
  75. package/skills/gsd-map-codebase/SKILL.md +83 -0
  76. package/skills/gsd-mempalace-capture/SKILL.md +71 -0
  77. package/skills/gsd-mempalace-recall/SKILL.md +102 -0
  78. package/skills/gsd-milestone-summary/SKILL.md +51 -0
  79. package/skills/gsd-mvp-phase/SKILL.md +45 -0
  80. package/skills/gsd-new-milestone/SKILL.md +45 -0
  81. package/skills/gsd-new-project/SKILL.md +47 -0
  82. package/skills/gsd-ns-context/SKILL.md +24 -0
  83. package/skills/gsd-ns-ideate/SKILL.md +23 -0
  84. package/skills/gsd-ns-manage/SKILL.md +35 -0
  85. package/skills/gsd-ns-project/SKILL.md +26 -0
  86. package/skills/gsd-ns-review/SKILL.md +28 -0
  87. package/skills/gsd-ns-workflow/SKILL.md +33 -0
  88. package/skills/gsd-pause-work/SKILL.md +43 -0
  89. package/skills/gsd-phase/SKILL.md +57 -0
  90. package/skills/gsd-plan-phase/SKILL.md +63 -0
  91. package/skills/gsd-plan-review-convergence/SKILL.md +60 -0
  92. package/skills/gsd-pr-branch/SKILL.md +26 -0
  93. package/skills/gsd-profile-user/SKILL.md +47 -0
  94. package/skills/gsd-progress/SKILL.md +49 -0
  95. package/skills/gsd-quick/SKILL.md +174 -0
  96. package/skills/gsd-resume-work/SKILL.md +31 -0
  97. package/skills/gsd-review/SKILL.md +42 -0
  98. package/skills/gsd-review-backlog/SKILL.md +63 -0
  99. package/skills/gsd-secure-phase/SKILL.md +36 -0
  100. package/skills/gsd-settings/SKILL.md +29 -0
  101. package/skills/gsd-ship/SKILL.md +24 -0
  102. package/skills/gsd-sketch/SKILL.md +60 -0
  103. package/skills/gsd-spec-phase/SKILL.md +63 -0
  104. package/skills/gsd-spike/SKILL.md +57 -0
  105. package/skills/gsd-stats/SKILL.md +20 -0
  106. package/skills/gsd-surface/SKILL.md +162 -0
  107. package/skills/gsd-thread/SKILL.md +24 -0
  108. package/skills/gsd-ui-phase/SKILL.md +35 -0
  109. package/skills/gsd-ui-review/SKILL.md +33 -0
  110. package/skills/gsd-ultraplan-phase/SKILL.md +34 -0
  111. package/skills/gsd-undo/SKILL.md +35 -0
  112. package/skills/gsd-update/SKILL.md +50 -0
  113. package/skills/gsd-validate-phase/SKILL.md +36 -0
  114. package/skills/gsd-verify-work/SKILL.md +39 -0
  115. package/skills/gsd-workspace/SKILL.md +53 -0
  116. 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
+ };
@@ -26,46 +26,63 @@
26
26
  const graphify = require("./graphify.cjs");
27
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
28
  const io = require("./io.cjs");
29
+ // Phase 2 (#1646): route through the Hub per ADR-959 §III(B) line 75.
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
31
+ const commandRoutingHub = require("./command-routing-hub.cjs");
32
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
33
+ const cjsCommandRouterAdapter = require("./cjs-command-router-adapter.cjs");
29
34
  const { output, ERROR_REASON } = io;
35
+ const { makeInvalidArgs } = commandRoutingHub;
36
+ const { routeHubCommandFamily } = cjsCommandRouterAdapter;
30
37
  // ─── Implementation ───────────────────────────────────────────────────────────
31
38
  function routeGraphifyCommand({ args, cwd, raw, error, _graphify }) {
32
- const subcommand = args[1];
33
39
  const g = _graphify ?? graphify;
34
- if (subcommand === 'query') {
35
- const term = args[2];
36
- if (!term) {
37
- error('Usage: gsd-tools graphify query <term>', ERROR_REASON.USAGE);
38
- return;
39
- }
40
- const budgetIdx = args.indexOf('--budget');
41
- let budget = null;
42
- if (budgetIdx !== -1) {
43
- const rawBudget = args[budgetIdx + 1];
44
- if (rawBudget === undefined || Number.isNaN(parseInt(rawBudget, 10))) {
45
- error('Usage: gsd-tools graphify query <term> [--budget <N>]', ERROR_REASON.USAGE);
46
- return;
47
- }
48
- budget = parseInt(rawBudget, 10);
49
- }
50
- output(g.graphifyQuery(cwd, term, { budget }), raw);
51
- }
52
- else if (subcommand === 'status') {
53
- output(g.graphifyStatus(cwd), raw);
54
- }
55
- else if (subcommand === 'diff') {
56
- output(g.graphifyDiff(cwd), raw);
57
- }
58
- else if (subcommand === 'build') {
59
- if (args[2] === 'snapshot') {
60
- output(g.writeSnapshot(cwd), raw);
61
- }
62
- else {
63
- output(g.graphifyBuild(cwd), raw);
64
- }
65
- }
66
- else {
67
- error('Unknown graphify subcommand. Available: build, query, status, diff', ERROR_REASON.SDK_UNKNOWN_COMMAND);
68
- }
40
+ // Phase 2 (#1646): routes through the Command Routing Hub per ADR-959 §III(B)
41
+ // line 75. Validation handlers return `makeInvalidArgs(...)` Results (Q2=C,
42
+ // Q4=ii); the Hub → adapter translation preserves ERROR_REASON granularity
43
+ // via the exitReason field (Phase 1, #1644). Success handlers keep direct
44
+ // `output()` calls (audit's formatAuditReport quirk sets this precedent).
45
+ // The unknown-subcommand path is owned by the Hub's manifest check; the
46
+ // adapter passes SDK_UNKNOWN_COMMAND for UnknownCommand Results.
47
+ routeHubCommandFamily({
48
+ family: 'graphify',
49
+ args,
50
+ // Alphabetical order produces a stable, byte-identical `Available:` list
51
+ // in the unknown-subcommand message (matches the pre-conversion text).
52
+ subcommands: ['build', 'diff', 'query', 'status'],
53
+ handlers: {
54
+ query: () => {
55
+ const term = args[2];
56
+ if (!term) {
57
+ return makeInvalidArgs('term', 'Usage: gsd-tools graphify query <term>', ERROR_REASON.USAGE);
58
+ }
59
+ const budgetIdx = args.indexOf('--budget');
60
+ let budget = null;
61
+ if (budgetIdx !== -1) {
62
+ const rawBudget = args[budgetIdx + 1];
63
+ if (rawBudget === undefined || Number.isNaN(parseInt(rawBudget, 10))) {
64
+ return makeInvalidArgs('--budget', 'Usage: gsd-tools graphify query <term> [--budget <N>]', ERROR_REASON.USAGE);
65
+ }
66
+ budget = parseInt(rawBudget, 10);
67
+ }
68
+ output(g.graphifyQuery(cwd, term, { budget }), raw);
69
+ },
70
+ status: () => output(g.graphifyStatus(cwd), raw),
71
+ diff: () => output(g.graphifyDiff(cwd), raw),
72
+ build: () => {
73
+ if (args[2] === 'snapshot') {
74
+ output(g.writeSnapshot(cwd), raw);
75
+ }
76
+ else {
77
+ output(g.graphifyBuild(cwd), raw);
78
+ }
79
+ },
80
+ },
81
+ unknownMessage: (subcommand, available) => `Unknown graphify subcommand. Available: ${available.join(', ')}`,
82
+ error,
83
+ cwd,
84
+ raw,
85
+ });
69
86
  }
70
87
  module.exports = {
71
88
  routeGraphifyCommand,
@@ -1724,10 +1724,14 @@ function cmdAgentSkills(cwd, agentType, raw, jsonMode) {
1724
1724
  }, raw);
1725
1725
  return;
1726
1726
  }
1727
- if (block) {
1728
- process.stdout.write(block);
1729
- }
1730
- process.exit(0);
1727
+ // #1400: emit the raw block via the synchronous-flush output() helper (the same
1728
+ // one the --json branch uses) rather than process.stdout.write + process.exit(0).
1729
+ // When stdout is a pipe/file (how workflows consume this via command
1730
+ // substitution) the async stdout buffer is torn down by process.exit() before
1731
+ // it drains — on Windows this reliably truncates the write to 0 bytes, so every
1732
+ // ${AGENT_SKILLS_*} substitution expands empty. output() writes every byte with
1733
+ // writeAllSync and returns, letting the event loop drain naturally.
1734
+ output(block || '', true, block || '');
1731
1735
  }
1732
1736
  function buildSkillManifest(cwd, skillsDir = null) {
1733
1737
  const canonicalRoots = skillsDir
@@ -523,9 +523,12 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
523
523
  *
524
524
  * @param srcAgentsDir source agents directory (e.g. agents/)
525
525
  * @param resolvedProfile profile filter from resolveProfile()
526
- * @param converter (content: string) → string pure per-file converter
526
+ * @param converter (content: string, isGlobal?: boolean) → string per-file
527
+ * converter; scope-aware converters (copilot/antigravity)
528
+ * read isGlobal, single-arg converters ignore it (#1173)
529
+ * @param isGlobal install scope passed through to the converter
527
530
  */
528
- function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, converter) {
531
+ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, converter, isGlobal = false) {
529
532
  if (!node_fs_1.default.existsSync(srcAgentsDir))
530
533
  return srcAgentsDir;
531
534
  const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-agents-'));
@@ -544,7 +547,7 @@ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, conve
544
547
  }
545
548
  }
546
549
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcAgentsDir, entry.name), 'utf8');
547
- const converted = converter(content);
550
+ const converted = converter(content, isGlobal);
548
551
  node_fs_1.default.writeFileSync(node_path_1.default.join(stageDir, entry.name), converted, 'utf8');
549
552
  }
550
553
  }