@opengsd/gsd-core 1.6.0-rc.1 → 1.6.0-rc.2
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/agents/gsd-roadmapper.md +6 -0
- package/bin/install.js +92 -332
- package/commands/gsd/capture.md +5 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +18 -3
- package/gsd-core/bin/lib/adr-parser.cjs +21 -6
- package/gsd-core/bin/lib/capability-registry.cjs +48 -48
- package/gsd-core/bin/lib/commands.cjs +247 -0
- package/gsd-core/bin/lib/config-loader.cjs +6 -0
- package/gsd-core/bin/lib/config.cjs +6 -0
- package/gsd-core/bin/lib/frontmatter.cjs +7 -3
- package/gsd-core/bin/lib/phase-id.cjs +25 -11
- package/gsd-core/bin/lib/phase.cjs +4 -4
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
- package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
- package/gsd-core/bin/lib/roadmap-parser.cjs +29 -8
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
- package/gsd-core/bin/lib/roadmap.cjs +5 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
- package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
- package/gsd-core/bin/lib/surface.cjs +12 -19
- package/gsd-core/bin/lib/validate.cjs +5 -2
- package/gsd-core/bin/lib/verify.cjs +11 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
- package/gsd-core/bin/shared/config-defaults.manifest.json +2 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/context-budget.md +8 -8
- package/gsd-core/references/execute-phase-context-guard.md +16 -0
- package/gsd-core/references/planning-config.md +1 -0
- package/gsd-core/references/prohibition-probe.md +15 -9
- package/gsd-core/workflows/autonomous.md +33 -33
- package/gsd-core/workflows/diagnose-issues.md +6 -1
- package/gsd-core/workflows/execute-phase.md +8 -6
- package/gsd-core/workflows/help/modes/full.md +10 -0
- package/gsd-core/workflows/list-seeds.md +63 -0
- package/gsd-core/workflows/manager.md +37 -37
- package/gsd-core/workflows/pr-branch.md +156 -0
- package/gsd-core/workflows/quick.md +6 -1
- package/gsd-core/workflows/review.md +10 -2
- package/gsd-core/workflows/spec-phase.md +8 -3
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/package.json +4 -1
- package/scripts/prompt-injection-scan.sh +1 -0
|
@@ -510,13 +510,49 @@ function normalizeContent(filePath, content, opts = {}) {
|
|
|
510
510
|
}
|
|
511
511
|
return { content: normalized, encoding };
|
|
512
512
|
}
|
|
513
|
+
// Rename errnos that are transient on Windows: a concurrent reader (or an AV
|
|
514
|
+
// scanner / indexer) holding the target open makes renameSync fail briefly.
|
|
515
|
+
// Same idiom as capability-ledger.cts / capability-consent.cts.
|
|
516
|
+
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
517
|
+
const RENAME_MAX_ATTEMPTS = 3;
|
|
518
|
+
const RENAME_RETRY_BACKOFF_MS = 50;
|
|
519
|
+
/** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
|
|
520
|
+
let _renameSleepBuf = null;
|
|
521
|
+
function renameBackoff() {
|
|
522
|
+
if (_renameSleepBuf === null)
|
|
523
|
+
_renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
524
|
+
Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Atomic publish with bounded retry on transient Windows lock errnos.
|
|
528
|
+
* Returns null on success, or the final error if every attempt failed.
|
|
529
|
+
*/
|
|
530
|
+
function atomicRenameWithRetry(tmpPath, filePath) {
|
|
531
|
+
let renameErr = null;
|
|
532
|
+
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
|
|
533
|
+
try {
|
|
534
|
+
node_fs_1.default.renameSync(tmpPath, filePath);
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
renameErr = err;
|
|
539
|
+
if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
|
|
540
|
+
renameBackoff();
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return renameErr;
|
|
547
|
+
}
|
|
513
548
|
function platformWriteSync(filePath, content, opts = {}) {
|
|
514
549
|
const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
|
|
515
550
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
|
|
516
551
|
const tmpPath = filePath + '.tmp.' + process.pid;
|
|
552
|
+
// Step 1: write the sibling tmp file. If THIS fails, nothing was published, so a
|
|
553
|
+
// direct fallback write cannot truncate a concurrent reader of an existing file.
|
|
517
554
|
try {
|
|
518
555
|
node_fs_1.default.writeFileSync(tmpPath, normalized, encoding);
|
|
519
|
-
node_fs_1.default.renameSync(tmpPath, filePath);
|
|
520
556
|
}
|
|
521
557
|
catch {
|
|
522
558
|
try {
|
|
@@ -524,7 +560,25 @@ function platformWriteSync(filePath, content, opts = {}) {
|
|
|
524
560
|
}
|
|
525
561
|
catch { /* already gone */ }
|
|
526
562
|
node_fs_1.default.writeFileSync(filePath, normalized, encoding);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// Step 2: atomic publish, retrying transient Windows locks.
|
|
566
|
+
const renameErr = atomicRenameWithRetry(tmpPath, filePath);
|
|
567
|
+
if (renameErr === null)
|
|
568
|
+
return;
|
|
569
|
+
try {
|
|
570
|
+
node_fs_1.default.unlinkSync(tmpPath);
|
|
571
|
+
}
|
|
572
|
+
catch { /* already gone */ }
|
|
573
|
+
if (RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
|
|
574
|
+
// A live reader still holds the target open after every retry. A non-atomic
|
|
575
|
+
// direct write here would truncate that reader (the exact corruption this seam
|
|
576
|
+
// exists to prevent), so surface the error instead of falling back.
|
|
577
|
+
throw renameErr;
|
|
527
578
|
}
|
|
579
|
+
// Atomic publish is genuinely impossible here (e.g. EXDEV cross-device move):
|
|
580
|
+
// fall back to a direct write to preserve write availability.
|
|
581
|
+
node_fs_1.default.writeFileSync(filePath, normalized, encoding);
|
|
528
582
|
}
|
|
529
583
|
function platformReadSync(filePath, opts = {}) {
|
|
530
584
|
const encoding = opts.encoding ?? 'utf-8';
|
|
@@ -33,7 +33,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
33
33
|
};
|
|
34
34
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
35
35
|
const node_path_1 = __importDefault(require("node:path"));
|
|
36
|
-
const node_os_1 = __importDefault(require("node:os"));
|
|
37
36
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
38
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
38
|
const installProfiles = require("./install-profiles.cjs");
|
|
@@ -41,7 +40,9 @@ const { readActiveProfile, resolveProfile, loadSkillsManifest, } = installProfil
|
|
|
41
40
|
const clusters_cjs_1 = require("./clusters.cjs");
|
|
42
41
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
42
|
const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
|
|
44
|
-
const { findInstallSourceRoot
|
|
43
|
+
const { findInstallSourceRoot } = runtimeArtifactLayout;
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
|
+
const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
|
|
45
46
|
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
|
46
47
|
/**
|
|
47
48
|
* Read the surface state from a runtime config directory.
|
|
@@ -265,26 +266,18 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry)
|
|
|
265
266
|
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
|
|
266
267
|
// Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
|
|
267
268
|
// so SKILL.md bodies reference the install target (pathPrefix), not the
|
|
268
|
-
// converter's default ~/.claude paths (#813).
|
|
269
|
-
//
|
|
270
|
-
|
|
269
|
+
// converter's default ~/.claude paths (#813). Delegated to the conversion
|
|
270
|
+
// module's deep seam (ADR-1508 / #1511 Phase 2) — no attribution resolver
|
|
271
|
+
// needed here (proven: Co-Authored-By never appears in staged content; see
|
|
272
|
+
// brief PROVEN KEY FACT). No getInstallExports() call required.
|
|
271
273
|
for (const kind of layout.kinds) {
|
|
272
274
|
const staged = kind.stage(resolved);
|
|
273
275
|
if (kind.kind === 'skills') {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
pathPrefix = installExports.computePathPrefix({
|
|
280
|
-
isGlobal: scope === 'global',
|
|
281
|
-
isOpencode: layout.runtime === 'opencode',
|
|
282
|
-
isWindowsHost: process.platform === 'win32',
|
|
283
|
-
resolvedTarget,
|
|
284
|
-
homeDir,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
installExports.applyRuntimeContentRewritesInPlace(staged, layout.runtime, pathPrefix);
|
|
276
|
+
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
|
|
277
|
+
runtime: layout.runtime,
|
|
278
|
+
configDir: layout.configDir,
|
|
279
|
+
scope: layout.scope ?? 'global',
|
|
280
|
+
});
|
|
288
281
|
}
|
|
289
282
|
const dest = node_path_1.default.join(layout.configDir, kind.destSubpath);
|
|
290
283
|
_syncGsdDir(staged, dest, kind, skillManifest);
|
|
@@ -37,14 +37,17 @@ exports.canonicalPlanStem = canonicalPlanStem;
|
|
|
37
37
|
exports.phaseVariants = phaseVariants;
|
|
38
38
|
exports.buildRoadmapPhaseVariants = buildRoadmapPhaseVariants;
|
|
39
39
|
exports.buildNotStartedPhaseVariants = buildNotStartedPhaseVariants;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const phaseIdMod = require("./phase-id.cjs");
|
|
42
|
+
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod;
|
|
40
43
|
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
|
|
41
44
|
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
|
|
42
45
|
// deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
|
|
43
|
-
exports.phaseDirNameRe =
|
|
46
|
+
exports.phaseDirNameRe = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`, 'i');
|
|
44
47
|
// Extracts the full phase token from a directory name, including milestone-prefixed
|
|
45
48
|
// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
|
|
46
49
|
// Greedily captures all leading all-digit segments before the first letter-start segment.
|
|
47
|
-
exports.PHASE_TOKEN_FROM_DIR_RE =
|
|
50
|
+
exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d+)*[A-Z]?(?:\\.\\d+)*)(?:-[a-z]|$)`, 'i');
|
|
48
51
|
exports.MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
|
49
52
|
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
|
|
50
53
|
function canonicalPlanStem(stem) {
|
|
@@ -1887,8 +1887,17 @@ function cmdVerifyCodebaseDrift(cwd, raw) {
|
|
|
1887
1887
|
else if (status === 'D')
|
|
1888
1888
|
deleted.push(file);
|
|
1889
1889
|
}
|
|
1890
|
-
|
|
1891
|
-
|
|
1890
|
+
// loadConfig() returns a flattened object — there is no nested `workflow`
|
|
1891
|
+
// key. Read the raw config.json directly to access workflow-scoped keys,
|
|
1892
|
+
// matching the pattern used in check-command-router.cts:readWorkflowConfig.
|
|
1893
|
+
let wf;
|
|
1894
|
+
try {
|
|
1895
|
+
const rawCfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'config.json'), 'utf-8'));
|
|
1896
|
+
wf = rawCfg['workflow'];
|
|
1897
|
+
}
|
|
1898
|
+
catch {
|
|
1899
|
+
wf = undefined;
|
|
1900
|
+
}
|
|
1892
1901
|
const threshold = Number.isInteger(wf?.drift_threshold) && wf?.drift_threshold >= 1
|
|
1893
1902
|
? wf?.drift_threshold
|
|
1894
1903
|
: 3;
|
|
@@ -693,6 +693,206 @@ function cmdWorktreeCleanupWave(cwd, args = []) {
|
|
|
693
693
|
process.exitCode = 1;
|
|
694
694
|
}
|
|
695
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Pure planner for the per-agent wave-manifest append.
|
|
698
|
+
*
|
|
699
|
+
* Validates the candidate entry at write time using the SAME rules the
|
|
700
|
+
* cleanup-wave reader enforces (via `normalizeCleanupManifestEntry`), so an
|
|
701
|
+
* entry that `record-agent` accepts is guaranteed to survive
|
|
702
|
+
* `normalizeCleanupManifest` on read — a field that would be silently dropped
|
|
703
|
+
* at cleanup time fails loudly here instead.
|
|
704
|
+
*
|
|
705
|
+
* `agent_id` is treated write-strict (required) even though the reader is
|
|
706
|
+
* lenient (nullable): the whole point of this verb is to catch an
|
|
707
|
+
* under-populated entry at write time, and an entry whose author cannot be
|
|
708
|
+
* identified defeats that. A duplicate `(worktree_path, branch)` is also
|
|
709
|
+
* rejected loudly — the reader dedups on that key, so a re-record would be
|
|
710
|
+
* silently dropped (the failure mode this verb exists to eliminate). The
|
|
711
|
+
* on-disk shape stays the existing 4-field entry (`agent_id`, `worktree_path`,
|
|
712
|
+
* `branch`, `expected_base`) — no schema change; the reader re-derives
|
|
713
|
+
* `allowed_bases`.
|
|
714
|
+
*/
|
|
715
|
+
function planWorktreeRecordAgent(manifestRaw, fields) {
|
|
716
|
+
// 1. Write-strict required-field check (loud, with which flag is missing).
|
|
717
|
+
// Trim first so a whitespace-only value (" ") is rejected here rather
|
|
718
|
+
// than deferred to a guaranteed `git worktree remove` failure at cleanup.
|
|
719
|
+
const agentId = (fields.agentId || '').trim();
|
|
720
|
+
const worktreePath = (fields.worktreePath || '').trim();
|
|
721
|
+
const branch = (fields.branch || '').trim();
|
|
722
|
+
const base = (fields.base || '').trim();
|
|
723
|
+
const missing = [];
|
|
724
|
+
if (!agentId)
|
|
725
|
+
missing.push('--agent-id');
|
|
726
|
+
if (!worktreePath)
|
|
727
|
+
missing.push('--path');
|
|
728
|
+
if (!branch)
|
|
729
|
+
missing.push('--branch');
|
|
730
|
+
if (!base)
|
|
731
|
+
missing.push('--base');
|
|
732
|
+
if (missing.length > 0) {
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
reason: 'missing_field',
|
|
736
|
+
hint: `record-agent requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
|
|
737
|
+
entry: null,
|
|
738
|
+
manifest: null,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
// 2. Shared validation: run the candidate through the reader's normalizer.
|
|
742
|
+
// If it returns null the reader would drop this entry on read — reject now.
|
|
743
|
+
const candidate = {
|
|
744
|
+
agent_id: agentId,
|
|
745
|
+
worktree_path: worktreePath,
|
|
746
|
+
branch,
|
|
747
|
+
expected_base: base,
|
|
748
|
+
};
|
|
749
|
+
const entry = normalizeCleanupManifestEntry(candidate);
|
|
750
|
+
if (!entry) {
|
|
751
|
+
return {
|
|
752
|
+
ok: false,
|
|
753
|
+
reason: 'invalid_entry',
|
|
754
|
+
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ^worktree-agent-[A-Za-z0-9._/-]+$ (got branch="${branch}"). Fix the field and re-run.`,
|
|
755
|
+
entry: null,
|
|
756
|
+
manifest: null,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
// 3. Parse the existing manifest. The init shell ({orchestrator_root, worktrees: []})
|
|
760
|
+
// is written inline by the orchestrator before any agent spawns; a missing or
|
|
761
|
+
// malformed manifest is a loud failure here, not a silent under-populated write.
|
|
762
|
+
let parsed;
|
|
763
|
+
try {
|
|
764
|
+
parsed = JSON.parse(manifestRaw);
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
return {
|
|
768
|
+
ok: false,
|
|
769
|
+
reason: 'invalid_manifest_json',
|
|
770
|
+
hint: 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before recording agents.',
|
|
771
|
+
entry: null,
|
|
772
|
+
manifest: null,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
// Accept the canonical {worktrees: []} shell or a bare top-level array (both
|
|
776
|
+
// are read by normalizeCleanupManifest); preserve any other top-level keys.
|
|
777
|
+
let worktrees;
|
|
778
|
+
let writeBack;
|
|
779
|
+
if (Array.isArray(parsed)) {
|
|
780
|
+
worktrees = parsed;
|
|
781
|
+
writeBack = worktrees;
|
|
782
|
+
}
|
|
783
|
+
else if (parsed && typeof parsed === 'object') {
|
|
784
|
+
const container = parsed;
|
|
785
|
+
if (container.worktrees === undefined)
|
|
786
|
+
container.worktrees = [];
|
|
787
|
+
if (!Array.isArray(container.worktrees)) {
|
|
788
|
+
return {
|
|
789
|
+
ok: false,
|
|
790
|
+
reason: 'manifest_shape_invalid',
|
|
791
|
+
hint: 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.',
|
|
792
|
+
entry: null,
|
|
793
|
+
manifest: null,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
worktrees = container.worktrees;
|
|
797
|
+
writeBack = container;
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
return {
|
|
801
|
+
ok: false,
|
|
802
|
+
reason: 'manifest_shape_invalid',
|
|
803
|
+
hint: 'Manifest must be a JSON object {"worktrees": []} or a top-level array.',
|
|
804
|
+
entry: null,
|
|
805
|
+
manifest: null,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
// 4. Reject a duplicate (worktree_path, branch). The reader dedups on this
|
|
809
|
+
// exact key, but only over entries that NORMALIZE successfully — so an
|
|
810
|
+
// existing malformed same-key entry (which the reader would drop) must NOT
|
|
811
|
+
// block recording a valid one. Run each existing entry through the reader's
|
|
812
|
+
// own normalizer and compare only the entries the reader would keep; this
|
|
813
|
+
// matches its dedup behavior exactly. A real duplicate signals an upstream
|
|
814
|
+
// double-spawn — surface it loudly instead of silently dropping it.
|
|
815
|
+
const dupKey = `${entry.worktree_path}\0${entry.branch}`;
|
|
816
|
+
const isDuplicate = worktrees.some((existing) => {
|
|
817
|
+
const normalized = normalizeCleanupManifestEntry(existing);
|
|
818
|
+
return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dupKey;
|
|
819
|
+
});
|
|
820
|
+
if (isDuplicate) {
|
|
821
|
+
return {
|
|
822
|
+
ok: false,
|
|
823
|
+
reason: 'duplicate_entry',
|
|
824
|
+
hint: `The manifest already records worktree_path="${entry.worktree_path}" branch="${entry.branch}". The cleanup reader dedups on (worktree_path, branch), so re-recording would be silently dropped — this usually signals an upstream double-spawn. Investigate rather than re-record.`,
|
|
825
|
+
entry: null,
|
|
826
|
+
manifest: null,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
// 5. Append the minimal 4-field entry, matching the existing on-disk format.
|
|
830
|
+
const recorded = {
|
|
831
|
+
agent_id: entry.agent_id,
|
|
832
|
+
worktree_path: entry.worktree_path,
|
|
833
|
+
branch: entry.branch,
|
|
834
|
+
expected_base: entry.expected_base,
|
|
835
|
+
};
|
|
836
|
+
worktrees.push(recorded);
|
|
837
|
+
return {
|
|
838
|
+
ok: true,
|
|
839
|
+
reason: 'ok',
|
|
840
|
+
entry: recorded,
|
|
841
|
+
manifest: `${JSON.stringify(writeBack, null, 2)}\n`,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* CLI command: append a validated per-agent entry to a wave cleanup manifest.
|
|
846
|
+
*
|
|
847
|
+
* Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
|
|
848
|
+
*
|
|
849
|
+
* Fails loudly (non-zero exit + recovery hint on stderr) when a field is
|
|
850
|
+
* missing/garbled or the manifest is absent/malformed, rather than appending an
|
|
851
|
+
* under-populated entry that the cleanup reader would silently drop.
|
|
852
|
+
*/
|
|
853
|
+
function cmdWorktreeRecordAgent(cwd, args = [], deps = {}) {
|
|
854
|
+
const flag = (name) => {
|
|
855
|
+
const i = args.indexOf(name);
|
|
856
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : '';
|
|
857
|
+
};
|
|
858
|
+
const write = deps.write || ((s) => process.stdout.write(s));
|
|
859
|
+
const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
|
|
860
|
+
const manifestPath = flag('--manifest');
|
|
861
|
+
if (!manifestPath) {
|
|
862
|
+
writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
|
|
863
|
+
process.exitCode = 2;
|
|
864
|
+
return { ok: false, reason: 'usage', entry: null };
|
|
865
|
+
}
|
|
866
|
+
const resolved = node_path_1.default.resolve(cwd, manifestPath);
|
|
867
|
+
const readFile = deps.readFile || ((p) => node_fs_1.default.readFileSync(p, 'utf8'));
|
|
868
|
+
let manifestRaw;
|
|
869
|
+
try {
|
|
870
|
+
manifestRaw = readFile(resolved);
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before recording agents.`;
|
|
874
|
+
writeErr(`[gsd] worktree.record-agent: manifest_read_failed — ${hint}\n`);
|
|
875
|
+
write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: err.message }, null, 2)}\n`);
|
|
876
|
+
process.exitCode = 1;
|
|
877
|
+
return { ok: false, reason: 'manifest_read_failed', hint, entry: null };
|
|
878
|
+
}
|
|
879
|
+
const plan = planWorktreeRecordAgent(manifestRaw, {
|
|
880
|
+
agentId: flag('--agent-id'),
|
|
881
|
+
worktreePath: flag('--path'),
|
|
882
|
+
branch: flag('--branch'),
|
|
883
|
+
base: flag('--base'),
|
|
884
|
+
});
|
|
885
|
+
if (!plan.ok || plan.manifest === null) {
|
|
886
|
+
writeErr(`[gsd] worktree.record-agent: ${plan.reason} — ${plan.hint || ''}\n`);
|
|
887
|
+
write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
|
|
888
|
+
process.exitCode = 1;
|
|
889
|
+
return { ok: false, reason: plan.reason, hint: plan.hint, entry: null };
|
|
890
|
+
}
|
|
891
|
+
const writeFile = deps.writeFile || ((p, content) => node_fs_1.default.writeFileSync(p, content, 'utf8'));
|
|
892
|
+
writeFile(resolved, plan.manifest);
|
|
893
|
+
write(`${JSON.stringify({ ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }, null, 2)}\n`);
|
|
894
|
+
return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
|
|
895
|
+
}
|
|
696
896
|
/**
|
|
697
897
|
* Reap orphaned linked worktrees whose lock owner process is dead, whose
|
|
698
898
|
* branch tip is fully merged into the default branch, and whose lock file
|
|
@@ -978,6 +1178,8 @@ module.exports = {
|
|
|
978
1178
|
planWorktreeWaveCleanup,
|
|
979
1179
|
executeWorktreeWaveCleanupPlan,
|
|
980
1180
|
cmdWorktreeCleanupWave,
|
|
1181
|
+
planWorktreeRecordAgent,
|
|
1182
|
+
cmdWorktreeRecordAgent,
|
|
981
1183
|
reapOrphanWorktrees,
|
|
982
1184
|
cmdWorktreeReapOrphans,
|
|
983
1185
|
resolveWorktreeRoot,
|
|
@@ -29,14 +29,14 @@ Every workflow that spawns agents or reads significant content must follow these
|
|
|
29
29
|
|
|
30
30
|
## Context Degradation Tiers
|
|
31
31
|
|
|
32
|
-
Monitor context usage and adjust behavior accordingly:
|
|
33
|
-
|
|
34
|
-
| Tier | Usage | Behavior |
|
|
35
|
-
|
|
36
|
-
| PEAK | 0-30% | Full operations. Read bodies, spawn multiple agents, inline results. |
|
|
37
|
-
| GOOD | 30-50% | Normal operations. Prefer frontmatter reads, delegate aggressively. |
|
|
38
|
-
| DEGRADING | 50-70% | Economize. Frontmatter-only reads, minimal inlining, warn user about budget. |
|
|
39
|
-
| POOR | 70%+ | Emergency mode. Checkpoint progress immediately. No new reads unless critical. |
|
|
32
|
+
Monitor context usage and adjust behavior accordingly. The `workflow.context_guard_mode` config key (values: `auto`, `warn`, `off`; default `warn`) controls how `execute-phase.md` responds when the guard fires at a wave boundary.
|
|
33
|
+
|
|
34
|
+
| Tier | Usage | Behavior | Trigger Action (execute-phase) |
|
|
35
|
+
|------|-------|----------|-------------------------------|
|
|
36
|
+
| PEAK | 0-30% | Full operations. Read bodies, spawn multiple agents, inline results. | None |
|
|
37
|
+
| GOOD | 30-50% | Normal operations. Prefer frontmatter reads, delegate aggressively. | None |
|
|
38
|
+
| DEGRADING | 50-70% | Economize. Frontmatter-only reads, minimal inlining, warn user about budget. | Emit warning, continue |
|
|
39
|
+
| POOR | 70%+ | Emergency mode. Checkpoint progress immediately. No new reads unless critical. | `warn`: emit warning + recommend `/gsd:pause-work`. `auto`: invoke pause-work before next wave. `off`: proceed anyway. |
|
|
40
40
|
|
|
41
41
|
## Context Degradation Warning Signs
|
|
42
42
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
0. **Context exhaustion guard — `context_guard` (BEFORE spawning, #1452):**
|
|
2
|
+
|
|
3
|
+
Before spawning any agents for this wave, self-assess context pressure using the
|
|
4
|
+
degradation signals in `references/context-budget.md`. Signs of POOR tier (70%+):
|
|
5
|
+
increasing vagueness, skipped steps, silent partial completion.
|
|
6
|
+
|
|
7
|
+
Read `workflow.context_guard_mode` from `.planning/config.json` (default `warn`).
|
|
8
|
+
|
|
9
|
+
| Tier | `warn` (default) | `auto` | `off` |
|
|
10
|
+
|------|-----------------|--------|-------|
|
|
11
|
+
| PEAK / GOOD | No output | No output | No output |
|
|
12
|
+
| DEGRADING (50-70%) | Emit: "⚠ Context pressure DEGRADING — switching to frontmatter-only reads for remaining waves." Continue. | Same as warn | Skip |
|
|
13
|
+
| POOR (70%+) | Emit: "🛑 Context pressure POOR — risk of context exhaustion. Run `/gsd:pause-work` to checkpoint before this wave, then resume in a fresh session." Continue (user decides). | Invoke `/gsd:pause-work` immediately and halt. Do NOT spawn wave agents. | Skip |
|
|
14
|
+
|
|
15
|
+
The guard is heuristic — no programmatic context-percentage API exists. Use your
|
|
16
|
+
assessment of degradation signals, not a fixed token count.
|
|
@@ -267,6 +267,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
|
|
|
267
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
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. |
|
|
269
269
|
| `workflow.mvp_mode` | boolean | `false` | `true`, `false` | Persist the MVP-mode flag in config so every phase defaults to MVP framing without requiring `--mvp` on the CLI. Resolved via the chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → this config value → `false`. When `true`, the planner, executor, verifier, and discovery surfaces (progress, stats, graphify) all treat the phase as an MVP vertical slice (UI → API → DB) of one user-visible capability. |
|
|
270
|
+
| `workflow.context_guard_mode` | string | `"warn"` | `"auto"`, `"warn"`, `"off"` | Context exhaustion guard mode for `execute-phase`. Before each wave, the orchestrator self-assesses context pressure using degradation signals from `context-budget.md`. `"warn"` (default): emit a warning and recommend `/gsd:pause-work` when POOR tier is detected. `"auto"`: automatically invoke `/gsd:pause-work` before the next wave when POOR tier is detected. `"off"`: disable the guard. The guard is heuristic — no programmatic context-% API exists. |
|
|
270
271
|
| `workflow.plan_chunked` | boolean | `false` | `true`, `false` | Enable chunked planning mode. When `true`, the plan-phase orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3–5 min each). Each plan is committed individually for crash resilience. Particularly useful on Windows where long-lived Tasks may hang on stdio. Also activated by the `--chunked` flag. |
|
|
271
272
|
| `workflow.code_review_command` | string\|null | `null` | Any shell command | External code-review command integrated into `/gsd:ship`. The diff is piped to the command via stdin; the command must output JSON with a `verdict` field (`"APPROVED"` or `"REVISE"`). Non-zero exit or `"REVISE"` verdict blocks the ship workflow. When unset, the built-in review flow runs. Example: `my-review-tool --review`. |
|
|
272
273
|
| `workflow.inline_plan_threshold` | number | `2` | `0`–`10` | Plans with ≤N tasks execute inline instead of spawning a subagent |
|
|
@@ -157,7 +157,7 @@ A `resolved`/`test`-tier prohibition MAY carry an **optional `check` descriptor*
|
|
|
157
157
|
the wired mechanical check, so verify-phase locates it deterministically instead of inventing
|
|
158
158
|
`{kind, target, rule}` each run. The descriptor is captured at spec-phase (soft / optional —
|
|
159
159
|
the author wires it when the negative test or lint rule already exists) and is represented as
|
|
160
|
-
**
|
|
160
|
+
**five flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}`
|
|
161
161
|
object:
|
|
162
162
|
|
|
163
163
|
- `check_kind` — `node-test` | `lint-rule` (which producer mechanism runs the check).
|
|
@@ -165,16 +165,19 @@ object:
|
|
|
165
165
|
- `check_rule` — the `ruleId` to filter on, **lint-rule only** (absent for `node-test`).
|
|
166
166
|
- `check_violation_fixture` — path to a KNOWN-BAD subject the #1279 prover runs the check against to
|
|
167
167
|
machine-prove fail-first (rides BOTH kinds; for `node-test` it is injected via `GSD_PROHIB_SUBJECT`).
|
|
168
|
+
- `check_clean_fixture` — **optional** path to a KNOWN-CLEAN control subject (#1346). When present the
|
|
169
|
+
node-test prover also runs the check against it and requires GREEN, proving the violation's RED is
|
|
170
|
+
caused by the subject's *content* (not merely by `GSD_PROHIB_SUBJECT` being set). Absent → no control.
|
|
168
171
|
|
|
169
172
|
The flat-scalar shape is load-bearing: the shared `parseMustHavesBlock` is a flat parser and a
|
|
170
173
|
nested object would flatten/mangle the round-trip (ADR-550 2026-06-15 addendum; #644 "no parser
|
|
171
174
|
rewrite" precedent). `projectProhibitions` emits these keys **only for a well-formed descriptor**
|
|
172
175
|
(valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path;
|
|
173
|
-
`check_violation_fixture` only when non-empty), and verify-phase reads them
|
|
174
|
-
`descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`.
|
|
175
|
-
closes
|
|
176
|
-
descriptor authoring**: a prohibition authored with
|
|
177
|
-
projection alone.
|
|
176
|
+
`check_violation_fixture` and `check_clean_fixture` only when non-empty), and verify-phase reads them
|
|
177
|
+
back via `descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`.
|
|
178
|
+
This closes the locate (#1278), the machine-proof-fixture (#1279), and the causation-control (#1346)
|
|
179
|
+
halves with **zero manual descriptor authoring**: a prohibition authored with the scalars greens
|
|
180
|
+
end-to-end through the projection alone.
|
|
178
181
|
|
|
179
182
|
**Fail-closed + backward-compat.** A partial descriptor (`lint-rule` missing `check_rule`), an
|
|
180
183
|
unknown `check_kind`, an **absent** descriptor, OR a descriptor with **no `check_violation_fixture`**
|
|
@@ -182,8 +185,11 @@ falls through to the producer's fail-closed paths (`located: false`, or located-
|
|
|
182
185
|
never a silent green. A prohibition with no descriptor parses and disposes byte-identically to today.
|
|
183
186
|
`failFirst` is **not** sourced from the descriptor and is **demoted** (machine-proven fail-first
|
|
184
187
|
DELIVERED in #1279 — no path greens on attestation alone, FF-08); the `dispositionForProhibition`
|
|
185
|
-
policy is unchanged.
|
|
186
|
-
|
|
188
|
+
policy is unchanged. Causation (**#1346**): the node-test proof confirms the fixture exists and the
|
|
189
|
+
check goes RED; supplying `check_clean_fixture` adds an opt-in control that *also* requires GREEN on a
|
|
190
|
+
known-clean subject, proving the red is content-caused. With no clean fixture the control cannot run,
|
|
191
|
+
so that one residual case (a deceptive test reding merely because the env var is set) stays a
|
|
192
|
+
documented constraint — an author opts into the stronger proof by wiring a clean control subject.
|
|
187
193
|
|
|
188
194
|
## Output schema
|
|
189
195
|
|
|
@@ -191,7 +197,7 @@ The probe emits, per kept prohibition, an item of the form:
|
|
|
191
197
|
|
|
192
198
|
```
|
|
193
199
|
{ requirement_id, category, status, verification, resolution, reason, statement,
|
|
194
|
-
check_kind?, check_target?, check_rule? }
|
|
200
|
+
check_kind?, check_target?, check_rule?, check_violation_fixture?, check_clean_fixture? }
|
|
195
201
|
```
|
|
196
202
|
|
|
197
203
|
where `statement` is the must-NOT sentence and `category` is the values/safety/ethics class
|