@opengsd/gsd-core 1.5.0-rc.4 → 1.5.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 (89) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/LICENSE +1 -1
  3. package/agents/gsd-executor.md +22 -0
  4. package/agents/gsd-phase-researcher.md +1 -1
  5. package/agents/gsd-planner.md +4 -0
  6. package/agents/gsd-project-researcher.md +1 -1
  7. package/agents/gsd-verifier.md +45 -13
  8. package/bin/install.js +56 -100
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/execute-phase.md +1 -1
  11. package/commands/gsd/plan-phase.md +1 -1
  12. package/gemini-extension.json +1 -1
  13. package/gsd-core/bin/gsd-tools.cjs +85 -53
  14. package/gsd-core/bin/lib/agent-command-router.cjs +2 -2
  15. package/gsd-core/bin/lib/agent-install-check.cjs +143 -0
  16. package/gsd-core/bin/lib/audit-command-router.cjs +4 -4
  17. package/gsd-core/bin/lib/capability-activation.cjs +37 -10
  18. package/gsd-core/bin/lib/capability-registry.cjs +2 -0
  19. package/gsd-core/bin/lib/capability-state.cjs +80 -30
  20. package/gsd-core/bin/lib/capability-writer.cjs +5 -4
  21. package/gsd-core/bin/lib/check-command-router.cjs +50 -51
  22. package/gsd-core/bin/lib/commands.cjs +20 -2
  23. package/gsd-core/bin/lib/config-loader.cjs +3 -4
  24. package/gsd-core/bin/lib/config-schema.cjs +1 -1
  25. package/gsd-core/bin/lib/config-types.cjs +2 -1
  26. package/gsd-core/bin/lib/config.cjs +5 -2
  27. package/gsd-core/bin/lib/decisions.cjs +19 -1
  28. package/gsd-core/bin/lib/docs.cjs +14 -2
  29. package/gsd-core/bin/lib/frontmatter.cjs +2 -2
  30. package/gsd-core/bin/lib/gap-checker.cjs +5 -2
  31. package/gsd-core/bin/lib/git-base-branch.cjs +27 -1
  32. package/gsd-core/bin/lib/graphify-command-router.cjs +6 -8
  33. package/gsd-core/bin/lib/graphify.cjs +7 -31
  34. package/gsd-core/bin/lib/gsd2-import.cjs +2 -2
  35. package/gsd-core/bin/lib/init.cjs +30 -4
  36. package/gsd-core/bin/lib/intel-command-router.cjs +6 -3
  37. package/gsd-core/bin/lib/intel.cjs +28 -34
  38. package/gsd-core/bin/lib/io.cjs +2 -4
  39. package/gsd-core/bin/lib/learnings.cjs +2 -2
  40. package/gsd-core/bin/lib/loop-resolver.cjs +45 -167
  41. package/gsd-core/bin/lib/milestone.cjs +13 -5
  42. package/gsd-core/bin/lib/model-resolver.cjs +3 -4
  43. package/gsd-core/bin/lib/phase-id.cjs +3 -5
  44. package/gsd-core/bin/lib/phase-locator.cjs +3 -6
  45. package/gsd-core/bin/lib/phase.cjs +59 -13
  46. package/gsd-core/bin/lib/probe-core.cjs +40 -11
  47. package/gsd-core/bin/lib/profile-output.cjs +5 -2
  48. package/gsd-core/bin/lib/prohibition-enforcement.cjs +660 -0
  49. package/gsd-core/bin/lib/roadmap-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +28 -25
  51. package/gsd-core/bin/lib/roadmap.cjs +9 -4
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
  53. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
  54. package/gsd-core/bin/lib/state.cjs +75 -17
  55. package/gsd-core/bin/lib/task-command-router.cjs +2 -2
  56. package/gsd-core/bin/lib/teams-status.cjs +74 -0
  57. package/gsd-core/bin/lib/template.cjs +11 -2
  58. package/gsd-core/bin/lib/uat.cjs +64 -2
  59. package/gsd-core/bin/lib/verification.cjs +8 -5
  60. package/gsd-core/bin/lib/verify.cjs +311 -4
  61. package/gsd-core/bin/lib/workstream-inventory.cjs +2 -2
  62. package/gsd-core/bin/lib/workstream.cjs +8 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +44 -3
  64. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  65. package/gsd-core/references/planner-antipatterns.md +46 -0
  66. package/gsd-core/references/planning-config.md +5 -1
  67. package/gsd-core/references/prohibition-probe.md +80 -2
  68. package/gsd-core/references/worktree-branch-check.md +11 -5
  69. package/gsd-core/templates/verification-report.md +16 -3
  70. package/gsd-core/workflows/docs-update.md +23 -31
  71. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +9 -0
  72. package/gsd-core/workflows/execute-phase.md +7 -2
  73. package/gsd-core/workflows/map-codebase.md +8 -10
  74. package/gsd-core/workflows/plan-phase.md +6 -0
  75. package/gsd-core/workflows/quick.md +26 -2
  76. package/gsd-core/workflows/settings-advanced.md +5 -5
  77. package/gsd-core/workflows/settings-integrations.md +5 -5
  78. package/gsd-core/workflows/spec-phase.md +30 -2
  79. package/gsd-core/workflows/verify-phase.md +22 -7
  80. package/hooks/dist/gsd-worktree-path-guard.js +34 -18
  81. package/hooks/gsd-worktree-path-guard.js +34 -18
  82. package/package.json +3 -3
  83. package/scripts/ci-prepare-test-scope.cjs +56 -14
  84. package/scripts/diff-touches-shipped-paths.cjs +5 -11
  85. package/scripts/gen-capability-registry.cjs +27 -1
  86. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  87. package/scripts/research-profiles.cjs +2 -2
  88. package/scripts/run-tests.cjs +1 -0
  89. package/gsd-core/bin/lib/core.cjs +0 -345
@@ -14,8 +14,6 @@ const node_path_1 = __importDefault(require("node:path"));
14
14
  const node_os_1 = __importDefault(require("node:os"));
15
15
  const validate_cjs_1 = require("./validate.cjs");
16
16
  const validate_cjs_2 = require("./validate.cjs");
17
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
18
- const core = require("./core.cjs");
19
17
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
20
18
  const planningWorkspace = require("./planning-workspace.cjs");
21
19
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -31,7 +29,27 @@ const package_identity_cjs_1 = require("./package-identity.cjs");
31
29
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
32
30
  const schema_detect_cjs_1 = require("./schema-detect.cjs");
33
31
  const artifacts_cjs_1 = require("./artifacts.cjs");
34
- const { loadConfig, normalizePhaseName, phaseTokenMatches, escapeRegex, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS, inspectWorktreeHealth, } = core;
32
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
33
+ const agentInstallCheck = require("./agent-install-check.cjs");
34
+ const { checkAgentsInstalled } = agentInstallCheck;
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const ioMod = require("./io.cjs");
37
+ const { output, error } = ioMod;
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
39
+ const configLoaderMod = require("./config-loader.cjs");
40
+ const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod;
41
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const phaseIdMod = require("./phase-id.cjs");
43
+ const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId } = phaseIdMod;
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const phaseLocatorMod = require("./phase-locator.cjs");
46
+ const { findPhaseInternal } = phaseLocatorMod;
47
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
48
+ const roadmapParserMod = require("./roadmap-parser.cjs");
49
+ const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = roadmapParserMod;
50
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
51
+ const worktreeSafetyMod = require("./worktree-safety.cjs");
52
+ const { inspectWorktreeHealth } = worktreeSafetyMod;
35
53
  const { planningDir } = planningWorkspace;
36
54
  const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
37
55
  const { writeStateMd } = stateMod;
@@ -215,6 +233,293 @@ function scanNegativeGrepCommentEcho(content) {
215
233
  }
216
234
  return { errors, warnings };
217
235
  }
236
+ /**
237
+ * Issue #968 — file-wide negative-grep sibling conflict detector.
238
+ * A file-wide negative grep gate (! grep -Eq 'PAT' FILE or grep -c 'PAT' FILE == 0)
239
+ * bans a construct across the WHOLE file. When a sibling task in the same plan
240
+ * legitimately requires the same construct in the same file, the two gates are
241
+ * mutually unsatisfiable. This is a WARN-only check (never changes valid:false).
242
+ */
243
+ function scanFileWideNegativeGateConflict(content) {
244
+ const warnings = [];
245
+ // Normalize newlines; join backslash line-continuations (same as #429).
246
+ const text = (content || '')
247
+ .replace(/\r\n/g, '\n')
248
+ .replace(/\r/g, '\n')
249
+ .replace(/\\\n/g, ' ');
250
+ // Allowlisted patterns: <!-- planner-region-allow: PAT -->
251
+ const allow = new Set();
252
+ const allowRe = /<!--\s*planner-region-allow:\s*(.+?)\s*-->/g;
253
+ let am;
254
+ while ((am = allowRe.exec(text)) !== null)
255
+ allow.add(am[1]);
256
+ // Helper predicates (reused from #429 style).
257
+ // Zero-equality comparison: spaced == 0 or -eq 0.
258
+ const zeroCmp = (s) => /\s==?\s*0\b/.test(s) || /-eq\s+0\b/.test(s) || /\bequals\s+0\b/.test(s);
259
+ // grep options include -c / --count
260
+ const optsHaveCount = (opts) => /(?:^|\s)-[A-Za-z]*c[A-Za-z]*(?=\s|$)/.test(opts) || /--count\b/.test(opts);
261
+ // grep options include -v / --invert-match (inverted count is NOT a negative gate)
262
+ const optsHaveInvert = (opts) => /(?:^|\s)-[A-Za-z]*v[A-Za-z]*(?=\s|$)/.test(opts) || /--invert-match\b/.test(opts);
263
+ // A bareword that is a plausible grep pattern (not a stray flag/number).
264
+ const plausibleBare = (s) => /[A-Za-z0-9_]/.test(s) && !/^[-=!<>0-9]+$/.test(s);
265
+ // Regex to extract grep arguments: opts run then PAT (quoted or bare).
266
+ const grepArgRe = /grep((?:\s+-{1,2}[A-Za-z][A-Za-z-]*)+)\s+(?:'([^']*)'|"([^"]*)"|([^\s'"|>&;$()\[\]]+))/g;
267
+ // FIX 1 (ReDoS): Linear-time "does reqText satisfy the grep pattern" — no RegExp execution.
268
+ // Never calls new RegExp, so no catastrophic backtracking is possible.
269
+ //
270
+ // Handles literal patterns and `.`/`.*/`.+`/`\s`-style wildcard gaps and `^`/`$` anchors.
271
+ // Patterns using character classes (`[…]`), alternation (`a|b`), or other regex constructs
272
+ // fall back to a conservative literal-substring check, so the detector may NOT warn on those
273
+ // (false-negative is the safe direction for a warn-only advisory).
274
+ const patternRequiredIn = (pat, reqText) => {
275
+ const hay = (reqText || '').slice(0, 8000); // bound the haystack
276
+ if (!pat)
277
+ return false;
278
+ // Strip ERE anchors — position constraints don't change whether the construct is required.
279
+ pat = pat.replace(/^\^/, '').replace(/\$$/, '');
280
+ if (!pat)
281
+ return false;
282
+ // Pure literal (no regex metacharacters): direct substring.
283
+ if (!/[.*+?^${}()|[\]\\]/.test(pat))
284
+ return hay.includes(pat);
285
+ const SENT = ' ';
286
+ // Replace simple wildcard gaps (\s* \w+ .* .+ .? bare .) with a sentinel.
287
+ let work = pat
288
+ .replace(/\\[sSwWdD][*+?]?/g, SENT)
289
+ .replace(/\.[*+?]/g, SENT)
290
+ .replace(/\./g, SENT);
291
+ work = work.replace(/\\(.)/g, '$1'); // de-escape \( \. etc → literal char
292
+ const joined = work.split(SENT).join('');
293
+ // Unhandled regex constructs remain → safe literal-substring fallback on the raw pattern.
294
+ if (/[*+?^${}()|[\]]/.test(joined))
295
+ return hay.includes(pat);
296
+ const frags = work.split(SENT).filter(Boolean);
297
+ if (!frags.length)
298
+ return false; // all-wildcard pattern → no meaningful requirement
299
+ let pos = 0;
300
+ for (const f of frags) {
301
+ const idx = hay.indexOf(f, pos);
302
+ if (idx === -1)
303
+ return false;
304
+ pos = idx + f.length;
305
+ }
306
+ return true;
307
+ };
308
+ // FIX 2 (file basename over-match): exact normalized match; basename fallback ONLY for
309
+ // unqualified gate files (no path separator).
310
+ const normPath = (p) => p.replace(/^\.\//, '').trim();
311
+ // File-wide discriminator: a token AFTER PAT that looks like a path.
312
+ // Paths have /, a file extension, or match a known task <files> entry.
313
+ // Globs (containing *) are excluded (unresolvable — no warn).
314
+ const looksLikePath = (token) => !token.includes('*') &&
315
+ (token.includes('/') || /\.[a-zA-Z]{1,6}$/.test(token));
316
+ // FIX 5 (hasLeadingNot): collapse to one command-boundary-anchored regex.
317
+ // Negation at a command boundary: start of segment, or after ; & | ( newline / then / do.
318
+ // FIX 4 (isRegionScoped tightened): return true ONLY when grep is downstream of a
319
+ // sed line-range or awk range producer. Other pipe sources (cat, tac, etc.) are file-wide.
320
+ const isRegionScoped = (seg) => {
321
+ if (!seg.includes('|'))
322
+ return false;
323
+ const before = seg.slice(0, seg.lastIndexOf('|'));
324
+ // sed -n line/range extraction, e.g. sed -n '12,40p' FILE or sed -n '/a/,/b/p' FILE
325
+ if (/\bsed\s+-n\b/.test(before))
326
+ return true;
327
+ // awk range pattern, e.g. awk '/start/,/end/' FILE
328
+ if (/\bawk\b[^|]*\/[^/]*\/\s*,\s*\/[^/]*\//.test(before))
329
+ return true;
330
+ return false;
331
+ };
332
+ const taskRe = /<task[^>]*>([\s\S]*?)<\/task>/g;
333
+ const tasks = [];
334
+ let tm;
335
+ while ((tm = taskRe.exec(text)) !== null) {
336
+ const tc = tm[1];
337
+ // Extract task name.
338
+ const namem = tc.match(/<name>([\s\S]*?)<\/name>/);
339
+ const name = namem ? namem[1].trim() : 'unnamed';
340
+ // Extract <files> entries.
341
+ const filesm = tc.match(/<files>([\s\S]*?)<\/files>/);
342
+ const filesText = filesm ? filesm[1] : '';
343
+ const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
344
+ // Gate text: <verify>/<automated>/<acceptance_criteria>.
345
+ const gateFragments = [];
346
+ for (const tag of ['verify', 'automated', 'acceptance_criteria']) {
347
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
348
+ let mm;
349
+ while ((mm = re.exec(tc)) !== null)
350
+ gateFragments.push(mm[1]);
351
+ }
352
+ // Requirement text: <action>/<acceptance_criteria>.
353
+ const reqFragments = [];
354
+ for (const tag of ['action', 'acceptance_criteria']) {
355
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
356
+ let mm;
357
+ while ((mm = re.exec(tc)) !== null)
358
+ reqFragments.push(mm[1]);
359
+ }
360
+ // Strip XML tags from gate text so segments containing embedded
361
+ // XML closing tags (e.g. <automated>cmd</automated> nested inside <verify>)
362
+ // don't bleed into the file-path token extraction.
363
+ const rawGateText = gateFragments.join('\n');
364
+ const gateText = rawGateText.replace(/<[^>]+>/g, ' ');
365
+ tasks.push({
366
+ name,
367
+ files,
368
+ gateText,
369
+ reqText: reqFragments.join('\n'),
370
+ });
371
+ }
372
+ if (tasks.length < 2)
373
+ return { warnings, valid: true };
374
+ // FIX 3 (extensionless known files): build a normalized set of ALL tasks' <files> entries
375
+ // so that extensionless filenames like Dockerfile are also recognized as valid file tokens.
376
+ const knownFiles = new Set();
377
+ for (const t of tasks) {
378
+ for (const f of t.files)
379
+ knownFiles.add(normPath(f));
380
+ }
381
+ // Extended looksLikePath: accepts known <files> entries even without an extension.
382
+ const isFileLike = (token) => {
383
+ if (token.includes('*'))
384
+ return false; // exclude globs
385
+ if (looksLikePath(token))
386
+ return true;
387
+ return knownFiles.has(normPath(token));
388
+ };
389
+ // Dedup key: (taskAIdx, taskBIdx, pat, file)
390
+ const seen = new Set();
391
+ // For each task A, scan gate text for file-wide negative grep bans.
392
+ for (let ai = 0; ai < tasks.length; ai++) {
393
+ const taskA = tasks[ai];
394
+ // Split gate text into shell segments (split on && / || within lines).
395
+ const segments = taskA.gateText.split('\n').flatMap(line => line.split(/\s*(?:&&|\|\|)\s*/));
396
+ for (const seg of segments) {
397
+ if (!/grep/.test(seg))
398
+ continue;
399
+ // FIX 5: Negation at a command boundary: start of segment, or after ; & | ( newline / then / do.
400
+ // Also handles ! negating an entire pipeline (e.g. ! cat FILE | grep ...).
401
+ const hasLeadingNot =
402
+ // Direct ! grep: negation immediately before grep keyword
403
+ /(?:^|[\n;&|(]|\bthen\b|\bdo\b)\s*!\s*grep/.test(seg) ||
404
+ // Pipeline negation: ! at command boundary, grep appears in pipeline after |
405
+ (/(?:^|[\n;&|(]|\bthen\b|\bdo\b)\s*!\s*\w/.test(seg) && /\|\s*grep\b/.test(seg));
406
+ const hasCountZero = zeroCmp(seg);
407
+ // Extract grep invocation and check for count.
408
+ grepArgRe.lastIndex = 0;
409
+ let pat = null;
410
+ let file = null;
411
+ let isBan = false;
412
+ // FIX 4 helper: given a segment and the grep match end position, find the
413
+ // file argument. First try the token immediately after PAT; if none qualifies,
414
+ // try a cat/tac producer or < FILE redirect from the full segment.
415
+ const resolveFileArg = (segment, afterPatStr) => {
416
+ // Primary: token immediately after PAT in the grep command
417
+ const fileM = afterPatStr.match(/^\s+([^\s'"|>&;$()\[\]]+)/);
418
+ const rawFile = fileM ? fileM[1] : null;
419
+ if (rawFile && isFileLike(rawFile))
420
+ return rawFile;
421
+ // FIX 4: For NON-region segments, also look for cat/tac producer or < FILE redirect
422
+ const catM = segment.match(/\b(?:cat|tac)\s+([^\s'"|>&;()]+)/);
423
+ if (catM && isFileLike(catM[1]))
424
+ return catM[1];
425
+ const redirM = segment.match(/<\s*([^\s'"|>&;()]+)/);
426
+ if (redirM && isFileLike(redirM[1]))
427
+ return redirM[1];
428
+ return null;
429
+ };
430
+ // If leading !, it might be a count or a direct !grep
431
+ if (hasLeadingNot && !hasCountZero) {
432
+ // Direct ! grep PAT FILE form: grep opts PAT FILE
433
+ // Extract PAT and FILE from the grep invocation
434
+ grepArgRe.lastIndex = 0;
435
+ let gm;
436
+ while ((gm = grepArgRe.exec(seg)) !== null) {
437
+ const opts = gm[1];
438
+ if (optsHaveInvert(opts))
439
+ continue; // -v form: not a ban
440
+ // PAT
441
+ const rawPat = gm[2] !== undefined ? gm[2] :
442
+ gm[3] !== undefined ? gm[3] :
443
+ gm[4] !== undefined && plausibleBare(gm[4]) ? gm[4] : null;
444
+ if (!rawPat)
445
+ continue;
446
+ // FILE: next non-option token after PAT (or cat/tac/redirect in segment)
447
+ const afterPat = seg.slice((gm.index || 0) + gm[0].length);
448
+ const rawFile = resolveFileArg(seg, afterPat);
449
+ if (rawFile) {
450
+ pat = rawPat;
451
+ file = rawFile;
452
+ isBan = true;
453
+ }
454
+ }
455
+ }
456
+ if (!isBan && hasCountZero) {
457
+ // count grep form: grep -c PAT FILE == 0 or [ $(grep -c PAT FILE) -eq 0 ]
458
+ grepArgRe.lastIndex = 0;
459
+ let gm;
460
+ while ((gm = grepArgRe.exec(seg)) !== null) {
461
+ const opts = gm[1];
462
+ if (!optsHaveCount(opts) || optsHaveInvert(opts))
463
+ continue;
464
+ const rawPat = gm[2] !== undefined ? gm[2] :
465
+ gm[3] !== undefined ? gm[3] :
466
+ gm[4] !== undefined && plausibleBare(gm[4]) ? gm[4] : null;
467
+ if (!rawPat)
468
+ continue;
469
+ const afterPat = seg.slice((gm.index || 0) + gm[0].length);
470
+ const rawFile = resolveFileArg(seg, afterPat);
471
+ if (rawFile) {
472
+ pat = rawPat;
473
+ file = rawFile;
474
+ isBan = true;
475
+ }
476
+ }
477
+ }
478
+ if (!isBan || !pat || !file)
479
+ continue;
480
+ if (allow.has(pat))
481
+ continue;
482
+ // Skip if region-scoped (grep downstream of a sed/awk pipe — region extracted)
483
+ if (isRegionScoped(seg))
484
+ continue;
485
+ // For each other task B: check if B's <files> includes FILE AND B's reqText contains PAT
486
+ for (let bi = 0; bi < tasks.length; bi++) {
487
+ if (bi === ai)
488
+ continue;
489
+ const taskB = tasks[bi];
490
+ // FIX 2: Exact normalized match; basename fallback ONLY for unqualified gate files.
491
+ const gateFile = normPath(file);
492
+ const bMatchesFile = taskB.files.some((bf) => {
493
+ const nbf = normPath(bf);
494
+ if (nbf === gateFile)
495
+ return true;
496
+ // basename fallback only when the gate file is an unqualified bare filename (no dir separator)
497
+ if (!gateFile.includes('/') && node_path_1.default.basename(nbf) === gateFile)
498
+ return true;
499
+ return false;
500
+ });
501
+ if (!bMatchesFile)
502
+ continue;
503
+ // FIX 1: Use linear-time patternRequiredIn instead of new RegExp (ReDoS-safe).
504
+ const bRequiresPat = patternRequiredIn(pat, taskB.reqText);
505
+ if (!bRequiresPat)
506
+ continue;
507
+ const dedupeKey = `${ai}:${bi}:${pat}:${file}`;
508
+ if (seen.has(dedupeKey))
509
+ continue;
510
+ seen.add(dedupeKey);
511
+ warnings.push(`Region-scope conflict (#968): task "${taskA.name}" negative-greps "${pat}" file-wide on ${file}, ` +
512
+ `but sibling task "${taskB.name}" requires it in the same file. ` +
513
+ `A file-wide ban is unsatisfiable when a sibling needs the construct elsewhere — ` +
514
+ `region-scope task "${taskA.name}"'s gate (sed -n/awk range then grep) or use an AST/test check. ` +
515
+ `See planner-antipatterns.md "Region-Scoped Negative Gates", or add ` +
516
+ `<!-- planner-region-allow: ${pat} --> if intentional.`);
517
+ }
518
+ }
519
+ }
520
+ // This detector is warn-only: it never sets valid=false.
521
+ return { warnings, valid: true };
522
+ }
218
523
  function cmdVerifyPlanStructure(cwd, filePath, raw) {
219
524
  if (!filePath) {
220
525
  error('file path required');
@@ -272,6 +577,8 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
272
577
  const echoScan = scanNegativeGrepCommentEcho(content);
273
578
  errors.push(...echoScan.errors);
274
579
  warnings.push(...echoScan.warnings);
580
+ const conflictScan = scanFileWideNegativeGateConflict(content);
581
+ warnings.push(...conflictScan.warnings);
275
582
  output({
276
583
  valid: errors.length === 0,
277
584
  errors,
@@ -1121,7 +1428,6 @@ function cmdValidateHealth(cwd, options, raw) {
1121
1428
  if (phaseConvention === 'milestone-prefixed') {
1122
1429
  if (node_fs_1.default.existsSync(roadmapPath)) {
1123
1430
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1124
- const { getMilestoneFromPhaseId } = core;
1125
1431
  const mismatches = checkMilestonePrefixMismatches(roadmapContent, {
1126
1432
  getMilestoneFromPhaseId: getMilestoneFromPhaseId,
1127
1433
  });
@@ -1610,6 +1916,7 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
1610
1916
  }
1611
1917
  module.exports = {
1612
1918
  scanNegativeGrepCommentEcho,
1919
+ scanFileWideNegativeGateConflict,
1613
1920
  cmdVerifySummary,
1614
1921
  cmdVerifyPlanStructure,
1615
1922
  cmdVerifyPhaseCompleteness,
@@ -19,8 +19,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
19
19
  const node_fs_1 = __importDefault(require("node:fs"));
20
20
  const node_path_1 = __importDefault(require("node:path"));
21
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
- const core = require("./core.cjs");
23
- const { readSubdirectories } = core;
22
+ const coreUtilsMod = require("./core-utils.cjs");
23
+ const { readSubdirectories } = coreUtilsMod;
24
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
25
  const planScan = require("./plan-scan.cjs");
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -18,8 +18,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  const node_fs_1 = __importDefault(require("node:fs"));
19
19
  const node_path_1 = __importDefault(require("node:path"));
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
- const core = require("./core.cjs");
22
- const { output, error, toPosixPath, getMilestoneInfo, generateSlugInternal } = core;
21
+ const io = require("./io.cjs");
22
+ const { output, error } = io;
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const coreUtils = require("./core-utils.cjs");
25
+ const { toPosixPath, generateSlugInternal } = coreUtils;
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ const roadmapParser = require("./roadmap-parser.cjs");
28
+ const { getMilestoneInfo } = roadmapParser;
23
29
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
24
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
31
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -172,8 +172,7 @@ function executeWorktreePrunePlan(plan, deps = {}) {
172
172
  }
173
173
  const result = execGit(['worktree', 'prune'], { cwd: plan.repoRoot });
174
174
  if (result.timedOut) {
175
- // AC4: surface timedOut as a first-class field so callers (e.g.
176
- // pruneOrphanedWorktrees in core.cjs) can log a structured WARNING rather
175
+ // AC4: surface timedOut as a first-class field so callers can log a structured WARNING rather
177
176
  // than silently ignoring it (PRED.k302 — error-swallowing-empty-sentinel).
178
177
  return {
179
178
  ok: false,
@@ -305,11 +304,14 @@ function normalizeCleanupManifestEntry(entry) {
305
304
  return null;
306
305
  if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch))
307
306
  return null;
307
+ const rawAllowedBases = Array.isArray(e.allowed_bases) ? e.allowed_bases : [];
308
+ const allowedBases = Array.from(new Set([expectedBase, ...rawAllowedBases.filter((base) => typeof base === 'string' && base.length > 0)]));
308
309
  return {
309
310
  agent_id: typeof e.agent_id === 'string' ? e.agent_id : null,
310
311
  worktree_path: worktreePath,
311
312
  branch,
312
313
  expected_base: expectedBase,
314
+ allowed_bases: allowedBases,
313
315
  };
314
316
  }
315
317
  function normalizeCleanupManifest(manifest) {
@@ -530,7 +532,10 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
530
532
  break;
531
533
  }
532
534
  const mergeBase = execGit(['merge-base', 'HEAD', entry.branch], { cwd: plan.repoRoot });
533
- if (!gitResultOk(mergeBase) || mergeBase.stdout.trim() !== entry.expected_base) {
535
+ const allowedBases = Array.isArray(entry.allowed_bases) && entry.allowed_bases.length > 0
536
+ ? entry.allowed_bases
537
+ : [entry.expected_base];
538
+ if (!gitResultOk(mergeBase) || !allowedBases.includes(mergeBase.stdout.trim())) {
534
539
  result.status = 'blocked';
535
540
  result.reason = 'base_mismatch';
536
541
  result.stderr = mergeBase?.stderr || '';
@@ -927,6 +932,40 @@ function cmdWorktreeReapOrphans(cwd) {
927
932
  }
928
933
  // Unused exports kept for API compatibility
929
934
  void parseWorktreeListPaths;
935
+ // ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ─────────────
936
+ /**
937
+ * Resolve the main worktree root when running inside a git worktree.
938
+ * In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
939
+ * Returns the main worktree path, or cwd if not in a worktree.
940
+ */
941
+ function resolveWorktreeRoot(cwd) {
942
+ const context = resolveWorktreeContext(cwd, {
943
+ existsSync: node_fs_1.default.existsSync,
944
+ });
945
+ return context.effectiveRoot;
946
+ }
947
+ /**
948
+ * Clear stale worktree metadata references via `git worktree prune`.
949
+ *
950
+ * Destructive linked-worktree removal is disabled by default for safety.
951
+ *
952
+ * @param repoRoot - absolute path to the main (or any) worktree of
953
+ * the repository; used as `cwd` for git commands.
954
+ * @returns list of worktree paths that were removed (always empty)
955
+ */
956
+ function pruneOrphanedWorktrees(repoRoot) {
957
+ try {
958
+ const plan = planWorktreePrune(repoRoot, { allowDestructive: false }, { parseWorktreePorcelain });
959
+ const pruneResult = executeWorktreePrunePlan(plan);
960
+ if (pruneResult && pruneResult.timedOut) {
961
+ process.stderr.write('[gsd-tools] WARNING: worktree health check degraded' +
962
+ ' — git worktree prune timed out after 10s.' +
963
+ ' Orphaned worktree metadata may remain until the next successful run.\n');
964
+ }
965
+ }
966
+ catch { /* never crash the caller */ }
967
+ return [];
968
+ }
930
969
  module.exports = {
931
970
  resolveWorktreeContext,
932
971
  parseWorktreePorcelain,
@@ -941,4 +980,6 @@ module.exports = {
941
980
  cmdWorktreeCleanupWave,
942
981
  reapOrphanWorktrees,
943
982
  cmdWorktreeReapOrphans,
983
+ resolveWorktreeRoot,
984
+ pruneOrphanedWorktrees,
944
985
  };
@@ -53,6 +53,8 @@
53
53
  "workflow.cross_ai_command",
54
54
  "workflow.cross_ai_timeout",
55
55
  "workflow.subagent_timeout",
56
+ "workflow.test_command",
57
+ "workflow.build_command",
56
58
  "executor.stall_detect_interval_minutes",
57
59
  "executor.stall_threshold_minutes",
58
60
  "workflow.inline_plan_threshold",
@@ -128,3 +128,49 @@ When the literal MUST appear in the plan body verbatim — e.g. the plan documen
128
128
  ```
129
129
 
130
130
  One marker per literal. The marker exempts only the exact literal it names.
131
+
132
+ ## Region-Scoped Negative Gates
133
+
134
+ > Surfaced at plan-write time by `verify.plan-structure` (the `validate_plan` step), WARN-level. Issue #968.
135
+
136
+ A **negative grep** — `! grep -Eq 'PAT' file` or `grep -c 'PAT' file == 0` — asserts a construct is absent. `grep` is file-scoped by nature: it has no notion of function or region. This breaks down when a phase splits one file across parallel tasks with legitimately opposite needs for the same construct in different regions:
137
+
138
+ - **Task A** bans the construct file-wide — a synchronous factory must not block on a refresh: `! grep -Eq 'await .*refresh' app/page.py`.
139
+ - **Task B** legitimately requires it elsewhere in the same file — a post-reindex handler must `await bridge.refresh()` to repopulate state.
140
+
141
+ Both occurrences are real, correct production code in different functions of one file. A file-wide negative grep cannot say "absent in function X, present in function Y", so the two gates are mutually unsatisfiable with a direct call — the executor is pushed into an indirection whose only purpose is to relocate the matched string out of the file (pure gate-appeasement, zero behaviour change). This is distinct from Comment-Text Discipline (#429): there is no comment echo and no allowlist helps — the construct must genuinely be present in one region and absent in another.
142
+
143
+ **The fix:** region-scope the negative gate so "absent in region X" stops implying "absent file-wide."
144
+
145
+ ### Bad — file-wide ban unsatisfiable against a sibling's real code
146
+
147
+ ```xml
148
+ <!-- Task A -->
149
+ <verify><automated>! grep -Eq 'await .*refresh' app/page.py</automated></verify>
150
+ <!-- Task B (same file, different function) -->
151
+ <action>Add a post-reindex handler in app/page.py that awaits bridge.refresh().</action>
152
+ <files>app/page.py</files>
153
+ ```
154
+
155
+ Task B writing `await bridge.refresh()` trips Task A's file-wide gate, though both are correct.
156
+
157
+ ### Good — scope the gate to the factory region
158
+
159
+ ```xml
160
+ <!-- Task A: ban only inside the synchronous factory, not the whole file -->
161
+ <verify><automated>! awk '/^def make_page/,/^def /' app/page.py | grep -Eq 'await .*refresh'</automated></verify>
162
+ <!-- or a fixed line range -->
163
+ <verify><automated>! sed -n '12,40p' app/page.py | grep -Eq 'await .*refresh'</automated></verify>
164
+ ```
165
+
166
+ The factory region is asserted clean; the reindex handler elsewhere in the same file keeps its required `await bridge.refresh()`. Both gates pass with no code restructuring. Prefer an AST/structural check or a focused unit test where region extraction is fragile.
167
+
168
+ ### When the split is intentional and unavoidable
169
+
170
+ If region-scoping is genuinely impractical and the file split is intentional, suppress the warning with a marker naming the pattern:
171
+
172
+ ```
173
+ <!-- planner-region-allow: await .*refresh -->
174
+ ```
175
+
176
+ One marker per pattern. The marker exempts only the exact pattern it names. Prefer region-scoping over suppression.
@@ -36,6 +36,8 @@ Configuration options for `.planning/` directory behavior.
36
36
  | `git.quick_branch_template` | `null` | Optional branch template for quick-task runs |
37
37
  | `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. Note: if your branch is ahead of `origin/HEAD` (a diverged milestone or feature branch), GSD auto-degrades to sequential and prints a warning; set `worktree.baseRef:"head"` in `.claude/settings.local.json` to restore parallel execution. See the branch-divergence note below. |
38
38
  | `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). |
39
+ | `workflow.test_command` | `null` | Custom shell command run as the regression/test gate by verify-phase, execute-phase, audit-fix, and post-merge-gate. When unset, GSD auto-detects (Makefile / package.json / Cargo.toml / go.mod / pyproject.toml). Example: `npm test`. |
40
+ | `workflow.build_command` | `null` | Custom shell command run as the build gate by the post-merge gate. When unset, the build step is skipped/auto-detected. Example: `npm run build`. |
39
41
  | `workflow.inline_plan_threshold` | `2` | Plans with this many tasks or fewer execute inline (Pattern C) instead of spawning a subagent. Avoids ~14K token spawn overhead for small plans. Set to `0` to always spawn subagents. |
40
42
  | `manager.flags.discuss` | `""` | Flags passed to `/gsd:discuss-phase` when dispatched from manager (e.g. `"--auto --analyze"`) |
41
43
  | `manager.flags.plan` | `""` | Flags passed to plan workflow when dispatched from manager |
@@ -220,7 +222,7 @@ Squash merge is recommended — keeps main branch history clean while preserving
220
222
 
221
223
  ## Complete Field Reference
222
224
 
223
- Generated from `CONFIG_DEFAULTS` (core.cjs) and `VALID_CONFIG_KEYS` (config.cjs).
225
+ Generated from `CONFIG_DEFAULTS` (configuration.cjs) and `VALID_CONFIG_KEYS` (config-schema.cjs).
224
226
 
225
227
  ### Core Fields
226
228
 
@@ -262,6 +264,8 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
262
264
  | `workflow.skip_discuss` | boolean | `false` | `true`, `false` | Skip discuss phase entirely |
263
265
  | `workflow.use_worktrees` | boolean | `true` | `true`, `false` | Run executor agents in isolated git worktrees |
264
266
  | `workflow.subagent_timeout` | number | `300000` | Any positive integer (ms) | Timeout for parallel subagent tasks (default: 5 minutes) |
267
+ | `workflow.test_command` | string\|null | `null` | Any shell command | Regression/test gate command run by verify-phase, execute-phase, audit-fix, and post-merge-gate. Unset → GSD auto-detects (Makefile / package.json / Cargo.toml / go.mod / pyproject.toml). |
268
+ | `workflow.build_command` | string\|null | `null` | Any shell command | Build gate command run by the post-merge gate. Unset → build step auto-detected/skipped. |
265
269
  | `workflow.inline_plan_threshold` | number | `2` | `0`–`10` | Plans with ≤N tasks execute inline instead of spawning a subagent |
266
270
  | `workflow.code_review` | boolean | `true` | `true`, `false` | Enable built-in code review step in the ship workflow |
267
271
  | `workflow.code_review_depth` | string | `"standard"` | `"light"`, `"standard"`, `"deep"` | Depth level for code review analysis in the ship workflow |