@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.
- package/.claude-plugin/plugin.json +1 -1
- package/LICENSE +1 -1
- package/agents/gsd-executor.md +22 -0
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-planner.md +4 -0
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-verifier.md +45 -13
- package/bin/install.js +56 -100
- package/commands/gsd/autonomous.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/plan-phase.md +1 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +85 -53
- package/gsd-core/bin/lib/agent-command-router.cjs +2 -2
- package/gsd-core/bin/lib/agent-install-check.cjs +143 -0
- package/gsd-core/bin/lib/audit-command-router.cjs +4 -4
- package/gsd-core/bin/lib/capability-activation.cjs +37 -10
- package/gsd-core/bin/lib/capability-registry.cjs +2 -0
- package/gsd-core/bin/lib/capability-state.cjs +80 -30
- package/gsd-core/bin/lib/capability-writer.cjs +5 -4
- package/gsd-core/bin/lib/check-command-router.cjs +50 -51
- package/gsd-core/bin/lib/commands.cjs +20 -2
- package/gsd-core/bin/lib/config-loader.cjs +3 -4
- package/gsd-core/bin/lib/config-schema.cjs +1 -1
- package/gsd-core/bin/lib/config-types.cjs +2 -1
- package/gsd-core/bin/lib/config.cjs +5 -2
- package/gsd-core/bin/lib/decisions.cjs +19 -1
- package/gsd-core/bin/lib/docs.cjs +14 -2
- package/gsd-core/bin/lib/frontmatter.cjs +2 -2
- package/gsd-core/bin/lib/gap-checker.cjs +5 -2
- package/gsd-core/bin/lib/git-base-branch.cjs +27 -1
- package/gsd-core/bin/lib/graphify-command-router.cjs +6 -8
- package/gsd-core/bin/lib/graphify.cjs +7 -31
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -2
- package/gsd-core/bin/lib/init.cjs +30 -4
- package/gsd-core/bin/lib/intel-command-router.cjs +6 -3
- package/gsd-core/bin/lib/intel.cjs +28 -34
- package/gsd-core/bin/lib/io.cjs +2 -4
- package/gsd-core/bin/lib/learnings.cjs +2 -2
- package/gsd-core/bin/lib/loop-resolver.cjs +45 -167
- package/gsd-core/bin/lib/milestone.cjs +13 -5
- package/gsd-core/bin/lib/model-resolver.cjs +3 -4
- package/gsd-core/bin/lib/phase-id.cjs +3 -5
- package/gsd-core/bin/lib/phase-locator.cjs +3 -6
- package/gsd-core/bin/lib/phase.cjs +59 -13
- package/gsd-core/bin/lib/probe-core.cjs +40 -11
- package/gsd-core/bin/lib/profile-output.cjs +5 -2
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +660 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +28 -25
- package/gsd-core/bin/lib/roadmap.cjs +9 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
- package/gsd-core/bin/lib/state.cjs +75 -17
- package/gsd-core/bin/lib/task-command-router.cjs +2 -2
- package/gsd-core/bin/lib/teams-status.cjs +74 -0
- package/gsd-core/bin/lib/template.cjs +11 -2
- package/gsd-core/bin/lib/uat.cjs +64 -2
- package/gsd-core/bin/lib/verification.cjs +8 -5
- package/gsd-core/bin/lib/verify.cjs +311 -4
- package/gsd-core/bin/lib/workstream-inventory.cjs +2 -2
- package/gsd-core/bin/lib/workstream.cjs +8 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +44 -3
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/references/planner-antipatterns.md +46 -0
- package/gsd-core/references/planning-config.md +5 -1
- package/gsd-core/references/prohibition-probe.md +80 -2
- package/gsd-core/references/worktree-branch-check.md +11 -5
- package/gsd-core/templates/verification-report.md +16 -3
- package/gsd-core/workflows/docs-update.md +23 -31
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +9 -0
- package/gsd-core/workflows/execute-phase.md +7 -2
- package/gsd-core/workflows/map-codebase.md +8 -10
- package/gsd-core/workflows/plan-phase.md +6 -0
- package/gsd-core/workflows/quick.md +26 -2
- package/gsd-core/workflows/settings-advanced.md +5 -5
- package/gsd-core/workflows/settings-integrations.md +5 -5
- package/gsd-core/workflows/spec-phase.md +30 -2
- package/gsd-core/workflows/verify-phase.md +22 -7
- package/hooks/dist/gsd-worktree-path-guard.js +34 -18
- package/hooks/gsd-worktree-path-guard.js +34 -18
- package/package.json +3 -3
- package/scripts/ci-prepare-test-scope.cjs +56 -14
- package/scripts/diff-touches-shipped-paths.cjs +5 -11
- package/scripts/gen-capability-registry.cjs +27 -1
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
- package/scripts/research-profiles.cjs +2 -2
- package/scripts/run-tests.cjs +1 -0
- 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
|
-
|
|
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
|
|
23
|
-
const { readSubdirectories } =
|
|
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
|
|
22
|
-
const { output, error
|
|
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
|
|
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
|
-
|
|
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` (
|
|
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 |
|