gsd-pi 2.38.0-dev.4d4d14a → 2.38.0-dev.5492881
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/dist/resource-loader.js +34 -1
- package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
- package/dist/resources/extensions/gsd/auto-loop.js +538 -469
- package/dist/resources/extensions/gsd/auto-post-unit.js +9 -3
- package/dist/resources/extensions/gsd/auto-prompts.js +18 -14
- package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
- package/dist/resources/extensions/gsd/commands.js +2 -1
- package/dist/resources/extensions/gsd/doctor.js +20 -1
- package/dist/resources/extensions/gsd/exit-command.js +2 -1
- package/dist/resources/extensions/gsd/files.js +4 -0
- package/dist/resources/extensions/gsd/git-service.js +22 -11
- package/dist/resources/extensions/gsd/guided-flow.js +82 -32
- package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
- package/dist/resources/extensions/gsd/prompts/run-uat.md +2 -0
- package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
- package/dist/resources/extensions/mcp-client/index.js +14 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
- package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
- package/src/resources/extensions/gsd/auto-loop.ts +342 -304
- package/src/resources/extensions/gsd/auto-post-unit.ts +10 -3
- package/src/resources/extensions/gsd/auto-prompts.ts +20 -14
- package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
- package/src/resources/extensions/gsd/commands.ts +2 -2
- package/src/resources/extensions/gsd/doctor.ts +22 -1
- package/src/resources/extensions/gsd/exit-command.ts +2 -2
- package/src/resources/extensions/gsd/files.ts +3 -1
- package/src/resources/extensions/gsd/git-service.ts +31 -9
- package/src/resources/extensions/gsd/guided-flow.ts +110 -38
- package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
- package/src/resources/extensions/gsd/prompts/run-uat.md +2 -0
- package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
- package/src/resources/extensions/gsd/tests/auto-loop.test.ts +106 -31
- package/src/resources/extensions/mcp-client/index.ts +17 -1
|
@@ -671,6 +671,43 @@ export function nativeAddAll(basePath: string): void {
|
|
|
671
671
|
gitFileExec(basePath, ["add", "-A"]);
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Stage all files with pathspec exclusions (git add -A -- ':!pattern' ...).
|
|
676
|
+
* Excluded paths are never hashed by git, preventing hangs on large
|
|
677
|
+
* untracked artifact trees (57GB+, 11K+ files). See #1605.
|
|
678
|
+
*
|
|
679
|
+
* Falls back to plain `git add -A` when no exclusions are provided.
|
|
680
|
+
* Always uses the CLI path (not libgit2) because libgit2's add_all
|
|
681
|
+
* does not support pathspec exclusion syntax.
|
|
682
|
+
*
|
|
683
|
+
* When excluded paths are already covered by .gitignore, git may exit
|
|
684
|
+
* with code 1 and an "ignored by .gitignore" warning. This is harmless
|
|
685
|
+
* (the staging succeeds for all non-ignored files) and is suppressed.
|
|
686
|
+
*/
|
|
687
|
+
export function nativeAddAllWithExclusions(basePath: string, exclusions: readonly string[]): void {
|
|
688
|
+
if (exclusions.length === 0) {
|
|
689
|
+
nativeAddAll(basePath);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const pathspecs = exclusions.map(e => `:!${e}`);
|
|
693
|
+
try {
|
|
694
|
+
execFileSync("git", ["add", "-A", "--", ...pathspecs], {
|
|
695
|
+
cwd: basePath,
|
|
696
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
697
|
+
encoding: "utf-8",
|
|
698
|
+
env: GIT_NO_PROMPT_ENV,
|
|
699
|
+
});
|
|
700
|
+
} catch (err: unknown) {
|
|
701
|
+
// git exits 1 when pathspec exclusions reference paths already covered
|
|
702
|
+
// by .gitignore. The staging itself succeeds — only suppress that case.
|
|
703
|
+
const stderr = (err as { stderr?: string })?.stderr ?? "";
|
|
704
|
+
if (stderr.includes("ignored by one of your .gitignore files")) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
throw new GSDError(GSD_GIT_ERROR, `git add -A with exclusions failed in ${basePath}: ${getErrorMessage(err)}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
674
711
|
/**
|
|
675
712
|
* Stage specific files.
|
|
676
713
|
* Native: libgit2 index add.
|
|
@@ -25,6 +25,8 @@ You are the UAT runner. Execute every check defined in `{{uatPath}}` as deeply a
|
|
|
25
25
|
### Automation rules by mode
|
|
26
26
|
|
|
27
27
|
- `artifact-driven` — verify with shell commands, scripts, file reads, and artifact structure checks.
|
|
28
|
+
- `browser-executable` — use browser tools to navigate to the target URL and verify expected behavior. Capture screenshots as evidence. Record pass/fail with specific assertions.
|
|
29
|
+
- `runtime-executable` — execute the specified command or script. Capture stdout/stderr as evidence. Record pass/fail based on exit code and output.
|
|
28
30
|
- `live-runtime` — exercise the real runtime path. Start or connect to the app/service if needed, use browser/runtime/network checks, and verify observable behavior.
|
|
29
31
|
- `mixed` — run all automatable artifact-driven and live-runtime checks. Separate any remaining human-only checks explicitly.
|
|
30
32
|
- `human-experience` — automate setup, preconditions, screenshots, logs, and objective checks, but do **not** invent subjective PASS results. Mark taste-based, experiential, or purely human-judgment checks as `NEEDS-HUMAN` and use an overall verdict of `PARTIAL` unless every required check was objective and passed.
|
|
@@ -39,6 +39,35 @@ export function markSliceDoneInRoadmap(basePath: string, mid: string, sid: strin
|
|
|
39
39
|
return true;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Mark a slice as not done ([ ]) in the milestone roadmap.
|
|
44
|
+
* Idempotent — no-op if already unchecked or if the slice isn't found.
|
|
45
|
+
*
|
|
46
|
+
* @returns true if the roadmap was modified, false if no change was needed
|
|
47
|
+
*/
|
|
48
|
+
export function markSliceUndoneInRoadmap(basePath: string, mid: string, sid: string): boolean {
|
|
49
|
+
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
50
|
+
if (!roadmapFile) return false;
|
|
51
|
+
|
|
52
|
+
let content: string;
|
|
53
|
+
try {
|
|
54
|
+
content = readFileSync(roadmapFile, "utf-8");
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const updated = content.replace(
|
|
60
|
+
new RegExp(`^(\\s*-\\s+)\\[x\\]\\s+\\*\\*${sid}:`, "m"),
|
|
61
|
+
`$1[ ] **${sid}:`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (updated === content) return false;
|
|
65
|
+
|
|
66
|
+
atomicWriteSync(roadmapFile, updated);
|
|
67
|
+
clearParseCache();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
42
71
|
/**
|
|
43
72
|
* Mark a task as done ([x]) in the slice plan.
|
|
44
73
|
* Idempotent — no-op if already checked or if the task isn't found.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
resolveAgentEnd,
|
|
8
8
|
runUnit,
|
|
9
9
|
autoLoop,
|
|
10
|
+
detectStuck,
|
|
10
11
|
_resetPendingResolve,
|
|
11
12
|
_setActiveSession,
|
|
12
13
|
isSessionSwitchInFlight,
|
|
@@ -1042,7 +1043,7 @@ test("handleAgentEnd in auto.ts is a thin wrapper calling resolveAgentEnd", () =
|
|
|
1042
1043
|
|
|
1043
1044
|
// ── Stuck counter tests ──────────────────────────────────────────────────────
|
|
1044
1045
|
|
|
1045
|
-
test("stuck
|
|
1046
|
+
test("stuck detection: stops when sliding window detects same unit 3 consecutive times", async () => {
|
|
1046
1047
|
_resetPendingResolve();
|
|
1047
1048
|
|
|
1048
1049
|
const ctx = makeMockCtx();
|
|
@@ -1077,20 +1078,15 @@ test("stuck counter: stops when deriveState returns same unit 5 consecutive time
|
|
|
1077
1078
|
|
|
1078
1079
|
const loopPromise = autoLoop(ctx, pi, s, deps);
|
|
1079
1080
|
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1081
|
+
// Sliding window: iteration 1 pushes [A], iteration 2 pushes [A,A],
|
|
1082
|
+
// iteration 3 pushes [A,A,A] → Rule 2 fires (3 consecutive) → Level 1 recovery.
|
|
1083
|
+
// Level 1 invalidates caches and continues. Iteration 4 pushes [A,A,A,A] →
|
|
1084
|
+
// Rule 2 fires again → Level 2 hard stop.
|
|
1085
|
+
// Iterations 1-3 each run a unit (3 resolves needed). Iteration 3 triggers
|
|
1086
|
+
// Level 1 (cache invalidation + continue). Iteration 4 triggers Level 2 (stop
|
|
1087
|
+
// before runUnit), so no 4th resolve needed.
|
|
1086
1088
|
|
|
1087
|
-
|
|
1088
|
-
// Iteration 2: derivedKey === lastDerivedUnit → sameUnitCount=1.
|
|
1089
|
-
// Iteration 3: sameUnitCount=2. Iteration 4: sameUnitCount=3.
|
|
1090
|
-
// Iteration 5: sameUnitCount=4. Iteration 6: sameUnitCount=5 → stop.
|
|
1091
|
-
// So we need to resolve 5 agent_end events (iterations 1-5 each run a unit).
|
|
1092
|
-
|
|
1093
|
-
for (let i = 0; i < 5; i++) {
|
|
1089
|
+
for (let i = 0; i < 3; i++) {
|
|
1094
1090
|
await new Promise((r) => setTimeout(r, 30));
|
|
1095
1091
|
resolveAgentEnd(makeEvent());
|
|
1096
1092
|
}
|
|
@@ -1105,17 +1101,13 @@ test("stuck counter: stops when deriveState returns same unit 5 consecutive time
|
|
|
1105
1101
|
stopReason.includes("Stuck"),
|
|
1106
1102
|
`stop reason should mention 'Stuck', got: ${stopReason}`,
|
|
1107
1103
|
);
|
|
1108
|
-
assert.ok(
|
|
1109
|
-
stopReason.includes("execute-task"),
|
|
1110
|
-
"stop reason should include unitType",
|
|
1111
|
-
);
|
|
1112
1104
|
assert.ok(
|
|
1113
1105
|
stopReason.includes("M001/S01/T01"),
|
|
1114
1106
|
"stop reason should include unitId",
|
|
1115
1107
|
);
|
|
1116
1108
|
});
|
|
1117
1109
|
|
|
1118
|
-
test("stuck
|
|
1110
|
+
test("stuck detection: window resets recovery when deriveState returns a different unit", async () => {
|
|
1119
1111
|
_resetPendingResolve();
|
|
1120
1112
|
|
|
1121
1113
|
const ctx = makeMockCtx();
|
|
@@ -1176,10 +1168,11 @@ test("stuck counter: resets when deriveState returns a different unit", async ()
|
|
|
1176
1168
|
|
|
1177
1169
|
await loopPromise;
|
|
1178
1170
|
|
|
1179
|
-
//
|
|
1171
|
+
// Level 1 recovery fires on iteration 3 (cache invalidation + continue),
|
|
1172
|
+
// then iteration 4 derives T02 — no Level 2 hard stop.
|
|
1180
1173
|
assert.ok(
|
|
1181
1174
|
!stopCalled,
|
|
1182
|
-
"stopAuto should NOT have been called —
|
|
1175
|
+
"stopAuto should NOT have been called — different unit broke stuck pattern",
|
|
1183
1176
|
);
|
|
1184
1177
|
assert.ok(
|
|
1185
1178
|
deriveCallCount >= 4,
|
|
@@ -1187,7 +1180,7 @@ test("stuck counter: resets when deriveState returns a different unit", async ()
|
|
|
1187
1180
|
);
|
|
1188
1181
|
});
|
|
1189
1182
|
|
|
1190
|
-
test("stuck
|
|
1183
|
+
test("stuck detection: does not push to window during verification retry", async () => {
|
|
1191
1184
|
_resetPendingResolve();
|
|
1192
1185
|
|
|
1193
1186
|
const ctx = makeMockCtx();
|
|
@@ -1249,10 +1242,10 @@ test("stuck counter: does not increment during verification retry", async () =>
|
|
|
1249
1242
|
await loopPromise;
|
|
1250
1243
|
|
|
1251
1244
|
// Even though same unit was derived 4 times, verification retries should
|
|
1252
|
-
// not
|
|
1245
|
+
// not push to the sliding window, so stuck detection should not have fired
|
|
1253
1246
|
assert.ok(
|
|
1254
1247
|
!stopReason.includes("Stuck"),
|
|
1255
|
-
`stuck
|
|
1248
|
+
`stuck detection should not fire during verification retries, got: ${stopReason}`,
|
|
1256
1249
|
);
|
|
1257
1250
|
assert.equal(
|
|
1258
1251
|
verifyCallCount,
|
|
@@ -1261,24 +1254,106 @@ test("stuck counter: does not increment during verification retry", async () =>
|
|
|
1261
1254
|
);
|
|
1262
1255
|
});
|
|
1263
1256
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1257
|
+
// ── detectStuck unit tests ────────────────────────────────────────────────────
|
|
1258
|
+
|
|
1259
|
+
test("detectStuck: returns null for fewer than 2 entries", () => {
|
|
1260
|
+
assert.equal(detectStuck([]), null);
|
|
1261
|
+
assert.equal(detectStuck([{ key: "A" }]), null);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test("detectStuck: Rule 1 — same error twice in a row", () => {
|
|
1265
|
+
const result = detectStuck([
|
|
1266
|
+
{ key: "A", error: "ENOENT: file not found" },
|
|
1267
|
+
{ key: "A", error: "ENOENT: file not found" },
|
|
1268
|
+
]);
|
|
1269
|
+
assert.ok(result?.stuck, "should detect same error repeated");
|
|
1270
|
+
assert.ok(result?.reason.includes("Same error repeated"));
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
test("detectStuck: Rule 1 — different errors do not trigger", () => {
|
|
1274
|
+
const result = detectStuck([
|
|
1275
|
+
{ key: "A", error: "ENOENT: file not found" },
|
|
1276
|
+
{ key: "A", error: "EACCES: permission denied" },
|
|
1277
|
+
]);
|
|
1278
|
+
assert.equal(result, null);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
test("detectStuck: Rule 2 — same unit 3 consecutive times", () => {
|
|
1282
|
+
const result = detectStuck([
|
|
1283
|
+
{ key: "execute-task/M001/S01/T01" },
|
|
1284
|
+
{ key: "execute-task/M001/S01/T01" },
|
|
1285
|
+
{ key: "execute-task/M001/S01/T01" },
|
|
1286
|
+
]);
|
|
1287
|
+
assert.ok(result?.stuck);
|
|
1288
|
+
assert.ok(result?.reason.includes("3 consecutive times"));
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
test("detectStuck: Rule 2 — 2 consecutive does not trigger", () => {
|
|
1292
|
+
assert.equal(detectStuck([
|
|
1293
|
+
{ key: "A" },
|
|
1294
|
+
{ key: "A" },
|
|
1295
|
+
]), null);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("detectStuck: Rule 3 — oscillation A→B→A→B", () => {
|
|
1299
|
+
const result = detectStuck([
|
|
1300
|
+
{ key: "A" },
|
|
1301
|
+
{ key: "B" },
|
|
1302
|
+
{ key: "A" },
|
|
1303
|
+
{ key: "B" },
|
|
1304
|
+
]);
|
|
1305
|
+
assert.ok(result?.stuck);
|
|
1306
|
+
assert.ok(result?.reason.includes("Oscillation"));
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("detectStuck: Rule 3 — non-oscillation pattern A→B→C→B", () => {
|
|
1310
|
+
assert.equal(detectStuck([
|
|
1311
|
+
{ key: "A" },
|
|
1312
|
+
{ key: "B" },
|
|
1313
|
+
{ key: "C" },
|
|
1314
|
+
{ key: "B" },
|
|
1315
|
+
]), null);
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
test("detectStuck: Rule 1 takes priority over Rule 2 when both match", () => {
|
|
1319
|
+
const result = detectStuck([
|
|
1320
|
+
{ key: "A", error: "test error" },
|
|
1321
|
+
{ key: "A", error: "test error" },
|
|
1322
|
+
{ key: "A", error: "test error" },
|
|
1323
|
+
]);
|
|
1324
|
+
assert.ok(result?.stuck);
|
|
1325
|
+
// Rule 1 fires first
|
|
1326
|
+
assert.ok(result?.reason.includes("Same error repeated"));
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
test("detectStuck: truncates long error strings", () => {
|
|
1330
|
+
const longError = "x".repeat(500);
|
|
1331
|
+
const result = detectStuck([
|
|
1332
|
+
{ key: "A", error: longError },
|
|
1333
|
+
{ key: "A", error: longError },
|
|
1334
|
+
]);
|
|
1335
|
+
assert.ok(result?.stuck);
|
|
1336
|
+
assert.ok(result!.reason.length < 300, "reason should be truncated");
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
test("stuck detection: logs debug output with stuck-detected phase", () => {
|
|
1340
|
+
// Structural test: verify the auto-loop.ts source contains
|
|
1341
|
+
// stuck-detected and stuck-counter-reset debug log phases, plus detectStuck
|
|
1267
1342
|
const src = readFileSync(
|
|
1268
1343
|
resolve(import.meta.dirname, "..", "auto-loop.ts"),
|
|
1269
1344
|
"utf-8",
|
|
1270
1345
|
);
|
|
1271
1346
|
assert.ok(
|
|
1272
1347
|
src.includes('"stuck-detected"'),
|
|
1273
|
-
"auto-loop.ts must log phase: 'stuck-detected' when stuck
|
|
1348
|
+
"auto-loop.ts must log phase: 'stuck-detected' when stuck detection fires",
|
|
1274
1349
|
);
|
|
1275
1350
|
assert.ok(
|
|
1276
1351
|
src.includes('"stuck-counter-reset"'),
|
|
1277
|
-
"auto-loop.ts must log phase: 'stuck-counter-reset' when
|
|
1352
|
+
"auto-loop.ts must log phase: 'stuck-counter-reset' when recovery resets on new unit",
|
|
1278
1353
|
);
|
|
1279
1354
|
assert.ok(
|
|
1280
|
-
src.includes("
|
|
1281
|
-
"auto-loop.ts must
|
|
1355
|
+
src.includes("detectStuck"),
|
|
1356
|
+
"auto-loop.ts must use detectStuck for sliding window analysis",
|
|
1282
1357
|
);
|
|
1283
1358
|
});
|
|
1284
1359
|
|
|
@@ -114,6 +114,22 @@ function getServerConfig(name: string): McpServerConfig | undefined {
|
|
|
114
114
|
return readConfigs().find((s) => s.name === name);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** Resolve ${VAR} references in env values against process.env. */
|
|
118
|
+
function resolveEnv(env: Record<string, string>): Record<string, string> {
|
|
119
|
+
const resolved: Record<string, string> = {};
|
|
120
|
+
for (const [key, value] of Object.entries(env)) {
|
|
121
|
+
if (typeof value === "string") {
|
|
122
|
+
resolved[key] = value.replace(
|
|
123
|
+
/\$\{([^}]+)\}/g,
|
|
124
|
+
(_match, varName) => process.env[varName] ?? "",
|
|
125
|
+
);
|
|
126
|
+
} else {
|
|
127
|
+
resolved[key] = value;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return resolved;
|
|
131
|
+
}
|
|
132
|
+
|
|
117
133
|
async function getOrConnect(name: string, signal?: AbortSignal): Promise<Client> {
|
|
118
134
|
const existing = connections.get(name);
|
|
119
135
|
if (existing) return existing.client;
|
|
@@ -128,7 +144,7 @@ async function getOrConnect(name: string, signal?: AbortSignal): Promise<Client>
|
|
|
128
144
|
transport = new StdioClientTransport({
|
|
129
145
|
command: config.command,
|
|
130
146
|
args: config.args,
|
|
131
|
-
env: config.env ? { ...process.env, ...config.env } as Record<string, string> : undefined,
|
|
147
|
+
env: config.env ? { ...process.env, ...resolveEnv(config.env) } as Record<string, string> : undefined,
|
|
132
148
|
cwd: config.cwd,
|
|
133
149
|
stderr: "pipe",
|
|
134
150
|
});
|