@opengsd/gsd-core 1.5.0-rc.5 → 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/bin/install.js +53 -99
- 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 +36 -5
- package/gsd-core/bin/lib/decisions.cjs +19 -1
- package/gsd-core/bin/lib/phase-id.cjs +1 -1
- package/gsd-core/bin/lib/phase.cjs +41 -6
- package/gsd-core/bin/lib/probe-core.cjs +7 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +201 -26
- package/gsd-core/bin/lib/roadmap-parser.cjs +25 -20
- 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 +64 -15
- package/gsd-core/bin/lib/teams-status.cjs +74 -0
- package/gsd-core/bin/lib/uat.cjs +56 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +7 -1
- package/gsd-core/references/prohibition-probe.md +48 -19
- package/gsd-core/references/worktree-branch-check.md +11 -5
- package/gsd-core/workflows/docs-update.md +23 -31
- package/gsd-core/workflows/execute-phase.md +2 -1
- package/gsd-core/workflows/map-codebase.md +8 -10
- package/gsd-core/workflows/plan-phase.md +6 -0
- package/gsd-core/workflows/quick.md +23 -2
- package/gsd-core/workflows/spec-phase.md +11 -6
- package/gsd-core/workflows/verify-phase.md +3 -3
- package/hooks/dist/gsd-worktree-path-guard.js +34 -18
- package/hooks/gsd-worktree-path-guard.js +34 -18
- package/package.json +2 -2
- package/scripts/ci-prepare-test-scope.cjs +56 -14
- package/scripts/diff-touches-shipped-paths.cjs +5 -11
- package/scripts/run-tests.cjs +1 -0
|
@@ -71,6 +71,17 @@ process.stdin.on('end', () => {
|
|
|
71
71
|
process.exit(0); // main repo, submodule, or separate-git-dir — no-op
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
|
|
75
|
+
// are always on a `worktree-agent-*` branch (the positive allow-list enforced
|
|
76
|
+
// by worktree-branch-check.md, #2924). A manually-created linked worktree (plain
|
|
77
|
+
// non-GSD work, e.g. Claude Code plan-mode) is on the user's own branch, so the
|
|
78
|
+
// guard must be a no-op there. Detached HEAD / error → not GSD-managed → no-op.
|
|
79
|
+
const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
|
|
80
|
+
const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
|
|
81
|
+
if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
82
|
+
process.exit(0); // not a GSD-managed executor worktree — no-op
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
// Get the raw --show-toplevel output for the worktree (cwd).
|
|
75
86
|
// We keep it raw (not path.resolve'd) to compare directly with the
|
|
76
87
|
// file's toplevel — same git binary, same format, no normalization needed.
|
|
@@ -110,15 +121,9 @@ process.stdin.on('end', () => {
|
|
|
110
121
|
|
|
111
122
|
if (!checkDir) {
|
|
112
123
|
// Walked to root without finding any directory — path is synthetic.
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
reason:
|
|
117
|
-
`Worktree path guard: '${filePath}' has no existing ancestor directory — ` +
|
|
118
|
-
`cannot verify it is inside the worktree '${wtTopRaw}'. Use a relative path instead.`,
|
|
119
|
-
};
|
|
120
|
-
process.stdout.write(JSON.stringify(output));
|
|
121
|
-
process.exit(2);
|
|
124
|
+
// A path with no existing ancestor is not the #260 main-repo vector;
|
|
125
|
+
// #260 is caught by the different-git-root branch below. Fail open. (#1342)
|
|
126
|
+
process.exit(0);
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
// Ask git for the toplevel of the file's location.
|
|
@@ -130,15 +135,26 @@ process.stdin.on('end', () => {
|
|
|
130
135
|
const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
|
|
131
136
|
|
|
132
137
|
if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
// The target's location is not a git work tree. Two sub-cases:
|
|
139
|
+
// - Inside a .git directory (e.g. /main-repo/.git/config or .git/hooks/*)
|
|
140
|
+
// → an absolute write into a repository's internals; still a #260-class
|
|
141
|
+
// escape (and dangerous) → BLOCK.
|
|
142
|
+
// - Truly outside all git repositories (e.g. ~/.claude/plans/) → not the
|
|
143
|
+
// main-repo vector → fail open. (#1342)
|
|
144
|
+
const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
|
|
145
|
+
if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
|
|
146
|
+
const output = {
|
|
147
|
+
decision: 'block',
|
|
148
|
+
reason:
|
|
149
|
+
`Worktree path guard: '${filePath}' is inside a git internal (.git) directory, ` +
|
|
150
|
+
`not the active worktree at '${wtTopRaw}'. Writing to repository internals via an ` +
|
|
151
|
+
`absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
|
|
152
|
+
};
|
|
153
|
+
process.stdout.write(JSON.stringify(output));
|
|
154
|
+
process.exit(2);
|
|
155
|
+
}
|
|
156
|
+
// Outside all git repositories — fail open (#1342).
|
|
157
|
+
process.exit(0);
|
|
142
158
|
}
|
|
143
159
|
|
|
144
160
|
const fileTopRaw = fileTopResult.stdout.trim();
|
|
@@ -71,6 +71,17 @@ process.stdin.on('end', () => {
|
|
|
71
71
|
process.exit(0); // main repo, submodule, or separate-git-dir — no-op
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
|
|
75
|
+
// are always on a `worktree-agent-*` branch (the positive allow-list enforced
|
|
76
|
+
// by worktree-branch-check.md, #2924). A manually-created linked worktree (plain
|
|
77
|
+
// non-GSD work, e.g. Claude Code plan-mode) is on the user's own branch, so the
|
|
78
|
+
// guard must be a no-op there. Detached HEAD / error → not GSD-managed → no-op.
|
|
79
|
+
const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
|
|
80
|
+
const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
|
|
81
|
+
if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
82
|
+
process.exit(0); // not a GSD-managed executor worktree — no-op
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
// Get the raw --show-toplevel output for the worktree (cwd).
|
|
75
86
|
// We keep it raw (not path.resolve'd) to compare directly with the
|
|
76
87
|
// file's toplevel — same git binary, same format, no normalization needed.
|
|
@@ -110,15 +121,9 @@ process.stdin.on('end', () => {
|
|
|
110
121
|
|
|
111
122
|
if (!checkDir) {
|
|
112
123
|
// Walked to root without finding any directory — path is synthetic.
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
reason:
|
|
117
|
-
`Worktree path guard: '${filePath}' has no existing ancestor directory — ` +
|
|
118
|
-
`cannot verify it is inside the worktree '${wtTopRaw}'. Use a relative path instead.`,
|
|
119
|
-
};
|
|
120
|
-
process.stdout.write(JSON.stringify(output));
|
|
121
|
-
process.exit(2);
|
|
124
|
+
// A path with no existing ancestor is not the #260 main-repo vector;
|
|
125
|
+
// #260 is caught by the different-git-root branch below. Fail open. (#1342)
|
|
126
|
+
process.exit(0);
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
// Ask git for the toplevel of the file's location.
|
|
@@ -130,15 +135,26 @@ process.stdin.on('end', () => {
|
|
|
130
135
|
const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
|
|
131
136
|
|
|
132
137
|
if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
// The target's location is not a git work tree. Two sub-cases:
|
|
139
|
+
// - Inside a .git directory (e.g. /main-repo/.git/config or .git/hooks/*)
|
|
140
|
+
// → an absolute write into a repository's internals; still a #260-class
|
|
141
|
+
// escape (and dangerous) → BLOCK.
|
|
142
|
+
// - Truly outside all git repositories (e.g. ~/.claude/plans/) → not the
|
|
143
|
+
// main-repo vector → fail open. (#1342)
|
|
144
|
+
const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
|
|
145
|
+
if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
|
|
146
|
+
const output = {
|
|
147
|
+
decision: 'block',
|
|
148
|
+
reason:
|
|
149
|
+
`Worktree path guard: '${filePath}' is inside a git internal (.git) directory, ` +
|
|
150
|
+
`not the active worktree at '${wtTopRaw}'. Writing to repository internals via an ` +
|
|
151
|
+
`absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
|
|
152
|
+
};
|
|
153
|
+
process.stdout.write(JSON.stringify(output));
|
|
154
|
+
process.exit(2);
|
|
155
|
+
}
|
|
156
|
+
// Outside all git repositories — fail open (#1342).
|
|
157
|
+
process.exit(0);
|
|
142
158
|
}
|
|
143
159
|
|
|
144
160
|
const fileTopRaw = fileTopResult.stdout.trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengsd/gsd-core",
|
|
3
|
-
"version": "1.5.0
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gsd-core": "bin/install.js",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"eslint-plugin-no-only-tests": "^3.4.0",
|
|
63
63
|
"fast-check": "^4.8.0",
|
|
64
64
|
"globals": "^16.5.0",
|
|
65
|
-
"js-yaml": "^4.
|
|
65
|
+
"js-yaml": "^4.2.0",
|
|
66
66
|
"typescript": "^6.0.3",
|
|
67
67
|
"typescript-eslint": "^8.60.0"
|
|
68
68
|
},
|
|
@@ -16,13 +16,37 @@ const path = require('path');
|
|
|
16
16
|
|
|
17
17
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// Suite sentinels understood by run-tests.cjs (scripts/run-tests.cjs SUITES).
|
|
20
|
+
// A sentinel resolves to its live file set at run time, so — unlike an explicit
|
|
21
|
+
// filename — it can never reference a since-deleted test file.
|
|
22
|
+
const SUITE_SENTINELS = ['all', 'unit', 'integration', 'install', 'security', 'slow'];
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// Fast smoke set used when scope detection yields no targeted tests. Each entry
|
|
25
|
+
// MUST resolve via run-tests.cjs: an existing repo-relative test file or a
|
|
26
|
+
// SUITE_SENTINELS token. A stale filename here (a test deleted by a refactor)
|
|
27
|
+
// is exactly what broke CI in #1329 — `tests/core.test.cjs`, deleted in #1291,
|
|
28
|
+
// was still listed and crashed every scoped lane that hit this fallback. The
|
|
29
|
+
// parity guard in tests/bug-641-files-from-suite-token.test.cjs fails the
|
|
30
|
+
// moment an entry stops resolving; resolveSelection() filters at write time so
|
|
31
|
+
// a stale entry degrades instead of crashing the lane.
|
|
32
|
+
const FALLBACK = [
|
|
33
|
+
'tests/command-contract.test.cjs',
|
|
34
|
+
'tests/commands.test.cjs',
|
|
35
|
+
'tests/package-manifest.test.cjs',
|
|
36
|
+
];
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
// Last-resort selector when every FALLBACK entry has been deleted: the whole
|
|
39
|
+
// unit suite, resolved live by run-tests.cjs (the #408/#641 sentinel path).
|
|
40
|
+
const FALLBACK_SENTINEL = 'unit';
|
|
41
|
+
|
|
42
|
+
// An entry is runnable if it is a suite sentinel or an existing file under root.
|
|
43
|
+
function isResolvable(entry, root) {
|
|
44
|
+
return SUITE_SENTINELS.includes(entry) || fs.existsSync(path.join(root, entry));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Resolve the scoped test selection for the lane. Pure (no I/O beyond the
|
|
48
|
+
// existence probe under `root`) so it can be unit-tested directly.
|
|
49
|
+
function resolveSelection({ scope, targeted, windows, root }) {
|
|
26
50
|
let selected;
|
|
27
51
|
if (scope === 'windows') {
|
|
28
52
|
selected = windows;
|
|
@@ -32,20 +56,38 @@ function main() {
|
|
|
32
56
|
throw new ExitError(1, `::error::Unknown test scope: ${scope}`);
|
|
33
57
|
}
|
|
34
58
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
59
|
+
// Detected list passes through verbatim: affected-tests-lib.cjs already filters
|
|
60
|
+
// deleted files, and the list may legitimately carry a suite sentinel.
|
|
61
|
+
const detected = (selected || '').split(/\s+/).filter(Boolean);
|
|
62
|
+
if (detected.length > 0) {
|
|
63
|
+
return detected;
|
|
38
64
|
}
|
|
39
65
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
// Empty detection → smoke fallback, existence-filtered so a stale entry can
|
|
67
|
+
// never crash the scoped lane (#1329). If nothing survives, use the unit
|
|
68
|
+
// sentinel, which run-tests.cjs always resolves.
|
|
69
|
+
const survivors = FALLBACK.filter((f) => isResolvable(f, root));
|
|
70
|
+
return survivors.length > 0 ? survivors : [FALLBACK_SENTINEL];
|
|
71
|
+
}
|
|
43
72
|
|
|
44
|
-
|
|
45
|
-
|
|
73
|
+
function main() {
|
|
74
|
+
const root = process.cwd();
|
|
75
|
+
const lines = resolveSelection({
|
|
76
|
+
scope: process.env.TEST_SCOPE || '',
|
|
77
|
+
targeted: process.env.TARGETED_TESTS || '',
|
|
78
|
+
windows: process.env.WINDOWS_TESTS || '',
|
|
79
|
+
root,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const content = lines.join('\n') + '\n';
|
|
83
|
+
fs.writeFileSync(path.join(root, '.ci-selected-tests.txt'), content, 'utf-8');
|
|
46
84
|
|
|
47
85
|
process.stdout.write('Scoped tests:\n');
|
|
48
86
|
process.stdout.write(content);
|
|
49
87
|
}
|
|
50
88
|
|
|
51
|
-
|
|
89
|
+
if (require.main === module) {
|
|
90
|
+
runMain(main);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection, main };
|
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
* - package.json (always included by `npm pack`, regardless of `files`)
|
|
13
13
|
* - every entry in package.json `files`, treated as either an exact
|
|
14
14
|
* file match or a directory prefix (matching `npm pack` semantics).
|
|
15
|
-
* - CI-gating test paths: `tests/<anything>`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* production fix MUST be pickable or CI fails on the hotfix run.
|
|
15
|
+
* - CI-gating test paths: `tests/<anything>` — these don't ship in the
|
|
16
|
+
* tarball, but they gate the hotfix-branch test job. A test fixture
|
|
17
|
+
* update that aligns with a cherry-picked production fix MUST be
|
|
18
|
+
* pickable or CI fails on the hotfix run.
|
|
20
19
|
* #3621 — root cause of the v1.42.3 hotfix red CI.
|
|
21
20
|
*
|
|
22
21
|
* `package-lock.json` is intentionally NOT considered shipped — `npm pack`
|
|
@@ -65,12 +64,7 @@ function loadShipPrefixes(pkgPath) {
|
|
|
65
64
|
// in hotfix.yml, this lets `test(####):` fixture-alignment commits be
|
|
66
65
|
// cherry-picked alongside their production counterparts.
|
|
67
66
|
function isCiGating(diffPath) {
|
|
68
|
-
|
|
69
|
-
// SDK vitest specs live next to source. Production source ships via
|
|
70
|
-
// sdk/dist/ (already in package.json `files`); the test files are what's
|
|
71
|
-
// missing from that surface.
|
|
72
|
-
if (diffPath.startsWith('sdk/src/') && /\.(test|spec)\.(ts|cjs|mjs|js)$/.test(diffPath)) return true;
|
|
73
|
-
return false;
|
|
67
|
+
return diffPath.startsWith('tests/');
|
|
74
68
|
}
|
|
75
69
|
|
|
76
70
|
function isShipped(diffPath, shipPrefixes) {
|
package/scripts/run-tests.cjs
CHANGED
|
@@ -461,6 +461,7 @@ function main() {
|
|
|
461
461
|
// them so the local runner matches CI; tests that need them set them explicitly.
|
|
462
462
|
delete process.env.GSD_PROJECT;
|
|
463
463
|
delete process.env.GSD_WORKSTREAM;
|
|
464
|
+
delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
464
465
|
|
|
465
466
|
// Log selected files to stderr for CI / harness-test visibility.
|
|
466
467
|
// node:test default reporter doesn't echo filenames, so this gives
|