@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
@@ -205,20 +205,59 @@ function reconstructFrontmatter(obj) {
205
205
  }
206
206
  return lines.join('\n');
207
207
  }
208
+ /**
209
+ * Slice a frontmatter YAML body into per-top-level-key raw text segments. Each segment
210
+ * runs from a column-0 `key:` line through the line before the next column-0 key (or the
211
+ * end), capturing all nested indented content. Used by `spliceFrontmatter` for per-key
212
+ * identity preservation (#1572): a structurally-unchanged key keeps its original raw
213
+ * text, so the lossy `reconstructFrontmatter` never touches object-lists the caller did
214
+ * not modify (e.g. must_haves.artifacts / .prohibitions).
215
+ */
216
+ function sliceTopLevelFrontmatterSegments(yaml) {
217
+ const lines = yaml.split(/\r?\n/);
218
+ const segments = [];
219
+ let current = null;
220
+ for (const line of lines) {
221
+ // A column-0 `key:` (no leading whitespace) starts a new top-level segment.
222
+ if (/^[A-Za-z0-9_-]+:/.test(line)) {
223
+ if (current)
224
+ segments.push({ key: current.key, raw: current.raw.join('\n') });
225
+ const keyName = line.match(/^([A-Za-z0-9_-]+):/)[1];
226
+ current = { key: keyName, raw: [line] };
227
+ }
228
+ else if (current) {
229
+ current.raw.push(line);
230
+ }
231
+ // Stray lines before the first top-level key (rare in frontmatter) are dropped.
232
+ }
233
+ if (current)
234
+ segments.push({ key: current.key, raw: current.raw.join('\n') });
235
+ return segments;
236
+ }
237
+ /**
238
+ * Regenerate one frontmatter key's serialization, fail-closed if the lossy
239
+ * `reconstructFrontmatter` cannot represent the value (#1572 codex review). Object-list
240
+ * items (e.g. must_haves.artifacts `{path, provides}` maps) serialize as the literal
241
+ * string "[object Object]"; rather than silently emit that and destroy the data, refuse
242
+ * so the caller (cmdFrontmatterSet/Merge) errors out WITHOUT writing — directing the
243
+ * user to edit the file directly. The reported #1572 case (mutating an UNRELATED field)
244
+ * is unaffected: unchanged keys preserve their original raw text and never reach here.
245
+ */
246
+ function regenerateFrontmatterKey(key, value) {
247
+ const rendered = reconstructFrontmatter({ [key]: value });
248
+ if (/\[object Object\]/.test(rendered)) {
249
+ throw new Error(`frontmatter: cannot faithfully serialize key "${key}" — it contains a nested object-list ` +
250
+ `(e.g. must_haves.artifacts) the frontmatter writer cannot represent, and serializing it would ` +
251
+ `emit "[object Object]". Edit the file directly instead of using frontmatter set/merge.`);
252
+ }
253
+ return rendered;
254
+ }
208
255
  function spliceFrontmatter(content, newObj) {
209
256
  const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
210
257
  if (match) {
211
- // Identity-preservation (additive, lossless round-trip): `reconstructFrontmatter` is a
212
- // deliberately lossy serializer — it cannot faithfully re-emit nested object-list items
213
- // (e.g. must_haves.artifacts / must_haves.prohibitions, whose items are `{ path, provides }`
214
- // / `{ statement, status, … }` maps). When the caller is writing back a value that is
215
- // STRUCTURALLY UNCHANGED from the original parse (the canonical CRUD round-trip and the
216
- // #644 prohibition schema round-trip both do this), regenerating from the lossy object would
217
- // silently mangle those blocks. Detect that case by deep-equality against a re-parse of the
218
- // original frontmatter and preserve the ORIGINAL raw text verbatim — a true no-op splice.
219
- // This touches neither the parser (`extractFrontmatter`) nor `parseMustHavesBlock`; it only
220
- // makes the existing splice faithful when nothing changed. A genuine mutation (different
221
- // object) still flows through `reconstructFrontmatter` exactly as before.
258
+ const fmBlock = match[0];
259
+ // Whole-document no-op guard: a true no-op returns content verbatim (byte-exact,
260
+ // including any formatting the lossy serializer would normalize).
222
261
  try {
223
262
  if (frontmatterDeepEqual(extractFrontmatter(content), newObj)) {
224
263
  return content;
@@ -227,10 +266,63 @@ function spliceFrontmatter(content, newObj) {
227
266
  catch {
228
267
  /* fall through to regeneration on any comparison hiccup */
229
268
  }
230
- const yamlStr = reconstructFrontmatter(newObj);
231
- return `---\n${yamlStr}\n---` + content.slice(match[0].length);
269
+ // Per-key identity preservation (#1572). `reconstructFrontmatter` is a deliberately
270
+ // lossy serializer — it cannot faithfully re-emit nested object-list items (e.g.
271
+ // must_haves.artifacts / .prohibitions, whose items are `{ path, provides }` /
272
+ // `{ statement, status }` maps; `extractFrontmatter` flattens those to scalar
273
+ // strings, so a round-trip drops `provides:` and collapses the list to a malformed
274
+ // inline array). For any top-level key whose value is STRUCTURALLY UNCHANGED between
275
+ // the original parse and `newObj`, preserve that key's ORIGINAL raw text verbatim;
276
+ // regenerate only keys that actually changed. This generalizes the whole-document
277
+ // no-op guard above to per-key fidelity, so mutating `wave` no longer destroys an
278
+ // unrelated `must_haves` block. Keys absent from the original (genuinely new) are
279
+ // regenerated and appended; keys absent from `newObj` are preserved (never silently
280
+ // deleted by a set/merge).
281
+ const fmLines = fmBlock.split(/\r?\n/);
282
+ const inner = fmLines.slice(1, -1).join('\n'); // drop the opening `---` and closing `---`
283
+ let originalParsed;
284
+ try {
285
+ originalParsed = extractFrontmatter(fmBlock);
286
+ }
287
+ catch {
288
+ originalParsed = {};
289
+ }
290
+ const segments = sliceTopLevelFrontmatterSegments(inner);
291
+ const emitted = [];
292
+ const seen = new Set();
293
+ for (const seg of segments) {
294
+ seen.add(seg.key);
295
+ if (Object.prototype.hasOwnProperty.call(newObj, seg.key)) {
296
+ // Key is in newObj: preserve original raw text if structurally unchanged,
297
+ // otherwise regenerate. The key SET is defined by newObj — keys that were in
298
+ // the original but are absent from newObj are intentionally dropped (the real
299
+ // cmdSet/cmdMerge flow always passes the full merged object, so this only
300
+ // matters for direct unit callers and matches spliceFrontmatter's contract:
301
+ // the result frontmatter IS newObj).
302
+ if (frontmatterDeepEqual(newObj[seg.key], originalParsed[seg.key])) {
303
+ emitted.push(seg.raw); // unchanged → preserve original raw text verbatim
304
+ }
305
+ else {
306
+ emitted.push(regenerateFrontmatterKey(seg.key, newObj[seg.key])); // changed → regenerate (fail-closed on object-lists)
307
+ }
308
+ }
309
+ // else: key absent from newObj → drop (not emitted).
310
+ }
311
+ // Append genuinely-new keys not present in the original frontmatter.
312
+ for (const k of Object.keys(newObj)) {
313
+ if (!seen.has(k)) {
314
+ emitted.push(regenerateFrontmatterKey(k, newObj[k]));
315
+ }
316
+ }
317
+ const yamlStr = emitted.join('\n');
318
+ return `---\n${yamlStr}\n---` + content.slice(fmBlock.length);
232
319
  }
320
+ // No existing frontmatter — generate from scratch, fail-closed on unrepresentable values.
233
321
  const yamlStr = reconstructFrontmatter(newObj);
322
+ if (/\[object Object\]/.test(yamlStr)) {
323
+ throw new Error('frontmatter: cannot faithfully serialize the requested frontmatter — it contains a nested ' +
324
+ 'object-list (e.g. must_haves.artifacts) the writer cannot represent. Edit the file directly.');
325
+ }
234
326
  return `---\n${yamlStr}\n---\n\n` + content;
235
327
  }
236
328
  /**
@@ -434,9 +526,35 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
434
526
  }
435
527
  fm[field] = parsedValue;
436
528
  const newContent = spliceFrontmatter(content, fm);
529
+ // #1660: a no-op set (newContent unchanged) with a dict-valued field means the lossy
530
+ // frontmatter parser made the new value's projection equal the original's — the change
531
+ // did not apply (bites object-list fields like must_haves). Detection lives in the pure
532
+ // exported helper noOpObjectListSetError so the mutation gate (property/unit set) covers
533
+ // it — the cmd path itself is not in that set.
534
+ const noOpErr = noOpObjectListSetError(content, newContent, parsedValue);
535
+ if (noOpErr) {
536
+ output({ error: noOpErr, field }, raw, undefined);
537
+ return;
538
+ }
437
539
  (0, shell_command_projection_cjs_1.platformWriteSync)(fullPath, newContent);
438
540
  output({ updated: true, field, value: parsedValue }, raw, 'true');
439
541
  }
542
+ /**
543
+ * #1660: detect a frontmatter `set` that would be a silent no-op on a dict-valued field.
544
+ * Returns an error message when the splice produced no content change but the new value
545
+ * is a dict (object-list fields like must_haves, whose `{path, provides}` items flatten to
546
+ * scalar strings under extractFrontmatter so a replacement can deep-equal the original's
547
+ * projection), else null. Scalars and scalar arrays round-trip faithfully, so idempotent
548
+ * sets of those are intentionally NOT flagged. Pure and unit-tested directly (the cmd path
549
+ * is not in Stryker's property/unit set, so the detection must be testable in isolation).
550
+ */
551
+ function noOpObjectListSetError(originalContent, newContent, parsedValue) {
552
+ if (newContent !== originalContent)
553
+ return null;
554
+ if (parsedValue === null || typeof parsedValue !== 'object' || Array.isArray(parsedValue))
555
+ return null;
556
+ return 'frontmatter set had no effect — the supplied value is equivalent to the existing field under the frontmatter parser, which cannot faithfully round-trip object-list fields like must_haves. Edit the file directly.';
557
+ }
440
558
  function cmdFrontmatterMerge(cwd, filePath, data, raw) {
441
559
  if (!filePath || !data) {
442
560
  error('file and data required');
@@ -489,6 +607,7 @@ module.exports = {
489
607
  parseFrontmatter: extractFrontmatter,
490
608
  reconstructFrontmatter,
491
609
  spliceFrontmatter,
610
+ noOpObjectListSetError,
492
611
  parseMustHavesBlock,
493
612
  FRONTMATTER_SCHEMAS,
494
613
  cmdFrontmatterGet,
@@ -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,
@@ -43,6 +43,10 @@ const security_cjs_1 = require("./security.cjs");
43
43
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
45
45
  const frontmatterMod = require("./frontmatter.cjs");
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
47
+ const verificationMod = require("./verification.cjs");
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
49
+ const uatPredicateMod = require("./uat-predicate.cjs");
46
50
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
47
51
  const agentInstallCheck = require("./agent-install-check.cjs");
48
52
  const { checkAgentsInstalled } = agentInstallCheck;
@@ -61,6 +65,8 @@ const { pruneOrphanedWorktrees } = worktreeSafety;
61
65
  const { planningPaths, planningDir, planningRoot, findContextMdIn, } = planningWorkspace;
62
66
  const { determinePhaseStatus } = commandsMod;
63
67
  const { extractFrontmatter } = frontmatterMod;
68
+ const { readVerificationStatus } = verificationMod;
69
+ const { evaluateUatPassed } = uatPredicateMod;
64
70
  // Unused but imported for structural parity
65
71
  void stripShippedMilestones;
66
72
  // Accept all bold/colon variants of the Requirements header (#2769)
@@ -71,6 +77,45 @@ function listPhaseSummaryFiles(phaseDir) {
71
77
  function listPhasePlanFiles(phaseDir) {
72
78
  return scanPhasePlans(phaseDir)['planFiles'];
73
79
  }
80
+ function verificationNextCommand(status, phaseNumber, slashRuntime) {
81
+ if (status === 'gaps_found') {
82
+ return `${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', slashRuntime)} ${phaseNumber} --gaps`;
83
+ }
84
+ if (status === 'human_needed' || status === 'stale') {
85
+ return `${(0, runtime_slash_cjs_1.formatGsdSlash)('verify-work', slashRuntime)} ${phaseNumber}`;
86
+ }
87
+ if (status === 'missing' || status === 'unknown') {
88
+ return `${(0, runtime_slash_cjs_1.formatGsdSlash)('execute-phase', slashRuntime)} ${phaseNumber}`;
89
+ }
90
+ return '';
91
+ }
92
+ function projectCompletionStatus(implementationComplete, verificationPassed) {
93
+ if (implementationComplete && verificationPassed)
94
+ return 'complete';
95
+ if (implementationComplete)
96
+ return 'executed';
97
+ return 'incomplete';
98
+ }
99
+ function buildPhaseCompletionProjection(cwd, phaseNumber, phaseDir, planCount, summaryCount, slashRuntime) {
100
+ const implementationComplete = planCount > 0 && summaryCount >= planCount;
101
+ const phaseFullDir = phaseDir ? node_path_1.default.join(cwd, phaseDir) : '';
102
+ const verificationStatus = implementationComplete
103
+ ? readVerificationStatus(phaseFullDir)
104
+ : { status: 'not_required', next_action: '', next_command: '' };
105
+ const projectedVerificationStatus = verificationStatus.status;
106
+ const projectedVerificationAction = verificationStatus.next_action;
107
+ const verificationPassed = projectedVerificationStatus === 'passed';
108
+ const phaseComplete = implementationComplete && verificationPassed;
109
+ return {
110
+ implementation_complete: implementationComplete,
111
+ verification_status: projectedVerificationStatus,
112
+ verification_passed: verificationPassed,
113
+ phase_complete: phaseComplete,
114
+ completion_status: projectCompletionStatus(implementationComplete, verificationPassed),
115
+ verification_next_action: projectedVerificationAction,
116
+ verification_next_command: verificationNextCommand(projectedVerificationStatus, phaseNumber, slashRuntime),
117
+ };
118
+ }
74
119
  function getLatestCompletedMilestone(cwd) {
75
120
  const milestonesPath = node_path_1.default.join(planningRoot(cwd), 'MILESTONES.md');
76
121
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(milestonesPath);
@@ -636,6 +681,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
636
681
  error('phase required for init verify-work');
637
682
  }
638
683
  const config = loadConfig(cwd);
684
+ const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
639
685
  let phaseInfo = findPhaseInternal(cwd, phase);
640
686
  if (phaseInfo?.['archived']) {
641
687
  const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
@@ -664,15 +710,30 @@ function cmdInitVerifyWork(cwd, phase, raw) {
664
710
  };
665
711
  }
666
712
  }
713
+ const phaseDir = phaseInfo?.['directory'] || null;
714
+ const planCount = phaseInfo?.['plans']?.length || 0;
715
+ const summaryCount = phaseInfo?.['summaries']?.length || 0;
716
+ const completion = buildPhaseCompletionProjection(cwd, phaseInfo?.['phase_number'] || phase, phaseDir, planCount, summaryCount, _slashRuntime);
717
+ const uatReport = phaseDir
718
+ ? evaluateUatPassed(node_path_1.default.join(cwd, phaseDir), {
719
+ policy: { requireVerification: true },
720
+ })
721
+ : null;
667
722
  const result = {
668
723
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
669
724
  checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
670
725
  commit_docs: config.commit_docs,
671
726
  phase_found: !!phaseInfo,
672
- phase_dir: phaseInfo?.['directory'] || null,
727
+ phase_dir: phaseDir,
673
728
  phase_number: phaseInfo?.['phase_number'] || null,
674
729
  phase_name: phaseInfo?.['phase_name'] || null,
675
730
  has_verification: phaseInfo?.['has_verification'] || false,
731
+ phase_completion: {
732
+ ...completion,
733
+ uat_passed: uatReport?.passed ?? false,
734
+ uat_blockers: uatReport?.blockers ?? [],
735
+ ready_to_transition: completion.phase_complete && (uatReport?.passed ?? false),
736
+ },
676
737
  };
677
738
  output(withProjectRoot(cwd, result), raw);
678
739
  }
@@ -1035,18 +1096,23 @@ function cmdInitManager(cwd, raw) {
1035
1096
  let hasResearch = false;
1036
1097
  let lastActivity = null;
1037
1098
  let isActive = false;
1099
+ let completion = buildPhaseCompletionProjection(cwd, phaseNum, null, planCount, summaryCount, _slashRuntime);
1038
1100
  try {
1039
1101
  const dirs = _phaseDirEntries.filter(isDirInMilestone);
1040
1102
  const dirMatch = dirs.find((d) => phaseTokenMatches(d, normalized));
1041
1103
  if (dirMatch) {
1042
1104
  const fullDir = node_path_1.default.join(phasesDir, dirMatch);
1105
+ const phaseDirRel = toPosixPath(node_path_1.default.relative(cwd, fullDir));
1043
1106
  const phaseFiles = node_fs_1.default.readdirSync(fullDir);
1044
1107
  planCount = listPhasePlanFiles(fullDir).length;
1045
1108
  summaryCount = listPhaseSummaryFiles(fullDir).length;
1046
1109
  hasContext = findContextMdIn(fullDir) !== null;
1047
1110
  hasResearch = phaseFiles.some((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
1048
- if (summaryCount >= planCount && planCount > 0)
1111
+ completion = buildPhaseCompletionProjection(cwd, phaseNum, phaseDirRel, planCount, summaryCount, _slashRuntime);
1112
+ if (completion.phase_complete)
1049
1113
  diskStatus = 'complete';
1114
+ else if (completion.implementation_complete)
1115
+ diskStatus = 'executed';
1050
1116
  else if (summaryCount > 0)
1051
1117
  diskStatus = 'partial';
1052
1118
  else if (planCount > 0)
@@ -1079,7 +1145,7 @@ function cmdInitManager(cwd, raw) {
1079
1145
  /* intentionally empty */
1080
1146
  }
1081
1147
  const roadmapComplete = _checkboxStates.get(phaseNum) || false;
1082
- if (roadmapComplete && diskStatus !== 'complete') {
1148
+ if (roadmapComplete && completion.phase_complete && diskStatus !== 'complete') {
1083
1149
  diskStatus = 'complete';
1084
1150
  }
1085
1151
  phases.push({
@@ -1093,6 +1159,7 @@ function cmdInitManager(cwd, raw) {
1093
1159
  plan_count: planCount,
1094
1160
  summary_count: summaryCount,
1095
1161
  roadmap_complete: roadmapComplete,
1162
+ ...completion,
1096
1163
  last_activity: lastActivity,
1097
1164
  is_active: isActive,
1098
1165
  });
@@ -1107,22 +1174,42 @@ function cmdInitManager(cwd, raw) {
1107
1174
  phase['display_name'] = name;
1108
1175
  }
1109
1176
  }
1110
- const completedNums = new Set(phases.filter((p) => p['disk_status'] === 'complete').map((p) => p['number']));
1177
+ function normalizePhaseNumber(value) {
1178
+ return value
1179
+ .split('.')
1180
+ .map((part) => {
1181
+ const match = /^(\d+)([A-Z]?)$/i.exec(part);
1182
+ if (!match)
1183
+ return part;
1184
+ return `${Number(match[1])}${match[2].toUpperCase()}`;
1185
+ })
1186
+ .join('.');
1187
+ }
1188
+ const completedNums = new Set(phases
1189
+ .filter((p) => p['phase_complete'] === true)
1190
+ .map((p) => normalizePhaseNumber(p['number'])));
1191
+ const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number']), p]));
1111
1192
  const _allCompletedPattern = /-\s*\[x\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
1112
1193
  let _allMatch;
1113
1194
  while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
1114
- completedNums.add(_allMatch[1]);
1195
+ const phaseNum = normalizePhaseNumber(_allMatch[1]);
1196
+ const phase = phaseMap.get(phaseNum);
1197
+ if (!phase || phase['phase_complete'] === true) {
1198
+ completedNums.add(phaseNum);
1199
+ }
1115
1200
  }
1116
- const phaseMap = new Map(phases.map((p) => [p['number'], p]));
1117
1201
  function reaches(from, to, visited = new Set()) {
1118
- if (visited.has(from))
1202
+ const normalizedFrom = normalizePhaseNumber(from);
1203
+ const normalizedTo = normalizePhaseNumber(to);
1204
+ if (visited.has(normalizedFrom))
1119
1205
  return false;
1120
- visited.add(from);
1121
- const p = phaseMap.get(from);
1206
+ visited.add(normalizedFrom);
1207
+ const p = phaseMap.get(normalizedFrom);
1122
1208
  if (!p || !p['dep_phases'] || p['dep_phases'].length === 0)
1123
1209
  return false;
1124
- if (p['dep_phases'].includes(to))
1210
+ if (p['dep_phases'].some((dep) => normalizePhaseNumber(dep) === normalizedTo)) {
1125
1211
  return true;
1212
+ }
1126
1213
  return p['dep_phases'].some((dep) => reaches(dep, to, visited));
1127
1214
  }
1128
1215
  function hasDepRelationship(numA, numB) {
@@ -1134,8 +1221,8 @@ function cmdInitManager(cwd, raw) {
1134
1221
  phase['deps_satisfied'] = true;
1135
1222
  }
1136
1223
  else {
1137
- const depNums = phase['depends_on'].match(/\d+(?:\.\d+)*/g) || [];
1138
- phase['deps_satisfied'] = depNums.every((n) => completedNums.has(n));
1224
+ const depNums = phase['depends_on'].match(/\d+[A-Z]?(?:\.\d+)*/gi) || [];
1225
+ phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n)));
1139
1226
  phase['dep_phases'] = depNums;
1140
1227
  }
1141
1228
  }
@@ -1167,7 +1254,16 @@ function cmdInitManager(cwd, raw) {
1167
1254
  continue;
1168
1255
  if (/^999(?:\.|$)/.test(phase['number']))
1169
1256
  continue;
1170
- if (phase['disk_status'] === 'planned' && phase['deps_satisfied']) {
1257
+ if (phase['disk_status'] === 'executed') {
1258
+ recommendedActions.push({
1259
+ phase: phase['number'],
1260
+ phase_name: phase['name'],
1261
+ action: 'verify',
1262
+ reason: `Implementation complete; verification ${phase['verification_status']}`,
1263
+ command: phase['verification_next_command'],
1264
+ });
1265
+ }
1266
+ else if (phase['disk_status'] === 'planned' && phase['deps_satisfied']) {
1171
1267
  recommendedActions.push({
1172
1268
  phase: phase['number'],
1173
1269
  phase_name: phase['name'],
@@ -1211,7 +1307,7 @@ function cmdInitManager(cwd, raw) {
1211
1307
  return true;
1212
1308
  });
1213
1309
  const nonBacklogPhases = phases.filter((p) => !/^999(?:\.|$)/.test(p['number']));
1214
- const completedCount = nonBacklogPhases.filter((p) => p['disk_status'] === 'complete').length;
1310
+ const completedCount = nonBacklogPhases.filter((p) => p['phase_complete'] === true).length;
1215
1311
  const sanitizeFlags = (rawVal) => {
1216
1312
  const val = typeof rawVal === 'string' ? rawVal : '';
1217
1313
  if (!val)
@@ -1238,7 +1334,7 @@ function cmdInitManager(cwd, raw) {
1238
1334
  phases,
1239
1335
  phase_count: phases.length,
1240
1336
  completed_count: completedCount,
1241
- in_progress_count: phases.filter((p) => ['partial', 'planned', 'discussed', 'researched'].includes(p['disk_status'])).length,
1337
+ in_progress_count: phases.filter((p) => ['executed', 'partial', 'planned', 'discussed', 'researched'].includes(p['disk_status'])).length,
1242
1338
  recommended_actions: filteredActions,
1243
1339
  waiting_signal: waitingSignal,
1244
1340
  all_complete: completedCount === nonBacklogPhases.length && nonBacklogPhases.length > 0,
@@ -1258,6 +1354,7 @@ function cmdInitProgress(cwd, raw) {
1258
1354
  }
1259
1355
  const config = loadConfig(cwd);
1260
1356
  const milestone = getMilestoneInfo(cwd);
1357
+ const _slashRuntime = (0, runtime_slash_cjs_1.resolveRuntime)(cwd);
1261
1358
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1262
1359
  const phases = [];
1263
1360
  let currentPhase = null;
@@ -1307,24 +1404,29 @@ function cmdInitProgress(cwd, raw) {
1307
1404
  const plans = listPhasePlanFiles(phasePath);
1308
1405
  const summaries = listPhaseSummaryFiles(phasePath);
1309
1406
  const hasResearch = phaseFiles.some((f) => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
1310
- const status = summaries.length >= plans.length && plans.length > 0
1407
+ const phaseDirRel = toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningDir(cwd), 'phases', dir)));
1408
+ const completion = buildPhaseCompletionProjection(cwd, phaseNumber, phaseDirRel, plans.length, summaries.length, _slashRuntime);
1409
+ const status = completion.phase_complete
1311
1410
  ? 'complete'
1312
- : plans.length > 0
1313
- ? 'in_progress'
1314
- : hasResearch
1315
- ? 'researched'
1316
- : 'pending';
1411
+ : completion.implementation_complete
1412
+ ? 'executed'
1413
+ : plans.length > 0
1414
+ ? 'in_progress'
1415
+ : hasResearch
1416
+ ? 'researched'
1417
+ : 'pending';
1317
1418
  const phaseInfo = {
1318
1419
  number: phaseNumber,
1319
1420
  name: phaseName,
1320
- directory: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(planningDir(cwd), 'phases', dir))),
1421
+ directory: phaseDirRel,
1321
1422
  status,
1322
1423
  plan_count: plans.length,
1323
1424
  summary_count: summaries.length,
1324
1425
  has_research: hasResearch,
1426
+ ...completion,
1325
1427
  };
1326
1428
  phases.push(phaseInfo);
1327
- if (!currentPhase && (status === 'in_progress' || status === 'researched')) {
1429
+ if (!currentPhase && (status === 'executed' || status === 'in_progress' || status === 'researched')) {
1328
1430
  currentPhase = phaseInfo;
1329
1431
  }
1330
1432
  if (!nextPhase && status === 'pending') {
@@ -1340,7 +1442,8 @@ function cmdInitProgress(cwd, raw) {
1340
1442
  if (!seenPhaseNums.has(stripped)) {
1341
1443
  const checkboxComplete = roadmapCheckboxStates.get(num) === true ||
1342
1444
  roadmapCheckboxStates.get(stripped) === true;
1343
- const status = checkboxComplete ? 'complete' : 'not_started';
1445
+ const completion = buildPhaseCompletionProjection(cwd, num, null, 0, 0, _slashRuntime);
1446
+ const status = 'not_started';
1344
1447
  const phaseInfo = {
1345
1448
  number: num,
1346
1449
  name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
@@ -1349,9 +1452,11 @@ function cmdInitProgress(cwd, raw) {
1349
1452
  plan_count: 0,
1350
1453
  summary_count: 0,
1351
1454
  has_research: false,
1455
+ roadmap_complete: checkboxComplete,
1456
+ ...completion,
1352
1457
  };
1353
1458
  phases.push(phaseInfo);
1354
- if (!nextPhase && !currentPhase && status !== 'complete') {
1459
+ if (!nextPhase && !currentPhase && !checkboxComplete) {
1355
1460
  nextPhase = phaseInfo;
1356
1461
  }
1357
1462
  }
@@ -1373,7 +1478,7 @@ function cmdInitProgress(cwd, raw) {
1373
1478
  phases,
1374
1479
  phase_count: phases.length,
1375
1480
  completed_count: phases.filter((p) => p['status'] === 'complete').length,
1376
- in_progress_count: phases.filter((p) => p['status'] === 'in_progress').length,
1481
+ in_progress_count: phases.filter((p) => ['executed', 'in_progress'].includes(p['status'])).length,
1377
1482
  current_phase: currentPhase,
1378
1483
  next_phase: nextPhase,
1379
1484
  paused_at: pausedAt,
@@ -1724,10 +1829,14 @@ function cmdAgentSkills(cwd, agentType, raw, jsonMode) {
1724
1829
  }, raw);
1725
1830
  return;
1726
1831
  }
1727
- if (block) {
1728
- process.stdout.write(block);
1729
- }
1730
- process.exit(0);
1832
+ // #1400: emit the raw block via the synchronous-flush output() helper (the same
1833
+ // one the --json branch uses) rather than process.stdout.write + process.exit(0).
1834
+ // When stdout is a pipe/file (how workflows consume this via command
1835
+ // substitution) the async stdout buffer is torn down by process.exit() before
1836
+ // it drains — on Windows this reliably truncates the write to 0 bytes, so every
1837
+ // ${AGENT_SKILLS_*} substitution expands empty. output() writes every byte with
1838
+ // writeAllSync and returns, letting the event loop drain naturally.
1839
+ output(block || '', true, block || '');
1731
1840
  }
1732
1841
  function buildSkillManifest(cwd, skillsDir = null) {
1733
1842
  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
  }