pi-goal-list-loop-audit 0.38.47 → 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,17 @@
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
+
3
15
  ## 0.38.47 — layered prompts: skeleton plus on-demand detail (2026-09-11)
4
16
 
5
17
  ### Changed
package/docs/INDEX.md CHANGED
@@ -16,7 +16,7 @@ Policy contracts and recent changes live in the `audit/` directory of the
16
16
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
17
17
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
18
18
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
19
- v0.35.14–v0.38.47 continue through the supervisor freeze (`/glla pause`),
19
+ v0.35.14–v0.38.48 continue through the supervisor freeze (`/glla pause`),
20
20
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
21
21
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
22
22
  durable state-root selection, blank-until-resume auditor context, frozen
@@ -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 {
@@ -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,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.47",
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",