@opengsd/gsd-core 1.5.0-rc.2 → 1.5.0-rc.4

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 (131) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +1 -1
  3. package/agents/gsd-assumptions-analyzer.md +1 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-code-reviewer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debugger.md +1 -1
  8. package/agents/gsd-doc-writer.md +1 -1
  9. package/agents/gsd-eval-auditor.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-integration-checker.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +47 -0
  13. package/agents/gsd-nyquist-auditor.md +1 -0
  14. package/agents/gsd-phase-researcher.md +1 -1
  15. package/agents/gsd-plan-checker.md +1 -1
  16. package/agents/gsd-planner.md +1 -1
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +1 -1
  19. package/agents/gsd-roadmapper.md +55 -2
  20. package/agents/gsd-security-auditor.md +1 -0
  21. package/agents/gsd-ui-auditor.md +1 -1
  22. package/agents/gsd-ui-checker.md +1 -1
  23. package/agents/gsd-ui-researcher.md +1 -1
  24. package/agents/gsd-verifier.md +13 -2
  25. package/bin/install.js +61 -64
  26. package/commands/gsd/mempalace-capture.md +71 -0
  27. package/commands/gsd/mempalace-recall.md +102 -0
  28. package/commands/gsd/ns-context.md +4 -2
  29. package/commands/gsd/progress.md +2 -1
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +277 -95
  32. package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
  33. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
  35. package/gsd-core/bin/lib/capability-state.cjs +128 -21
  36. package/gsd-core/bin/lib/capability-writer.cjs +354 -0
  37. package/gsd-core/bin/lib/check-command-router.cjs +328 -1
  38. package/gsd-core/bin/lib/clusters.cjs +2 -0
  39. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  40. package/gsd-core/bin/lib/commands.cjs +33 -10
  41. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  42. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  43. package/gsd-core/bin/lib/config.cjs +81 -26
  44. package/gsd-core/bin/lib/core.cjs +5 -2
  45. package/gsd-core/bin/lib/edge-probe.cjs +25 -2
  46. package/gsd-core/bin/lib/frontmatter.cjs +53 -1
  47. package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
  48. package/gsd-core/bin/lib/init.cjs +36 -11
  49. package/gsd-core/bin/lib/install-profiles.cjs +57 -1
  50. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  51. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  52. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  53. package/gsd-core/bin/lib/phase.cjs +99 -23
  54. package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
  55. package/gsd-core/bin/lib/probe-core.cjs +117 -1
  56. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  57. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  58. package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
  59. package/gsd-core/bin/lib/roadmap.cjs +97 -7
  60. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1946 -0
  61. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +54 -30
  62. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
  63. package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
  64. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  65. package/gsd-core/bin/lib/state-document.cjs +46 -1
  66. package/gsd-core/bin/lib/state.cjs +461 -94
  67. package/gsd-core/bin/lib/verify.cjs +92 -8
  68. package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
  69. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  70. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  71. package/gsd-core/bin/shared/model-catalog.json +1 -0
  72. package/gsd-core/references/edge-probe.md +11 -0
  73. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  74. package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
  75. package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
  76. package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
  77. package/gsd-core/references/prohibition-probe.md +248 -0
  78. package/gsd-core/templates/config.json +1 -1
  79. package/gsd-core/templates/spec.md +14 -0
  80. package/gsd-core/workflows/audit-milestone.md +5 -3
  81. package/gsd-core/workflows/autonomous.md +10 -5
  82. package/gsd-core/workflows/code-review-fix.md +9 -7
  83. package/gsd-core/workflows/code-review.md +8 -6
  84. package/gsd-core/workflows/complete-milestone.md +1 -5
  85. package/gsd-core/workflows/discuss-phase.md +14 -0
  86. package/gsd-core/workflows/execute-phase.md +86 -146
  87. package/gsd-core/workflows/execute-plan.md +21 -6
  88. package/gsd-core/workflows/help/modes/full.md +7 -1
  89. package/gsd-core/workflows/new-project.md +3 -3
  90. package/gsd-core/workflows/next.md +50 -2
  91. package/gsd-core/workflows/pause-work.md +7 -1
  92. package/gsd-core/workflows/plan-phase.md +91 -221
  93. package/gsd-core/workflows/plan-review-convergence.md +14 -4
  94. package/gsd-core/workflows/pr-branch.md +4 -2
  95. package/gsd-core/workflows/profile-user.md +3 -1
  96. package/gsd-core/workflows/progress.md +58 -1
  97. package/gsd-core/workflows/quick.md +12 -8
  98. package/gsd-core/workflows/resume-project.md +17 -1
  99. package/gsd-core/workflows/review.md +19 -2
  100. package/gsd-core/workflows/secure-phase.md +4 -2
  101. package/gsd-core/workflows/settings-advanced.md +2 -0
  102. package/gsd-core/workflows/settings.md +27 -1
  103. package/gsd-core/workflows/ship.md +58 -5
  104. package/gsd-core/workflows/spec-phase.md +75 -0
  105. package/gsd-core/workflows/validate-phase.md +4 -2
  106. package/gsd-core/workflows/verify-phase.md +14 -4
  107. package/gsd-core/workflows/verify-work.md +27 -11
  108. package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
  109. package/hooks/dist/gsd-statusline.js +1 -1
  110. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  111. package/hooks/gsd-ensure-canonical-path.js +305 -0
  112. package/hooks/gsd-statusline.js +1 -1
  113. package/hooks/hooks.json +1 -0
  114. package/hooks/managed-hooks-registry.cjs +1 -0
  115. package/package.json +5 -4
  116. package/scripts/affected-tests-lib.cjs +16 -4
  117. package/scripts/build-hooks.js +7 -0
  118. package/scripts/changeset/new.cjs +17 -3
  119. package/scripts/fix-slash-commands.cjs +15 -3
  120. package/scripts/gen-capability-registry.cjs +373 -49
  121. package/scripts/gen-inventory-manifest.cjs +1 -4
  122. package/scripts/gen-loop-host-contract.cjs +55 -0
  123. package/scripts/issue-version-gate.cjs +140 -0
  124. package/scripts/lint-allow-test-rule-refs.allowlist.json +327 -0
  125. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  126. package/scripts/lint-test-file-count.allowlist.json +14 -0
  127. package/scripts/mutation-matrix.cjs +108 -7
  128. package/scripts/pr-target-policy.cjs +63 -0
  129. package/scripts/release-tarball-smoke.cjs +7 -1
  130. package/scripts/research-profiles.cjs +5 -5
  131. package/scripts/run-tests.cjs +178 -17
@@ -206,7 +206,6 @@ function buildNewProjectConfig(userChoices) {
206
206
  ui_phase: true,
207
207
  ui_safety_gate: true,
208
208
  ai_integration_phase: true,
209
- tdd_mode: false,
210
209
  human_verify_mode: 'end-of-phase',
211
210
  text_mode: false,
212
211
  research_before_questions: false,
@@ -234,7 +233,7 @@ function buildNewProjectConfig(userChoices) {
234
233
  project_code: null,
235
234
  phase_naming: 'sequential',
236
235
  agent_skills: {},
237
- claude_md_path: './CLAUDE.md',
236
+ claude_md_path: './.claude/CLAUDE.md',
238
237
  plan_review: {
239
238
  source_grounding: true,
240
239
  source_grounding_authority: 'grep',
@@ -369,6 +368,42 @@ function cmdConfigEnsureSection(cwd, raw) {
369
368
  output(ensureConfigFileResult, raw, 'exists');
370
369
  }
371
370
  }
371
+ /**
372
+ * Shared helper: write a single key-path into an in-memory config object.
373
+ *
374
+ * Prototype-pollution guard: reject dangerous segments via inline literal
375
+ * comparisons on the exact key used to index `current`, immediately before
376
+ * each write. The inline comparison is the barrier CodeQL's
377
+ * js/prototype-pollution-utility query recognises — the previous Set-based
378
+ * pre-loop check was functionally correct but not traced through, so
379
+ * code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
380
+ *
381
+ * Returns the previous value at the leaf key (undefined if absent).
382
+ * Never writes to disk — callers handle persistence.
383
+ * Calls error() (process.exit(1)) on prototype-pollution attempts.
384
+ */
385
+ function _setNestedValue(config, keyPath, parsedValue) {
386
+ const keys = keyPath.split('.');
387
+ let current = config;
388
+ for (let i = 0; i < keys.length - 1; i++) {
389
+ const key = keys[i];
390
+ if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
391
+ error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
392
+ }
393
+ const existingChild = current[key];
394
+ if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
395
+ current[key] = {};
396
+ }
397
+ current = current[key];
398
+ }
399
+ const lastKey = keys[keys.length - 1];
400
+ if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
401
+ error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
402
+ }
403
+ const previousValue = current[lastKey];
404
+ current[lastKey] = parsedValue;
405
+ return previousValue;
406
+ }
372
407
  /**
373
408
  * Sets a value in the config file, allowing nested values via dot notation (e.g.,
374
409
  * "workflow.research").
@@ -389,35 +424,52 @@ function setConfigValue(cwd, keyPath, parsedValue) {
389
424
  catch (err) {
390
425
  error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
391
426
  }
392
- // Set nested value using dot notation (e.g., "workflow.research").
393
- // Prototype-pollution guard: reject dangerous segments via inline literal
394
- // comparisons on the exact key used to index `current`, immediately before
395
- // each write. The inline comparison is the barrier CodeQL's
396
- // js/prototype-pollution-utility query recognises — the previous Set-based
397
- // pre-loop check was functionally correct but not traced through, so
398
- // code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
399
- const keys = keyPath.split('.');
400
- let current = config;
401
- for (let i = 0; i < keys.length - 1; i++) {
402
- const key = keys[i];
403
- if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
404
- error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
405
- }
406
- if (current[key] === undefined || typeof current[key] !== 'object') {
407
- current[key] = {};
427
+ const previousValue = _setNestedValue(config, keyPath, parsedValue);
428
+ // Write back
429
+ try {
430
+ (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2));
431
+ return { updated: true, key: keyPath, value: parsedValue, previousValue };
432
+ }
433
+ catch (err) {
434
+ error('Failed to write config.json: ' + err.message);
435
+ }
436
+ });
437
+ }
438
+ /**
439
+ * Batched sibling of setConfigValue: apply multiple key-path writes in a
440
+ * single load → set-all → write cycle inside ONE withPlanningLock call.
441
+ *
442
+ * Returns { updated: true, results: SetConfigValueResult[] } on success.
443
+ * An empty entries array is a no-op and returns { updated: false, results: [] }.
444
+ *
445
+ * Prototype-pollution guards are enforced per entry (identical inline-literal
446
+ * guards as setConfigValue — CodeQL barrier requirement).
447
+ */
448
+ function setConfigValues(cwd, entries) {
449
+ if (entries.length === 0) {
450
+ return { updated: false, results: [] };
451
+ }
452
+ const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
453
+ return withPlanningLock(cwd, () => {
454
+ // Load existing config or start with empty object
455
+ let config = {};
456
+ try {
457
+ if (node_fs_1.default.existsSync(configPath)) {
458
+ config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
408
459
  }
409
- current = current[key];
410
460
  }
411
- const lastKey = keys[keys.length - 1];
412
- if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
413
- error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
461
+ catch (err) {
462
+ error('Failed to read config.json: ' + err.message, ERROR_REASON.CONFIG_PARSE_FAILED);
414
463
  }
415
- const previousValue = current[lastKey]; // Capture previous value before overwriting
416
- current[lastKey] = parsedValue;
417
- // Write back
464
+ const results = [];
465
+ for (const entry of entries) {
466
+ const previousValue = _setNestedValue(config, entry.keyPath, entry.value);
467
+ results.push({ updated: true, key: entry.keyPath, value: entry.value, previousValue });
468
+ }
469
+ // Write back once for all entries
418
470
  try {
419
471
  (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(config, null, 2));
420
- return { updated: true, key: keyPath, value: parsedValue, previousValue };
472
+ return { updated: true, results };
421
473
  }
422
474
  catch (err) {
423
475
  error('Failed to write config.json: ' + err.message);
@@ -735,4 +787,7 @@ module.exports = {
735
787
  cmdConfigNewProject,
736
788
  cmdConfigPath,
737
789
  cmdMigrateConfig,
790
+ // Exported for programmatic use by capability-writer and tests
791
+ setConfigValue,
792
+ setConfigValues,
738
793
  };
@@ -47,7 +47,7 @@ const { loadConfig, isGitIgnored, CONFIG_DEFAULTS, _warnUnknownProfileOverrides,
47
47
  // ─── Model Resolver Module (extracted from core, ADR-857 phase 2f / #888) ────
48
48
  // eslint-disable-next-line @typescript-eslint/no-require-imports
49
49
  const modelResolverModule = require("./model-resolver.cjs");
50
- const { resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES, resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier, VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, } = modelResolverModule;
50
+ const { resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES, resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier, VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, _resetModelPolicyWarningCacheForTests, } = modelResolverModule;
51
51
  // ─── Path helpers ────────────────────────────────────────────────────────────
52
52
  // toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877).
53
53
  // The destructured bindings above (from coreUtilsModule) make them available to
@@ -305,7 +305,10 @@ module.exports = {
305
305
  resolveTierEntry,
306
306
  resolveModelPolicy,
307
307
  KNOWN_PROVIDERS: model_catalog_cjs_1.KNOWN_PROVIDERS,
308
- _resetRuntimeWarningCacheForTests,
308
+ _resetRuntimeWarningCacheForTests: () => {
309
+ _resetRuntimeWarningCacheForTests();
310
+ _resetModelPolicyWarningCacheForTests();
311
+ },
309
312
  pathExistsInternal,
310
313
  gitWorktreeInfoInternal,
311
314
  generateSlugInternal,
@@ -19,7 +19,7 @@
19
19
  * edges, and (via probe-core) merges author resolutions into a coverage report.
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.EDGE_VALIDATORS = exports.TAXONOMY = exports.VALID_SHAPES = exports.SHAPE_CUES = void 0;
22
+ exports.EDGE_VALIDATORS = exports.UNCLASSIFIED_CATEGORY = exports.TAXONOMY = exports.VALID_SHAPES = exports.SHAPE_CUES = void 0;
23
23
  exports.classifyShape = classifyShape;
24
24
  exports.applicableCategories = applicableCategories;
25
25
  exports.validateRequirement = validateRequirement;
@@ -74,8 +74,15 @@ function applicableCategories(shapes) {
74
74
  * taxonomy; both verification tiers require a non-empty `resolution` (an explicit AC's text
75
75
  * or a backstop note) so plan-phase has a criterion to lift.
76
76
  */
77
+ /**
78
+ * Pseudo-category for a requirement whose prose matched NO shape cue (#1110). It is a soft
79
+ * "review manually" signal, NOT a 9th taxonomy category: it stays out of `TAXONOMY` (the closed
80
+ * eight) and only joins `EDGE_VALIDATORS.categories` so `analyzeCoverage` accepts the item.
81
+ */
82
+ exports.UNCLASSIFIED_CATEGORY = 'unclassified';
83
+ const UNCLASSIFIED_PROBE = 'unclassified — review manually';
77
84
  exports.EDGE_VALIDATORS = {
78
- categories: exports.TAXONOMY.map((c) => c.id),
85
+ categories: [...exports.TAXONOMY.map((c) => c.id), exports.UNCLASSIFIED_CATEGORY],
79
86
  verification: ['explicit', 'backstop'],
80
87
  requiredFieldsByVerification: { explicit: ['resolution'], backstop: ['resolution'] },
81
88
  };
@@ -126,6 +133,22 @@ function proposeEdges(requirement) {
126
133
  }
127
134
  else {
128
135
  shapes = classifyShape(requirement.text);
136
+ if (shapes.length === 0) {
137
+ // Prose present but no shape cue matched. Do NOT silently drop it (#1110): an
138
+ // edge-relevant requirement whose phrasing missed every cue would otherwise vanish from
139
+ // coverage with no signal — the exact blind spot this probe exists to catch. Surface ONE
140
+ // soft, dismissible "unclassified — review manually" candidate. The explicit `shapes: []`
141
+ // opt-out (handled above) stays silent — that is the author's deliberate "no edge surface".
142
+ return [{
143
+ requirement_id: requirement.id,
144
+ category: exports.UNCLASSIFIED_CATEGORY,
145
+ status: 'unresolved',
146
+ verification: null,
147
+ resolution: null,
148
+ reason: null,
149
+ probe: UNCLASSIFIED_PROBE,
150
+ }];
151
+ }
129
152
  }
130
153
  return applicableCategories(shapes).map((catId) => {
131
154
  const cat = exports.TAXONOMY.find((c) => c.id === catId);
@@ -202,13 +202,60 @@ function reconstructFrontmatter(obj) {
202
202
  return lines.join('\n');
203
203
  }
204
204
  function spliceFrontmatter(content, newObj) {
205
- const yamlStr = reconstructFrontmatter(newObj);
206
205
  const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
207
206
  if (match) {
207
+ // Identity-preservation (additive, lossless round-trip): `reconstructFrontmatter` is a
208
+ // deliberately lossy serializer — it cannot faithfully re-emit nested object-list items
209
+ // (e.g. must_haves.artifacts / must_haves.prohibitions, whose items are `{ path, provides }`
210
+ // / `{ statement, status, … }` maps). When the caller is writing back a value that is
211
+ // STRUCTURALLY UNCHANGED from the original parse (the canonical CRUD round-trip and the
212
+ // #644 prohibition schema round-trip both do this), regenerating from the lossy object would
213
+ // silently mangle those blocks. Detect that case by deep-equality against a re-parse of the
214
+ // original frontmatter and preserve the ORIGINAL raw text verbatim — a true no-op splice.
215
+ // This touches neither the parser (`extractFrontmatter`) nor `parseMustHavesBlock`; it only
216
+ // makes the existing splice faithful when nothing changed. A genuine mutation (different
217
+ // object) still flows through `reconstructFrontmatter` exactly as before.
218
+ try {
219
+ if (frontmatterDeepEqual(extractFrontmatter(content), newObj)) {
220
+ return content;
221
+ }
222
+ }
223
+ catch {
224
+ /* fall through to regeneration on any comparison hiccup */
225
+ }
226
+ const yamlStr = reconstructFrontmatter(newObj);
208
227
  return `---\n${yamlStr}\n---` + content.slice(match[0].length);
209
228
  }
229
+ const yamlStr = reconstructFrontmatter(newObj);
210
230
  return `---\n${yamlStr}\n---\n\n` + content;
211
231
  }
232
+ /**
233
+ * Structural deep-equality for two parsed frontmatter objects. Order-sensitive for arrays
234
+ * (YAML lists are ordered), key-order-insensitive for objects. Used only by `spliceFrontmatter`
235
+ * to recognize a no-op write-back; intentionally narrow (handles the string / string[] /
236
+ * nested-object shapes `extractFrontmatter` produces).
237
+ */
238
+ function frontmatterDeepEqual(a, b) {
239
+ if (a === b)
240
+ return true;
241
+ if (a == null || b == null)
242
+ return a === b;
243
+ if (Array.isArray(a) || Array.isArray(b)) {
244
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
245
+ return false;
246
+ return a.every((v, i) => frontmatterDeepEqual(v, b[i]));
247
+ }
248
+ if (typeof a === 'object' && typeof b === 'object') {
249
+ const ao = a;
250
+ const bo = b;
251
+ const ak = Object.keys(ao);
252
+ const bk = Object.keys(bo);
253
+ if (ak.length !== bk.length)
254
+ return false;
255
+ return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && frontmatterDeepEqual(ao[k], bo[k]));
256
+ }
257
+ return false;
258
+ }
212
259
  function parseMustHavesBlock(content, blockName) {
213
260
  // Extract a specific block from must_haves in raw frontmatter YAML
214
261
  // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
@@ -431,6 +478,11 @@ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
431
478
  }
432
479
  module.exports = {
433
480
  extractFrontmatter,
481
+ // Additive alias (#644 prohibition-probe schema contract): the probe round-trip seam reads a
482
+ // frontmatter object via `parseFrontmatter` (the name the contract test pins). It is the SAME
483
+ // function as `extractFrontmatter` — a bare-object parse with no behavior change — exposed under
484
+ // the alias so the prohibition schema round-trip and any future caller can use the canonical name.
485
+ parseFrontmatter: extractFrontmatter,
434
486
  reconstructFrontmatter,
435
487
  spliceFrontmatter,
436
488
  parseMustHavesBlock,
@@ -0,0 +1,194 @@
1
+ "use strict";
2
+ /**
3
+ * Git Base-Branch Resolver — issue #1146.
4
+ *
5
+ * Single source of truth for detecting the repository's default branch.
6
+ * Replaces the duplicated per-workflow bash detection that only consulted
7
+ * `refs/remotes/origin/HEAD` then hardcoded `:-main`, which silently
8
+ * returned "main" for repos whose default branch is "master" whenever
9
+ * origin/HEAD was unset (git init + remote add / fetch without set-head /
10
+ * most CI checkouts / many worktrees).
11
+ *
12
+ * Precedence ladder (highest to lowest):
13
+ * 1. `git.base_branch` config override from .planning/config.json
14
+ * 2. `git symbolic-ref --short refs/remotes/origin/HEAD` (fast, no network)
15
+ * 3. `git remote show origin` HEAD branch ← AUTHORITATIVE; works when #2 unset
16
+ * 4. Local branch existence: "master" present + "main" absent → "master";
17
+ * "main" present → "main"
18
+ * 5. "main" (last-resort default)
19
+ *
20
+ * Every git subprocess is bounded with a timeout (≤ 30 s); on timeout/error
21
+ * the resolver degrades gracefully to the next tier — it never throws.
22
+ *
23
+ * Pure/testable: all I/O is injectable via the `deps` argument so unit
24
+ * tests can run without touching the real filesystem or spawning real git.
25
+ */
26
+ var __importDefault = (this && this.__importDefault) || function (mod) {
27
+ return (mod && mod.__esModule) ? mod : { "default": mod };
28
+ };
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.readConfigBaseBranch = readConfigBaseBranch;
31
+ exports.trySymbolicRef = trySymbolicRef;
32
+ exports.tryRemoteShow = tryRemoteShow;
33
+ exports.tryLocalBranch = tryLocalBranch;
34
+ exports.resolveBaseBranch = resolveBaseBranch;
35
+ exports.cmdGitBaseBranch = cmdGitBaseBranch;
36
+ const node_fs_1 = __importDefault(require("node:fs"));
37
+ const node_path_1 = __importDefault(require("node:path"));
38
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
39
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
40
+ /**
41
+ * Safely look up `git.base_branch` from the project's config.json.
42
+ * Returns the configured value (a non-empty, non-null string) or null.
43
+ */
44
+ function readConfigBaseBranch(planningDir, deps) {
45
+ const readFile = deps?.readFile ??
46
+ ((p) => { try {
47
+ return node_fs_1.default.readFileSync(p, 'utf8');
48
+ }
49
+ catch {
50
+ return null;
51
+ } });
52
+ const configPath = node_path_1.default.join(planningDir, 'config.json');
53
+ const raw = readFile(configPath);
54
+ if (!raw)
55
+ return null;
56
+ let cfg;
57
+ try {
58
+ cfg = JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg))
64
+ return null;
65
+ const top = cfg;
66
+ // Support both "git.base_branch" (nested) and "base_branch" (flat legacy)
67
+ const gitSection = top.git;
68
+ if (gitSection && typeof gitSection === 'object' && !Array.isArray(gitSection)) {
69
+ const nested = gitSection.base_branch;
70
+ if (typeof nested === 'string' && nested.trim())
71
+ return nested.trim();
72
+ }
73
+ const flat = top.base_branch;
74
+ if (typeof flat === 'string' && flat.trim())
75
+ return flat.trim();
76
+ return null;
77
+ }
78
+ /**
79
+ * Try `git symbolic-ref --short refs/remotes/origin/HEAD` (no network).
80
+ * Strips the `origin/` prefix to return just the branch name.
81
+ * Returns null if unset or on error/timeout.
82
+ */
83
+ function trySymbolicRef(cwd, execGit) {
84
+ try {
85
+ const r = execGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd, timeout: 5_000 });
86
+ if (r.exitCode !== 0 || !r.stdout)
87
+ return null;
88
+ // Output is e.g. "origin/main" — strip the prefix
89
+ const branch = r.stdout.trim().replace(/^origin\//, '');
90
+ return branch || null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /**
97
+ * Try `git remote show origin` to read the HEAD branch.
98
+ * This is authoritative when origin/HEAD is unset locally.
99
+ * Requires network access but succeeds in the common CI case where
100
+ * origin/HEAD was never set after `git init && git remote add origin`.
101
+ *
102
+ * Parses the line: `HEAD branch: <name>`
103
+ * Returns null on error, timeout, or if the output is malformed.
104
+ */
105
+ function tryRemoteShow(cwd, execGit) {
106
+ try {
107
+ const r = execGit(['remote', 'show', 'origin'], { cwd, timeout: 15_000 });
108
+ if (r.exitCode !== 0 || !r.stdout)
109
+ return null;
110
+ // The line looks like: " HEAD branch: master"
111
+ const m = r.stdout.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
112
+ if (!m)
113
+ return null;
114
+ const branch = m[1];
115
+ // git emits "(unknown)" when the remote is offline but the local cache
116
+ // resolved it; treat that as non-authoritative and fall through.
117
+ if (!branch || branch === '(unknown)')
118
+ return null;
119
+ return branch;
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ /**
126
+ * Detect local branch existence as a tie-breaker when no remote info is available.
127
+ *
128
+ * Rules:
129
+ * - "master" present AND "main" absent → "master"
130
+ * - "main" present → "main"
131
+ * - Neither → null (fall through to default)
132
+ *
133
+ * Returns null on error/timeout.
134
+ */
135
+ function tryLocalBranch(cwd, execGit) {
136
+ try {
137
+ const r = execGit(['branch', '--list', 'main', 'master'], { cwd, timeout: 5_000 });
138
+ if (r.exitCode !== 0 || !r.stdout)
139
+ return null;
140
+ // `git branch --list main master` outputs one line per matching branch
141
+ const lines = r.stdout.split('\n').map(l => l.trim().replace(/^\*\s*/, ''));
142
+ const hasMain = lines.includes('main');
143
+ const hasMaster = lines.includes('master');
144
+ if (hasMaster && !hasMain)
145
+ return 'master';
146
+ if (hasMain)
147
+ return 'main';
148
+ return null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Resolve the default/base branch for the repository at `cwd`.
156
+ *
157
+ * Consults the full precedence ladder and always returns a non-empty string.
158
+ * Never throws.
159
+ */
160
+ function resolveBaseBranch(cwd, deps) {
161
+ const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
162
+ // Derive .planning dir relative to cwd (mirrors planningDir() in core.cjs)
163
+ const planningDir = node_path_1.default.join(cwd, '.planning');
164
+ // 1. Config override
165
+ const configured = readConfigBaseBranch(planningDir, deps);
166
+ if (configured)
167
+ return configured;
168
+ // 2. symbolic-ref (fast, no network)
169
+ const symref = trySymbolicRef(cwd, execGit);
170
+ if (symref)
171
+ return symref;
172
+ // 3. git remote show origin (authoritative when origin/HEAD unset)
173
+ const remoteShow = tryRemoteShow(cwd, execGit);
174
+ if (remoteShow)
175
+ return remoteShow;
176
+ // 4. Local branch existence
177
+ const local = tryLocalBranch(cwd, execGit);
178
+ if (local)
179
+ return local;
180
+ // 5. Last-resort default
181
+ return 'main';
182
+ }
183
+ // ─── CLI entry point ──────────────────────────────────────────────────────────
184
+ /**
185
+ * CLI command: `gsd-tools git base-branch`
186
+ * Resolves the default branch and writes it to stdout (raw string, newline-terminated).
187
+ * Called by workflows via `gsd_run query git.base-branch`.
188
+ */
189
+ function cmdGitBaseBranch(cwd, _args, deps) {
190
+ const branch = resolveBaseBranch(cwd, deps);
191
+ const write = deps?.write ?? ((s) => process.stdout.write(s));
192
+ write(branch + '\n');
193
+ return branch;
194
+ }
@@ -182,10 +182,11 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
182
182
  ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
183
183
  : null;
184
184
  const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
185
+ const wf = (config.workflow ?? {});
185
186
  const result = {
186
187
  executor_model: resolveModelInternal(cwd, 'gsd-executor'),
187
188
  verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
188
- tdd_mode: options['tdd'] || config.tdd_mode || false,
189
+ tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
189
190
  commit_docs: config.commit_docs,
190
191
  sub_repos: config.sub_repos,
191
192
  parallelization: config.parallelization,
@@ -300,15 +301,16 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
300
301
  const granularityOverride = options['granularity'];
301
302
  assertValidGranularityOverride(granularityOverride, error);
302
303
  const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
304
+ const wf = (config.workflow ?? {});
303
305
  const result = {
304
306
  researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
305
307
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
306
308
  checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
307
- tdd_mode: options['tdd'] || config.tdd_mode || false,
309
+ tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
308
310
  granularity,
309
- research_enabled: config.research,
311
+ research_enabled: wf['research'],
310
312
  plan_checker_enabled: config.plan_checker,
311
- nyquist_validation_enabled: config.nyquist_validation,
313
+ nyquist_validation_enabled: wf['nyquist_validation'],
312
314
  commit_docs: config.commit_docs,
313
315
  text_mode: config.text_mode,
314
316
  auto_advance: !!(config.auto_advance),
@@ -507,12 +509,13 @@ function cmdInitNewMilestone(cwd, raw) {
507
509
  catch {
508
510
  /* intentionally empty */
509
511
  }
512
+ const wf = (config.workflow ?? {});
510
513
  const result = {
511
514
  researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
512
515
  synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'),
513
516
  roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
514
517
  commit_docs: config.commit_docs,
515
- research_enabled: config.research,
518
+ research_enabled: wf['research'],
516
519
  current_milestone: milestone['version'],
517
520
  current_milestone_name: milestone['name'],
518
521
  latest_completed_milestone: latestCompleted?.version || null,
@@ -1530,7 +1533,9 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1530
1533
  // It returns [] cheaply when no roots are configured, so the realpath cost only
1531
1534
  // occurs when the caller has actually set trusted_global_roots.
1532
1535
  const trustedGlobalRoots = (0, security_cjs_1.loadTrustedGlobalRoots)(config);
1533
- const validPaths = [];
1536
+ // Each entry is either a filesystem include ({ kind: 'include', ref, display }) or a
1537
+ // Skill-tool directive ({ kind: 'directive', name }) for plugin-provided namespaced skills.
1538
+ const validEntries = [];
1534
1539
  for (const skillPath of skillPaths) {
1535
1540
  if (typeof skillPath !== 'string')
1536
1541
  continue;
@@ -1540,10 +1545,25 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1540
1545
  process.stderr.write(`[agent-skills] WARNING: "global:" prefix with empty skill name — skipping\n`);
1541
1546
  continue;
1542
1547
  }
1543
- if (!/^[a-zA-Z0-9_-]+$/.test(skillName)) {
1548
+ // Accept: one or more [A-Za-z0-9_-]+ segments joined by single colons.
1549
+ // Rejects: empty segments (::), leading/trailing colon, dots, slashes, backslashes.
1550
+ if (!/^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)*$/.test(skillName)) {
1544
1551
  process.stderr.write(`[agent-skills] WARNING: Invalid global skill name "${skillName}" — skipping\n`);
1545
1552
  continue;
1546
1553
  }
1554
+ const isNamespaced = skillName.includes(':');
1555
+ if (isNamespaced) {
1556
+ // Plugin-provided namespaced skill: no filesystem path exists locally.
1557
+ if (runtime === 'claude') {
1558
+ // Emit a natural-language Skill-tool directive (not a @-include).
1559
+ validEntries.push({ kind: 'directive', name: skillName });
1560
+ }
1561
+ else {
1562
+ process.stderr.write(`[agent-skills] WARNING: Plugin-namespaced skill "global:${skillName}" requires a Skill-tool-capable runtime (claude) — skipping on runtime "${runtime}"\n`);
1563
+ }
1564
+ continue;
1565
+ }
1566
+ // Non-namespaced bare name: attempt filesystem resolution as before.
1547
1567
  if (globalSkillsBase === null) {
1548
1568
  process.stderr.write(`[agent-skills] WARNING: Runtime "${runtime}" does not use a skills directory — "global:${skillName}" is not supported on this runtime\n`);
1549
1569
  continue;
@@ -1567,7 +1587,7 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1567
1587
  }
1568
1588
  process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`);
1569
1589
  }
1570
- validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
1590
+ validEntries.push({ kind: 'include', ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
1571
1591
  continue;
1572
1592
  }
1573
1593
  const pathCheck = (0, security_cjs_1.validatePath)(skillPath, projectRoot);
@@ -1580,11 +1600,16 @@ function buildAgentSkillsBlock(config, agentType, projectRoot) {
1580
1600
  process.stderr.write(`[agent-skills] WARNING: Skill not found at "${skillPath}/SKILL.md" — skipping\n`);
1581
1601
  continue;
1582
1602
  }
1583
- validPaths.push({ ref: `${skillPath}/SKILL.md`, display: skillPath });
1603
+ validEntries.push({ kind: 'include', ref: `${skillPath}/SKILL.md`, display: skillPath });
1584
1604
  }
1585
- if (validPaths.length === 0)
1605
+ if (validEntries.length === 0)
1586
1606
  return '';
1587
- const lines = validPaths.map((p) => `- @${p.ref}`).join('\n');
1607
+ const lines = validEntries.map((entry) => {
1608
+ if (entry.kind === 'directive') {
1609
+ return `- Load the \`${entry.name}\` skill via the Skill tool before proceeding (plugin-provided).`;
1610
+ }
1611
+ return `- @${entry.ref}`;
1612
+ }).join('\n');
1588
1613
  return `<agent_skills>\nRead these user-configured skills:\n${lines}\n</agent_skills>`;
1589
1614
  }
1590
1615
  function cmdAgentSkills(cwd, agentType, raw, jsonMode) {