@opengsd/gsd-core 1.5.0-rc.2 → 1.5.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 (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-mempalace-curator.md +47 -0
  3. package/bin/install.js +25 -7
  4. package/commands/gsd/mempalace-capture.md +71 -0
  5. package/commands/gsd/mempalace-recall.md +102 -0
  6. package/commands/gsd/ns-context.md +4 -2
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +110 -92
  9. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  10. package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
  11. package/gsd-core/bin/lib/capability-state.cjs +31 -18
  12. package/gsd-core/bin/lib/check-command-router.cjs +328 -1
  13. package/gsd-core/bin/lib/clusters.cjs +2 -0
  14. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  15. package/gsd-core/bin/lib/commands.cjs +33 -10
  16. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  17. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  18. package/gsd-core/bin/lib/config.cjs +1 -2
  19. package/gsd-core/bin/lib/core.cjs +5 -2
  20. package/gsd-core/bin/lib/init.cjs +8 -5
  21. package/gsd-core/bin/lib/install-profiles.cjs +2 -1
  22. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  23. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  24. package/gsd-core/bin/lib/phase.cjs +71 -11
  25. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  26. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  27. package/gsd-core/bin/lib/roadmap.cjs +97 -7
  28. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1700 -0
  29. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +20 -28
  30. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
  31. package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
  32. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  33. package/gsd-core/bin/lib/state-document.cjs +46 -1
  34. package/gsd-core/bin/lib/state.cjs +221 -35
  35. package/gsd-core/bin/lib/verify.cjs +19 -4
  36. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  37. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  38. package/gsd-core/bin/shared/model-catalog.json +1 -0
  39. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  40. package/gsd-core/templates/config.json +1 -1
  41. package/gsd-core/workflows/audit-milestone.md +5 -3
  42. package/gsd-core/workflows/autonomous.md +10 -5
  43. package/gsd-core/workflows/code-review-fix.md +9 -7
  44. package/gsd-core/workflows/code-review.md +8 -6
  45. package/gsd-core/workflows/discuss-phase.md +14 -0
  46. package/gsd-core/workflows/execute-phase.md +82 -143
  47. package/gsd-core/workflows/help/modes/full.md +3 -1
  48. package/gsd-core/workflows/new-project.md +3 -3
  49. package/gsd-core/workflows/plan-phase.md +89 -221
  50. package/gsd-core/workflows/profile-user.md +3 -1
  51. package/gsd-core/workflows/progress.md +58 -1
  52. package/gsd-core/workflows/quick.md +9 -6
  53. package/gsd-core/workflows/review.md +19 -2
  54. package/gsd-core/workflows/secure-phase.md +4 -2
  55. package/gsd-core/workflows/settings-advanced.md +2 -0
  56. package/gsd-core/workflows/ship.md +57 -0
  57. package/gsd-core/workflows/validate-phase.md +4 -2
  58. package/gsd-core/workflows/verify-work.md +27 -11
  59. package/package.json +3 -2
  60. package/scripts/affected-tests-lib.cjs +16 -4
  61. package/scripts/gen-capability-registry.cjs +359 -48
  62. package/scripts/gen-inventory-manifest.cjs +1 -4
  63. package/scripts/gen-loop-host-contract.cjs +55 -0
  64. package/scripts/issue-version-gate.cjs +140 -0
  65. package/scripts/lint-allow-test-rule-refs.allowlist.json +326 -0
  66. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  67. package/scripts/lint-test-file-count.allowlist.json +8 -0
  68. package/scripts/release-tarball-smoke.cjs +7 -1
  69. package/scripts/run-tests.cjs +74 -14
@@ -245,34 +245,76 @@ function updateCurrentPositionFields(content, fields) {
245
245
  let posBody = posMatch[2];
246
246
  const statusDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Status'];
247
247
  const lastActivityDefaults = state_document_cjs_1.KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
248
- if (fields.status && /^Status:/m.test(posBody)) {
249
- // Only replace when the existing Current Position Status is a known template default.
250
- const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
251
- const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
252
- const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
253
- const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
254
- const isDefault = !existingStatus || isInList || matchesPattern;
255
- if (isDefault) {
256
- posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
257
- }
258
- }
259
- if (fields.lastActivity && /^Last activity:/im.test(posBody)) {
260
- // Only replace when the existing Current Position Last activity is a known template
261
- // default (a bare ISO date). Executor-authored narrative prose is preserved.
262
- const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
263
- const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
264
- // A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
265
- // A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
266
- // was authored by the executor and must be preserved.
267
- const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
268
- const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
269
- const isDefault = !existingActivity || isDateShape || inList;
270
- if (isDefault) {
271
- posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
272
- }
273
- }
274
- if (fields.plan && /^Plan:/m.test(posBody)) {
275
- posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
248
+ if (fields.status) {
249
+ if (/^Status:/m.test(posBody)) {
250
+ // Inline format: Status: value — only replace when the existing value is a
251
+ // known template default (Knuth invariant: preserve executor-authored values).
252
+ const existingStatusMatch = posBody.match(/^Status:\s*(.+)$/m);
253
+ const existingStatus = existingStatusMatch ? existingStatusMatch[1].trim() : null;
254
+ const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
255
+ const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
256
+ const isDefault = !existingStatus || isInList || matchesPattern;
257
+ if (isDefault) {
258
+ posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`);
259
+ }
260
+ }
261
+ else {
262
+ // Table format: | Status | value | — apply the same preserve-authored guard
263
+ // as the inline branch: only overwrite a known template default.
264
+ // (Finding 2 code-review: the table branch was unconditional before this fix.)
265
+ const existingStatus = (0, state_document_cjs_1.stateExtractField)(posBody, 'Status');
266
+ const isInList = existingStatus && statusDefaults.some(d => d.toLowerCase() === existingStatus.toLowerCase());
267
+ const matchesPattern = existingStatus && state_document_cjs_1.KNOWN_STATUS_PATTERNS.some(p => p.test(existingStatus));
268
+ const isDefault = !existingStatus || isInList || matchesPattern;
269
+ if (isDefault) {
270
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Status', fields.status);
271
+ if (replaced !== null)
272
+ posBody = replaced;
273
+ }
274
+ }
275
+ }
276
+ if (fields.lastActivity) {
277
+ if (/^Last activity:/im.test(posBody)) {
278
+ // Inline format — only replace when the existing value is a known template
279
+ // default (a bare ISO date). Executor-authored narrative prose is preserved.
280
+ const existingActivityMatch = posBody.match(/^Last activity:\s*(.+)$/im);
281
+ const existingActivity = existingActivityMatch ? existingActivityMatch[1].trim() : null;
282
+ // A bare ISO date (YYYY-MM-DD with nothing after) is handler-generated.
283
+ // A date with a narrative suffix (e.g. "2026-02-15 -- blocked by infra...")
284
+ // was authored by the executor and must be preserved.
285
+ const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
286
+ const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
287
+ const isDefault = !existingActivity || isDateShape || inList;
288
+ if (isDefault) {
289
+ posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`);
290
+ }
291
+ }
292
+ else {
293
+ // Table format — apply the same preserve-authored guard as the inline branch:
294
+ // only overwrite a bare ISO date or a known default; preserve narrative prose.
295
+ // (Finding 2 code-review: the table branch was unconditional before this fix.)
296
+ const existingActivity = (0, state_document_cjs_1.stateExtractField)(posBody, 'Last Activity')
297
+ ?? (0, state_document_cjs_1.stateExtractField)(posBody, 'Last activity');
298
+ const isDateShape = existingActivity && /^\d{4}-\d{2}-\d{2}$/.test(existingActivity);
299
+ const inList = existingActivity && lastActivityDefaults.some(d => d.toLowerCase() === existingActivity.toLowerCase());
300
+ const isDefault = !existingActivity || isDateShape || inList;
301
+ if (isDefault) {
302
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last Activity', fields.lastActivity)
303
+ ?? (0, state_document_cjs_1.stateReplaceField)(posBody, 'Last activity', fields.lastActivity);
304
+ if (replaced !== null)
305
+ posBody = replaced;
306
+ }
307
+ }
308
+ }
309
+ if (fields.plan) {
310
+ if (/^Plan:/m.test(posBody)) {
311
+ posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`);
312
+ }
313
+ else {
314
+ const replaced = (0, state_document_cjs_1.stateReplaceField)(posBody, 'Plan', fields.plan);
315
+ if (replaced !== null)
316
+ posBody = replaced;
317
+ }
276
318
  }
277
319
  return content.replace(posPattern, () => `${posMatch[1]}${posBody}`);
278
320
  }
@@ -560,6 +602,101 @@ function cmdStateAddBlocker(cwd, text, raw) {
560
602
  result['created'] = true;
561
603
  output(result, raw, 'true');
562
604
  }
605
+ function cmdStateAddRoadmapEvolution(cwd, options, raw) {
606
+ const statePath = planningPaths(cwd).state;
607
+ if (!node_fs_1.default.existsSync(statePath)) {
608
+ output({ error: 'STATE.md not found' }, raw, undefined);
609
+ return;
610
+ }
611
+ const { phase, action, after, note, note_file, urgent } = options;
612
+ let noteText = undefined;
613
+ try {
614
+ noteText = readTextArgOrFile(cwd, note, note_file, 'note');
615
+ }
616
+ catch (err) {
617
+ output({ added: false, reason: err.message }, raw, 'false');
618
+ return;
619
+ }
620
+ // Reject missing / empty / whitespace-only notes — an evolution entry with no
621
+ // narrative is meaningless and would corrupt the section with a dangling bullet.
622
+ if (!noteText || !noteText.trim()) {
623
+ output({ error: 'note required' }, raw, undefined);
624
+ return;
625
+ }
626
+ // Flatten line breaks so the entry is always a single Markdown bullet. The
627
+ // dedupe + rendering contract is line-oriented; a multiline --note-file would
628
+ // otherwise spill continuation lines outside the bullet and defeat dedupe.
629
+ // Internal spacing (e.g. dollar columns) is preserved.
630
+ const flatNote = noteText.replace(/\s*[\r\n]+\s*/g, ' ').trim();
631
+ const actionText = (action && action.trim()) || 'changed';
632
+ const afterText = after && after.trim() ? ` after Phase ${after.trim()}` : '';
633
+ const urgentText = urgent ? ' (URGENT)' : '';
634
+ const entry = `- Phase ${phase || '?'} ${actionText}${afterText}: ${flatNote}${urgentText}`;
635
+ let duplicate = false;
636
+ let created = false;
637
+ let subsectionCreated = false;
638
+ // The Roadmap Evolution subsection lives under `## Accumulated Context`. Scope
639
+ // every lookup to that section's body so a `### Roadmap Evolution` heading in an
640
+ // unrelated h2 section (or a fenced example) can never be matched or mutated.
641
+ // The accBody lookahead stops only at the next h2 (`\n##[^#]`), so nested h3
642
+ // subsections stay inside the captured Accumulated Context body.
643
+ // Section boundaries mirror the sibling handlers (add-decision/add-blocker):
644
+ // a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
645
+ // so following sections are preserved without data loss (see the CRLF test).
646
+ readModifyWriteStateMd(statePath, (content) => {
647
+ const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
648
+ const accMatch = content.match(accPattern);
649
+ if (accMatch) {
650
+ const accHeader = accMatch[1];
651
+ const accBody = accMatch[2];
652
+ // Find `### Roadmap Evolution` WITHIN the Accumulated Context body only.
653
+ // Bounded by the next h3/h2 or the end of the section body.
654
+ const subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i;
655
+ const subMatch = accBody.match(subPattern);
656
+ if (subMatch) {
657
+ let subBody = subMatch[2];
658
+ // Dedupe: exact (trimmed) line already present is a no-op replay.
659
+ if (subBody.split('\n').some((line) => line.trim() === entry.trim())) {
660
+ duplicate = true;
661
+ return content;
662
+ }
663
+ subBody = subBody.replace(/None yet\.?\s*\n?/gi, '');
664
+ subBody = subBody.trimEnd() + '\n' + entry + '\n';
665
+ const newAccBody = accBody.replace(subPattern, (_m, header) => `${header}${subBody}`);
666
+ return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
667
+ }
668
+ // Subsection missing — append it at the end of the Accumulated Context body.
669
+ subsectionCreated = true;
670
+ const trimmedAcc = accBody.trimEnd();
671
+ const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`;
672
+ return content.replace(accPattern, () => `${accHeader}${block}`);
673
+ }
674
+ // No `## Accumulated Context` — DWIM: create both at end of file.
675
+ // Mirrors the add-decision / add-blocker auto-create behavior.
676
+ created = true;
677
+ subsectionCreated = true;
678
+ const scaffold = [
679
+ '',
680
+ '## Accumulated Context',
681
+ '',
682
+ '### Roadmap Evolution',
683
+ '',
684
+ entry,
685
+ '',
686
+ ].join('\n');
687
+ return content.trimEnd() + '\n' + scaffold;
688
+ }, cwd);
689
+ if (duplicate) {
690
+ output({ added: false, reason: 'duplicate', entry }, raw, 'false');
691
+ return;
692
+ }
693
+ const result = { added: true, entry };
694
+ if (created)
695
+ result['created'] = true;
696
+ if (subsectionCreated)
697
+ result['subsection_created'] = true;
698
+ output(result, raw, 'true');
699
+ }
563
700
  function cmdStateResolveBlocker(cwd, text, raw) {
564
701
  const statePath = planningPaths(cwd).state;
565
702
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -679,7 +816,9 @@ function cmdStateRecordSession(cwd, options, raw) {
679
816
  // newly-written Stopped at / Resume file end up in the second (invisible) block.
680
817
  // Fix: when a `## Session` heading already exists, normalize THAT block in place
681
818
  // (insert / replace canonical bold-label lines within the existing section).
682
- // Only append a brand-new section when NO `## Session` heading exists at all.
819
+ // A `## Session Continuity` heading (bootstrap shape) is handled additively —
820
+ // missing canonical fields are inserted while the heading and any prose are
821
+ // preserved (#1101). Only append a brand-new section when NEITHER heading exists.
683
822
  const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null));
684
823
  const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At');
685
824
  const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File');
@@ -689,9 +828,14 @@ function cmdStateRecordSession(cwd, options, raw) {
689
828
  ? options.resume_file
690
829
  : 'None';
691
830
  const stoppedAtValue = options.stopped_at || 'None';
692
- // Determine whether a ## Session heading already exists in the body.
693
- const existingSessionHeading = /^## Session\s*$/im.test(content);
694
- if (existingSessionHeading) {
831
+ // Determine whether a session heading already exists in the body. The
832
+ // canonical normalized form is `## Session`; the bootstrap templates
833
+ // (workstream.cts, gsd2-import.cts, templates/state.md) instead emit
834
+ // `## Session Continuity`. Treat each separately so we never append a
835
+ // duplicate section alongside an existing one.
836
+ const existingCanonicalSession = /^## Session[ \t]*$/im.test(content);
837
+ const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content);
838
+ if (existingCanonicalSession) {
695
839
  // Normalize in place: replace the ENTIRE BODY of the existing ## Session
696
840
  // section (heading + all content up to the next ## heading or EOF) with
697
841
  // canonical bold-label lines. The negative-lookahead per-line pattern
@@ -708,8 +852,31 @@ function cmdStateRecordSession(cwd, options, raw) {
708
852
  '',
709
853
  ].join('\n'));
710
854
  }
855
+ else if (existingSessionContinuity) {
856
+ // #1101: a `## Session Continuity` section already exists (bootstrap
857
+ // shape). Previously this fell through to the append branch and created
858
+ // a SECOND `## Session` block — a duplicate. Instead, insert only the
859
+ // canonical fields that are still missing, right after the heading,
860
+ // preserving the `## Session Continuity` heading and ALL existing lines
861
+ // (e.g. prose like "Next recommended action"). Fields already updated in
862
+ // place above (needs* false) are not re-inserted. A function replacement
863
+ // is used so `$`-bearing caller values are inserted literally (#3454).
864
+ const linesToInsert = [];
865
+ if (needsLastSession)
866
+ linesToInsert.push(`**Last session:** ${now}`);
867
+ if (needsStoppedAt)
868
+ linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`);
869
+ if (needsResumeFile)
870
+ linesToInsert.push(`**Resume file:** ${resumeValue}`);
871
+ if (linesToInsert.length > 0) {
872
+ // Case-insensitive to match the `existingSessionContinuity` detection
873
+ // above (#1101 review F3) — otherwise a lowercase heading would detect
874
+ // but no-op the insert while still reporting the fields as updated.
875
+ content = content.replace(/^(## Session Continuity[ \t]*\n)/im, (_m, heading) => heading + linesToInsert.join('\n') + '\n');
876
+ }
877
+ }
711
878
  else {
712
- // No ## Session heading exists at all — append a new canonical section.
879
+ // No session heading exists at all — append a new canonical section.
713
880
  const scaffold = [
714
881
  '',
715
882
  '## Session',
@@ -741,6 +908,20 @@ function cmdStateRecordSession(cwd, options, raw) {
741
908
  output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
742
909
  }
743
910
  }
911
+ /**
912
+ * Match the session section body from a STATE.md body. #1101: recognise the
913
+ * bootstrap `## Session Continuity` heading but PREFER the normalized `## Session`
914
+ * block when both exist (legacy duplicate files), so the reader agrees with the
915
+ * writer (which updates `## Session` first). `(?:^|\n)` line-anchors (kept out of
916
+ * `/m` so `$` stays end-of-string for the `(?=\n##|$)` section boundary), which
917
+ * excludes an h3 `### Session Continuity`; the trailing-` Archive` boundary still
918
+ * excludes `## Session Continuity Archive` (preserving the #2444 scoping).
919
+ * Returns the match whose group 1 is the section body, or null.
920
+ */
921
+ function matchSessionSection(body) {
922
+ return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i)
923
+ || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i);
924
+ }
744
925
  function cmdStateSnapshot(cwd, raw) {
745
926
  const statePath = planningPaths(cwd).state;
746
927
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -816,7 +997,9 @@ function cmdStateSnapshot(cwd, raw) {
816
997
  stopped_at: null,
817
998
  resume_file: null,
818
999
  };
819
- const sessionMatch = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
1000
+ // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1001
+ // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1002
+ const sessionMatch = matchSessionSection(body);
820
1003
  if (sessionMatch) {
821
1004
  const sessionSection = sessionMatch[1];
822
1005
  // Accept both `**Last Date:**` (canonical template form) and `**Last session:**`
@@ -872,7 +1055,9 @@ function buildStateFrontmatter(bodyContent, cwd) {
872
1055
  // historical "Stopped at:" prose elsewhere in the body (e.g. in a
873
1056
  // Session Continuity Archive section) never overwrites the current value.
874
1057
  // Fall back to full-body search only when no ## Session section exists.
875
- const sessionSectionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
1058
+ // #1101: prefer the canonical `## Session` block, falling back to the bootstrap
1059
+ // `## Session Continuity` heading. See matchSessionSection for the anchoring.
1060
+ const sessionSectionMatch = matchSessionSection(bodyContent);
876
1061
  const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent;
877
1062
  const stoppedAt = (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped At') || (0, state_document_cjs_1.stateExtractField)(sessionBodyScope, 'Stopped at');
878
1063
  const pausedAt = (0, state_document_cjs_1.stateExtractField)(bodyContent, 'Paused At');
@@ -2146,6 +2331,7 @@ module.exports = {
2146
2331
  cmdStateUpdateProgress,
2147
2332
  cmdStateAddDecision,
2148
2333
  cmdStateAddBlocker,
2334
+ cmdStateAddRoadmapEvolution,
2149
2335
  cmdStateResolveBlocker,
2150
2336
  cmdStateRecordSession,
2151
2337
  cmdStateSnapshot,
@@ -1352,7 +1352,7 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
1352
1352
  const pDir = planningDir(cwd);
1353
1353
  const phasesDir = node_path_1.default.join(pDir, 'phases');
1354
1354
  if (!node_fs_1.default.existsSync(phasesDir)) {
1355
- output({ drift_detected: false, blocking: false, message: 'No phases directory' }, raw);
1355
+ output({ block: false, drift_detected: false, blocking: false, message: 'No phases directory' }, raw);
1356
1356
  return;
1357
1357
  }
1358
1358
  let phaseDir = null;
@@ -1369,7 +1369,7 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
1369
1369
  phaseDir = exact;
1370
1370
  }
1371
1371
  if (!phaseDir) {
1372
- output({ drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` }, raw);
1372
+ output({ block: false, drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` }, raw);
1373
1373
  return;
1374
1374
  }
1375
1375
  const allFiles = [];
@@ -1392,14 +1392,20 @@ function cmdVerifySchemaDrift(cwd, phaseArg, skipFlag, raw) {
1392
1392
  executionLog += '\n' + gitLog.stdout;
1393
1393
  }
1394
1394
  const result = (0, schema_detect_cjs_1.checkSchemaDrift)(allFiles, executionLog, { skipCheck: !!skipFlag });
1395
+ const isSkipped = !!result['skipped'];
1395
1396
  output({
1397
+ // Uniform gate contract: `block` = true means "this gate's bad condition is met".
1398
+ // When skipCheck is true (GSD_SKIP_SCHEMA_CHECK=true), the gate is bypassed —
1399
+ // block must be false regardless of whether drift was detected.
1400
+ // drift_detected and blocking are kept for compatibility.
1401
+ block: isSkipped ? false : !!result['driftDetected'],
1396
1402
  drift_detected: result['driftDetected'],
1397
1403
  blocking: result['blocking'],
1398
1404
  schema_files: result['schemaFiles'],
1399
1405
  orms: result['orms'],
1400
1406
  unpushed_orms: result['unpushedOrms'],
1401
1407
  message: result['message'],
1402
- skipped: result['skipped'] || false,
1408
+ skipped: isSkipped,
1403
1409
  }, raw);
1404
1410
  }
1405
1411
  function cmdVerifyCodebaseDrift(cwd, raw) {
@@ -1412,6 +1418,8 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1412
1418
  const structurePath = node_path_1.default.join(codebaseDir, 'STRUCTURE.md');
1413
1419
  if (!node_fs_1.default.existsSync(structurePath)) {
1414
1420
  emit({
1421
+ // Uniform gate contract: block = action_required (false when skipped).
1422
+ block: false,
1415
1423
  skipped: true,
1416
1424
  reason: 'no-structure-md',
1417
1425
  action_required: false,
@@ -1426,6 +1434,7 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1426
1434
  }
1427
1435
  catch (err) {
1428
1436
  emit({
1437
+ block: false,
1429
1438
  skipped: true,
1430
1439
  reason: 'cannot-read-structure-md: ' + (err instanceof Error ? err.message : String(err)),
1431
1440
  action_required: false,
@@ -1438,6 +1447,7 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1438
1447
  const revProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', 'HEAD'], { cwd });
1439
1448
  if (revProbe.exitCode !== 0) {
1440
1449
  emit({
1450
+ block: false,
1441
1451
  skipped: true,
1442
1452
  reason: 'not-a-git-repo',
1443
1453
  action_required: false,
@@ -1459,6 +1469,7 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1459
1469
  const diff = (0, shell_command_projection_cjs_1.execGit)(['diff', '--name-status', base, 'HEAD'], { cwd });
1460
1470
  if (diff.exitCode !== 0) {
1461
1471
  emit({
1472
+ block: false,
1462
1473
  skipped: true,
1463
1474
  reason: 'git-diff-failed',
1464
1475
  action_required: false,
@@ -1500,10 +1511,13 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1500
1511
  action,
1501
1512
  runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd),
1502
1513
  });
1514
+ const actionRequired = !!driftResult['actionRequired'];
1503
1515
  emit({
1516
+ // Uniform gate contract: block = action_required.
1517
+ block: actionRequired,
1504
1518
  skipped: !!driftResult['skipped'],
1505
1519
  reason: driftResult['reason'] || null,
1506
- action_required: !!driftResult['actionRequired'],
1520
+ action_required: actionRequired,
1507
1521
  directive: driftResult['directive'],
1508
1522
  spawn_mapper: !!driftResult['spawnMapper'],
1509
1523
  affected_paths: driftResult['affectedPaths'] || [],
@@ -1516,6 +1530,7 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1516
1530
  }
1517
1531
  catch (err) {
1518
1532
  emit({
1533
+ block: false,
1519
1534
  skipped: true,
1520
1535
  reason: 'exception: ' + (err && err instanceof Error ? err.message : String(err)),
1521
1536
  action_required: false,
@@ -13,7 +13,7 @@
13
13
  "project_code": null,
14
14
  "phase_id_convention": null,
15
15
  "mode": "interactive",
16
- "claude_md_path": "./CLAUDE.md",
16
+ "claude_md_path": "./.claude/CLAUDE.md",
17
17
  "git": {
18
18
  "branching_strategy": "none",
19
19
  "create_tag": true,
@@ -28,7 +28,6 @@
28
28
  "verifier": true,
29
29
  "nyquist_validation": true,
30
30
  "ai_integration_phase": true,
31
- "tdd_mode": false,
32
31
  "human_verify_mode": "end-of-phase",
33
32
  "auto_advance": false,
34
33
  "_auto_chain_active": false,
@@ -10,17 +10,11 @@
10
10
  "brave_search",
11
11
  "firecrawl",
12
12
  "exa_search",
13
- "workflow.research",
14
13
  "workflow.plan_check",
15
14
  "workflow.verifier",
16
- "workflow.nyquist_validation",
17
- "workflow.ai_integration_phase",
18
- "workflow.ui_phase",
19
- "workflow.ui_safety_gate",
20
15
  "workflow.auto_advance",
21
16
  "workflow.node_repair",
22
17
  "workflow.node_repair_budget",
23
- "workflow.tdd_mode",
24
18
  "workflow.human_verify_mode",
25
19
  "workflow.text_mode",
26
20
  "workflow.research_before_questions",
@@ -29,21 +23,12 @@
29
23
  "workflow.auto_prune_state",
30
24
  "workflow.use_worktrees",
31
25
  "workflow.worktree_skip_hooks",
32
- "workflow.code_review",
33
- "workflow.code_review_depth",
34
26
  "workflow.code_review_command",
35
- "workflow.pattern_mapper",
36
27
  "workflow.plan_bounce",
37
28
  "workflow.plan_bounce_script",
38
29
  "workflow.plan_bounce_passes",
39
30
  "workflow.plan_chunked",
40
31
  "workflow.plan_review_convergence",
41
- "workflow.post_planning_gaps",
42
- "workflow.security_enforcement",
43
- "workflow.security_asvs_level",
44
- "workflow.security_block_on",
45
- "workflow.drift_threshold",
46
- "workflow.drift_action",
47
32
  "code_quality.fallow.enabled",
48
33
  "code_quality.fallow.scope",
49
34
  "code_quality.fallow.profile",
@@ -76,7 +61,6 @@
76
61
  "workflow.context_coverage_gate",
77
62
  "statusline.show_last_command",
78
63
  "statusline.context_position",
79
- "workflow.ui_review",
80
64
  "workflow.max_discuss_passes",
81
65
  "features.thinking_partner",
82
66
  "context",
@@ -90,8 +74,6 @@
90
74
  "manager.flags.execute",
91
75
  "response_language",
92
76
  "context_window",
93
- "intel.enabled",
94
- "graphify.enabled",
95
77
  "graphify.build_timeout",
96
78
  "graphify.auto_update",
97
79
  "claude_md_path",
@@ -153,6 +153,7 @@
153
153
  "gsd-eval-planner": { "golden": "opus", "balanced": "opus", "budget": "sonnet", "phaseType": "planning", "routingTier": "heavy" },
154
154
  "gsd-framework-selector": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "planning", "routingTier": "heavy" },
155
155
  "gsd-intel-updater": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "light" },
156
+ "gsd-mempalace-curator": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "light" },
156
157
  "gsd-security-auditor": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "verification", "routingTier": "heavy" },
157
158
  "gsd-user-profiler": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "research", "routingTier": "heavy" }
158
159
  }
@@ -0,0 +1,61 @@
1
+ # Loop Hook Dispatch Contract
2
+
3
+ Generic reference for consuming the `--raw` JSON output of `gsd_run loop render-hooks <point>`
4
+ in any host-loop workflow. This document is point-agnostic — it applies to every loop
5
+ extension point (discuss:pre, discuss:post, plan:pre, plan:post, execute:pre, execute:wave:pre,
6
+ execute:wave:post, execute:post, verify:pre, verify:post, ship:pre, ship:post).
7
+
8
+ ## Envelope shape
9
+
10
+ ```json
11
+ {
12
+ "point": "discuss:pre",
13
+ "activeHooks": [
14
+ { "kind": "contribution", "into": "orchestrator", "fragment": { "inline": "..." } },
15
+ { "kind": "step", "ref": { "skill": "my-skill" } },
16
+ { "kind": "gate", "check": { "query": "..." }, "blocking": true, "onError": "skip" }
17
+ ],
18
+ "rendered": "..."
19
+ }
20
+ ```
21
+
22
+ `activeHooks` is an array of enabled hook entries for the named point. It is empty (or absent)
23
+ when no capability has registered an active hook at this point — treat that as a no-op.
24
+
25
+ ## Dispatch rules by `kind`
26
+
27
+ ### `contribution`
28
+
29
+ Inject `fragment.inline` verbatim into the context for the role named in `into`
30
+ (e.g. `orchestrator`, `planner`). Do not paraphrase — the text is the product.
31
+
32
+ ### `step`
33
+
34
+ Dispatch the referenced unit:
35
+
36
+ - `ref.skill` present → dispatch via the Skill tool with skill id `gsd-<ref.skill>`.
37
+ - `ref.agent` present → dispatch via the Agent tool with `subagent_type` = `ref.agent`.
38
+ Before dispatching an agent, print the canonical liveness banner so users know silence
39
+ is expected and do not kill a healthy agent:
40
+
41
+ ```
42
+ ◆ Spawning <agent>... (runs in a subagent — no output until it returns; expected, not a freeze)
43
+ ```
44
+
45
+ Wait for the result before continuing to the next hook or the next step.
46
+
47
+ ### `gate`
48
+
49
+ Evaluate `check` (one of `query`, `predicate`, or `agentVerdict`). Then honor `blocking`:
50
+
51
+ - `blocking: true` → if the check returns `block: true`, surface `check.message` to the user
52
+ and stop the current step. Do not continue.
53
+ - `blocking: false` → advisory only; surface the message but continue regardless of outcome.
54
+
55
+ Honor `onError` if the check itself errors: `skip` means treat as non-blocking and continue;
56
+ `fail` means surface the error and stop.
57
+
58
+ ## Empty / absent `activeHooks`
59
+
60
+ If `activeHooks` is absent, null, or an empty array, skip silently and continue to the next
61
+ step in the workflow. No output to the user is needed.
@@ -58,5 +58,5 @@
58
58
  },
59
59
  "project_code": null,
60
60
  "agent_skills": {},
61
- "claude_md_path": "./CLAUDE.md"
61
+ "claude_md_path": "./.claude/CLAUDE.md"
62
62
  }
@@ -143,13 +143,15 @@ For each REQ-ID, determine status using all three sources:
143
143
 
144
144
  ## 5.5. Nyquist Compliance Discovery
145
145
 
146
- Skip if `workflow.nyquist_validation` is explicitly `false` (absent = enabled).
146
+ Skip if the Nyquist capability is inactive.
147
147
 
148
148
  ```bash
149
- NYQUIST_CONFIG=$(gsd_run query config-get workflow.nyquist_validation --raw --default true 2>/dev/null)
149
+ VERIFY_POST_HOOKS_JSON=$(gsd_run loop render-hooks verify:post --raw)
150
150
  ```
151
151
 
152
- If `false`: skip entirely.
152
+ Resolve active step hooks from `VERIFY_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "validate-phase"`.
153
+
154
+ If no active validate-phase step hook exists: skip entirely.
153
155
 
154
156
  For each phase directory, check `*-VALIDATION.md`. If exists, parse frontmatter (`nyquist_compliant`, `wave_0_complete`).
155
157
 
@@ -453,17 +453,22 @@ Skill(skill="gsd-execute-phase", args="${PHASE_NUM} --no-transition")
453
453
 
454
454
  Auto-invoke code review and fix chain. Autonomous mode chains both review and fix (unlike execute-phase/quick which only suggest fix).
455
455
 
456
- **Config gate:**
456
+ **Capability dispatch:**
457
457
  ```bash
458
- CODE_REVIEW_ENABLED=$(gsd_run query config-get workflow.code_review 2>/dev/null || echo "true")
458
+ EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw)
459
459
  ```
460
- If `"false"`: display "Code review skipped (workflow.code_review=false)" and proceed to 3d.
460
+
461
+ Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
462
+
463
+ If no active code-review step hook exists: display "Code review skipped (code-review capability inactive)" and proceed to 3d. This covers `workflow.code_review=false` through the Capability Registry; do not query the code-review toggle directly here.
464
+
465
+ For each active code-review step hook, dispatch the skill using the registry-provided stem:
461
466
 
462
467
  ```
463
- Skill(skill="gsd-code-review", args="${PHASE_NUM}")
468
+ Skill(skill="gsd-${ref.skill}", args="${PHASE_NUM}")
464
469
  ```
465
470
 
466
- Parse status from REVIEW.md frontmatter. If "clean" or "skipped": proceed to 3d. If findings found: auto-invoke:
471
+ Parse status from REVIEW.md frontmatter. If "clean" or "skipped": proceed to 3d. If findings found after the capability-dispatched review, auto-invoke the consolidated fix entry point:
467
472
  ```
468
473
  Skill(skill="gsd-code-review", args="${PHASE_NUM} --fix --auto")
469
474
  ```