pi-goal-list-loop-audit 0.38.46 → 0.38.48

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.48 — atomic task batches + pending-task completion gate (2026-09-11)
4
+
5
+ ### Added
6
+
7
+ - **update_task_batch tool:** validate whole → verify every `complete` milestone's `verificationContract` → copy + apply → persist once. A rejected batch or a mid-batch milestone failure leaves state untouched.
8
+ - **Pending-task completion gate:** `complete_goal` with open committed tasks is refused BEFORE the auditor, naming each open item (`- <id>: "<title>" (<status>)`) with a `complete_goal_tasks_refused` ledger entry. Refusal mutates nothing and cannot be bypassed via `newObjective` (gate sits before the pivot branch).
9
+ - **Deferral exemption:** `record_goal_judgment` accepts `taskId` only with `choice=deferred`; stamps `task.deferred = { reason, followUp, at }` in the same persist as the ledger entry. `inline` + `taskId` and unknown ids are refused with no ledger change.
10
+
11
+ ### Tests
12
+
13
+ - `tests/task-atomicity-gate.test.ts`: 7 pure-core + 10 MockPi pins (mid-batch kill untouched, refusal names each task, deferral pass-through). `tsc` clean.
14
+
15
+ ## 0.38.47 — layered prompts: skeleton plus on-demand detail (2026-09-11)
16
+
17
+ ### Changed
18
+
19
+ - **Prompt layers:** every `prompts/*.md` file is now a static skeleton plus on-demand detail blocks (`<!-- glla-layer: detail <id> -->`). The continuation template carries three conditional details — `auditor-disapproval` (live disapproval), `survey-pivot` (survey objective), `session-restart` (resync/recovery) — loaded only when their trigger is already known at render time; loop, draft, and plan prompts are skeleton-only (per-iteration essential or already file-granular on-demand). Static layers resolve before dynamic substitution, in file order every turn.
20
+ - **Measured saving:** clean-scenario full continuation render drops 24,347 to 21,801 chars (~10%, est. tokens 6,087 to 5,451); firing predicates re-add exactly their own bodies.
21
+ - **Loud failure:** a missing prompt file, unclosed/duplicate block, or unknown detail id throws naming the file — the `[template-not-found]`, inline mini-prompt, and `[DRAFTING] Clarify` silent fallbacks are gone.
22
+ - **Mid-turn contingencies stay put:** subagent death, provider errors, detached commits, stalls, and task workflow remain in the skeleton (their trigger cannot be known before the turn; no mid-turn fetch exists).
23
+ - **Pins:** `tests/prompt-layers.test.ts` (19) — full-assembly render-diff for all 7 files, exact-delta proofs, loud-missing, order determinism; `tests/context-growth-measurement.test.ts` and `tests/context-checkpoint.test.ts` re-baselined deliberately (deltas verified byte-exact against a pre-change worktree).
24
+
3
25
  ## 0.38.46 — rich Antigravity-style terminal summaries (2026-09-11)
4
26
 
5
27
  ### Changed
package/INSTALL.md CHANGED
@@ -31,11 +31,11 @@ GLLA loads into new pi sessions. If pi is already open, reload that session:
31
31
 
32
32
  ## Updating
33
33
 
34
- The status line always shows the running version (`glla: … · v0.38.44`).
34
+ The status line always shows the running version (`glla: … · v0.38.46`).
35
35
  When the npm registry is ahead, it also nudges:
36
36
 
37
37
  ```text
38
- glla: … · v0.38.43 · update v0.38.44 available
38
+ glla: … · v0.38.45 · update v0.38.46 available
39
39
  ```
40
40
 
41
41
  The nudge comes from a daily sidecar check (`.pi-glla/update-check.json`),
@@ -45,7 +45,7 @@ refreshed on command contact and never blocking a turn. To update:
45
45
  pi install npm:pi-goal-list-loop-audit@latest
46
46
  ```
47
47
 
48
- Then `/reload` every open pi session — a session keeps running the
48
+ Then `/reload` every open pi session: a session keeps running the
49
49
  version it loaded with until reloaded. `/glla version` says whether the
50
50
  current session is stale and repeats the update command.
51
51
 
@@ -58,7 +58,7 @@ and confirmation UX:
58
58
  pi install npm:@juicesharp/rpiv-ask-user-question
59
59
  ```
60
60
 
61
- For best automation and quality, add the **pinned parallel-orchestration companion** (`pi-subagents` 0.62.0) — GLLA's power-max choice for `runs.all` fan-out, `runs.lanes` worker→review→fix chains, structured verification, worktree isolation, and durable recovery:
61
+ For best automation and quality, add the **pinned parallel-orchestration companion** (`pi-subagents` 0.62.0): GLLA's power-max choice for `runs.all` fan-out, `runs.lanes` worker→review→fix chains, structured verification, worktree isolation, and durable recovery:
62
62
 
63
63
  ```bash
64
64
  pi install npm:pi-subagents@0.62.0
@@ -260,9 +260,9 @@ supervisor, or let it own the session instead of GLLA.
260
260
 
261
261
  ## Further reading
262
262
 
263
- - [`README.md`](README.md) — product overview, first-use guide, commands,
263
+ - [`README.md`](README.md): product overview, first-use guide, commands,
264
264
  companion policy, autonomy model, recovery, and maintainer map;
265
- - [`docs/DESIGN.md`](docs/DESIGN.md) — architecture and design decisions;
266
- - [`docs/INDEX.md`](docs/INDEX.md) — shipped and repository-only documentation;
267
- - [`docs/RELEASING.md`](docs/RELEASING.md) — release process;
268
- - [`CHANGELOG.md`](CHANGELOG.md) — version history.
265
+ - [`docs/DESIGN.md`](docs/DESIGN.md): architecture and design decisions;
266
+ - [`docs/INDEX.md`](docs/INDEX.md): shipped and repository-only documentation;
267
+ - [`docs/RELEASING.md`](docs/RELEASING.md): release process;
268
+ - [`CHANGELOG.md`](CHANGELOG.md): version history.
package/README.md CHANGED
@@ -94,7 +94,7 @@ Done when:
94
94
  ```
95
95
 
96
96
  The contract is the important part. Replace the example with the result you
97
- actually want and checks that another person—or another agent—could inspect.
97
+ actually want and checks that another person (or another agent) could inspect.
98
98
 
99
99
  For an objective that needs shaping, start with bare `/goal` and answer the
100
100
  interview. GLLA will research the ambiguity, ask focused questions, and show a
@@ -132,7 +132,7 @@ forcing every problem into a loop.
132
132
  | `/list` | Several outcomes or a backlog of independently verifiable items | Each item is worked and audited separately; the queue advances safely |
133
133
  | `/loop` | Ongoing improvement with no single final item | A metric, specification, audit cadence, bound, or `/loop stop` ends the process |
134
134
 
135
- ### `/goal` — one outcome
135
+ ### `/goal`: one outcome
136
136
 
137
137
  ```text
138
138
  /goal # interview + Confirm
@@ -152,7 +152,7 @@ A goal is the best default for work with a finish line. If the agent discovers
152
152
  that the objective is too large, it can propose a bounded task plan instead of
153
153
  quietly inventing an unbounded backlog.
154
154
 
155
- ### `/list` — a durable work pool
155
+ ### `/list`: a durable work pool
156
156
 
157
157
  ```text
158
158
  /list "fix the cache. Done when: tests pass"
@@ -179,11 +179,11 @@ bounded bootstrap turn containing `propose_task_list`. Confirm the redraft;
179
179
  automatic repeats are fenced. Use `/list resume` or `/glla resume` for an
180
180
  intentional retry of a waiting/restored queue, and `/list next` when you
181
181
  intentionally want to skip or choose another queued item. `/list start` is
182
- also explicit: it activates the queued head, or—when the queue is empty—uses
182
+ also explicit: it activates the queued head, or, when the queue is empty, uses
183
183
  one clear recent user request as a seed for the normal Confirm-gated list
184
184
  drafting flow. Ambiguous context is never queued automatically.
185
185
 
186
- ### `/loop` — an improvement process
186
+ ### `/loop`: an improvement process
187
187
 
188
188
  ```text
189
189
  /loop # interview + Confirm
@@ -274,13 +274,13 @@ GLLA is the supervisor. These companions add capabilities around it:
274
274
 
275
275
  ### Recommended for almost everyone
276
276
 
277
- - **`@juicesharp/rpiv-ask-user-question`** — structured questions, multi-select,
277
+ - **`@juicesharp/rpiv-ask-user-question`**: structured questions, multi-select,
278
278
  previews, and Confirm dialogs for drafting and decisions. GLLA has a prose
279
279
  fallback, but this is the intended UX.
280
280
 
281
- ### Recommended for power — parallel orchestration (`pi-subagents`)
281
+ ### Recommended for power: parallel orchestration (`pi-subagents`)
282
282
 
283
- - **`pi-subagents` 0.62.0 (pinned) — the power-max choice for GLLA.** Use it
283
+ - **`pi-subagents` 0.62.0 (pinned): the power-max choice for GLLA.** Use it
284
284
  when you want the best automation and quality: `runs.all` parallel fan-out,
285
285
  `runs.lanes` worker→review→fix chains, `outputSchema` + `acceptance` structured
286
286
  verification, `runs.host` gated shell, worktree isolation, model routing
@@ -295,7 +295,7 @@ GLLA is the supervisor. These companions add capabilities around it:
295
295
 
296
296
  The main pi session remains the owner of the goal/list/loop; subagents are
297
297
  workers and cannot silently replace the parent's objective. A short or mostly
298
- sequential goal can still run cleanly without workers — install when
298
+ sequential goal can still run cleanly without workers; install when
299
299
  parallelism will pay for its coordination and model usage.
300
300
 
301
301
  Display coexistence: pi-subagents renders its own inline run panels +
@@ -304,7 +304,7 @@ GLLA is the supervisor. These companions add capabilities around it:
304
304
  swapping order, set pi-subagents `inlineToolDisplay: "summary"` (one
305
305
  stable row per run) and pin `fleetViewPlacement`; GLLA's own richness is
306
306
  the `subagentDisplayRichness` setting (`/glla` → Subagents): `rich`
307
- (default — worker rows + task linkage), `compact` (count line), `quiet`
307
+ (default: worker rows + task linkage), `compact` (count line), `quiet`
308
308
  (hung/aborting workers only). Upstream triple-render report:
309
309
  nicobailon/pi-subagents#1931.
310
310
 
@@ -319,17 +319,17 @@ recommendation in the same session. Existing Tintin-era agent files are
319
319
  cleaned only when GLLA's management marker proves that GLLA owns them; old
320
320
  settings are not silently remapped to a different role. Do not stack
321
321
  `@quintinshaw/pi-dynamic-workflows` as a competing orchestrator alongside
322
- GLLA + `pi-subagents` in the same session — duplicate tools and competing
322
+ GLLA + `pi-subagents` in the same session: duplicate tools and competing
323
323
  orchestration events create ambiguous ownership. Use its quality helpers
324
324
  (`verify`/`judgePanel`/`loopUntilDry`) only as isolated complements if needed.
325
325
 
326
326
  ### Useful, but optional
327
327
 
328
- - **`@pi-unipi/notify`** — Telegram, Gotify, or ntfy delivery when you need
328
+ - **`@pi-unipi/notify`**: Telegram, Gotify, or ntfy delivery when you need
329
329
  alerts away from the desktop. GLLA's local notifications work without it;
330
330
  when no command is configured, it auto-detects `notify-send`/`osascript`;
331
331
  `notify=off` silences notifications.
332
- - **`pi-chrome`** — logged-in browser research and interaction when a goal needs
332
+ - **`pi-chrome`**: logged-in browser research and interaction when a goal needs
333
333
  a real web session. It is not required for repository-only work.
334
334
 
335
335
  ### What not to combine with GLLA
@@ -501,4 +501,4 @@ repository material, not first-use package content.
501
501
 
502
502
  ## License
503
503
 
504
- GNU Affero General Public License v3.0-only — see [LICENSE](LICENSE).
504
+ GNU Affero General Public License v3.0-only; see [LICENSE](LICENSE).
package/docs/INDEX.md CHANGED
@@ -1,16 +1,14 @@
1
- # docs/ — index
1
+ # docs/ index
2
2
 
3
3
  Ordered by reading path, not alphabetically.
4
4
 
5
5
  ## Active focus (recent work, durable artifacts)
6
6
 
7
- This package's policy contracts and recent changes are recorded in
8
- the audit/ directory of the **repository checkout** — it is not
9
- shipped in the npm tarball (see "Repository-only material" below).
7
+ Policy contracts and recent changes live in the `audit/` directory of the
8
+ **repository checkout**; that directory is not shipped in the npm tarball
9
+ (see "Repository-only material" below). The shipped entry points are:
10
10
 
11
- For shipped docs, the relevant entry points are:
12
-
13
- - `../CHANGELOG.md` — user-facing changelog; an `Unreleased` section may lead
11
+ - `../CHANGELOG.md`: user-facing changelog; an `Unreleased` section may lead
14
12
  the file, followed by the current released version. v0.35.5 adopted the six-label completion
15
13
  recap; v0.35.6 added typed-boundary regression pins; v0.35.7 added
16
14
  deterministic fast-fail pre-audits, zero-pause autonomous execution, and
@@ -18,7 +16,7 @@ For shipped docs, the relevant entry points are:
18
16
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
19
17
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
20
18
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
21
- v0.35.14–v0.38.46 continue through the supervisor freeze (`/glla pause`),
19
+ v0.35.14–v0.38.48 continue through the supervisor freeze (`/glla pause`),
22
20
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
23
21
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
24
22
  durable state-root selection, blank-until-resume auditor context, frozen
@@ -28,43 +26,43 @@ For shipped docs, the relevant entry points are:
28
26
  packed-artifact release verification, and the 2026-09-07 display/lifecycle/
29
27
  settings audit pass (abort-latch send guards, ownership compare-and-swap,
30
28
  auditor inherit/clear parity) and the v0.38.25 post-objective summary
31
- (canonical approval render, persist + replay, audit-goal counts line), the v0.38.27 selective port of PRs #45/#46 (`/loop pause` soft-hold, widget subtask count), the v0.38.28 provenance-row closure fix, and the v0.38.29 compact active-card recovery/judgment projection — see CHANGELOG.md for the full trail.
32
- - `../README.md` — what the plugin is, install, quickstart, and the
29
+ (canonical approval render, persist + replay, audit-goal counts line), the v0.38.27 selective port of PRs #45/#46 (`/loop pause` soft-hold, widget subtask count), the v0.38.28 provenance-row closure fix, and the v0.38.29 compact active-card recovery/judgment projection; see CHANGELOG.md for the full trail.
30
+ - `../README.md`: what the plugin is, install, quickstart, and the
33
31
  architectural guarantee (drafting + confirm + detached auditor).
34
- - `../INSTALL.md` — source install / local development setup; the recommended
32
+ - `../INSTALL.md`: source install / local development setup; the recommended
35
33
  companion plugins and the `auditor reads / writes are path-checked`
36
34
  note.
37
35
 
38
36
  ## Entry points
39
- - `../README.md` — what the plugin is, install, quickstart
40
- - `SETTINGS.md` — canonical settings reference (files, precedence, every key)
41
- - `../INSTALL.md` — source install / local development setup
42
- - `../CHANGELOG.md` — user-facing changelog; the first versioned heading is
37
+ - `../README.md`: what the plugin is, install, quickstart
38
+ - `SETTINGS.md`: canonical settings reference (files, precedence, every key)
39
+ - `../INSTALL.md`: source install / local development setup
40
+ - `../CHANGELOG.md`: user-facing changelog; the first versioned heading is
43
41
  the current released package version (use `/glla version` to compare with
44
42
  the registry); post-release work may appear in `Unreleased` above it.
45
43
 
46
44
  ## Architecture
47
- - `DESIGN.md` — plugin design (types, state, extension lifecycle)
48
- - `DESIGN-long-running-supervision.md` — v0.36.0 event/progress-driven supervision, aggressive recovery, terminal recaps, and future decision checklist
49
- - `GLLA-POSITIONING-AND-DECOMPOSITION-2026-08-08.md` — ecosystem
45
+ - `DESIGN.md`: plugin design (types, state, extension lifecycle)
46
+ - `DESIGN-long-running-supervision.md`: v0.36.0 event/progress-driven supervision, aggressive recovery, terminal recaps, and future decision checklist
47
+ - `GLLA-POSITIONING-AND-DECOMPOSITION-2026-08-08.md`: ecosystem
50
48
  positioning, competitor review, and the goal.ts decomposition plan
51
- (the current strategic doc — read this before touching
49
+ (the current strategic doc; read this before touching
52
50
  `extensions/loops/goal.ts`)
53
- - `VISION-ASSIST.md` — vision-assist plugin notes
54
- - `RELEASING.md` — how to publish to npm
51
+ - `VISION-ASSIST.md`: vision-assist plugin notes
52
+ - `RELEASING.md`: how to publish to npm
55
53
 
56
54
  ## Supporting material
57
- - `../prompts/` — goal/loop drafting prompt templates
58
- - `../schemas/` — goal state JSON schema
59
- - `../examples/` — example objective files
60
- - `../CHANGELOG.md` — user-facing changelog (unreleased at top)
61
- - `/glla bug` — `extensions/goal-commands.ts:cmdGllaBug` captures failure context to `<stateDir>/bugs/<ts>-<id>.md` without touching `active.jsonl`/`goals/*.md` (see `tests/glla-bug-capture.test.ts`)
55
+ - `../prompts/`: goal/loop drafting prompt templates
56
+ - `../schemas/`: goal state JSON schema
57
+ - `../examples/`: example objective files
58
+ - `../CHANGELOG.md`: user-facing changelog (unreleased at top)
59
+ - `/glla bug`: `extensions/goal-commands.ts:cmdGllaBug` captures failure context to `<stateDir>/bugs/<ts>-<id>.md` without touching `active.jsonl`/`goals/*.md` (see `tests/glla-bug-capture.test.ts`)
62
60
 
63
61
  ## Repository-only material
64
62
  The audit history and competitor research live in `audit/` and `.research/`
65
63
  for contributors, but are intentionally not included in the npm tarball.
66
64
 
67
65
  ## Research material
68
- `.research/` — competitor plugin sources pulled from npm tarballs for study
66
+ `.research/`: competitor plugin sources pulled from npm tarballs for study
69
67
  (gitignored, local only). Re-pull with `cd .research && npm pack <pkg> &&
70
68
  tar xzf <tgz>`; see the positioning doc's appendix for the package list.
@@ -56,6 +56,7 @@ import {
56
56
  type ObjectiveRepairTarget,
57
57
  } from "./goal-loop-core.js";
58
58
  import { auditorSurfaceSuppressed } from "./loops/goal-auditor-surface.js";
59
+ import { assemblePrompt } from "./prompt-layers.js";
59
60
  import {
60
61
  createContinuationDispatch,
61
62
  transitionDispatch,
@@ -1235,7 +1236,7 @@ export function buildContinuationContent(goal: Goal, opts: { resync?: string; fi
1235
1236
  const resync = opts.resync ?? "";
1236
1237
  const marker = buildMarkerContent(goal.id);
1237
1238
  const needsFull = (opts.firstSend ?? false) || needsFullContinuation(goal);
1238
- if (needsFull) return { content: resync + continuationPrompt(goal), kind: resync ? "full+resync" : "full" };
1239
+ if (needsFull) return { content: resync + continuationPrompt(goal, { includeRestartDetail: Boolean(resync) || Boolean(goal.autoResumedAt) }), kind: resync ? "full+resync" : "full" };
1239
1240
  if (resync) return { content: resync + marker, kind: "resync" };
1240
1241
  return { content: marker, kind: "marker" };
1241
1242
  }
@@ -1493,7 +1494,7 @@ export function buildPostCompactResync(briefExcerpt?: string): string {
1493
1494
  return lines.join("\n") + "\n\n";
1494
1495
  }
1495
1496
 
1496
- export function continuationPrompt(goal: Goal): string {
1497
+ export function continuationPrompt(goal: Goal, opts: { includeRestartDetail?: boolean } = {}): string {
1497
1498
  // Read the .md file as the template, then substitute {{tokens}}.
1498
1499
  // For v0.1.0 we inline-substitute so we don't need fs at runtime.
1499
1500
  const next = findNextPendingTask(goal.taskList?.tasks ?? []);
@@ -1503,13 +1504,17 @@ export function continuationPrompt(goal: Goal): string {
1503
1504
  const taskSummary = goal.taskList?.tasks.length
1504
1505
  ? buildTaskSummary(goal.taskList.tasks)
1505
1506
  : "(no task list)";
1506
- const tmplPath = path.resolve(__dirname, "..", "prompts", "goal-loop-continuation.md");
1507
- let tmpl: string;
1508
- try {
1509
- tmpl = fs.readFileSync(tmplPath, "utf-8");
1510
- } catch {
1511
- tmpl = "[template-not-found]";
1512
- }
1507
+ // Layered assembly: skeleton always; details only when their trigger is
1508
+ // already known at render time. Static layers resolve before dynamic
1509
+ // ${...} substitution, in file order every turn. Mid-turn contingencies
1510
+ // (subagent death, provider errors, detached commits, stalls, task
1511
+ // workflow) stay in the skeleton: their trigger cannot be known before
1512
+ // the turn, and there is no mid-turn detail fetch.
1513
+ const detailIds = new Set<string>();
1514
+ if (liveDisapproval(goal.auditHistory ?? [])) detailIds.add("auditor-disapproval");
1515
+ if (isFullAuditObjective(goal.objective)) detailIds.add("survey-pivot");
1516
+ if (opts.includeRestartDetail || goal.autoResumedAt) detailIds.add("session-restart");
1517
+ const tmpl = assemblePrompt("goal-loop-continuation.md", detailIds);
1513
1518
  // v0.25.0 (contract items 22/28): conditional directives — aggressiveMode
1514
1519
  // TODOs from the audit cap, and the full-audit fan-out directive when the
1515
1520
  // objective reads as a survey pivot. The canonical judgment policy and
@@ -97,6 +97,15 @@ export function workCommand(mode: Policy | "loop" | undefined, command: string):
97
97
  return `${workCommandRoot(mode)} ${command}`;
98
98
  }
99
99
 
100
+ export interface TaskDeferral {
101
+ /** Why the durable action is unsafe, impossible, or blocked right now. */
102
+ reason: string;
103
+ /** The bounded durable follow-up. */
104
+ followUp: string;
105
+ /** ISO timestamp of the recorded judgment. */
106
+ at: string;
107
+ }
108
+
100
109
  export interface Task {
101
110
  id: string;
102
111
  title: string;
@@ -106,6 +115,11 @@ export interface Task {
106
115
  /** Optional verification gate for milestone-checked tasks. */
107
116
  verificationContract?: string;
108
117
  subtasks?: Task[];
118
+ /** A recorded deferral (via record_goal_judgment choice=deferred + taskId)
119
+ * exempts this task from the complete_goal pending-task gate. The
120
+ * exemption is durable state, not prose: the ledger holds the judgment,
121
+ * this stamp is the gate-readable pointer. */
122
+ deferred?: TaskDeferral;
109
123
  }
110
124
 
111
125
  export interface TaskList {
@@ -26,6 +26,7 @@ import {
26
26
  supervisorPaused,
27
27
  } from "./goal-loop-core.js";
28
28
  import { BACKOFF_IDLE_RETRY_MS, MEASURE_TIMEOUT_MS } from "./goal-loop-backoff.js";
29
+ import { loadPromptWhole } from "./prompt-layers.js";
29
30
  import {
30
31
  REPETITION,
31
32
  continueVariant,
@@ -285,15 +286,7 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
285
286
  // v0.23.0: metricless loops get their own prompt — no metric section,
286
287
  // anti-doorknob rules instead of anti-gaming rules.
287
288
  const metricless = !loop.measureCmd;
288
- const tmplPath = path.resolve(__dirname, "..", "..", "prompts", metricless ? "goal-loop-forever-metricless.md" : "goal-loop-forever.md");
289
- let tmpl: string;
290
- try {
291
- tmpl = fs.readFileSync(tmplPath, "utf-8");
292
- } catch {
293
- tmpl = metricless
294
- ? `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Metricless spec loop — make ONE real, inspectable change advancing the target. No cosmetic churn. ${variantNote} ${interventionNote}`
295
- : `[LOOP ITERATION ${loop.iteration + 1}] Target: ${loop.target}. Measure: ${loop.measureCmd} (${loop.direction}). Make ONE small change to improve the metric. ${interventionNote}`;
296
- }
289
+ const tmpl = loadPromptWhole(metricless ? "goal-loop-forever-metricless.md" : "goal-loop-forever.md");
297
290
  return tmpl
298
291
  .replace(/\$\{ITERATION\}/g, String(loop.iteration + 1))
299
292
  .replace(/\$\{TARGET\}/g, loop.target)
@@ -26,6 +26,7 @@ import { Type } from "typebox";
26
26
  // (positioning doc invariant #2). Property reads on the imported binding are
27
27
  // fine; wholesale replacement goes through replaceState().
28
28
  import { state, replaceState, persistStateLine } from "../goal-state.js";
29
+ import { loadPromptWhole } from "../prompt-layers.js";
29
30
 
30
31
  import {
31
32
  type Goal,
@@ -863,11 +864,9 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
863
864
  : target === "loop"
864
865
  ? `${label}: a loop target needs a metric and a direction — the agent will help you design them first (nothing activates until you confirm). Skip the interview entirely: /loop start "<target>" (bare = infinite metricless) or /loop start "<target>" measure="<cmd>" direction=min|max [window=5] [max=50] [time=h] [tokens=n] [branch=1].`
865
866
  : `${label}: the objective has no "Done when:" clause — the agent will grill you about it first (nothing activates until you confirm). Skip the interview entirely: /goal start <objective>.`);
866
- const tmplPath = path.resolve(__dirname, "..", "..", "prompts", file);
867
- let tmpl: string;
868
- try {
869
- tmpl = fs.readFileSync(tmplPath, "utf-8");
870
- if (target === "list") {
867
+ // Layered-prompt contract: a missing draft file fails loudly — never render lean.
868
+ let tmpl = loadPromptWhole(file);
869
+ if (target === "list") {
871
870
  tmpl = tmpl.replace(
872
871
  "[GOAL DRAFTING]",
873
872
  "[LIST DRAFTING — the confirmed item goes into the /list LIST, it does not activate immediately. " +
@@ -880,9 +879,6 @@ async function startDrafting(ctx: ExtensionContext, target: "goal" | "list" | "l
880
879
  "is still a SHORT task — never an aggregate wrapper ('land all N findings' with a '≥N commits' contract is the " +
881
880
  "canonical anti-pattern: the auto-committer squashes, the count fails, the auditor disapproves finished work).]",
882
881
  );
883
- }
884
- } catch {
885
- tmpl = `[DRAFTING] Clarify the user's ${target}, then call ${tool}.`;
886
882
  }
887
883
  // v0.14.0: the LLM grills (its strength — v0.13.0's canned questionnaire
888
884
  // accepted non-answers), the plugin enforces the floor: propose_goal_draft
@@ -134,6 +134,16 @@ isGoalRevisionCurrent,
134
134
  buildDurableChoiceRecord,
135
135
  normalizeDurableDeferRecommendationInput,
136
136
  } from "../goal-loop-core.js";
137
+ import {
138
+ applyValidatedBatch,
139
+ collectOpenTasks,
140
+ findTask,
141
+ formatOpenTaskRefusal,
142
+ patchTaskCopy,
143
+ validateTaskBatch,
144
+ withTaskStatus,
145
+ type TaskStatusUpdate,
146
+ } from "../task-batch.js";
137
147
  import {
138
148
  createContinuationDispatch,
139
149
  dispatchMatchesOwner,
@@ -556,6 +566,22 @@ function registerAgentTools(pi: any): void {
556
566
  details: {},
557
567
  };
558
568
  }
569
+ // v0.38.48: pending-task completion gate — a claim with open
570
+ // committed tasks is refused BEFORE the auditor, naming each open
571
+ // item. Tasks carrying a recorded deferral (record_goal_judgment
572
+ // choice=deferred + taskId) are exempt. Placed before newObjective:
573
+ // a pivot must not silently orphan committed tasks — finish them or
574
+ // defer them explicitly first. Refusal mutates nothing.
575
+ if (state.goal.taskList) {
576
+ const openTasks = collectOpenTasks(state.goal.taskList);
577
+ if (openTasks.length > 0) {
578
+ appendLedger(ctx.cwd, "complete_goal_tasks_refused", {
579
+ goalId: state.goal.id,
580
+ open: openTasks.map((t) => ({ id: t.id, title: t.title, status: t.status })),
581
+ });
582
+ return { content: [{ type: "text", text: formatOpenTaskRefusal(openTasks) }], details: {} };
583
+ }
584
+ }
559
585
  // v0.25.0 (contract item 15): atomic objective update + audit in one
560
586
  // call — the objective-drift disapprove loop (ship shifted work →
561
587
  // auditor disapproves the ORIGINAL objective) ends here. Ledgered so
@@ -1913,6 +1939,7 @@ function registerAgentTools(pi: any): void {
1913
1939
  durableFix: Type.Optional(Type.String({ maxLength: 500, description: "The maintainable root-cause action to show in the goal card" })),
1914
1940
  deferRecommendations: Type.Optional(Type.Array(Type.String({ maxLength: 500 }), { maxItems: 8, description: "Earlier bounded workaround/defer recommendations to retain as UI evidence" })),
1915
1941
  durableBlocked: Type.Optional(Type.Boolean({ description: "True only when the durable action is unsafe, impossible, or blocked for this turn" })),
1942
+ taskId: Type.Optional(Type.String({ description: "v0.38.48: with choice=deferred, attach this deferral to a task so the complete_goal pending-task gate exempts it. Only valid with choice=deferred." })),
1916
1943
  }),
1917
1944
  async execute(_id, params, signal, _onUpdate, execCtx) {
1918
1945
  const foreignJudgment = foreignToolGuard(execCtx);
@@ -1930,7 +1957,20 @@ function registerAgentTools(pi: any): void {
1930
1957
  durableFix?: string;
1931
1958
  deferRecommendations?: string[];
1932
1959
  durableBlocked?: boolean;
1960
+ taskId?: string;
1933
1961
  };
1962
+ // v0.38.48: a task-linked deferral exempts that task from the
1963
+ // complete_goal pending-task gate. Inline needs no exemption (the fix
1964
+ // ships now); an unknown task is refused before anything is recorded.
1965
+ const taskId = p.taskId?.trim() || undefined;
1966
+ if (taskId && p.choice !== "deferred") {
1967
+ return { content: [{ type: "text", text: "taskId is only valid with choice=deferred — an inline judgment implements the fix now and needs no task exemption. Nothing was recorded." }], details: {} };
1968
+ }
1969
+ const goal = state.goal;
1970
+ const deferredTask = taskId ? (goal.taskList ? findTask(goal.taskList, taskId) : null) : undefined;
1971
+ if (taskId && !deferredTask) {
1972
+ return { content: [{ type: "text", text: `No task "${taskId}" in this goal's task list — the judgment was NOT recorded.` }], details: {} };
1973
+ }
1934
1974
  const record = buildDurableChoiceRecord(p.choice, p.reason, p.followUp);
1935
1975
  if (!record.reason) {
1936
1976
  return { content: [{ type: "text", text: "A non-empty reason is required to record a durable-vs-defer judgment." }], details: {} };
@@ -1938,19 +1978,30 @@ function registerAgentTools(pi: any): void {
1938
1978
  if (record.choice === "deferred" && !record.followUp) {
1939
1979
  return { content: [{ type: "text", text: "A deferred judgment also requires a durable follow-up." }], details: {} };
1940
1980
  }
1941
- const goal = state.goal;
1942
1981
  const durableDeferRecommendation = durableDeferFactsForGoal(goal, record, p);
1943
1982
  const landed = appendLedger(ctx.cwd, "durable_defer_choice", {
1944
1983
  goalId: goal.id,
1945
1984
  ...record,
1985
+ ...(taskId ? { taskId, taskTitle: deferredTask!.title } : {}),
1946
1986
  durableDeferRecommendation,
1947
1987
  });
1948
1988
  if (!landed) {
1949
1989
  return { content: [{ type: "text", text: "The durable-vs-defer judgment could not be persisted; no choice was recorded." }], details: {} };
1950
1990
  }
1951
- if (!updateGoal({ durableDeferRecommendation }, ctx)) {
1991
+ // v0.38.48: the task stamp rides the SAME persist as the UI
1992
+ // projection — one write, never a stamped-but-unprojected split.
1993
+ // record.followUp is proven non-empty for deferred above; a taskId
1994
+ // always implies deferred (refused otherwise), so the non-null
1995
+ // assertion holds exactly when a stamp is written.
1996
+ const patch: Record<string, unknown> = { durableDeferRecommendation };
1997
+ if (taskId && goal.taskList) {
1998
+ patch.taskList = patchTaskCopy(goal.taskList, taskId, {
1999
+ deferred: { reason: record.reason, followUp: record.followUp!, at: nowIso() },
2000
+ });
2001
+ }
2002
+ if (!updateGoal(patch as Parameters<typeof updateGoal>[0], ctx)) {
1952
2003
  return {
1953
- content: [{ type: "text", text: `Recorded durable-vs-defer judgment: ${record.choice}, but its UI recommendation projection could not be persisted.` }],
2004
+ content: [{ type: "text", text: `Recorded durable-vs-defer judgment: ${record.choice} in the ledger, but its persistence failed${taskId ? " — the task exemption did NOT land, so the gate will still block" : ""}. Retry.` }],
1954
2005
  details: {},
1955
2006
  };
1956
2007
  }
@@ -1959,7 +2010,7 @@ function registerAgentTools(pi: any): void {
1959
2010
  // from the state ledger through Goal.durableDeferRecommendation.
1960
2011
  refreshUI(ctx, true);
1961
2012
  return {
1962
- content: [{ type: "text", text: `Recorded durable-vs-defer judgment: ${record.choice}.` }],
2013
+ content: [{ type: "text", text: `Recorded durable-vs-defer judgment: ${record.choice}.${taskId ? ` Task ${taskId} ("${deferredTask!.title}") is now exempt from the complete_goal pending-task gate.` : ""}` }],
1963
2014
  details: {},
1964
2015
  };
1965
2016
  },
@@ -2160,25 +2211,25 @@ function registerAgentTools(pi: any): void {
2160
2211
  return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
2161
2212
  }
2162
2213
  const tl = state.goal.taskList;
2163
- const queue: any[] = [...tl.tasks];
2164
- while (queue.length > 0) {
2165
- const t = queue.shift();
2166
- if (t.id === p.id && t.status !== "complete") {
2167
- const checkRes = await verifyTaskMilestone(ctx, t.verificationContract, signal);
2168
- if (checkRes) {
2169
- return {
2170
- content: [{
2171
- type: "text",
2172
- text: `Task ${p.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}\n\nTask ${p.id} remains in_progress. Fix the failure before marking complete.`,
2173
- }],
2174
- details: {},
2175
- };
2176
- }
2177
- t.status = "complete";
2178
- updateGoal({ taskList: tl }, ctx);
2179
- return { content: [{ type: "text", text: `Task ${p.id} marked complete.` }], details: {} };
2214
+ // v0.38.48: copy-swap — the live list is never mutated in place, so a
2215
+ // kill between verify and persist (or a persist failure) cannot leave
2216
+ // partial state. Message strings are unchanged (pinned by tests).
2217
+ const t = findTask(tl, p.id);
2218
+ if (t && t.status !== "complete") {
2219
+ const checkRes = await verifyTaskMilestone(ctx, t.verificationContract, signal);
2220
+ if (checkRes) {
2221
+ return {
2222
+ content: [{
2223
+ type: "text",
2224
+ text: `Task ${p.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}\n\nTask ${p.id} remains in_progress. Fix the failure before marking complete.`,
2225
+ }],
2226
+ details: {},
2227
+ };
2228
+ }
2229
+ if (!updateGoal({ taskList: withTaskStatus(tl, p.id, "complete") }, ctx)) {
2230
+ return { content: [{ type: "text", text: `Task ${p.id} could not be marked complete — the persist failed and no state changed. Retry.` }], details: {} };
2180
2231
  }
2181
- if (t.subtasks) queue.push(...t.subtasks);
2232
+ return { content: [{ type: "text", text: `Task ${p.id} marked complete.` }], details: {} };
2182
2233
  }
2183
2234
  return { content: [{ type: "text", text: `Task ${p.id} not found.` }], details: {} };
2184
2235
  },
@@ -2202,32 +2253,86 @@ function registerAgentTools(pi: any): void {
2202
2253
  return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
2203
2254
  }
2204
2255
  const tl = state.goal.taskList;
2205
- const queue: any[] = [...tl.tasks];
2206
- while (queue.length > 0) {
2207
- const t = queue.shift();
2208
- if (t.id === p.id) {
2209
- if (p.status === "complete") {
2210
- const checkRes = await verifyTaskMilestone(ctx, t.verificationContract, signal);
2211
- if (checkRes) {
2212
- return {
2213
- content: [{
2214
- type: "text",
2215
- text: `Task ${p.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}\n\nTask ${p.id} remains ${t.status}. Fix the failure before marking complete.`,
2216
- }],
2217
- details: {},
2218
- };
2219
- }
2256
+ // v0.38.48: copy-swap (see complete_task). Message strings unchanged.
2257
+ const t = findTask(tl, p.id);
2258
+ if (t) {
2259
+ if (p.status === "complete") {
2260
+ const checkRes = await verifyTaskMilestone(ctx, t.verificationContract, signal);
2261
+ if (checkRes) {
2262
+ return {
2263
+ content: [{
2264
+ type: "text",
2265
+ text: `Task ${p.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}\n\nTask ${p.id} remains ${t.status}. Fix the failure before marking complete.`,
2266
+ }],
2267
+ details: {},
2268
+ };
2220
2269
  }
2221
- t.status = p.status;
2222
- updateGoal({ taskList: tl }, ctx);
2223
- return { content: [{ type: "text", text: `Task ${p.id} → ${p.status}` }], details: {} };
2224
2270
  }
2225
- if (t.subtasks) queue.push(...t.subtasks);
2271
+ if (!updateGoal({ taskList: withTaskStatus(tl, p.id, p.status) }, ctx)) {
2272
+ return { content: [{ type: "text", text: `Task ${p.id} could not move to ${p.status} — the persist failed and no state changed. Retry.` }], details: {} };
2273
+ }
2274
+ return { content: [{ type: "text", text: `Task ${p.id} → ${p.status}` }], details: {} };
2226
2275
  }
2227
2276
  return { content: [{ type: "text", text: `Task ${p.id} not found.` }], details: {} };
2228
2277
  },
2229
2278
  }));
2230
2279
 
2280
+ pi.registerTool(defineTool({
2281
+ name: "update_task_batch",
2282
+ label: "Update task batch",
2283
+ description: "Atomically update several tasks at once: the whole batch is validated first, milestones are verified, then all statuses persist in one write. Any failure rejects the batch and leaves state untouched.",
2284
+ parameters: Type.Object({
2285
+ updates: Type.Array(Type.Object({
2286
+ id: Type.String({ description: "Task id to update" }),
2287
+ status: Type.Union([Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("complete")]),
2288
+ }), { description: "Status updates to apply atomically (each task id at most once)" }),
2289
+ }),
2290
+ async execute(_id, params, signal, _onUpdate, execCtx) {
2291
+ const foreignBatch = foreignToolGuard(execCtx);
2292
+ if (foreignBatch) return { content: [{ type: "text", text: foreignBatch }], details: {} };
2293
+ const ctx = currentToolContext(execCtx);
2294
+ if (!ctx) return staleToolResult();
2295
+ const p = params as { updates: TaskStatusUpdate[] };
2296
+ if (!state.goal || !state.goal.taskList) {
2297
+ return { content: [{ type: "text", text: "No task list in this goal." }], details: {} };
2298
+ }
2299
+ const tl = state.goal.taskList;
2300
+ // Phase 1 — validate the WHOLE batch before any verification runs.
2301
+ const validation = validateTaskBatch(tl, p.updates);
2302
+ if (!validation.ok) {
2303
+ return { content: [{ type: "text", text: validation.error }], details: {} };
2304
+ }
2305
+ // Phase 2 — verify every milestone moving to complete. All failures
2306
+ // are collected so one bad milestone does not hide another; NOTHING
2307
+ // is applied until every verification passes (mid-batch kill here
2308
+ // leaves state unchanged — the copy below does not exist yet).
2309
+ const failures: string[] = [];
2310
+ for (const e of validation.entries) {
2311
+ if (e.status === "complete" && e.task.status !== "complete") {
2312
+ const checkRes = await verifyTaskMilestone(ctx, e.task.verificationContract, signal);
2313
+ if (checkRes) {
2314
+ failures.push(`Task ${e.task.id} milestone verification FAILED for command \`${checkRes.failedCommand}\` (exit code ${checkRes.exitCode}):\n\n${checkRes.output}`);
2315
+ }
2316
+ }
2317
+ }
2318
+ if (failures.length > 0) {
2319
+ return {
2320
+ content: [{
2321
+ type: "text",
2322
+ text: `Task batch rejected — ${failures.length} milestone verification${failures.length === 1 ? "" : "s"} failed. No task was changed:\n\n${failures.join("\n\n")}`,
2323
+ }],
2324
+ details: {},
2325
+ };
2326
+ }
2327
+ // Phase 3 — copy, apply, persist ONCE.
2328
+ if (!updateGoal({ taskList: applyValidatedBatch(tl, validation.entries) }, ctx)) {
2329
+ return { content: [{ type: "text", text: "Task batch could not persist — the write failed and no state changed. Retry." }], details: {} };
2330
+ }
2331
+ const applied = validation.entries.map((e) => `${e.task.id} → ${e.status}`).join(", ");
2332
+ return { content: [{ type: "text", text: `Task batch applied (${validation.entries.length}): ${applied}.` }], details: {} };
2333
+ },
2334
+ }));
2335
+
2231
2336
  pi.registerTool(defineTool({
2232
2337
  name: "propose_goal_draft",
2233
2338
  label: "Propose goal draft",
@@ -0,0 +1,145 @@
1
+ // ============================================================================
2
+ // prompt-layers.ts — static skeleton + on-demand detail assembly for prompts/
3
+ // ============================================================================
4
+ // Every prompts/*.md file is one canonical, editor-renderable document.
5
+ // Detail sections are wrapped in zero-width structural markers:
6
+ //
7
+ // <!-- glla-layer: detail <id> -->
8
+ // ...section body, untouched...
9
+ // <!-- glla-layer: end -->
10
+ //
11
+ // Everything outside a detail block is the skeleton: always loaded.
12
+ // A detail block loads only when its phase predicate holds. Assembly order
13
+ // is file order, every turn — static layers resolve before dynamic
14
+ // ${...} substitution slots, so renders are deterministic.
15
+ //
16
+ // Loudness contract: any structural problem (missing file, unclosed block,
17
+ // duplicate id, end-without-start, unknown requested id) THROWS naming the
18
+ // file. A render never proceeds lean on a "[template-not-found]" fallback.
19
+ // Marker lines are structural only and are stripped from assembled output,
20
+ // so full assembly (every detail active) is byte-identical to the source
21
+ // file minus its marker lines.
22
+ // ============================================================================
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+
27
+ const LAYER_START_RE = /^\s*<!--\s*glla-layer:\s*detail\s+([A-Za-z0-9_-]+)\s*-->\s*$/;
28
+ const LAYER_END_RE = /^\s*<!--\s*glla-layer:\s*end\s*-->\s*$/;
29
+
30
+ export interface PromptSegment {
31
+ kind: "skeleton" | "detail";
32
+ /** Present only for detail segments. */
33
+ id?: string;
34
+ /** Exact source lines for this segment (marker lines excluded). */
35
+ lines: string[];
36
+ }
37
+
38
+ export function promptFilePath(name: string): string {
39
+ return path.resolve(__dirname, "..", "prompts", name);
40
+ }
41
+
42
+ /** Parse one prompt source into ordered segments. Throws naming the file
43
+ * on any structural defect. Pure (no fs) so tests can feed fixtures. */
44
+ export function parsePromptLayers(source: string, name: string): PromptSegment[] {
45
+ const segments: PromptSegment[] = [];
46
+ let current: PromptSegment = { kind: "skeleton", lines: [] };
47
+ let openId: string | null = null;
48
+ const seen = new Set<string>();
49
+ const fail = (why: string): never => {
50
+ throw new Error(`[glla] prompts/${name}: ${why}`);
51
+ };
52
+ for (const line of source.split("\n")) {
53
+ const start = line.match(LAYER_START_RE);
54
+ const end = LAYER_END_RE.test(line);
55
+ if (start) {
56
+ if (openId !== null) fail(`detail "${openId}" unclosed before detail "${start[1]}" starts`);
57
+ const id = start[1] as string;
58
+ if (seen.has(id)) fail(`duplicate detail id "${id}"`);
59
+ seen.add(id);
60
+ segments.push(current);
61
+ current = { kind: "detail", id, lines: [] };
62
+ openId = id;
63
+ continue;
64
+ }
65
+ if (end) {
66
+ if (openId === null) fail("glla-layer end marker without an open detail block");
67
+ segments.push(current);
68
+ current = { kind: "skeleton", lines: [] };
69
+ openId = null;
70
+ continue;
71
+ }
72
+ current.lines.push(line);
73
+ }
74
+ if (openId !== null) fail(`detail "${openId}" unclosed at end of file`);
75
+ segments.push(current);
76
+ return segments;
77
+ }
78
+
79
+ /** Read + parse a prompt file. Throws naming the path when unreadable. */
80
+ export function loadPromptSegments(name: string): PromptSegment[] {
81
+ const file = promptFilePath(name);
82
+ let source: string;
83
+ try {
84
+ source = fs.readFileSync(file, "utf-8");
85
+ } catch (err) {
86
+ throw new Error(
87
+ `[glla] prompt file missing: prompts/${name} (${file}): ${(err as Error)?.message ?? err}. ` +
88
+ "Refusing to render lean — restore the file, do not proceed without it.",
89
+ );
90
+ }
91
+ return parsePromptLayers(source, name);
92
+ }
93
+
94
+ /** Raw whole-file read. Throws (loud) when unreadable. Use for
95
+ * skeleton-only phases and for render-diff baselines. */
96
+ export function loadPromptWhole(name: string): string {
97
+ const file = promptFilePath(name);
98
+ try {
99
+ return fs.readFileSync(file, "utf-8");
100
+ } catch (err) {
101
+ throw new Error(
102
+ `[glla] prompt file missing: prompts/${name} (${file}): ${(err as Error)?.message ?? err}. ` +
103
+ "Refusing to render lean — restore the file, do not proceed without it.",
104
+ );
105
+ }
106
+ }
107
+
108
+ /** Detail ids declared by a prompt file, in file order. */
109
+ export function promptDetailIds(name: string): string[] {
110
+ const ids: string[] = [];
111
+ for (const seg of loadPromptSegments(name)) {
112
+ if (seg.kind === "detail" && seg.id) ids.push(seg.id);
113
+ }
114
+ return ids;
115
+ }
116
+
117
+ /** Assemble skeleton + the requested details in file order. Unknown ids
118
+ * throw. Empty `active` renders the bare skeleton. */
119
+ export function assemblePrompt(name: string, active: Iterable<string>): string {
120
+ const segments = loadPromptSegments(name);
121
+ const want = new Set(active);
122
+ const known = new Set<string>();
123
+ for (const seg of segments) {
124
+ if (seg.kind === "detail" && seg.id) known.add(seg.id);
125
+ }
126
+ for (const id of want) {
127
+ if (!known.has(id)) {
128
+ throw new Error(
129
+ `[glla] prompts/${name}: unknown detail id "${id}" (known: ${[...known].join(", ") || "(none)"}). ` +
130
+ "Refusing to render lean — fix the request, do not proceed without it.",
131
+ );
132
+ }
133
+ }
134
+ const out: string[] = [];
135
+ for (const seg of segments) {
136
+ if (seg.kind === "skeleton" || (seg.id && want.has(seg.id))) out.push(...seg.lines);
137
+ }
138
+ return out.join("\n");
139
+ }
140
+
141
+ /** Full assembly: every declared detail active. Render-diff gate: this must
142
+ * equal the source file minus its marker lines. */
143
+ export function assemblePromptFull(name: string): string {
144
+ return assemblePrompt(name, promptDetailIds(name));
145
+ }
@@ -0,0 +1,156 @@
1
+ // Task-batch atomicity core (v0.38.48).
2
+ //
3
+ // The single-task tools used to mutate the live task list in place and then
4
+ // persist — a mid-batch kill (or a late verification failure) left partial
5
+ // state behind with no record of what was intended. This module is the whole
6
+ // mutation discipline:
7
+ //
8
+ // validate whole (ids resolve, no duplicates) → verify milestones →
9
+ // copy + apply → persist once.
10
+ //
11
+ // Everything here is pure except the injected verify/persist callbacks, so
12
+ // the contract is pinnable without a harness: a rejected batch returns the
13
+ // ORIGINAL list object untouched, and the tools persist exactly once.
14
+
15
+ import type { Task, TaskList } from "./goal-loop-core.js";
16
+
17
+ export type TaskStatus = "pending" | "in_progress" | "complete";
18
+
19
+ export interface TaskStatusUpdate {
20
+ id: string;
21
+ status: TaskStatus;
22
+ }
23
+
24
+ export interface ValidatedTaskUpdate {
25
+ task: Task;
26
+ status: TaskStatus;
27
+ }
28
+
29
+ export type BatchValidation =
30
+ | { ok: true; entries: ValidatedTaskUpdate[] }
31
+ | { ok: false; error: string };
32
+
33
+ /** Breadth-first search over top-level tasks and subtasks, in list order. */
34
+ export function findTask(taskList: TaskList, id: string): Task | null {
35
+ const queue: Task[] = [...taskList.tasks];
36
+ while (queue.length > 0) {
37
+ const t = queue.shift()!;
38
+ if (t.id === id) return t;
39
+ if (t.subtasks) queue.push(...t.subtasks);
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Validate the WHOLE batch before anything is verified, copied, or
46
+ * persisted. Every id must resolve and appear exactly once; statuses must
47
+ * be legal. Any failure names the offending entry and changes nothing.
48
+ */
49
+ export function validateTaskBatch(
50
+ taskList: TaskList,
51
+ updates: TaskStatusUpdate[],
52
+ ): BatchValidation {
53
+ if (!Array.isArray(updates) || updates.length === 0) {
54
+ return { ok: false, error: "Task batch is empty — pass at least one {id, status} update." };
55
+ }
56
+ const seen = new Set<string>();
57
+ const entries: ValidatedTaskUpdate[] = [];
58
+ for (const u of updates) {
59
+ if (!u || typeof u.id !== "string" || !u.id) {
60
+ return { ok: false, error: "Task batch rejected — every update needs a non-empty string id. No task was changed." };
61
+ }
62
+ if (u.status !== "pending" && u.status !== "in_progress" && u.status !== "complete") {
63
+ return { ok: false, error: `Task batch rejected — update for \"${u.id}\" carries an illegal status ${JSON.stringify((u as { status: unknown }).status)}. No task was changed.` };
64
+ }
65
+ if (seen.has(u.id)) {
66
+ return { ok: false, error: `Task batch rejected — duplicate update for task \"${u.id}\" in one batch (each task may appear once). No task was changed.` };
67
+ }
68
+ seen.add(u.id);
69
+ const task = findTask(taskList, u.id);
70
+ if (!task) {
71
+ return { ok: false, error: `Task batch rejected — task \"${u.id}\" not found. No task was changed.` };
72
+ }
73
+ entries.push({ task, status: u.status });
74
+ }
75
+ return { ok: true, entries };
76
+ }
77
+
78
+ /**
79
+ * Apply validated entries to a DEEP COPY. The input list is never mutated,
80
+ * so a kill between copy and persist (or a persist failure) leaves the
81
+ * caller's state object exactly as it was.
82
+ */
83
+ export function applyValidatedBatch(
84
+ taskList: TaskList,
85
+ entries: ValidatedTaskUpdate[],
86
+ ): TaskList {
87
+ const next: TaskList = JSON.parse(JSON.stringify(taskList)) as TaskList;
88
+ for (const e of entries) {
89
+ const target = findTask(next, e.task.id);
90
+ // Proven by validateTaskBatch against the same shape — a missing target
91
+ // here would mean the list changed under us; fail loudly, never partial.
92
+ if (!target) throw new Error(`Task batch apply failed — task \"${e.task.id}\" vanished mid-batch. No persist was attempted.`);
93
+ target.status = e.status;
94
+ }
95
+ return next;
96
+ }
97
+
98
+ /** Copy with an arbitrary patch merged into one task; the input list is untouched. */
99
+ export function patchTaskCopy(
100
+ taskList: TaskList,
101
+ id: string,
102
+ patch: Partial<Task>,
103
+ ): TaskList {
104
+ const next: TaskList = JSON.parse(JSON.stringify(taskList)) as TaskList;
105
+ const target = findTask(next, id);
106
+ if (!target) throw new Error(`patchTaskCopy: task "${id}" not found.`);
107
+ Object.assign(target, patch);
108
+ return next;
109
+ }
110
+
111
+ /** Copy with a single task's status replaced; the input list is untouched. */
112
+ export function withTaskStatus(
113
+ taskList: TaskList,
114
+ id: string,
115
+ status: TaskStatus,
116
+ ): TaskList {
117
+ const task = findTask(taskList, id);
118
+ if (!task) throw new Error(`withTaskStatus: task \"${id}\" not found.`);
119
+ return applyValidatedBatch(taskList, [{ task, status }]);
120
+ }
121
+
122
+ export interface OpenTaskInfo {
123
+ id: string;
124
+ title: string;
125
+ status: TaskStatus;
126
+ }
127
+
128
+ /**
129
+ * Every committed task (top-level and subtask) still open, in list order.
130
+ * Complete tasks and tasks carrying a recorded deferral are not open — a
131
+ * deferral is an explicit durable-vs-defer judgment, not abandonment.
132
+ */
133
+ export function collectOpenTasks(taskList: TaskList): OpenTaskInfo[] {
134
+ const open: OpenTaskInfo[] = [];
135
+ const queue: Task[] = [...taskList.tasks];
136
+ while (queue.length > 0) {
137
+ const t = queue.shift()!;
138
+ if (t.status !== "complete" && !t.deferred) {
139
+ open.push({ id: t.id, title: t.title, status: t.status });
140
+ }
141
+ if (t.subtasks) queue.push(...t.subtasks);
142
+ }
143
+ return open;
144
+ }
145
+
146
+ /** The refusal text for the complete_goal pending-task gate. Names every open task. */
147
+ export function formatOpenTaskRefusal(open: OpenTaskInfo[]): string {
148
+ const lines = open.map((t) => `- ${t.id}: \"${t.title}\" (${t.status})`);
149
+ return (
150
+ `complete_goal REFUSED — ${open.length} open committed task${open.length === 1 ? " is" : "s are"} still unfinished:\n` +
151
+ lines.join("\n") +
152
+ `\n\nFinish them with complete_task or update_task_batch, or record an explicit ` +
153
+ `deferral for a genuinely blocked item with record_goal_judgment ` +
154
+ `(choice=\"deferred\", taskId=\"<id>\"). The claim was NOT sent to the auditor and no state changed.`
155
+ );
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.46",
3
+ "version": "0.38.48",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. A detached extension-less auditor process re-verifies every completion with raw evidence without holding the main pi turn; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",
@@ -77,6 +77,7 @@ When the agent calls any of these, the orchestrator tracks the call and persists
77
77
  - **Bound every long command.** Wrap test suites, builds, and dev servers in `timeout <seconds>` (e.g. `timeout 120 bun test src/lib`). An unbounded command that hangs burns an hour; a bounded one burns two minutes and tells you it hung. If a command produces no output for many minutes, treat it as hung: kill it, diagnose why, rerun bounded.
78
78
  - **Chunk output near context-full & microcompaction.** When the conversation is heavy (long-running audit, deep debug, big rollout), prefer smaller commits, smaller tool outputs, and focused reasoning — one or two punchy paragraphs, one well-scoped tool call at a time. Don't try to fit a thousand lines of work into one reply. Spool massive stdout/diffs to disk logs if needed. glla's auto-continue fires on `stop_reason="length"` and will reschedule you; chunking is cheaper than recovering from the cap. Save large file writes for their own turns.
79
79
 
80
+ <!-- glla-layer: detail auditor-disapproval -->
80
81
  ## WHEN THE AUDITOR DISAPPROVES
81
82
 
82
83
  If the orchestrator tells you the auditor disapproved, **investigate before asking the user**:
@@ -90,7 +91,9 @@ If the orchestrator tells you the auditor disapproved, **investigate before aski
90
91
  "The auditor's last 3 reports all complain about saves-3 not shipping. The current objective IS saves-3, but the work shipped is menu-3 + kingdom-2 (different items). I shipped those because [reason]. The auditor is disapproving because the original objective isn't literally shipped. Three options: A. /goal tweak the objective to menu-3+kingdom-2, then /goal resume — B. Re-scope saves-3 and ship it — C. Pivot to a different item entirely."
91
92
 
92
93
  Do NOT ask the user to choose between generic options like "/goal resume / Move on silently / Different item". Those options tell the user nothing. Always include YOUR ASSESSMENT with quoted objections and shipped evidence.
94
+ <!-- glla-layer: end -->
93
95
 
96
+ <!-- glla-layer: detail survey-pivot -->
94
97
  ## PIVOT DETECTION
95
98
 
96
99
  When the user says "do a full audit", "survey the project", "find all problems", "mark a tasklist", or similar — the goal is a SURVEY, not a single fix. You must:
@@ -100,6 +103,7 @@ When the user says "do a full audit", "survey the project", "find all problems",
100
103
  3. Use subagents to PARALLEL-survey different subsystems (game logic, UI, audio, tests, docs) — one `scout` agent per subsystem, spawned in a single message.
101
104
  4. Don't ship a single bug fix and then ask if the user wants to continue — the user already said "do a full audit".
102
105
  5. After the task list is confirmed, work through tasks systematically with `complete_task` / `update_task_status`.
106
+ <!-- glla-layer: end -->
103
107
 
104
108
  ## WHEN SUBAGENTS DIE: RESUME, DON'T RESPAWN
105
109
 
@@ -108,9 +112,11 @@ A failed subagent's SESSION survives its death (verified in pi-subagents source:
108
112
  1. **Resume it immediately**: `Agent(resume: "<id>", prompt: "Wrap up now: report what you found within ~150 lines.")`. A resumed agent already HAS its research; it only needs to report. Do not infer a reset time from the error text.
109
113
  2. **Resume failed with "not found"?** The manager forgot it (a /reload wipes the in-process registry — old IDs are unresumable). Only NOW respawn, and never the same wide brief: SPLIT into 2 narrower agents or ABSORB the remainder inline.
110
114
 
115
+ <!-- glla-layer: detail session-restart -->
111
116
  ## AFTER A SESSION RESTART: YOUR SUBAGENTS ARE DEAD
112
117
 
113
118
  Subagents run IN-PROCESS — a /reload or restart kills every running one instantly and silently (no failure event, no ✗), and their IDs become unresumable. On a restored session, check your transcript for in-flight agents and relaunch them (tight briefs) or absorb their scope — do NOT sit waiting for results that can never arrive. This is why long fan-out passes belong under a glla goal/list item: the goal plane survives restarts and re-drives the fan-out through the continuation; an ad-hoc fan-out dies with the tab.
119
+ <!-- glla-layer: end -->
114
120
 
115
121
  ## WHEN SUBAGENTS HIT PROVIDER/RUNTIME ERRORS
116
122