@opengsd/gsd-core 1.3.0 → 1.4.0-rc.1
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/agents/gsd-advisor-researcher.md +1 -20
- package/agents/gsd-ai-researcher.md +1 -20
- package/agents/gsd-domain-researcher.md +1 -20
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-phase-researcher.md +92 -166
- package/agents/gsd-planner.md +9 -36
- package/agents/gsd-project-researcher.md +62 -141
- package/agents/gsd-ui-researcher.md +2 -21
- package/agents/gsd-verifier.md +8 -2
- package/bin/install.js +85 -4
- package/commands/gsd/graphify.md +11 -6
- package/commands/gsd/import.md +6 -2
- package/commands/gsd/plan-phase.md +2 -2
- package/gsd-core/bin/check-latest-version.cjs +3 -2
- package/gsd-core/bin/gsd-tools.cjs +238 -32
- package/gsd-core/bin/lib/check-command-router.cjs +1 -0
- package/gsd-core/bin/lib/cli-exit.cjs +42 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
- package/gsd-core/bin/lib/commands.cjs +5 -4
- package/gsd-core/bin/lib/config.cjs +28 -4
- package/gsd-core/bin/lib/core.cjs +72 -28
- package/gsd-core/bin/lib/graphify.cjs +2 -2
- package/gsd-core/bin/lib/init-command-router.cjs +2 -2
- package/gsd-core/bin/lib/init.cjs +19 -3
- package/gsd-core/bin/lib/installer-migrations.cjs +61 -22
- package/gsd-core/bin/lib/intel.cjs +3 -20
- package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
- package/gsd-core/bin/lib/phase.cjs +3 -3
- package/gsd-core/bin/lib/research-provider.cjs +137 -0
- package/gsd-core/bin/lib/research-store.cjs +167 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
- package/gsd-core/bin/lib/security.cjs +73 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +3 -0
- package/gsd-core/bin/lib/validate.cjs +2 -2
- package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
- package/gsd-core/bin/lib/verification.cjs +193 -0
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
- package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
- package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
- package/gsd-core/references/planner-load-graph-context.md +36 -0
- package/gsd-core/references/planning-config.md +3 -1
- package/gsd-core/references/research-documentation-lookup.md +29 -0
- package/gsd-core/references/research-philosophy.md +29 -0
- package/gsd-core/references/research-verification-protocol.md +27 -0
- package/gsd-core/workflows/execute-phase.md +19 -8
- package/gsd-core/workflows/help/modes/full.md +2 -2
- package/gsd-core/workflows/ingest-docs.md +3 -2
- package/gsd-core/workflows/plan-phase.md +14 -10
- package/gsd-core/workflows/plan-review-convergence.md +3 -3
- package/gsd-core/workflows/review.md +22 -5
- package/gsd-core/workflows/ship.md +5 -8
- package/gsd-core/workflows/spec-phase.md +2 -1
- package/gsd-core/workflows/update.md +2 -1
- package/hooks/dist/gsd-context-monitor.js +1 -1
- package/hooks/dist/gsd-workflow-guard.js +1 -0
- package/hooks/dist/gsd-worktree-path-guard.js +1 -1
- package/hooks/gsd-context-monitor.js +1 -1
- package/hooks/gsd-workflow-guard.js +1 -0
- package/hooks/gsd-worktree-path-guard.js +1 -1
- package/package.json +4 -1
- package/scripts/affected-tests-lib.cjs +3 -2
- package/scripts/changeset/cli.cjs +183 -28
- package/scripts/changeset/lint.cjs +5 -4
- package/scripts/changeset/new.cjs +4 -4
- package/scripts/check-alias-drift.cjs +77 -71
- package/scripts/check-env.cjs +185 -179
- package/scripts/check-npm-integrity.cjs +115 -109
- package/scripts/ci-guard-runner.cjs +11 -5
- package/scripts/ci-prepare-test-scope.cjs +27 -22
- package/scripts/ci-rebase-check.cjs +46 -45
- package/scripts/ci-test-scope.cjs +6 -4
- package/scripts/diff-touches-shipped-paths.cjs +52 -44
- package/scripts/gen-inventory-manifest.cjs +38 -32
- package/scripts/gen-research-agents.cjs +276 -0
- package/scripts/lib/cli-exit.cjs +56 -0
- package/scripts/lint-command-contract.cjs +28 -22
- package/scripts/lint-descriptions.cjs +32 -28
- package/scripts/lint-docs-required.cjs +4 -4
- package/scripts/lint-legacy-dir-name.cjs +56 -52
- package/scripts/lint-pr-check-project-dir.cjs +3 -1
- package/scripts/lint-shell-command-projection-drift.cjs +27 -22
- package/scripts/lint-skill-deps.cjs +31 -26
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/lint-test-file-count.cjs +5 -4
- package/scripts/mutation-matrix.cjs +6 -3
- package/scripts/prompt-injection-scan.sh +1 -1
- package/scripts/release-notes/format-github-release-notes.cjs +8 -3
- package/scripts/release-tarball-smoke.cjs +6 -4
- package/scripts/research-profiles.cjs +149 -0
- package/scripts/run-affected-tests.cjs +2 -1
- package/scripts/run-cross-platform-tests.cjs +11 -7
- package/scripts/run-tests.cjs +8 -7
- package/scripts/strip-prose-atrefs.cjs +1 -1
- package/scripts/sync-runtime-launcher.cjs +0 -3
- package/scripts/verify-npm-publish.cjs +14 -26
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Research Store Module
|
|
4
|
+
*
|
|
5
|
+
* Provides deterministic cache key generation, TTL policy, path resolution,
|
|
6
|
+
* and JSON-backed put/get operations for research entries.
|
|
7
|
+
*
|
|
8
|
+
* ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc.
|
|
9
|
+
*/
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
14
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
17
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const DAY_MS = 86_400_000;
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// researchKey
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
function normalize(x) {
|
|
26
|
+
if (x === null || x === undefined)
|
|
27
|
+
return '';
|
|
28
|
+
if (typeof x === 'object')
|
|
29
|
+
return JSON.stringify(x).trim().toLowerCase();
|
|
30
|
+
// After excluding null, undefined, and object, x can only be a primitive —
|
|
31
|
+
// cast through number | string | boolean to avoid no-base-to-string on unknown.
|
|
32
|
+
return `${x}`.trim().toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
function researchKey(input) {
|
|
35
|
+
const parts = {
|
|
36
|
+
ecosystem: normalize(input.ecosystem),
|
|
37
|
+
library: normalize(input.library),
|
|
38
|
+
version: normalize(input.version),
|
|
39
|
+
query: normalize(input.query),
|
|
40
|
+
kind: normalize(input.kind),
|
|
41
|
+
};
|
|
42
|
+
const serialized = JSON.stringify(parts);
|
|
43
|
+
return node_crypto_1.default.createHash('sha256').update(serialized).digest('hex');
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// ttlForSource
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
function ttlForSource(source, confidence) {
|
|
49
|
+
if (source === 'curated' && confidence === 'HIGH')
|
|
50
|
+
return 30 * DAY_MS;
|
|
51
|
+
if (source === 'curated' && confidence === 'MEDIUM')
|
|
52
|
+
return 7 * DAY_MS;
|
|
53
|
+
return DAY_MS;
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// tierForSource / resolveStorePath
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
const CURATED_SOURCES = new Set(['curated']);
|
|
59
|
+
function tierForSource(source) {
|
|
60
|
+
return CURATED_SOURCES.has(source) ? 'user' : 'project';
|
|
61
|
+
}
|
|
62
|
+
function resolveStorePath(cwd, source, { homeDir = node_os_1.default.homedir() } = {}) {
|
|
63
|
+
if (tierForSource(source) === 'user') {
|
|
64
|
+
return node_path_1.default.join(homeDir, '.gsd', 'research-cache');
|
|
65
|
+
}
|
|
66
|
+
return node_path_1.default.join(cwd, '.planning', 'research', '.cache');
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// isValidResearchKey
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
/**
|
|
72
|
+
* Returns true iff key is a valid 64-character lowercase hexadecimal SHA-256
|
|
73
|
+
* string (the exact shape produced by researchKey). Any other shape —
|
|
74
|
+
* including path-traversal sequences — is rejected.
|
|
75
|
+
*/
|
|
76
|
+
function isValidResearchKey(key) {
|
|
77
|
+
return typeof key === 'string' && /^[0-9a-f]{64}$/.test(key);
|
|
78
|
+
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// putResearch
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
function putResearch(cwd, key, payload, { clock = Date, homeDir = node_os_1.default.homedir() } = {}) {
|
|
83
|
+
// Defense-in-depth: reject any key that is not a 64-char sha256 hex string.
|
|
84
|
+
if (!isValidResearchKey(key)) {
|
|
85
|
+
throw new Error('invalid research key');
|
|
86
|
+
}
|
|
87
|
+
const { content, source, provider, confidence, kind, version } = payload;
|
|
88
|
+
let ttl = ttlForSource(source, confidence);
|
|
89
|
+
// Cap TTL when version is blank/missing — a versionless curated entry must not
|
|
90
|
+
// get the long 30-day window since we can't know if it's still current.
|
|
91
|
+
if (!version) {
|
|
92
|
+
ttl = Math.min(ttl, DAY_MS);
|
|
93
|
+
}
|
|
94
|
+
const fetched_at = new Date(clock.now()).toISOString();
|
|
95
|
+
const entry = { content, source, provider, confidence, fetched_at, ttl, kind };
|
|
96
|
+
const dir = resolveStorePath(cwd, source, { homeDir });
|
|
97
|
+
// Belt-and-suspenders: ensure the resolved file path stays inside the store dir.
|
|
98
|
+
const resolvedDir = node_path_1.default.resolve(dir);
|
|
99
|
+
const filePath = node_path_1.default.join(dir, `${key}.json`);
|
|
100
|
+
const resolvedFile = node_path_1.default.resolve(filePath);
|
|
101
|
+
if (!resolvedFile.startsWith(resolvedDir + node_path_1.default.sep)) {
|
|
102
|
+
throw new Error('invalid research key');
|
|
103
|
+
}
|
|
104
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
105
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(filePath, JSON.stringify(entry));
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// getResearch
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
function getResearch(cwd, key, { clock = Date, homeDir = node_os_1.default.homedir() } = {}) {
|
|
112
|
+
// Defense-in-depth: reject any key that is not a 64-char sha256 hex string.
|
|
113
|
+
if (!isValidResearchKey(key)) {
|
|
114
|
+
return { hit: false, stale: false, entry: null };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
// Search both physical tiers: user (curated) and project (web/etc.)
|
|
118
|
+
const userDir = node_path_1.default.join(homeDir, '.gsd', 'research-cache');
|
|
119
|
+
const projectDir = node_path_1.default.join(cwd, '.planning', 'research', '.cache');
|
|
120
|
+
const tierDirs = [userDir, projectDir];
|
|
121
|
+
const candidates = [];
|
|
122
|
+
for (const dir of tierDirs) {
|
|
123
|
+
const resolvedDir = node_path_1.default.resolve(dir);
|
|
124
|
+
const filePath = node_path_1.default.join(dir, `${key}.json`);
|
|
125
|
+
// Belt-and-suspenders: ensure path stays inside tier dir
|
|
126
|
+
if (!node_path_1.default.resolve(filePath).startsWith(resolvedDir + node_path_1.default.sep))
|
|
127
|
+
continue;
|
|
128
|
+
if (!node_fs_1.default.existsSync(filePath))
|
|
129
|
+
continue;
|
|
130
|
+
let entry;
|
|
131
|
+
try {
|
|
132
|
+
entry = JSON.parse(node_fs_1.default.readFileSync(filePath, 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Corrupt file in this tier — skip it
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Finding 3: validate entry metadata shape before accepting as a candidate.
|
|
139
|
+
// An entry with missing/invalid fetched_at or ttl must be treated as a miss.
|
|
140
|
+
const parsedFetchedAt = Date.parse(entry.fetched_at);
|
|
141
|
+
if (!Number.isFinite(parsedFetchedAt))
|
|
142
|
+
continue;
|
|
143
|
+
if (typeof entry.ttl !== 'number' ||
|
|
144
|
+
!Number.isFinite(entry.ttl) ||
|
|
145
|
+
entry.ttl <= 0)
|
|
146
|
+
continue;
|
|
147
|
+
const age = clock.now() - parsedFetchedAt;
|
|
148
|
+
const stale = age > entry.ttl;
|
|
149
|
+
candidates.push({ entry, stale, age });
|
|
150
|
+
}
|
|
151
|
+
if (candidates.length === 0) {
|
|
152
|
+
return { hit: false, stale: false, entry: null };
|
|
153
|
+
}
|
|
154
|
+
// Prefer: non-stale over stale; among same-staleness, lowest age (most recent)
|
|
155
|
+
candidates.sort((a, b) => {
|
|
156
|
+
if (a.stale !== b.stale)
|
|
157
|
+
return a.stale ? 1 : -1; // non-stale first
|
|
158
|
+
return a.age - b.age; // lower age (more recent) first
|
|
159
|
+
});
|
|
160
|
+
const best = candidates[0];
|
|
161
|
+
return { hit: true, stale: best.stale, entry: best.entry };
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return { hit: false, stale: false, entry: null };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
module.exports = { isValidResearchKey, researchKey, ttlForSource, tierForSource, resolveStorePath, putResearch, getResearch };
|
|
@@ -25,8 +25,6 @@ const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}
|
|
|
25
25
|
// Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
|
|
26
26
|
// The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
|
|
27
27
|
const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
|
|
28
|
-
// Matches checklist phase references: - [ ] **Phase N:** or - [x] **Phase N:** (also decimal)
|
|
29
|
-
const CHECKLIST_PHASE_RE = /^(\s*-\s*\[[ x]\]\s*\*{0,2})Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
|
|
30
28
|
// ─── Pure computation helpers ─────────────────────────────────────────────────
|
|
31
29
|
/**
|
|
32
30
|
* Parse the ROADMAP.md content and build a list of phase entries with their
|
|
@@ -126,19 +124,6 @@ function buildNewDirName(oldDirName, newId, projectCode) {
|
|
|
126
124
|
const newBase = slug ? `${paddedMilestone}-${subStr}-${slug}` : `${paddedMilestone}-${subStr}`;
|
|
127
125
|
return projectCode ? `${projectCode}-${newBase}` : newBase;
|
|
128
126
|
}
|
|
129
|
-
/**
|
|
130
|
-
* Read project_code from config.json if present.
|
|
131
|
-
*/
|
|
132
|
-
function readProjectCode(configPath) {
|
|
133
|
-
try {
|
|
134
|
-
const raw = node_fs_1.default.readFileSync(configPath, 'utf8');
|
|
135
|
-
const parsed = JSON.parse(raw);
|
|
136
|
-
return typeof parsed['project_code'] === 'string' ? parsed['project_code'] : null;
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
127
|
// ─── computeMigrationPlan ─────────────────────────────────────────────────────
|
|
143
128
|
/**
|
|
144
129
|
* Compute a migration plan without touching the filesystem.
|
|
@@ -390,7 +375,7 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
390
375
|
// ── Real run: verify clean working tree ───────────────────────────────────
|
|
391
376
|
let gitStatus;
|
|
392
377
|
try {
|
|
393
|
-
gitStatus = (0, node_child_process_1.execSync)('git status --porcelain', { cwd, encoding: 'utf8' });
|
|
378
|
+
gitStatus = (0, node_child_process_1.execSync)('git status --porcelain', { cwd, encoding: 'utf8', windowsHide: true });
|
|
394
379
|
}
|
|
395
380
|
catch (err) {
|
|
396
381
|
throw new Error(`git status failed: ${err.message}`);
|
|
@@ -401,7 +386,7 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
401
386
|
// Capture HEAD sha for rollback
|
|
402
387
|
let headSha;
|
|
403
388
|
try {
|
|
404
|
-
headSha = (0, node_child_process_1.execSync)('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim();
|
|
389
|
+
headSha = (0, node_child_process_1.execSync)('git rev-parse HEAD', { cwd, encoding: 'utf8', windowsHide: true }).trim();
|
|
405
390
|
}
|
|
406
391
|
catch (err) {
|
|
407
392
|
throw new Error(`git rev-parse HEAD failed: ${err.message}`);
|
|
@@ -475,8 +460,8 @@ function applyMigration(cwd, plan, options = {}) {
|
|
|
475
460
|
catch (err) {
|
|
476
461
|
// Rollback via git reset --hard + git clean
|
|
477
462
|
try {
|
|
478
|
-
(0, node_child_process_1.execSync)(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe' });
|
|
479
|
-
(0, node_child_process_1.execSync)('git clean -fd .planning/phases/', { cwd, stdio: 'pipe' });
|
|
463
|
+
(0, node_child_process_1.execSync)(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe', windowsHide: true });
|
|
464
|
+
(0, node_child_process_1.execSync)('git clean -fd .planning/phases/', { cwd, stdio: 'pipe', windowsHide: true });
|
|
480
465
|
}
|
|
481
466
|
catch {
|
|
482
467
|
// Swallow rollback errors — surface original error
|
|
@@ -24,6 +24,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
24
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
25
|
exports.MARKDOWN_LINK_PATTERNS = exports.INJECTION_PATTERNS = void 0;
|
|
26
26
|
exports.validatePath = validatePath;
|
|
27
|
+
exports.loadTrustedGlobalRoots = loadTrustedGlobalRoots;
|
|
27
28
|
exports.requireSafePath = requireSafePath;
|
|
28
29
|
exports.scanForInjection = scanForInjection;
|
|
29
30
|
exports.sanitizeForPrompt = sanitizeForPrompt;
|
|
@@ -35,6 +36,7 @@ exports.validateFieldName = validateFieldName;
|
|
|
35
36
|
exports.validatePromptStructure = validatePromptStructure;
|
|
36
37
|
exports.scanEntropyAnomalies = scanEntropyAnomalies;
|
|
37
38
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
39
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
38
40
|
const node_path_1 = __importDefault(require("node:path"));
|
|
39
41
|
// ─── Path Traversal Prevention ──────────────────────────────────────────────
|
|
40
42
|
/**
|
|
@@ -92,6 +94,77 @@ function validatePath(filePath, baseDir, opts = {}) {
|
|
|
92
94
|
}
|
|
93
95
|
return { safe: true, resolved: resolvedPath };
|
|
94
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Load the opt-in trusted global roots allowlist from config.
|
|
99
|
+
*
|
|
100
|
+
* Reads `config.agent_skills_security.trusted_global_roots` (an array of
|
|
101
|
+
* path strings). Each entry is canonicalized via realpathSync: non-strings
|
|
102
|
+
* are dropped, leading `~/` is expanded to `os.homedir()`, entries that are
|
|
103
|
+
* not absolute after expansion are dropped (project-relative paths are
|
|
104
|
+
* rejected as a security boundary), and entries that do not exist on disk are
|
|
105
|
+
* dropped (a non-existent root is not trustworthy). The canonical realpath is
|
|
106
|
+
* used for all subsequent checks and as the stored value — this closes the
|
|
107
|
+
* case-insensitive bypass on macOS APFS (`/users/alice` vs `/Users/alice`)
|
|
108
|
+
* and ensures trust doesn't drift across re-invocations if a root is
|
|
109
|
+
* re-created at a different target. Results are de-duplicated by canonical path.
|
|
110
|
+
*/
|
|
111
|
+
function loadTrustedGlobalRoots(config) {
|
|
112
|
+
const roots = config?.['agent_skills_security'];
|
|
113
|
+
const raw = roots?.['trusted_global_roots'];
|
|
114
|
+
if (!Array.isArray(raw))
|
|
115
|
+
return [];
|
|
116
|
+
// Compute canonical homedir once for case-insensitive-safe comparison.
|
|
117
|
+
let realHome;
|
|
118
|
+
try {
|
|
119
|
+
realHome = node_fs_1.default.realpathSync(node_os_1.default.homedir());
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
realHome = node_os_1.default.homedir();
|
|
123
|
+
}
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
const result = [];
|
|
126
|
+
for (const entry of raw) {
|
|
127
|
+
if (typeof entry !== 'string')
|
|
128
|
+
continue;
|
|
129
|
+
let expanded;
|
|
130
|
+
if (entry === '~') {
|
|
131
|
+
expanded = node_os_1.default.homedir();
|
|
132
|
+
}
|
|
133
|
+
else if (entry.startsWith('~/')) {
|
|
134
|
+
expanded = node_path_1.default.join(node_os_1.default.homedir(), entry.slice(2));
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
expanded = entry;
|
|
138
|
+
}
|
|
139
|
+
if (!node_path_1.default.isAbsolute(expanded))
|
|
140
|
+
continue; // reject project-relative
|
|
141
|
+
// Canonicalize: resolve symlinks and normalise case. If the path doesn't
|
|
142
|
+
// exist or can't be read, skip it — a non-existent root is not trustworthy.
|
|
143
|
+
let real;
|
|
144
|
+
try {
|
|
145
|
+
real = node_fs_1.default.realpathSync(expanded);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue; // non-existent or unreadable — skip
|
|
149
|
+
}
|
|
150
|
+
// Reject dangerously broad roots: filesystem root (e.g. '/' or 'C:\' or UNC '\\server\share').
|
|
151
|
+
// Normalize both sides by stripping trailing path separators before comparing so that
|
|
152
|
+
// Windows UNC shares (where path.parse().root includes a trailing separator) are caught.
|
|
153
|
+
const stripTrailingSep = (p) => p.replace(/[\\/]+$/, '');
|
|
154
|
+
if (stripTrailingSep(node_path_1.default.parse(real).root) === stripTrailingSep(real))
|
|
155
|
+
continue;
|
|
156
|
+
// Reject homedir itself (canonical compare closes case-insensitive bypass).
|
|
157
|
+
// Apply stripTrailingSep for robustness on platforms where realpathSync may
|
|
158
|
+
// or may not include a trailing separator on the homedir path.
|
|
159
|
+
if (stripTrailingSep(real) === stripTrailingSep(realHome))
|
|
160
|
+
continue;
|
|
161
|
+
if (seen.has(real))
|
|
162
|
+
continue;
|
|
163
|
+
seen.add(real);
|
|
164
|
+
result.push(real);
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
95
168
|
/**
|
|
96
169
|
* Validate a file path and throw on traversal attempt.
|
|
97
170
|
* Convenience wrapper around validatePath for use in CLI commands.
|
|
@@ -381,6 +381,7 @@ function execGit(args, opts = {}) {
|
|
|
381
381
|
encoding: 'utf-8',
|
|
382
382
|
stdio: 'pipe',
|
|
383
383
|
timeout: opts.timeout ?? 10_000,
|
|
384
|
+
windowsHide: true,
|
|
384
385
|
});
|
|
385
386
|
return _spawnResult(result, 'git');
|
|
386
387
|
}
|
|
@@ -391,6 +392,7 @@ function execNpm(args, opts = {}) {
|
|
|
391
392
|
encoding: 'utf-8',
|
|
392
393
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
393
394
|
timeout: opts.timeout ?? 15_000,
|
|
395
|
+
windowsHide: true,
|
|
394
396
|
});
|
|
395
397
|
return _spawnResult(result, 'npm');
|
|
396
398
|
}
|
|
@@ -401,6 +403,7 @@ function execTool(program, args, opts = {}) {
|
|
|
401
403
|
encoding: 'utf-8',
|
|
402
404
|
stdio: 'pipe',
|
|
403
405
|
timeout: opts.timeout ?? 30_000,
|
|
406
|
+
windowsHide: true,
|
|
404
407
|
});
|
|
405
408
|
return _spawnResult(result, program);
|
|
406
409
|
}
|
|
@@ -90,7 +90,7 @@ function buildRoadmapPhaseVariants(roadmapContent) {
|
|
|
90
90
|
const roadmapPhaseVariants = new Set();
|
|
91
91
|
// Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
|
|
92
92
|
// and bracket-prefixed (### [GSD] Phase 2-01:) headings.
|
|
93
|
-
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*
|
|
93
|
+
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
94
94
|
let m;
|
|
95
95
|
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
|
96
96
|
roadmapPhases.add(m[1]);
|
|
@@ -102,7 +102,7 @@ function buildRoadmapPhaseVariants(roadmapContent) {
|
|
|
102
102
|
function buildNotStartedPhaseVariants(roadmapContent) {
|
|
103
103
|
const notStartedPhases = new Set();
|
|
104
104
|
// Also matches milestone-prefixed and bracket-prefixed checklist items.
|
|
105
|
-
const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*
|
|
105
|
+
const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*)[:\s*]/gi;
|
|
106
106
|
let um;
|
|
107
107
|
while ((um = uncheckedPattern.exec(roadmapContent)) !== null) {
|
|
108
108
|
for (const variant of phaseVariants(um[1]))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Verification-status subcommand router.
|
|
4
|
+
* Routes `verification.status <phaseDir>` to verification.cmdVerificationStatus.
|
|
5
|
+
*
|
|
6
|
+
* Note: `verification` (reads verifier-emitted status) is distinct from `verify`
|
|
7
|
+
* (runs verification checks like plan-structure/artifacts). Keep them separate.
|
|
8
|
+
*
|
|
9
|
+
* ADR-457 build-at-publish: source in src/verification-command-router.cts,
|
|
10
|
+
* compiled to gsd-core/bin/lib/verification-command-router.cjs (gitignored).
|
|
11
|
+
*/
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
|
+
const cjsCommandRouterAdapter = require("./cjs-command-router-adapter.cjs");
|
|
14
|
+
const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
|
|
15
|
+
// ─── Implementation ───────────────────────────────────────────────────────────
|
|
16
|
+
const VERIFICATION_SUBCOMMANDS = ['status'];
|
|
17
|
+
function routeVerificationCommand({ verification, args, cwd, raw, error, }) {
|
|
18
|
+
routeCjsCommandFamily({
|
|
19
|
+
args,
|
|
20
|
+
subcommands: VERIFICATION_SUBCOMMANDS,
|
|
21
|
+
unsupported: {},
|
|
22
|
+
error,
|
|
23
|
+
unknownMessage: (_subcommand, available) => `Unknown verification subcommand. Available: ${available.join(', ')}`,
|
|
24
|
+
handlers: {
|
|
25
|
+
status: () => verification.cmdVerificationStatus(cwd, args[2], raw),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
module.exports = {
|
|
30
|
+
routeVerificationCommand,
|
|
31
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Verification Status — single queryable home for verification-status routing.
|
|
4
|
+
*
|
|
5
|
+
* Issue #651: consolidate the pass/gaps_found/human_needed routing that was
|
|
6
|
+
* previously scattered across ship.md and execute-phase.md into a single
|
|
7
|
+
* tested module. Both workflow files will later consume this module's routing
|
|
8
|
+
* table as the single source of truth.
|
|
9
|
+
*
|
|
10
|
+
* ADR-457 build-at-publish: source in src/verification.cts, compiled to
|
|
11
|
+
* gsd-core/bin/lib/verification.cjs (gitignored).
|
|
12
|
+
*
|
|
13
|
+
* DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix: status extraction is scoped to
|
|
14
|
+
* the leading YAML frontmatter block only. A `status:` line in the body (e.g.
|
|
15
|
+
* inside a fenced code block) is ignored — this is the exact failure mode that
|
|
16
|
+
* issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors
|
|
17
|
+
* its regex at byte 0 of the document, which provides this guarantee.
|
|
18
|
+
*/
|
|
19
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
20
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
21
|
+
};
|
|
22
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
|
|
25
|
+
const core = require("./core.cjs");
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
27
|
+
const frontmatterMod = require("./frontmatter.cjs");
|
|
28
|
+
const { output, extractPhaseToken } = core;
|
|
29
|
+
const { extractFrontmatter } = frontmatterMod;
|
|
30
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
31
|
+
/** The set of status values that the gsd-verifier agent emits. */
|
|
32
|
+
const VERIFIER_STATUSES = ['passed', 'gaps_found', 'human_needed'];
|
|
33
|
+
/**
|
|
34
|
+
* Canonical routing table for verification statuses.
|
|
35
|
+
*
|
|
36
|
+
* This is the single source of truth — ship.md and execute-phase.md will
|
|
37
|
+
* later import from here instead of embedding their own message strings.
|
|
38
|
+
*
|
|
39
|
+
* INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed
|
|
40
|
+
* internally — the verifier (gsd-verifier.md) never emits them. The verifier only
|
|
41
|
+
* emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in
|
|
42
|
+
* readVerificationStatus excludes 'missing' and 'unknown' from raw-status table
|
|
43
|
+
* lookup so they can only be reached via internal construction paths.
|
|
44
|
+
*
|
|
45
|
+
* For 'gaps_found', next_command is built at call time in readVerificationStatus
|
|
46
|
+
* by substituting the phase number — it is NOT stored as a function in the table.
|
|
47
|
+
*/
|
|
48
|
+
const VERIFICATION_ROUTING_TABLE = {
|
|
49
|
+
passed: {
|
|
50
|
+
status: 'passed',
|
|
51
|
+
next_action: 'Verification passed — continue.',
|
|
52
|
+
next_command: '',
|
|
53
|
+
},
|
|
54
|
+
gaps_found: {
|
|
55
|
+
status: 'gaps_found',
|
|
56
|
+
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
|
|
57
|
+
// next_command is computed at call time; this entry is never returned directly.
|
|
58
|
+
next_command: '',
|
|
59
|
+
},
|
|
60
|
+
human_needed: {
|
|
61
|
+
status: 'human_needed',
|
|
62
|
+
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
|
|
63
|
+
next_command: '',
|
|
64
|
+
},
|
|
65
|
+
// INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when
|
|
66
|
+
// the file has no parseable frontmatter status. Never emitted by the verifier.
|
|
67
|
+
missing: {
|
|
68
|
+
status: 'missing',
|
|
69
|
+
next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.',
|
|
70
|
+
next_command: '/gsd:execute-phase',
|
|
71
|
+
},
|
|
72
|
+
// INTERNAL SENTINEL: constructed when the file has a status value not in
|
|
73
|
+
// VERIFIER_STATUSES. Never emitted by the verifier.
|
|
74
|
+
unknown: {
|
|
75
|
+
status: 'unknown',
|
|
76
|
+
next_action: '', // filled in dynamically with the raw value
|
|
77
|
+
next_command: '/gsd:execute-phase',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Build a 'missing' result from the routing table.
|
|
82
|
+
* Used for two early-return paths: no *-VERIFICATION.md file found, and
|
|
83
|
+
* file present but no parseable frontmatter status.
|
|
84
|
+
*/
|
|
85
|
+
function missingResult() {
|
|
86
|
+
const route = VERIFICATION_ROUTING_TABLE['missing'];
|
|
87
|
+
return {
|
|
88
|
+
status: route.status,
|
|
89
|
+
next_action: route.next_action,
|
|
90
|
+
next_command: route.next_command,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read the verification status from the first `*-VERIFICATION.md` file in
|
|
95
|
+
* phaseDir and return the routing result.
|
|
96
|
+
*
|
|
97
|
+
* Behavior:
|
|
98
|
+
* 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first).
|
|
99
|
+
* If none → status 'missing'.
|
|
100
|
+
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
|
|
101
|
+
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
|
|
102
|
+
* If no frontmatter block or no `status` key → status 'missing'.
|
|
103
|
+
* 3. Map to routing table. Unknown non-empty value → status 'unknown'.
|
|
104
|
+
*
|
|
105
|
+
* @param phaseDir - Absolute path to the phase directory.
|
|
106
|
+
* @param opts - Options. `opts.fs` allows test injection (defaults to node:fs).
|
|
107
|
+
*/
|
|
108
|
+
function readVerificationStatus(phaseDir, opts = {}) {
|
|
109
|
+
const fsImpl = opts.fs ?? node_fs_1.default;
|
|
110
|
+
// Phase token for the gaps_found command
|
|
111
|
+
const baseName = node_path_1.default.basename(phaseDir);
|
|
112
|
+
const phaseToken = extractPhaseToken(baseName);
|
|
113
|
+
const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName;
|
|
114
|
+
// 1. Find *-VERIFICATION.md
|
|
115
|
+
let verificationFile = null;
|
|
116
|
+
try {
|
|
117
|
+
const entries = fsImpl.readdirSync(phaseDir);
|
|
118
|
+
const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort();
|
|
119
|
+
verificationFile = candidates.length > 0 ? candidates[0] : null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Directory unreadable → treat as missing
|
|
123
|
+
verificationFile = null;
|
|
124
|
+
}
|
|
125
|
+
if (!verificationFile) {
|
|
126
|
+
return missingResult();
|
|
127
|
+
}
|
|
128
|
+
// 2. Read and parse frontmatter using the shared parser.
|
|
129
|
+
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
|
130
|
+
const filePath = node_path_1.default.join(phaseDir, verificationFile);
|
|
131
|
+
let rawStatus = null;
|
|
132
|
+
try {
|
|
133
|
+
const content = fsImpl.readFileSync(filePath, 'utf-8');
|
|
134
|
+
const fm = extractFrontmatter(content);
|
|
135
|
+
const statusVal = fm['status'];
|
|
136
|
+
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
|
|
137
|
+
// only accept string values — arrays and objects are not valid status values.
|
|
138
|
+
if (typeof statusVal === 'string') {
|
|
139
|
+
const trimmed = statusVal.trim();
|
|
140
|
+
rawStatus = trimmed.length > 0 ? trimmed : null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
rawStatus = null;
|
|
145
|
+
}
|
|
146
|
+
if (!rawStatus) {
|
|
147
|
+
return missingResult();
|
|
148
|
+
}
|
|
149
|
+
// 3. Route — exclude internal sentinels from raw-file lookup (they are
|
|
150
|
+
// constructed internally above, never written by the verifier).
|
|
151
|
+
if (rawStatus in VERIFICATION_ROUTING_TABLE && rawStatus !== 'missing' && rawStatus !== 'unknown') {
|
|
152
|
+
const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
|
|
153
|
+
// gaps_found: build the phase-specific command here rather than in the table.
|
|
154
|
+
const next_command = rawStatus === 'gaps_found'
|
|
155
|
+
? `/gsd:plan-phase ${phaseNumber} --gaps`
|
|
156
|
+
: entry.next_command;
|
|
157
|
+
return {
|
|
158
|
+
status: entry.status,
|
|
159
|
+
next_action: entry.next_action,
|
|
160
|
+
next_command,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Unknown value
|
|
164
|
+
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
|
|
165
|
+
return {
|
|
166
|
+
status: unknownRoute.status,
|
|
167
|
+
next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`,
|
|
168
|
+
next_command: unknownRoute.next_command,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
|
|
173
|
+
* emit via core.output().
|
|
174
|
+
*
|
|
175
|
+
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
|
176
|
+
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
|
177
|
+
* @param raw - Whether to emit raw (non-JSON) output.
|
|
178
|
+
*/
|
|
179
|
+
function cmdVerificationStatus(cwd, phaseDirArg, raw) {
|
|
180
|
+
if (!phaseDirArg) {
|
|
181
|
+
core.error('phase directory required for verification.status');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
|
|
185
|
+
const result = readVerificationStatus(phaseDir);
|
|
186
|
+
output(result, raw);
|
|
187
|
+
}
|
|
188
|
+
module.exports = {
|
|
189
|
+
VERIFIER_STATUSES,
|
|
190
|
+
VERIFICATION_ROUTING_TABLE,
|
|
191
|
+
readVerificationStatus,
|
|
192
|
+
cmdVerificationStatus,
|
|
193
|
+
};
|
|
@@ -550,13 +550,13 @@ function cmdValidateConsistency(cwd, raw) {
|
|
|
550
550
|
const roadmapContentRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
551
551
|
const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd);
|
|
552
552
|
const roadmapPhases = new Set();
|
|
553
|
-
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*
|
|
553
|
+
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
554
554
|
let m;
|
|
555
555
|
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
|
556
556
|
roadmapPhases.add(m[1]);
|
|
557
557
|
}
|
|
558
558
|
const fullRoadmapPhases = new Set();
|
|
559
|
-
const fullPhasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*
|
|
559
|
+
const fullPhasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
560
560
|
let fm;
|
|
561
561
|
while ((fm = fullPhasePattern.exec(roadmapContentRaw)) !== null) {
|
|
562
562
|
fullRoadmapPhases.add(fm[1]);
|
|
@@ -20,7 +20,7 @@ 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
22
|
const core = require("./core.cjs");
|
|
23
|
-
const {
|
|
23
|
+
const { readSubdirectories } = core;
|
|
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
|