@danypops/papyrus 0.46.2 → 0.47.0

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/README.md CHANGED
@@ -46,6 +46,8 @@ Task project names are explicit registrations, never ambient-directory guesses.
46
46
 
47
47
  `tasks.create` accepts an optional `idempotency_key`. Replays with the same caller, canonical project root, key, and payload return the original response without another mutation; conflicting payload reuse is rejected. Keys are retained for seven days, isolated across callers and projects, and then expire. Retry only when reusing the exact key and payload; an unkeyed create remains unsafe to replay after an ambiguous transport failure.
48
48
 
49
+ Task lifecycle mutations (`start`, `submit`, `reject`, `retry`, `cancel`, `reopen`, `complete`, `pause`, and `unpause`) are destination-state idempotent: repeating an already-achieved transition is a successful `changed: false` no-op and creates no duplicate history. Supply an `idempotency_key` for every mutation whose response could be lost. After an unknown outcome, call `tasks.show` and `tasks.mutation_status` with the original key, then replay only that exact operation/key if needed—never invent a new key from stale state. Completed receipts are retained for seven days; concurrent duplicate completion calls share one gate run. A genuinely incompatible transition returns typed `invalid-transition` details with current/intended status, allowed actions, and recovery guidance.
50
+
49
51
  Task lease responses are name-first: `tasks.claim`, `tasks.heartbeat_lease`, and `tasks.lease` return the reusable artifact alias as `taskName` plus `taskTitle`, not the backend UUID. Use `taskName` for later Task operations; retain the lease token for heartbeat or release.
50
52
 
51
53
  ### Context Mesh persistence model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.46.2",
3
+ "version": "0.47.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -102,10 +102,16 @@ const focusedCommand = buildCommand({
102
102
 
103
103
  function buildPauseUnpauseCommand(action: "pause" | "unpause") {
104
104
  return buildCommand({
105
- func: async function (this: TaskContext, flags: { sessionId?: string; sessionSecret?: string }) {
105
+ func: async function (this: TaskContext, flags: { sessionId?: string; sessionSecret?: string; idempotencyKey?: string }) {
106
106
  const focus = await this.client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(
107
107
  `tasks.${action}` as OperationName,
108
- { actor: "user", source: "cli", session_id: flags.sessionId, session_secret: flags.sessionSecret },
108
+ {
109
+ actor: "user",
110
+ source: "cli",
111
+ session_id: flags.sessionId,
112
+ session_secret: flags.sessionSecret,
113
+ ...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
114
+ },
109
115
  );
110
116
  render.call(this, focus, `Focused (${focus.status}): ${artifactLabel(focus.artifact)}`);
111
117
  },
@@ -119,6 +125,13 @@ function buildPauseUnpauseCommand(action: "pause" | "unpause") {
119
125
  placeholder: "secret",
120
126
  optional: true,
121
127
  },
128
+ idempotencyKey: {
129
+ brief: "Retry key for this exact mutation",
130
+ kind: "parsed",
131
+ parse: String,
132
+ placeholder: "key",
133
+ optional: true,
134
+ },
122
135
  },
123
136
  },
124
137
  docs: { brief: `${action === "pause" ? "Pause" : "Resume"} the active Task Focus` },
@@ -307,6 +320,21 @@ const eventFeedCommand = buildCommand({
307
320
  docs: { brief: "Feed of raw Task lifecycle events across every task" },
308
321
  });
309
322
 
323
+ const mutationStatusCommand = buildCommand({
324
+ func: async function (this: TaskContext, _flags: Record<string, never>, idempotencyKey: string) {
325
+ const receipt = await this.client.call<Record<string, unknown>, { receiptId: string; operation: string; state: string }>(
326
+ "tasks.mutation_status",
327
+ { idempotency_key: idempotencyKey },
328
+ );
329
+ render.call(this, receipt, `${receipt.operation}: ${receipt.state} (${receipt.receiptId})`);
330
+ },
331
+ parameters: {
332
+ flags: {},
333
+ positional: { kind: "tuple", parameters: [{ brief: "Original idempotency key", parse: String, placeholder: "key" }] },
334
+ },
335
+ docs: { brief: "Resolve an unknown Task lifecycle mutation outcome" },
336
+ });
337
+
310
338
  // -- CRUD -----------------------------------------------------------------------------------
311
339
 
312
340
  const createCommand = buildCommand({
@@ -788,12 +816,13 @@ const planCommand = buildCommand({
788
816
  });
789
817
 
790
818
  const completeCommand = buildCommand({
791
- func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
819
+ func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
792
820
  const completion = await this.client.call<Record<string, unknown>, CliCompletion>("tasks.complete", {
793
821
  id,
794
822
  actor: "user",
795
823
  source: "cli",
796
824
  session_id: flags.sessionId,
825
+ ...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
797
826
  });
798
827
  const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
799
828
  if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
@@ -806,24 +835,31 @@ const completeCommand = buildCommand({
806
835
  render.call(this, completion, lines.join("\n"));
807
836
  },
808
837
  parameters: {
809
- flags: { sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true } },
838
+ flags: {
839
+ sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
840
+ idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
841
+ },
810
842
  positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
811
843
  },
812
844
  docs: { brief: "Run gates and checklist review, then complete" },
813
845
  });
814
846
 
815
847
  const startCommand = buildCommand({
816
- func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
848
+ func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
817
849
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("tasks.start", {
818
850
  id,
819
851
  actor: "user",
820
852
  source: "cli",
821
853
  session_id: flags.sessionId,
854
+ ...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
822
855
  });
823
856
  render.call(this, artifact, `Started: ${artifactLabel(artifact)}`);
824
857
  },
825
858
  parameters: {
826
- flags: { sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true } },
859
+ flags: {
860
+ sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
861
+ idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
862
+ },
827
863
  positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
828
864
  },
829
865
  docs: { brief: "Lifecycle transition: todo -> in-progress" },
@@ -831,17 +867,21 @@ const startCommand = buildCommand({
831
867
 
832
868
  function buildSimpleTransitionCommand(action: "submit" | "reject" | "retry" | "cancel" | "reopen", brief: string) {
833
869
  return buildCommand({
834
- func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
870
+ func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
835
871
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>(`tasks.${action}` as OperationName, {
836
872
  id,
837
873
  actor: "user",
838
874
  source: "cli",
839
875
  session_id: flags.sessionId,
876
+ ...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
840
877
  });
841
878
  render.call(this, artifact, `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`);
842
879
  },
843
880
  parameters: {
844
- flags: { sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true } },
881
+ flags: {
882
+ sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
883
+ idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
884
+ },
845
885
  positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
846
886
  },
847
887
  docs: { brief },
@@ -920,6 +960,7 @@ const app = buildApplication(
920
960
  lease: leaseCommand,
921
961
  "reap-stale-leases": reapStaleLeasesCommand,
922
962
  "event-feed": eventFeedCommand,
963
+ "mutation-status": mutationStatusCommand,
923
964
  create: createCommand,
924
965
  list: listCommand,
925
966
  show: showCommand,
package/src/cli.ts CHANGED
@@ -162,8 +162,8 @@ const USAGE = `Usage:
162
162
  papyrus tasks graph [--session-id <id>] [--json]
163
163
  papyrus tasks active [--session-id <id>] [--json]
164
164
  papyrus tasks focused [--session-id <id>] [--json]
165
- papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--json]
166
- papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
165
+ papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--idempotency-key <key>] [--json]
166
+ papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--idempotency-key <key>] [--json]
167
167
  papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
168
168
  papyrus tasks reap-stale-focus [--json]
169
169
  papyrus tasks claim <id> --owner <owner> [--ttl-ms <ms>] [--note <text>] [--json]
@@ -172,17 +172,19 @@ const USAGE = `Usage:
172
172
  papyrus tasks lease <id> [--json]
173
173
  papyrus tasks reap-stale-leases [--json]
174
174
  papyrus tasks event-feed [--cursor <n>] [--limit <n>] [--event-types-json <json>] [--json]
175
+ papyrus tasks mutation-status <idempotency-key> [--json]
175
176
  papyrus tasks history <id> [--json]
176
177
  papyrus tasks scope [project|all|graph <root-id>] [--json]
177
178
  papyrus tasks assign-project <id> [project-root] [--json]
178
179
  papyrus tasks focus <id> [--session-id <id>] [--session-secret <secret>] [--json]
179
180
  papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--status todo --reason <reason>] [--json]
180
- papyrus tasks complete <id> [--session-id <id>] [--json]
181
- papyrus tasks start <id> [--session-id <id>] [--json]
182
- papyrus tasks submit <id> [--session-id <id>] [--json]
183
- papyrus tasks reject <id> [--session-id <id>] [--json]
184
- papyrus tasks retry <id> [--session-id <id>] [--json]
185
- papyrus tasks cancel <id> [--session-id <id>] [--json]
181
+ papyrus tasks complete <id> [--session-id <id>] [--idempotency-key <key>] [--json]
182
+ papyrus tasks start <id> [--session-id <id>] [--idempotency-key <key>] [--json]
183
+ papyrus tasks submit <id> [--session-id <id>] [--idempotency-key <key>] [--json]
184
+ papyrus tasks reject <id> [--session-id <id>] [--idempotency-key <key>] [--json]
185
+ papyrus tasks retry <id> [--session-id <id>] [--idempotency-key <key>] [--json]
186
+ papyrus tasks cancel <id> [--session-id <id>] [--idempotency-key <key>] [--json]
187
+ papyrus tasks reopen <id> [--session-id <id>] [--idempotency-key <key>] [--json]
186
188
  papyrus tasks cancel-subtree <id> [--session-id <id>] [--json]
187
189
  papyrus tasks depend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
188
190
  papyrus tasks undepend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
package/src/constants.ts CHANGED
@@ -9,7 +9,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
9
9
  export const DAEMON_UNIT_NAME = "papyrus.service";
10
10
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
11
11
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
12
- export const SQLITE_SCHEMA_VERSION = 26;
12
+ export const SQLITE_SCHEMA_VERSION = 27;
13
13
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
14
14
 
15
15
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -267,6 +267,8 @@ export const TASK_PROJECT_ALIAS_MAX_COUNT = 20;
267
267
  export const TASK_PROJECT_LIST_MAX_RESULTS = 100;
268
268
  export const TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
269
269
  export const TASK_CREATE_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
270
+ export const TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
271
+ export const TASK_MUTATION_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
270
272
  export const GRAPH_RENDER_PADDING_X = 2;
271
273
  export const GRAPH_RENDER_PADDING_Y = 1;
272
274
  export const GRAPH_RENDER_BOX_PADDING = 0;
package/src/db.ts CHANGED
@@ -176,6 +176,24 @@ CREATE TABLE IF NOT EXISTS task_create_requests (
176
176
  PRIMARY KEY (request_scope, idempotency_key)
177
177
  );
178
178
  CREATE INDEX IF NOT EXISTS task_create_requests_expiry_idx ON task_create_requests(expires_at);
179
+ CREATE TABLE IF NOT EXISTS task_mutation_requests (
180
+ request_scope TEXT NOT NULL,
181
+ idempotency_key TEXT NOT NULL,
182
+ receipt_id TEXT NOT NULL UNIQUE,
183
+ task_id TEXT REFERENCES artifacts(id) ON DELETE CASCADE,
184
+ operation TEXT NOT NULL,
185
+ request_hash TEXT NOT NULL,
186
+ state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
187
+ response_json TEXT,
188
+ created_at TEXT NOT NULL,
189
+ updated_at TEXT NOT NULL,
190
+ expires_at TEXT NOT NULL,
191
+ PRIMARY KEY (request_scope, idempotency_key),
192
+ CHECK ((state = 'pending' AND response_json IS NULL) OR (state = 'completed' AND response_json IS NOT NULL))
193
+ );
194
+ CREATE INDEX IF NOT EXISTS task_mutation_requests_expiry_idx ON task_mutation_requests(expires_at);
195
+ CREATE UNIQUE INDEX IF NOT EXISTS task_mutation_requests_pending_task_operation_idx
196
+ ON task_mutation_requests(task_id, operation) WHERE state = 'pending' AND task_id IS NOT NULL;
179
197
  CREATE TABLE IF NOT EXISTS artifact_events (
180
198
  id INTEGER PRIMARY KEY AUTOINCREMENT,
181
199
  artifact_id TEXT NOT NULL REFERENCES artifacts(id),
@@ -799,6 +817,32 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
799
817
  }
800
818
  },
801
819
  },
820
+ {
821
+ version: 27,
822
+ name: "task-lifecycle-mutation-receipts",
823
+ up: (db) => {
824
+ db.exec(`
825
+ CREATE TABLE IF NOT EXISTS task_mutation_requests (
826
+ request_scope TEXT NOT NULL,
827
+ idempotency_key TEXT NOT NULL,
828
+ receipt_id TEXT NOT NULL UNIQUE,
829
+ task_id TEXT REFERENCES artifacts(id) ON DELETE CASCADE,
830
+ operation TEXT NOT NULL,
831
+ request_hash TEXT NOT NULL,
832
+ state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
833
+ response_json TEXT,
834
+ created_at TEXT NOT NULL,
835
+ updated_at TEXT NOT NULL,
836
+ expires_at TEXT NOT NULL,
837
+ PRIMARY KEY (request_scope, idempotency_key),
838
+ CHECK ((state = 'pending' AND response_json IS NULL) OR (state = 'completed' AND response_json IS NOT NULL))
839
+ );
840
+ CREATE INDEX IF NOT EXISTS task_mutation_requests_expiry_idx ON task_mutation_requests(expires_at);
841
+ CREATE UNIQUE INDEX IF NOT EXISTS task_mutation_requests_pending_task_operation_idx
842
+ ON task_mutation_requests(task_id, operation) WHERE state = 'pending' AND task_id IS NOT NULL;
843
+ `);
844
+ },
845
+ },
802
846
  ];
803
847
 
804
848
  /**
@@ -107,9 +107,8 @@ export type TransitionTable<Action extends string, Status extends string> = Reco
107
107
  /**
108
108
  * The from/to-table lookup+validation half of a transition, split out from runTransition
109
109
  * (below) so a caller with its own side effects gated on "is this action even valid from the
110
- * current status" (e.g. Tasks.transition's dependency-blocking check and focus-store
111
- * bookkeeping, which must run -- or not -- before the status write itself) can call this
112
- * directly instead of duplicating the same lookup+throw three times. Every other caller
110
+ * current status" can call this directly instead of duplicating the same lookup+throw.
111
+ * Task lifecycle now uses its richer retry-safe transition primitive instead. Every other caller
113
112
  * (Document/Rule/Playbook, none of which have that ordering constraint) uses runTransition
114
113
  * instead, which does this same check plus the write in one call.
115
114
  */
@@ -19,17 +19,25 @@
19
19
  *
20
20
  * remove/remove_subtree/restore are not duplicated here -- see ./artifact-trash-vehicle.ts.
21
21
  */
22
- import { VehicleError, type VehicleLimits } from "@danypops/vehicle-core";
22
+ import { VehicleError, type VehicleLimits, type VehicleOperationContext } from "@danypops/vehicle-core";
23
23
  import type { VehicleRegistry } from "@danypops/vehicle-server";
24
24
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
25
- import { GATE_TIMEOUT_MAX_MS, TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH } from "../constants.ts";
25
+ import { GATE_TIMEOUT_MAX_MS, TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH, TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH } from "../constants.ts";
26
26
  import { PROOF_TYPES } from "../domain/checklist.ts";
27
27
  import { GATE_TYPES } from "../domain/gate.ts";
28
28
  import type { TaskViewMode } from "../domain/task-scope.ts";
29
29
  import { tasksOperations } from "../modules/tasks.ts";
30
30
  import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
31
+ import { TaskMutationIdempotencyConflictError, TaskMutationPendingError } from "../stores/task-mutation-request-store.ts";
31
32
  import type { TaskExecutionPlan } from "../task/task-execution.ts";
32
- import { type TaskCompletion, TaskProjectAmbiguousError, TaskProjectNotFoundError, type Tasks } from "../task/task-service.ts";
33
+ import {
34
+ type TaskCompletion,
35
+ TaskInvalidTransitionError,
36
+ TaskMutationReceiptNotFoundError,
37
+ TaskProjectAmbiguousError,
38
+ TaskProjectNotFoundError,
39
+ type Tasks,
40
+ } from "../task/task-service.ts";
33
41
  import {
34
42
  booleanProp,
35
43
  classifySessionAuthorization,
@@ -75,6 +83,13 @@ const GATE_OPERATION_LIMITS: VehicleLimits = {
75
83
  const objectProp = { type: "object" } as const;
76
84
  const arrayProp = { type: "array" } as const;
77
85
  const _boolProp = { type: "boolean" } as const;
86
+ const mutationIdempotencyProp = {
87
+ type: "string",
88
+ minLength: 1,
89
+ maxLength: TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH,
90
+ description:
91
+ "Retry key for this exact mutation. Reuse the same key after an unknown outcome; inspect mutation_status before choosing a new action.",
92
+ } as const;
78
93
 
79
94
  const gateProp = {
80
95
  type: "array",
@@ -251,13 +266,58 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
251
266
  * vehicle-registry's own secure-by-default handler-failed opacity still applies to a genuine
252
267
  * unexpected crash (see artifact-vehicle-shared.ts's classify* helpers).
253
268
  */
269
+ const throwLifecycleError = (error: unknown): never => {
270
+ if (error instanceof TaskInvalidTransitionError) {
271
+ throw new VehicleError("invalid-transition", error.message, {
272
+ category: "conflict",
273
+ details: {
274
+ operation: error.operation,
275
+ currentStatus: error.currentStatus,
276
+ intendedStatus: error.intendedStatus,
277
+ allowedActions: [...error.allowedActions],
278
+ recovery: error.recovery,
279
+ },
280
+ });
281
+ }
282
+ if (error instanceof TaskMutationIdempotencyConflictError) {
283
+ throw new VehicleError("idempotency-key-conflict", error.message, { category: "conflict" });
284
+ }
285
+ if (error instanceof TaskMutationPendingError) {
286
+ throw new VehicleError("mutation-pending", error.message, {
287
+ category: "conflict",
288
+ details: { receiptId: error.receiptId, operation: error.operation },
289
+ });
290
+ }
291
+ if (error instanceof TaskMutationReceiptNotFoundError) {
292
+ throw new VehicleError("mutation-receipt-not-found", error.message, { category: "not_found" });
293
+ }
294
+ throw error;
295
+ };
296
+ const classifyLifecycle = <T>(run: () => T): T => {
297
+ try {
298
+ const result = run();
299
+ return result instanceof Promise ? (result.catch(throwLifecycleError) as T) : result;
300
+ } catch (error) {
301
+ return throwLifecycleError(error);
302
+ }
303
+ };
254
304
  const call = (name: string, input: Record<string, unknown>): unknown =>
255
305
  classifySessionAuthorization(() =>
256
306
  classifyTaskCreateIdempotency(() =>
257
- classifyTaskExecutionBounds(() => classifyTaskDependencyCycles(() => moduleOperations.get(name)!.execute(input))),
307
+ classifyTaskExecutionBounds(() =>
308
+ classifyTaskDependencyCycles(() => classifyLifecycle(() => moduleOperations.get(name)!.execute(input))),
309
+ ),
258
310
  ),
259
311
  );
260
312
  const define = createOperationDefiner(registry, OWNER, "tasks", ["tasks:read", "tasks:write"], call);
313
+ const mutationInput = (
314
+ input: Record<string, unknown>,
315
+ context: VehicleOperationContext<Record<string, unknown>>,
316
+ ): Record<string, unknown> => ({
317
+ ...input,
318
+ idempotency_key: input.idempotency_key ?? context.idempotencyKey,
319
+ idempotency_caller: context.principal?.id ?? "anonymous",
320
+ });
261
321
 
262
322
  const resolveProject = (reference: string) => {
263
323
  try {
@@ -522,7 +582,8 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
522
582
  ): void => {
523
583
  define(action, description, "local-write", properties, required, resolve, (resolvedInput, context) => {
524
584
  const claims = context.principal?.claims as { sessionId?: string; sessionSecret?: string } | undefined;
525
- return call(`tasks.${action}`, { ...resolvedInput, session_id: claims?.sessionId, session_secret: claims?.sessionSecret });
585
+ const operationInput = action === "pause" || action === "unpause" ? mutationInput(resolvedInput, context) : resolvedInput;
586
+ return call(`tasks.${action}`, { ...operationInput, session_id: claims?.sessionId, session_secret: claims?.sessionSecret });
526
587
  });
527
588
  };
528
589
 
@@ -536,63 +597,67 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
536
597
  id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
537
598
  }),
538
599
  );
539
- focusOperation("pause", "Pauses the active Task Focus without clearing it.", { reason: stringProp }, [], (input) => input);
540
- focusOperation("unpause", "Resumes a paused Task Focus.", {}, [], (input) => input);
541
- focusOperation("clear_focus", "Clears the active Task Focus.", {}, [], (input) => input);
542
-
543
- define(
544
- "start",
545
- "Lifecycle transition: todo -> in-progress.",
546
- "local-write",
547
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
548
- [],
549
- resolveIdAndScope,
550
- );
551
- define(
552
- "submit",
553
- "Lifecycle transition: in-progress -> review.",
554
- "local-write",
555
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
556
- [],
557
- resolveIdAndScope,
558
- );
559
- define(
560
- "reject",
561
- "Lifecycle transition: review -> rejected.",
562
- "local-write",
563
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
564
- [],
565
- resolveIdAndScope,
566
- );
567
- define(
568
- "retry",
569
- "Lifecycle transition: rejected -> in-progress.",
570
- "local-write",
571
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
600
+ focusOperation(
601
+ "pause",
602
+ "Pauses Task Focus. Destination-state idempotent: replaying after success returns changed=false. Reuse idempotency_key after an unknown outcome.",
603
+ { reason: stringProp, idempotency_key: mutationIdempotencyProp },
572
604
  [],
573
- resolveIdAndScope,
605
+ (input) => input,
574
606
  );
575
- define(
576
- "cancel",
577
- "Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected. Reversible via tasks.reopen if this turns out to be premature.",
578
- "local-write",
579
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
607
+ focusOperation(
608
+ "unpause",
609
+ "Resumes paused Task Focus. Destination-state idempotent: replaying after success returns changed=false. Reuse idempotency_key after an unknown outcome.",
610
+ { idempotency_key: mutationIdempotencyProp },
580
611
  [],
581
- resolveIdAndScope,
612
+ (input) => input,
582
613
  );
614
+ focusOperation("clear_focus", "Clears the active Task Focus.", {}, [], (input) => input);
583
615
 
584
- define(
585
- "reopen",
586
- "Lifecycle transition: canceled -> todo. For a task legitimately canceled through a normal transition (e.g. a deliberate pause/park) that should resume -- distinct from tasks.update's status:todo path, which only recovers a task that was terminal at its own creation (a caller mistake), never one canceled/rejected later.",
587
- "local-write",
588
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
589
- [],
590
- resolveIdAndScope,
616
+ const transitionOperation = (action: "start" | "submit" | "reject" | "retry" | "cancel" | "reopen", description: string): void =>
617
+ define(
618
+ action,
619
+ `${description} Destination-state idempotent: a replay after success returns changed=false. After an unknown outcome, call tasks.show and mutation_status, then reuse the SAME idempotency_key; never retry with a new key from stale state.`,
620
+ "local-write",
621
+ {
622
+ id: stringProp,
623
+ name: stringProp,
624
+ reason: stringProp,
625
+ session_id: stringProp,
626
+ project_root: stringProp,
627
+ idempotency_key: mutationIdempotencyProp,
628
+ },
629
+ [],
630
+ resolveIdAndScope,
631
+ (input, context) => {
632
+ const result = call(`tasks.${action}`, mutationInput(input, context)) as {
633
+ title: string;
634
+ status: string;
635
+ changed: boolean;
636
+ receiptId?: string;
637
+ replayed?: boolean;
638
+ };
639
+ const text = result.changed
640
+ ? `${result.title} transitioned to ${result.status}.`
641
+ : result.replayed
642
+ ? `Recovered the prior ${action} receipt for ${result.title}; call tasks.show to confirm its current status before the next action.`
643
+ : `${result.title} was already ${result.status}; replay was a safe no-op.`;
644
+ return { ...result, content: [{ type: "text" as const, text }] };
645
+ },
646
+ );
647
+
648
+ transitionOperation("start", "Lifecycle transition: todo -> in-progress.");
649
+ transitionOperation("submit", "Lifecycle transition: in-progress -> review.");
650
+ transitionOperation("reject", "Lifecycle transition: review -> rejected.");
651
+ transitionOperation("retry", "Lifecycle transition: rejected -> in-progress.");
652
+ transitionOperation(
653
+ "cancel",
654
+ "Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected. Reversible via tasks.reopen if premature.",
591
655
  );
656
+ transitionOperation("reopen", "Lifecycle transition: canceled -> todo for work that should resume.");
592
657
 
593
658
  define(
594
659
  "complete",
595
- "Runs gates + checklist-proof review, then focuses one deterministic ready successor without claiming effort. Rejects (not completes) on gate/checklist failure.",
660
+ "Runs gates + checklist-proof review, then focuses one deterministic ready successor without claiming effort. Rejects on gate/checklist failure. Reuse the same idempotency_key after an unknown outcome so gates and history are not run twice; inspect mutation_status before choosing a new action. A replay after done is a changed=false no-op.",
596
661
  "local-write",
597
662
  {
598
663
  id: stringProp,
@@ -603,11 +668,12 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
603
668
  scope: { type: "string", enum: ["project", "graph", "all"] },
604
669
  root_task_id: stringProp,
605
670
  root_task_name: stringProp,
671
+ idempotency_key: mutationIdempotencyProp,
606
672
  },
607
673
  [],
608
674
  resolveIdAndScope,
609
- async (input) => {
610
- const result = (await call("tasks.complete", input)) as TaskCompletion;
675
+ async (input, context) => {
676
+ const result = (await call("tasks.complete", mutationInput(input, context))) as TaskCompletion;
611
677
  const dependencyIds = result.blocked.flatMap((entry) => entry.dependencyIds);
612
678
  const labels = labelsById(artifacts, dependencyIds);
613
679
  return { ...result, content: [{ type: "text" as const, text: completionContentText(labels, result) }] };
@@ -615,6 +681,16 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
615
681
  GATE_OPERATION_LIMITS,
616
682
  );
617
683
 
684
+ define(
685
+ "mutation_status",
686
+ "Resolves an unknown lifecycle mutation outcome by the original idempotency_key. Read this receipt before selecting another transition; never invent a replacement key for the same attempt.",
687
+ "read",
688
+ { idempotency_key: mutationIdempotencyProp },
689
+ ["idempotency_key"],
690
+ (input) => input,
691
+ (input, context) => call("tasks.mutation_status", mutationInput(input, context)),
692
+ );
693
+
618
694
  define(
619
695
  "run_gates",
620
696
  "Runs a Task's configured gates without transitioning its status -- for checking readiness before submit/complete.",
@@ -54,6 +54,7 @@ const FK_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
54
54
  { table: "task_events", column: "task_id" },
55
55
  { table: "task_scopes", column: "task_id" },
56
56
  { table: "task_views", column: "root_task_id" },
57
+ { table: "task_mutation_requests", column: "task_id" },
57
58
  { table: "artifact_events", column: "artifact_id" },
58
59
  { table: "artifact_events", column: "related_id" },
59
60
  ];
@@ -68,6 +69,7 @@ const TEXT_SCAN_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
68
69
  { table: "artifacts", column: "extra" },
69
70
  { table: "task_events", column: "reason" },
70
71
  { table: "task_events", column: "evidence_json" },
72
+ { table: "task_mutation_requests", column: "response_json" },
71
73
  ];
72
74
 
73
75
  /** Audit tables whose append-only guard must be suspended for exactly this migration's duration. */
package/src/index.ts CHANGED
@@ -35,4 +35,12 @@ export { taskContext } from "./task/task-context.ts";
35
35
  export { projectTaskExecution, type TaskExecutionPlan, type TaskExecutionState } from "./task/task-execution.ts";
36
36
  export { projectTaskGraph, type TaskGraphView } from "./task/task-graph-view.ts";
37
37
  export { fallbackLabel, projectTaskRelationships } from "./task/task-relationship-view.ts";
38
- export type { TaskCompletion, TaskGraph, TaskNode, TaskStatus } from "./task/task-service.ts";
38
+ export type {
39
+ TaskCompletion,
40
+ TaskGraph,
41
+ TaskLifecycleMutationResult,
42
+ TaskMutationReceiptView,
43
+ TaskNode,
44
+ TaskStatus,
45
+ } from "./task/task-service.ts";
46
+ export { TaskInvalidTransitionError, TaskMutationReceiptNotFoundError } from "./task/task-service.ts";