gsd-pi 2.5.1 → 2.6.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/README.md +1 -0
- package/dist/cli.js +7 -1
- package/package.json +1 -1
- package/src/resources/extensions/get-secrets-from-user.ts +271 -54
- package/src/resources/extensions/gsd/auto.ts +35 -7
- package/src/resources/extensions/gsd/doctor.ts +23 -4
- package/src/resources/extensions/gsd/files.ts +45 -1
- package/src/resources/extensions/gsd/git-service.ts +50 -9
- package/src/resources/extensions/gsd/session-forensics.ts +19 -6
- package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
- package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
- package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +43 -0
- package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
- package/src/resources/extensions/gsd/tests/parsers.test.ts +190 -0
- package/src/resources/extensions/gsd/types.ts +7 -0
|
@@ -26,8 +26,11 @@ export interface GitPreferences {
|
|
|
26
26
|
snapshots?: boolean;
|
|
27
27
|
pre_merge_check?: boolean | string;
|
|
28
28
|
commit_type?: string;
|
|
29
|
+
main_branch?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
|
|
33
|
+
|
|
31
34
|
export interface CommitOptions {
|
|
32
35
|
message: string;
|
|
33
36
|
allowEmpty?: boolean;
|
|
@@ -123,9 +126,11 @@ export class GitServiceImpl {
|
|
|
123
126
|
/**
|
|
124
127
|
* Smart staging: `git add -A` excluding GSD runtime paths via pathspec.
|
|
125
128
|
* Falls back to plain `git add -A` if the exclusion pathspec fails.
|
|
129
|
+
* @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS.
|
|
126
130
|
*/
|
|
127
|
-
private smartStage(): void {
|
|
128
|
-
const
|
|
131
|
+
private smartStage(extraExclusions: readonly string[] = []): void {
|
|
132
|
+
const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
|
|
133
|
+
const excludes = allExclusions.map(p => `':(exclude)${p}'`);
|
|
129
134
|
const args = ["add", "-A", "--", ".", ...excludes];
|
|
130
135
|
try {
|
|
131
136
|
this.git(args);
|
|
@@ -157,13 +162,14 @@ export class GitServiceImpl {
|
|
|
157
162
|
/**
|
|
158
163
|
* Auto-commit dirty working tree with a conventional chore message.
|
|
159
164
|
* Returns the commit message on success, or null if nothing to commit.
|
|
165
|
+
* @param extraExclusions Additional paths to exclude from staging (e.g. [".gsd/"] for pre-switch commits).
|
|
160
166
|
*/
|
|
161
|
-
autoCommit(unitType: string, unitId: string): string | null {
|
|
167
|
+
autoCommit(unitType: string, unitId: string, extraExclusions: readonly string[] = []): string | null {
|
|
162
168
|
// Quick check: is there anything dirty at all?
|
|
163
169
|
const status = this.git(["status", "--short"], { allowFailure: true });
|
|
164
170
|
if (!status) return null;
|
|
165
171
|
|
|
166
|
-
this.smartStage();
|
|
172
|
+
this.smartStage(extraExclusions);
|
|
167
173
|
|
|
168
174
|
// After smart staging, check if anything was actually staged
|
|
169
175
|
// (all changes might have been runtime files that got excluded)
|
|
@@ -183,6 +189,11 @@ export class GitServiceImpl {
|
|
|
183
189
|
* In the main tree: origin/HEAD symbolic-ref → main/master fallback → current branch.
|
|
184
190
|
*/
|
|
185
191
|
getMainBranch(): string {
|
|
192
|
+
// Explicit preference takes priority (double-check validity as defense-in-depth)
|
|
193
|
+
if (this.prefs.main_branch && VALID_BRANCH_NAME.test(this.prefs.main_branch)) {
|
|
194
|
+
return this.prefs.main_branch;
|
|
195
|
+
}
|
|
196
|
+
|
|
186
197
|
const wtName = detectWorktreeName(this.basePath);
|
|
187
198
|
if (wtName) {
|
|
188
199
|
const wtBranch = `worktree/${wtName}`;
|
|
@@ -297,8 +308,9 @@ export class GitServiceImpl {
|
|
|
297
308
|
}
|
|
298
309
|
}
|
|
299
310
|
|
|
300
|
-
// Auto-commit dirty state via smart staging before checkout
|
|
301
|
-
|
|
311
|
+
// Auto-commit dirty state via smart staging before checkout.
|
|
312
|
+
// Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
|
|
313
|
+
this.autoCommit("pre-switch", current, [".gsd/"]);
|
|
302
314
|
|
|
303
315
|
this.git(["checkout", branch]);
|
|
304
316
|
return created;
|
|
@@ -312,7 +324,8 @@ export class GitServiceImpl {
|
|
|
312
324
|
const current = this.getCurrentBranch();
|
|
313
325
|
if (current === mainBranch) return;
|
|
314
326
|
|
|
315
|
-
|
|
327
|
+
// Exclude .gsd/ to prevent merge conflicts when both branches modify planning artifacts.
|
|
328
|
+
this.autoCommit("pre-switch", current, [".gsd/"]);
|
|
316
329
|
|
|
317
330
|
this.git(["checkout", mainBranch]);
|
|
318
331
|
}
|
|
@@ -347,8 +360,36 @@ export class GitServiceImpl {
|
|
|
347
360
|
* Stub: to be implemented in T03.
|
|
348
361
|
*/
|
|
349
362
|
runPreMergeCheck(): PreMergeCheckResult {
|
|
350
|
-
|
|
351
|
-
|
|
363
|
+
if (this.prefs.pre_merge_check === false || this.prefs.pre_merge_check === undefined) {
|
|
364
|
+
return { passed: true, skipped: true };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Determine command: explicit string or auto-detect from package.json
|
|
368
|
+
let command: string;
|
|
369
|
+
if (typeof this.prefs.pre_merge_check === "string") {
|
|
370
|
+
command = this.prefs.pre_merge_check;
|
|
371
|
+
} else {
|
|
372
|
+
// Auto-detect: look for package.json with a test script
|
|
373
|
+
try {
|
|
374
|
+
const pkg = execSync("cat package.json", { cwd: this.basePath, encoding: "utf-8" });
|
|
375
|
+
const parsed = JSON.parse(pkg);
|
|
376
|
+
if (parsed.scripts?.test) {
|
|
377
|
+
command = "npm test";
|
|
378
|
+
} else {
|
|
379
|
+
return { passed: true, skipped: true };
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
return { passed: true, skipped: true };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
execSync(command, { cwd: this.basePath, stdio: "pipe", encoding: "utf-8" });
|
|
388
|
+
return { passed: true, skipped: false, command };
|
|
389
|
+
} catch (err) {
|
|
390
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
391
|
+
return { passed: false, skipped: false, command, error: msg };
|
|
392
|
+
}
|
|
352
393
|
}
|
|
353
394
|
|
|
354
395
|
// ─── Merge ─────────────────────────────────────────────────────────────
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - Tool results: { role: "toolResult", toolCallId: "toolu_...", toolName: "bash", isError: bool, content: ... }
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
21
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
22
22
|
import { execSync } from "node:child_process";
|
|
23
23
|
import { basename, join } from "node:path";
|
|
24
24
|
|
|
@@ -62,8 +62,16 @@ export interface RecoveryBriefing {
|
|
|
62
62
|
|
|
63
63
|
// ─── JSONL Parsing ────────────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
|
+
/** Max bytes to parse from a JSONL source. Prevents V8 OOM on bloated activity logs. */
|
|
66
|
+
const MAX_JSONL_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
67
|
+
|
|
65
68
|
function parseJSONL(raw: string): unknown[] {
|
|
66
|
-
|
|
69
|
+
// If the file is enormous, only parse the tail (most recent entries).
|
|
70
|
+
// This prevents the OOM crash path: large file → split → map → parse → OOM.
|
|
71
|
+
const source = raw.length > MAX_JSONL_BYTES
|
|
72
|
+
? raw.slice(-MAX_JSONL_BYTES)
|
|
73
|
+
: raw;
|
|
74
|
+
return source.trim().split("\n").map(line => {
|
|
67
75
|
try { return JSON.parse(line); }
|
|
68
76
|
catch { return null; }
|
|
69
77
|
}).filter(Boolean) as unknown[];
|
|
@@ -239,10 +247,15 @@ export function synthesizeCrashRecovery(
|
|
|
239
247
|
|
|
240
248
|
// Primary source: surviving pi session file
|
|
241
249
|
if (sessionFile && existsSync(sessionFile)) {
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
250
|
+
const stat = statSync(sessionFile, { throwIfNoEntry: false });
|
|
251
|
+
const fileSize = stat?.size ?? 0;
|
|
252
|
+
// Skip files that would blow up memory; fall back to activity log
|
|
253
|
+
if (fileSize <= MAX_JSONL_BYTES * 2) {
|
|
254
|
+
const raw = readFileSync(sessionFile, "utf-8");
|
|
255
|
+
const allEntries = parseJSONL(raw);
|
|
256
|
+
const sessionEntries = extractLastSession(allEntries);
|
|
257
|
+
trace = extractTrace(sessionEntries);
|
|
258
|
+
}
|
|
246
259
|
}
|
|
247
260
|
|
|
248
261
|
// Fallback: last GSD activity log
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the secrets collection gate in startAuto().
|
|
3
|
+
*
|
|
4
|
+
* Exercises getManifestStatus() → collectSecretsFromManifest() composition
|
|
5
|
+
* end-to-end using real filesystem state. Proves the three gate paths:
|
|
6
|
+
* 1. No manifest exists — gate skips silently
|
|
7
|
+
* 2. Pending keys exist — gate triggers collection
|
|
8
|
+
* 3. No pending keys — gate skips silently
|
|
9
|
+
*
|
|
10
|
+
* Uses temp directories with real .gsd/milestones/M001/ structure, mirroring
|
|
11
|
+
* the pattern from manifest-status.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import test from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { getManifestStatus } from '../files.ts';
|
|
20
|
+
import { collectSecretsFromManifest } from '../../get-secrets-from-user.ts';
|
|
21
|
+
|
|
22
|
+
function makeTempDir(prefix: string): string {
|
|
23
|
+
const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Create the .gsd/milestones/M001/ directory structure and write a secrets manifest. */
|
|
29
|
+
function writeManifest(base: string, content: string): void {
|
|
30
|
+
const mDir = join(base, '.gsd', 'milestones', 'M001');
|
|
31
|
+
mkdirSync(mDir, { recursive: true });
|
|
32
|
+
writeFileSync(join(mDir, 'M001-SECRETS.md'), content);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stub ctx with hasUI: false — collectOneSecret returns null (skip), showSecretsSummary is a no-op. */
|
|
36
|
+
function makeNoUICtx(cwd: string) {
|
|
37
|
+
return {
|
|
38
|
+
ui: {},
|
|
39
|
+
hasUI: false,
|
|
40
|
+
cwd,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Scenario 1: No manifest exists ──────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
test('secrets gate: no manifest exists — getManifestStatus returns null', async () => {
|
|
47
|
+
const tmp = makeTempDir('gate-no-manifest');
|
|
48
|
+
try {
|
|
49
|
+
// No .gsd directory at all
|
|
50
|
+
const result = await getManifestStatus(tmp, 'M001');
|
|
51
|
+
assert.strictEqual(result, null, 'should return null when no manifest file exists');
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─── Scenario 2: Pending keys exist ─────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async () => {
|
|
60
|
+
const tmp = makeTempDir('gate-pending');
|
|
61
|
+
const savedA = process.env.GSD_GATE_TEST_EXISTING;
|
|
62
|
+
try {
|
|
63
|
+
// Simulate one key already in env
|
|
64
|
+
process.env.GSD_GATE_TEST_EXISTING = 'already-here';
|
|
65
|
+
|
|
66
|
+
// Ensure pending keys are NOT in env
|
|
67
|
+
delete process.env.GSD_GATE_TEST_PEND_A;
|
|
68
|
+
delete process.env.GSD_GATE_TEST_PEND_B;
|
|
69
|
+
|
|
70
|
+
writeManifest(tmp, `# Secrets Manifest
|
|
71
|
+
|
|
72
|
+
**Milestone:** M001
|
|
73
|
+
**Generated:** 2025-06-20T10:00:00Z
|
|
74
|
+
|
|
75
|
+
### GSD_GATE_TEST_PEND_A
|
|
76
|
+
|
|
77
|
+
**Service:** ServiceA
|
|
78
|
+
**Status:** pending
|
|
79
|
+
**Destination:** dotenv
|
|
80
|
+
|
|
81
|
+
1. Get key A from dashboard
|
|
82
|
+
|
|
83
|
+
### GSD_GATE_TEST_PEND_B
|
|
84
|
+
|
|
85
|
+
**Service:** ServiceB
|
|
86
|
+
**Status:** pending
|
|
87
|
+
**Destination:** dotenv
|
|
88
|
+
|
|
89
|
+
1. Get key B from dashboard
|
|
90
|
+
|
|
91
|
+
### GSD_GATE_TEST_EXISTING
|
|
92
|
+
|
|
93
|
+
**Service:** ServiceC
|
|
94
|
+
**Status:** pending
|
|
95
|
+
**Destination:** dotenv
|
|
96
|
+
|
|
97
|
+
1. Already in env
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
// (a) Verify getManifestStatus shows pending keys
|
|
101
|
+
const status = await getManifestStatus(tmp, 'M001');
|
|
102
|
+
assert.notStrictEqual(status, null, 'manifest should exist');
|
|
103
|
+
assert.ok(status!.pending.length > 0, 'should have pending keys');
|
|
104
|
+
assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B']);
|
|
105
|
+
assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING']);
|
|
106
|
+
|
|
107
|
+
// (b) Call collectSecretsFromManifest with no-UI context
|
|
108
|
+
// With hasUI: false, collectOneSecret returns null → pending keys become "skipped"
|
|
109
|
+
const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp));
|
|
110
|
+
|
|
111
|
+
// (c) Verify return shape
|
|
112
|
+
assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)');
|
|
113
|
+
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped');
|
|
114
|
+
assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped');
|
|
115
|
+
assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']);
|
|
116
|
+
|
|
117
|
+
// (d) Verify manifest on disk was updated — pending entries that went through
|
|
118
|
+
// collection are now "skipped". The existing-in-env entry retains its manifest
|
|
119
|
+
// status ("pending") because collectSecretsFromManifest only updates entries
|
|
120
|
+
// that flow through collectOneSecret. At runtime, getManifestStatus overrides
|
|
121
|
+
// env-present entries to "existing" regardless of manifest status.
|
|
122
|
+
const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md');
|
|
123
|
+
const updatedContent = readFileSync(manifestPath, 'utf8');
|
|
124
|
+
assert.ok(
|
|
125
|
+
updatedContent.includes('**Status:** skipped'),
|
|
126
|
+
'formerly-pending entries should now have status "skipped" in the manifest file',
|
|
127
|
+
);
|
|
128
|
+
// Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk
|
|
129
|
+
const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g);
|
|
130
|
+
assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"');
|
|
131
|
+
const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g);
|
|
132
|
+
assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk');
|
|
133
|
+
|
|
134
|
+
// (e) Verify getManifestStatus now shows no pending
|
|
135
|
+
const statusAfter = await getManifestStatus(tmp, 'M001');
|
|
136
|
+
assert.notStrictEqual(statusAfter, null);
|
|
137
|
+
assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection');
|
|
138
|
+
} finally {
|
|
139
|
+
delete process.env.GSD_GATE_TEST_EXISTING;
|
|
140
|
+
if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA;
|
|
141
|
+
delete process.env.GSD_GATE_TEST_PEND_A;
|
|
142
|
+
delete process.env.GSD_GATE_TEST_PEND_B;
|
|
143
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ─── Scenario 3: No pending keys — all collected or in env ──────────────────
|
|
148
|
+
|
|
149
|
+
test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => {
|
|
150
|
+
const tmp = makeTempDir('gate-no-pending');
|
|
151
|
+
const savedKey = process.env.GSD_GATE_TEST_ENVKEY;
|
|
152
|
+
try {
|
|
153
|
+
process.env.GSD_GATE_TEST_ENVKEY = 'some-value';
|
|
154
|
+
|
|
155
|
+
writeManifest(tmp, `# Secrets Manifest
|
|
156
|
+
|
|
157
|
+
**Milestone:** M001
|
|
158
|
+
**Generated:** 2025-06-20T10:00:00Z
|
|
159
|
+
|
|
160
|
+
### ALREADY_COLLECTED
|
|
161
|
+
|
|
162
|
+
**Service:** ServiceX
|
|
163
|
+
**Status:** collected
|
|
164
|
+
**Destination:** dotenv
|
|
165
|
+
|
|
166
|
+
1. Was collected previously
|
|
167
|
+
|
|
168
|
+
### ALREADY_SKIPPED
|
|
169
|
+
|
|
170
|
+
**Service:** ServiceY
|
|
171
|
+
**Status:** skipped
|
|
172
|
+
**Destination:** dotenv
|
|
173
|
+
|
|
174
|
+
1. Not needed
|
|
175
|
+
|
|
176
|
+
### GSD_GATE_TEST_ENVKEY
|
|
177
|
+
|
|
178
|
+
**Service:** ServiceZ
|
|
179
|
+
**Status:** pending
|
|
180
|
+
**Destination:** dotenv
|
|
181
|
+
|
|
182
|
+
1. In env already
|
|
183
|
+
`);
|
|
184
|
+
|
|
185
|
+
const result = await getManifestStatus(tmp, 'M001');
|
|
186
|
+
assert.notStrictEqual(result, null, 'manifest should exist');
|
|
187
|
+
assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip');
|
|
188
|
+
assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']);
|
|
189
|
+
assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']);
|
|
190
|
+
assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']);
|
|
191
|
+
} finally {
|
|
192
|
+
delete process.env.GSD_GATE_TEST_ENVKEY;
|
|
193
|
+
if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey;
|
|
194
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|