@gajae-code/coding-agent 0.12.20 → 0.12.21

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
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.21] - 2026-08-09
6
+
7
+ ### Fixed
8
+ - Strict session resume now sanitizes stale replay metadata in memory without mutating the selected transcript, then publishes the terminal resume breadcrumb only after the first durable user-visible write. Declined auto-continuations restore their deferred predecessor terminal instead of dropping client settlement evidence.
9
+ - Todo completion reminders now honor `todo.reminders.max` across an interactive turn rather than stopping after the first reminder.
10
+ - Hidden `nextTurn` messages that trigger a follow-up model call no longer deadlock the predecessor prompt during post-prompt settlement.
11
+
5
12
  ## [0.12.20] - 2026-08-09
6
13
 
7
14
  ### Fixed
@@ -880,6 +880,7 @@ export declare const SessionManagerTestHooks: {
880
880
  materializedCacheMaxBytesOverride?: number;
881
881
  beforeResidentTransitionIndexBuild?: () => void;
882
882
  afterForkSnapshot?: () => void | Promise<void>;
883
+ afterForkTranscriptPublished?: () => void | Promise<void>;
883
884
  beforeEphemeralArtifactManagerInstall?: (dir: string) => void | Promise<void>;
884
885
  beforePersistPatchFence?: (attempt: number) => void;
885
886
  };
@@ -7,9 +7,18 @@ import type { RawArgumentValidationResult } from "@gajae-code/ai/types";
7
7
  */
8
8
  export declare const TODO_DONE_ALIASES: readonly ["complete", "completed"];
9
9
  export declare function isDoneAlias(value: string): boolean;
10
+ /**
11
+ * Legal `op` values. Exported so the todo_write schema and this contract read
12
+ * from one list: a rejection that names a vocabulary the schema no longer
13
+ * accepts is exactly the drift this file exists to prevent.
14
+ */
15
+ export declare const TODO_OPS: readonly ["init", "start", "done", "rm", "drop", "append", "note"];
10
16
  /**
11
17
  * Single source of truth for todo_write pre-coercion validation. Every
12
18
  * rejection carries a code so the caller can surface an actionable correction
13
- * instead of a bare "raw arguments rejected" message.
19
+ * instead of a bare "raw arguments rejected" message, and every entry-level
20
+ * rejection names the failing `ops` index. Rejection stays batch-atomic, but a
21
+ * caller told which entry failed can resubmit the rest instead of rebuilding
22
+ * the payload from memory — or, as observed, silently abandoning the list.
14
23
  */
15
24
  export declare function validateRawTodoArguments(arguments_: Record<string, unknown>): RawArgumentValidationResult;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.12.20",
4
+ "version": "0.12.21",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -54,13 +54,13 @@
54
54
  "@agentclientprotocol/sdk": "1.3.0",
55
55
  "@babel/parser": "^7.29.3",
56
56
  "@mozilla/readability": "^0.6.0",
57
- "@gajae-code/stats": "0.12.20",
58
- "@gajae-code/agent-core": "0.12.20",
59
- "@gajae-code/ai": "0.12.20",
60
- "@gajae-code/bridge-client": "0.12.20",
61
- "@gajae-code/natives": "0.12.20",
62
- "@gajae-code/tui": "0.12.20",
63
- "@gajae-code/utils": "0.12.20",
57
+ "@gajae-code/stats": "0.12.21",
58
+ "@gajae-code/agent-core": "0.12.21",
59
+ "@gajae-code/ai": "0.12.21",
60
+ "@gajae-code/bridge-client": "0.12.21",
61
+ "@gajae-code/natives": "0.12.21",
62
+ "@gajae-code/tui": "0.12.21",
63
+ "@gajae-code/utils": "0.12.21",
64
64
  "@puppeteer/browsers": "^2.13.0",
65
65
  "@types/turndown": "5.0.6",
66
66
  "@xterm/headless": "^6.0.0",
@@ -753,7 +753,7 @@ type AutoCompactionTerminalStatus =
753
753
  * forced compaction retry).
754
754
  */
755
755
  type PreSubmitBuilder = {
756
- build: () => Promise<AgentMessage[]>;
756
+ build: () => Promise<AgentMessage[] | null>;
757
757
  reset: () => void;
758
758
  };
759
759
 
@@ -2629,6 +2629,7 @@ export class AgentSession {
2629
2629
  }
2630
2630
  async #settleEndedInFlight(promptWait?: "publication" | "full"): Promise<void> {
2631
2631
  const flushError = this.#endInFlight();
2632
+ const predecessorPromptStillInFlight = this.#promptInFlightCount > 0;
2632
2633
  if (promptWait === "publication") {
2633
2634
  await this.#agentEndPublicationPromise;
2634
2635
  } else if (promptWait === "full") {
@@ -2636,7 +2637,10 @@ export class AgentSession {
2636
2637
  await this.#waitForPostPromptRecovery();
2637
2638
  await this.#agentEndPublicationPromise;
2638
2639
  }
2639
- await this.#waitForSessionSettlement();
2640
+ // A post-prompt continuation runs before its predecessor prompt returns. Its
2641
+ // publication must settle, but session-wide settlement can still be owned by
2642
+ // that predecessor; waiting here would make each prompt wait on the other.
2643
+ if (!predecessorPromptStillInFlight) await this.#waitForSessionSettlement();
2640
2644
  if (flushError) throw flushError;
2641
2645
  }
2642
2646
 
@@ -8932,8 +8936,12 @@ export class AgentSession {
8932
8936
  // Phase A products (including the plan reference) remain stable.
8933
8937
  let attemptMessages: AgentMessage[] | undefined;
8934
8938
 
8935
- const buildPreSubmit = async (): Promise<AgentMessage[]> => {
8939
+ const buildPreSubmit = async (): Promise<AgentMessage[] | null> => {
8936
8940
  this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
8941
+ if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
8942
+ this.#resetInjectedContextSignatures();
8943
+ return null;
8944
+ }
8937
8945
  if (!phaseACompleted) {
8938
8946
  phaseACompleted = true;
8939
8947
  // Phase A (one-time side-effectful products; runs once).
@@ -9094,6 +9102,10 @@ export class AgentSession {
9094
9102
  message.role,
9095
9103
  );
9096
9104
  }
9105
+ if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
9106
+ this.#resetInjectedContextSignatures();
9107
+ return null;
9108
+ }
9097
9109
  attemptMessages = messages;
9098
9110
  return messages;
9099
9111
  };
@@ -9123,10 +9135,6 @@ export class AgentSession {
9123
9135
  if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
9124
9136
  },
9125
9137
  };
9126
- if (options?.onFinalPreflight && !(await options.onFinalPreflight({ hasPendingNextTurnMessages }))) {
9127
- this.#resetInjectedContextSignatures();
9128
- return;
9129
- }
9130
9138
  await this.#promptAgentWithIdleRetry(preSubmit, agentPromptOptions, predecessorAgentEndHold, {
9131
9139
  signal: preflightSignal,
9132
9140
  resourceRunId: this.#runResourceLeaseContext.getStore()?.resourceRunId,
@@ -16160,6 +16168,10 @@ export class AgentSession {
16160
16168
  continuationHold = undefined;
16161
16169
  try {
16162
16170
  const messages = await preSubmit.build();
16171
+ if (messages === null) {
16172
+ this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
16173
+ return;
16174
+ }
16163
16175
  if (!preflightAccepted) {
16164
16176
  await seam?.onPreflightAccepted?.();
16165
16177
  preflightAccepted = true;
@@ -6216,6 +6216,7 @@ export const SessionManagerTestHooks: {
6216
6216
  materializedCacheMaxBytesOverride?: number;
6217
6217
  beforeResidentTransitionIndexBuild?: () => void;
6218
6218
  afterForkSnapshot?: () => void | Promise<void>;
6219
+ afterForkTranscriptPublished?: () => void | Promise<void>;
6219
6220
  beforeEphemeralArtifactManagerInstall?: (dir: string) => void | Promise<void>;
6220
6221
  beforePersistPatchFence?: (attempt: number) => void;
6221
6222
  } = {};
@@ -6310,6 +6311,7 @@ export class SessionManager {
6310
6311
  #sessionFile: string | undefined;
6311
6312
  #flushed: boolean = false;
6312
6313
  #needsFullRewriteOnNextPersist: boolean = false;
6314
+ #readOnlyResume = false;
6313
6315
  #ensuredOnDisk: boolean = false;
6314
6316
  #recoveryHydrationContext: RecoveryHydrationContext | undefined;
6315
6317
  #recoveryPromotionTranscriptPath: string | undefined;
@@ -8754,6 +8756,7 @@ export class SessionManager {
8754
8756
  publishedSha256,
8755
8757
  };
8756
8758
  }
8759
+ await SessionManagerTestHooks.afterForkTranscriptPublished?.();
8757
8760
  if (forkArtifactPublication?.kind === "managed") {
8758
8761
  forkArtifactPublication.store.verifyRootSecurity();
8759
8762
  forkArtifactPublication.store.assertBound();
@@ -13429,6 +13432,7 @@ export class SessionManager {
13429
13432
  */
13430
13433
  async ensureOnDisk(): Promise<void> {
13431
13434
  if (!this.persist || !this.#sessionFile) return;
13435
+ if (this.#readOnlyResume) return;
13432
13436
  if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
13433
13437
  await this.#rewriteFile();
13434
13438
  this.#ensuredOnDisk = true;
@@ -13480,7 +13484,7 @@ export class SessionManager {
13480
13484
  await this.#closePersistWriterInternal();
13481
13485
  this.#flushed = true;
13482
13486
  }
13483
- if (this.#needsFullRewriteOnNextPersist) await this.#rewriteFileContents();
13487
+ if (this.#needsFullRewriteOnNextPersist && !this.#readOnlyResume) await this.#rewriteFileContents();
13484
13488
  });
13485
13489
  this.#retireEphemeralArtifacts();
13486
13490
  await this.#drainEphemeralArtifactCleanups();
@@ -14388,6 +14392,8 @@ export class SessionManager {
14388
14392
  if (records.length === 0) return;
14389
14393
  if (this.#coldSidecarActive()) this.#deactivateColdForBranchMutation();
14390
14394
  if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
14395
+ const sessionFile = this.#sessionFile;
14396
+ const publishResumeBreadcrumb = this.#readOnlyResume;
14391
14397
  await this.#queuePersistTask(async () => {
14392
14398
  for (let attempt = 0; attempt <= 2; attempt++) {
14393
14399
  const token = this.#capturePersistenceInputToken();
@@ -14396,8 +14402,12 @@ export class SessionManager {
14396
14402
  this.#needsFullRewriteOnNextPersist ||
14397
14403
  !this.#flushed ||
14398
14404
  (header?.version ?? 1) < CURRENT_SESSION_VERSION
14399
- )
14400
- return this.#rewriteFileContents();
14405
+ ) {
14406
+ await this.#rewriteFileContents();
14407
+ if (publishResumeBreadcrumb) writeTerminalBreadcrumb(this.cwd, sessionFile);
14408
+ this.#readOnlyResume = false;
14409
+ return;
14410
+ }
14401
14411
  const persistedRecords = records.map(record =>
14402
14412
  record.type === "entry_patch"
14403
14413
  ? (prepareEntryForPersistenceSync(
@@ -14411,27 +14421,40 @@ export class SessionManager {
14411
14421
  : record,
14412
14422
  );
14413
14423
  SessionManagerTestHooks.beforePersistPatchFence?.(attempt);
14424
+ let persisted = false;
14414
14425
  const written = this.#withSessionPersistenceFenceSync(() => {
14415
14426
  if (!this.#persistenceInputTokenMatches(token)) return false;
14416
14427
  if (this.destination.kind === "managed") {
14417
14428
  this.#appendManagedRecordsSync(persistedRecords);
14429
+ persisted = true;
14418
14430
  return true;
14419
14431
  }
14420
14432
  const writer = this.#ensurePersistWriter();
14421
14433
  if (!writer) {
14422
- this.#rewriteFile().catch(() => {});
14434
+ void this.#rewriteFile()
14435
+ .then(() => {
14436
+ if (publishResumeBreadcrumb) writeTerminalBreadcrumb(this.cwd, sessionFile);
14437
+ this.#readOnlyResume = false;
14438
+ })
14439
+ .catch(() => {});
14423
14440
  return true;
14424
14441
  }
14425
14442
  for (const persistedRecord of persistedRecords) writer.writeSync(persistedRecord);
14443
+ persisted = true;
14426
14444
  return true;
14427
14445
  });
14428
- if (written) return;
14446
+ if (written) {
14447
+ if (publishResumeBreadcrumb && persisted) writeTerminalBreadcrumb(this.cwd, sessionFile);
14448
+ if (persisted) this.#readOnlyResume = false;
14449
+ return;
14450
+ }
14429
14451
  }
14430
14452
  throw new Error("session_persistence_input_stale");
14431
14453
  });
14432
14454
  }
14433
14455
  _persist(entry: SessionEntry): void {
14434
14456
  if (!this.persist || !this.#sessionFile) return;
14457
+ const publishResumeBreadcrumb = this.#readOnlyResume;
14435
14458
  if (this.#persistError) throw this.#persistError;
14436
14459
 
14437
14460
  // Normally we wait for the first assistant message before persisting to avoid
@@ -14456,6 +14479,8 @@ export class SessionManager {
14456
14479
  // test-level tempDir cleanup.
14457
14480
  try {
14458
14481
  this.#rewriteFileSync();
14482
+ if (publishResumeBreadcrumb) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
14483
+ this.#readOnlyResume = false;
14459
14484
  } catch (err) {
14460
14485
  this.#recordPersistError(err);
14461
14486
  throw this.#persistError ?? toError(err);
@@ -14468,6 +14493,7 @@ export class SessionManager {
14468
14493
  // landing immediately after this call. Image externalization (rare) runs via
14469
14494
  // the synchronous blob-store path so blob bytes are durable before the JSONL
14470
14495
  // line referencing them is written.
14496
+ let persisted = false;
14471
14497
  try {
14472
14498
  this.#withSessionPersistenceFenceSync(() => {
14473
14499
  if (this.destination.kind === "managed") {
@@ -14478,6 +14504,7 @@ export class SessionManager {
14478
14504
  );
14479
14505
  const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
14480
14506
  this.#appendManagedRecordsSync([persistedEntry]);
14507
+ persisted = true;
14481
14508
  return;
14482
14509
  }
14483
14510
  const writer = this.#ensurePersistWriter();
@@ -14496,7 +14523,10 @@ export class SessionManager {
14496
14523
  );
14497
14524
  const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
14498
14525
  writer.writeSync(persistedEntry);
14526
+ persisted = true;
14499
14527
  });
14528
+ if (publishResumeBreadcrumb && persisted) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
14529
+ if (persisted) this.#readOnlyResume = false;
14500
14530
  } catch (err) {
14501
14531
  this.#recordPersistError(err);
14502
14532
  throw this.#persistError ?? toError(err);
@@ -17535,18 +17565,13 @@ export class SessionManager {
17535
17565
  const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage, destination);
17536
17566
  manager.#sessionMemoryMode = sessionMemoryMode;
17537
17567
  await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
17568
+ manager.#readOnlyResume = true;
17538
17569
  const ownershipInspection = revalidateResumeSessionIdentity(sessionPath, storage, inspected.identity);
17539
17570
  if (ownershipInspection.kind === "error") {
17540
17571
  await manager.close();
17541
17572
  return ownershipInspection;
17542
17573
  }
17543
- try {
17544
- await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
17545
- writeTerminalBreadcrumb(manager.cwd, sessionPath);
17546
- } catch (error) {
17547
- await manager.close();
17548
- throw error;
17549
- }
17574
+ manager.#sanitizeLoadedOpenAIResponsesReplayMetadata();
17550
17575
  return { kind: "opened", manager };
17551
17576
  }
17552
17577
 
@@ -485,6 +485,8 @@ describe("tool descriptor compatibility gate", () => {
485
485
  { ops: "not-an-array" },
486
486
  { ops: [{ op: "done", task: "x" }], stray: true },
487
487
  { _i: "Tracking progress", ops: [{ op: "done", task: "x" }] },
488
+ { ops: [{ op: "retitle", task: "x", newTask: "y" }] },
489
+ { ops: [{ op: "add", phase: "P", items: ["a"] }] },
488
490
  ];
489
491
  for (const payload of payloads) {
490
492
  expect(lazy.rawArgumentValidation(payload)).toEqual(eager.rawArgumentValidation(payload));
@@ -508,6 +510,55 @@ describe("tool descriptor compatibility gate", () => {
508
510
  ).toThrow("todo_write done and drop entries require a task or phase target");
509
511
  });
510
512
 
513
+ test("deferred and loaded todo_write reject a bad payload with the same message", async () => {
514
+ const session = makeSession({ "tools.discoveryMode": "all" });
515
+ session.hasUI = true;
516
+ const lazy = (await createTools(session)).find(tool => tool.name === "todo_write");
517
+ const eager = await BUILTIN_TOOL_DESCRIPTORS.todo_write.load(session);
518
+ if (!lazy || !eager) throw new Error("expected deferred and loaded todo_write tools");
519
+ const capture = (run: () => unknown): string => {
520
+ try {
521
+ run();
522
+ } catch (error) {
523
+ return error instanceof Error ? error.message : String(error);
524
+ }
525
+ throw new Error("expected a raw-argument rejection");
526
+ };
527
+ const call = (arguments_: Record<string, unknown>) => ({
528
+ id: "call-parity",
529
+ type: "toolCall" as const,
530
+ name: "todo_write",
531
+ arguments: arguments_,
532
+ });
533
+ // The observed incident: four usable entries discarded alongside one
534
+ // invented op. Whichever path runs first must say the same thing, or the
535
+ // caller that hit the cold registry sees none of the added guidance.
536
+ const invalidOp = call({
537
+ ops: [
538
+ { op: "append", phase: "Work", items: ["ship it"] },
539
+ { op: "done", task: "first" },
540
+ { op: "done", task: "second" },
541
+ { op: "done", task: "third" },
542
+ { op: "retitle", task: "third", newTask: "fourth" },
543
+ ],
544
+ });
545
+ const lazyOpMessage = capture(() => validateToolArguments(lazy, invalidOp));
546
+ expect(lazyOpMessage).toBe(capture(() => validateToolArguments(eager, invalidOp)));
547
+ expect(lazyOpMessage).toContain("todo_write operation entries require a known op value");
548
+ expect(lazyOpMessage).toContain("op must be one of: init, start, done, rm, drop, append, note");
549
+ expect(lazyOpMessage).toContain("ops[4]");
550
+
551
+ const unknownKey = call({
552
+ ops: [
553
+ { op: "done", task: "x" },
554
+ { op: "done", task: "y", bogusOpKey: 1 },
555
+ ],
556
+ });
557
+ const lazyKeyMessage = capture(() => validateToolArguments(lazy, unknownKey));
558
+ expect(lazyKeyMessage).toBe(capture(() => validateToolArguments(eager, unknownKey)));
559
+ expect(lazyKeyMessage).toContain('rejected key: "bogusOpKey" (ops[1])');
560
+ });
561
+
511
562
  test("deferred ask validator recovers the canonical round-zero pair", async () => {
512
563
  const session = makeSession({ "tools.discoveryMode": "all" });
513
564
  session.hasUI = true;
@@ -22,6 +22,23 @@ export function isDoneAlias(value: string): boolean {
22
22
  return (TODO_DONE_ALIASES as readonly string[]).includes(value);
23
23
  }
24
24
 
25
+ /**
26
+ * Legal `op` values. Exported so the todo_write schema and this contract read
27
+ * from one list: a rejection that names a vocabulary the schema no longer
28
+ * accepts is exactly the drift this file exists to prevent.
29
+ */
30
+ export const TODO_OPS = ["init", "start", "done", "rm", "drop", "append", "note"] as const;
31
+
32
+ function isKnownOp(value: string): boolean {
33
+ return (TODO_OPS as readonly string[]).includes(value);
34
+ }
35
+
36
+ /**
37
+ * The whole vocabulary, not a guess at which entry the caller meant. Static
38
+ * text, so clamping a rejection detail can never truncate it away.
39
+ */
40
+ const TODO_OP_VOCABULARY_HINT = `op must be one of: ${TODO_OPS.join(", ")}`;
41
+
25
42
  /**
26
43
  * `_i` is the intent field injected into tool schemas by the agent loop. The
27
44
  * loop strips it before validation, but replayed, bridged, and directly
@@ -46,15 +63,18 @@ const TODO_OP_KEYS_WITH_ALIASES = new Set([...TODO_OP_KEYS, "content"]);
46
63
  */
47
64
  const TODO_KEY_CORRECTIONS = new Map<string, string>([
48
65
  ["note", 'note is an op, not a key; note operations require both "task" and "text"'],
66
+ ["newTask", "there is no rename op; re-run init with the corrected list to rename a task"],
67
+ ["tasks", 'tasks is not a key; append operations take "items"'],
49
68
  ]);
50
69
 
51
70
  function unknownKeys(value: object, allowed: Set<string>): string[] {
52
71
  return Object.keys(value).filter(key => !allowed.has(key));
53
72
  }
54
73
 
55
- function keyRejectionDetail(keys: readonly string[]): RawArgumentRejectionDetail {
74
+ function keyRejectionDetail(keys: readonly string[], location?: string): RawArgumentRejectionDetail {
56
75
  const hints = keys.map(key => TODO_KEY_CORRECTIONS.get(key)).filter((hint): hint is string => hint !== undefined);
57
- return hints.length > 0 ? { rejectedKeys: keys, hint: hints.join("; ") } : { rejectedKeys: keys };
76
+ const parts = location === undefined ? hints : [location, ...hints];
77
+ return parts.length > 0 ? { rejectedKeys: keys, hint: parts.join("; ") } : { rejectedKeys: keys };
58
78
  }
59
79
 
60
80
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
@@ -64,15 +84,32 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
64
84
  /**
65
85
  * Single source of truth for todo_write pre-coercion validation. Every
66
86
  * rejection carries a code so the caller can surface an actionable correction
67
- * instead of a bare "raw arguments rejected" message.
87
+ * instead of a bare "raw arguments rejected" message, and every entry-level
88
+ * rejection names the failing `ops` index. Rejection stays batch-atomic, but a
89
+ * caller told which entry failed can resubmit the rest instead of rebuilding
90
+ * the payload from memory — or, as observed, silently abandoning the list.
68
91
  */
69
92
  export function validateRawTodoArguments(arguments_: Record<string, unknown>): RawArgumentValidationResult {
70
93
  const unknownRootKeys = unknownKeys(arguments_, TODO_WRITE_KEYS);
71
94
  if (unknownRootKeys.length > 0)
72
95
  return { outcome: "reject", code: "todo-write-unknown-root-key", detail: keyRejectionDetail(unknownRootKeys) };
73
96
  if (!Array.isArray(arguments_.ops)) return { outcome: "passthrough" };
74
- for (const entry of arguments_.ops) {
97
+ const ops: readonly unknown[] = arguments_.ops;
98
+ for (const [index, entry] of ops.entries()) {
75
99
  if (!isPlainRecord(entry)) continue;
100
+ const location = `ops[${index}]`;
101
+ // The op value is checked before the entry keys. An entry that invents
102
+ // both an op and a key is an unknown-op problem, and reporting the key
103
+ // first tells the caller which keys are legal while leaving it to guess
104
+ // that the operation it reached for does not exist at all.
105
+ const rawOp = entry.op;
106
+ const op = typeof rawOp === "string" && isDoneAlias(rawOp) ? "done" : rawOp;
107
+ if (typeof op === "string" && !isKnownOp(op))
108
+ return {
109
+ outcome: "reject",
110
+ code: "todo-write-unknown-op-value",
111
+ detail: { rejectedKeys: [op], hint: `${location}; ${TODO_OP_VOCABULARY_HINT}` },
112
+ };
76
113
  // `content` is normalized to `task` by the schema preprocessor, so it must
77
114
  // not be rejected here as an unknown key first.
78
115
  const unknownEntryKeys = unknownKeys(entry, TODO_OP_KEYS_WITH_ALIASES);
@@ -80,23 +117,23 @@ export function validateRawTodoArguments(arguments_: Record<string, unknown>): R
80
117
  return {
81
118
  outcome: "reject",
82
119
  code: "todo-write-unknown-op-entry-key",
83
- detail: keyRejectionDetail(unknownEntryKeys),
120
+ detail: keyRejectionDetail(unknownEntryKeys, location),
84
121
  };
85
- const op = typeof entry.op === "string" && isDoneAlias(entry.op) ? "done" : entry.op;
86
122
  const target = entry.task ?? entry.content;
87
123
  if ((op === "done" || op === "drop") && !target && !entry.phase) {
88
- return { outcome: "reject", code: "todo-write-done-drop-requires-target" };
124
+ return { outcome: "reject", code: "todo-write-done-drop-requires-target", detail: { hint: location } };
89
125
  }
90
126
  const list = entry.list;
91
127
  if (!Array.isArray(list)) continue;
92
- for (const item of list) {
128
+ const items: readonly unknown[] = list;
129
+ for (const [itemIndex, item] of items.entries()) {
93
130
  if (!isPlainRecord(item)) continue;
94
131
  const unknownItemKeys = unknownKeys(item, TODO_INIT_ENTRY_KEYS);
95
132
  if (unknownItemKeys.length > 0)
96
133
  return {
97
134
  outcome: "reject",
98
135
  code: "todo-write-unknown-init-entry-key",
99
- detail: keyRejectionDetail(unknownItemKeys),
136
+ detail: keyRejectionDetail(unknownItemKeys, `${location}.list[${itemIndex}]`),
100
137
  };
101
138
  }
102
139
  }
@@ -11,7 +11,7 @@ import type { ToolSession } from "../sdk";
11
11
  import type { SessionEntry } from "../session/session-manager";
12
12
  import { renderStatusLine, renderTreeList } from "../tui";
13
13
  import { PREVIEW_LIMITS } from "./render-utils";
14
- import { isDoneAlias, validateRawTodoArguments } from "./todo-contract";
14
+ import { isDoneAlias, TODO_OPS, validateRawTodoArguments } from "./todo-contract";
15
15
 
16
16
  // =============================================================================
17
17
  // Types
@@ -47,10 +47,7 @@ export interface TodoWriteToolDetails {
47
47
  // =============================================================================
48
48
 
49
49
  const TodoOp = z
50
- .preprocess(
51
- value => (typeof value === "string" && isDoneAlias(value) ? "done" : value),
52
- z.enum(["init", "start", "done", "rm", "drop", "append", "note"] as const),
53
- )
50
+ .preprocess(value => (typeof value === "string" && isDoneAlias(value) ? "done" : value), z.enum(TODO_OPS))
54
51
  .describe('operation to apply; use "done" to complete a task');
55
52
 
56
53
  const InitListEntry = z.object({