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.
Files changed (37) hide show
  1. package/dist/resource-loader.js +34 -1
  2. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
  3. package/dist/resources/extensions/gsd/auto-loop.js +538 -469
  4. package/dist/resources/extensions/gsd/auto-post-unit.js +9 -3
  5. package/dist/resources/extensions/gsd/auto-prompts.js +18 -14
  6. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  7. package/dist/resources/extensions/gsd/commands.js +2 -1
  8. package/dist/resources/extensions/gsd/doctor.js +20 -1
  9. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  10. package/dist/resources/extensions/gsd/files.js +4 -0
  11. package/dist/resources/extensions/gsd/git-service.js +22 -11
  12. package/dist/resources/extensions/gsd/guided-flow.js +82 -32
  13. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  14. package/dist/resources/extensions/gsd/prompts/run-uat.md +2 -0
  15. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  16. package/dist/resources/extensions/mcp-client/index.js +14 -1
  17. package/package.json +1 -1
  18. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  19. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  20. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  21. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  22. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
  23. package/src/resources/extensions/gsd/auto-loop.ts +342 -304
  24. package/src/resources/extensions/gsd/auto-post-unit.ts +10 -3
  25. package/src/resources/extensions/gsd/auto-prompts.ts +20 -14
  26. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  27. package/src/resources/extensions/gsd/commands.ts +2 -2
  28. package/src/resources/extensions/gsd/doctor.ts +22 -1
  29. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  30. package/src/resources/extensions/gsd/files.ts +3 -1
  31. package/src/resources/extensions/gsd/git-service.ts +31 -9
  32. package/src/resources/extensions/gsd/guided-flow.ts +110 -38
  33. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  34. package/src/resources/extensions/gsd/prompts/run-uat.md +2 -0
  35. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  36. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +106 -31
  37. 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 counter: stops when deriveState returns same unit 5 consecutive times", async () => {
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
- // The loop will dispatch the same unit each iteration. On iteration 1, sameUnitCount
1081
- // starts at 0 and the unit key is set. On iterations 2-5, sameUnitCount increments.
1082
- // At sameUnitCount=5 (iteration 6), stopAuto is called.
1083
- // Each iteration requires resolving an agent_end event.
1084
- // But the stuck counter fires BEFORE runUnit, so we only need to resolve 4 times
1085
- // (iterations 1-4 each run a unit, iteration 5 increments to 5 and stops).
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
- // Actually: iteration 1 sets lastDerivedUnit (sameUnitCount=0).
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 counter: resets when deriveState returns a different unit", async () => {
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
- // The counter should have reset when T02 was derived no stuck stop
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 — counter reset on unit change",
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 counter: does not increment during verification retry", async () => {
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 count, so stuck counter should not have fired
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 counter should not fire during verification retries, got: ${stopReason}`,
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
- test("stuck counter: logs debug output with stuck-detected phase", () => {
1265
- // Structural test: verify the auto-loop.ts source contains both
1266
- // stuck-detected and stuck-counter-reset debug log phases
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 counter fires",
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 counter resets on new unit",
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("sameUnitCount"),
1281
- "auto-loop.ts must track sameUnitCount for stuck detection",
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
  });