@danypops/papyrus 0.46.2 → 0.47.1

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.1",
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
  */
@@ -29,6 +29,8 @@ interface OperationSchemaNode {
29
29
  readonly properties?: Readonly<Record<string, OperationSchemaNode>>;
30
30
  readonly required?: readonly string[];
31
31
  readonly additionalProperties?: boolean | OperationSchemaNode;
32
+ /** A key not in `properties` is validated against the first pattern here whose RegExp matches it, instead of falling through to `additionalProperties` -- e.g. a free-form string-keyed map (tasks.create's checklist) uses `{"^.*$": entrySchema}` so a client-side JSON-Schema validator that reports `additionalProperties`-as-schema violations only as a generic top-level "must not have additional properties" (TypeBox's own real, confirmed behavior -- see vehicle-shell.ts's formatSchemaChildren for the matching tools_man rendering) instead descends into the real nested violation, matching an array's `items` precision. */
33
+ readonly patternProperties?: Readonly<Record<string, OperationSchemaNode>>;
32
34
  readonly items?: OperationSchemaNode;
33
35
  readonly minLength?: number;
34
36
  readonly maxLength?: number;
@@ -80,6 +82,12 @@ function validateSchemaValue(value: unknown, schema: OperationSchemaNode, path:
80
82
  }
81
83
  for (const key of Object.keys(record)) {
82
84
  if (key in (schema.properties ?? {})) continue;
85
+ const patternMatch = Object.entries(schema.patternProperties ?? {}).find(([pattern]) => new RegExp(pattern).test(key));
86
+ if (patternMatch) {
87
+ const issues = validateSchemaValue(record[key], patternMatch[1], [...path, key]);
88
+ if (issues.length > 0) return issues;
89
+ continue;
90
+ }
83
91
  if (schema.additionalProperties === false) return schemaIssue([...path, key], `${key} is not allowed`);
84
92
  if (typeof schema.additionalProperties === "object") {
85
93
  const issues = validateSchemaValue(record[key], schema.additionalProperties, [...path, key]);
@@ -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",
@@ -99,31 +114,47 @@ const gateProp = {
99
114
  ],
100
115
  } as const;
101
116
 
117
+ /**
118
+ * `patternProperties: {"^.*$": entrySchema}` rather than `additionalProperties: entrySchema`,
119
+ * despite both meaning "every key maps to entrySchema" for a free-form string-keyed map:
120
+ * confirmed live (2026-08-09) that TypeBox's own Value.Errors -- the schema validator Pi's tool-
121
+ * calling harness runs client-side, before a call ever reaches this daemon -- reports an
122
+ * additionalProperties-as-schema violation only as a generic top-level "must not have additional
123
+ * properties", with zero descent into which nested field actually broke, while the structurally
124
+ * identical items-as-schema case (gates, proof arrays below) descends and reports the exact
125
+ * broken field. patternProperties does not have that limitation and gives the same precision as
126
+ * items. See handlers/shared.ts's OperationSchemaNode.patternProperties for the matching
127
+ * server-side runtime check, and vehicle-shell.ts's formatSchemaChildren for the matching
128
+ * tools_man rendering.
129
+ */
102
130
  const checklistProp = {
103
131
  type: "object",
104
132
  description: "Map from completion criterion text to one or more typed proof references. An empty map clears the checklist.",
105
- additionalProperties: {
106
- type: "object",
107
- properties: {
108
- proof: {
109
- type: "array",
110
- minItems: 1,
111
- items: {
112
- type: "object",
113
- description: "Accepted proof shape: {type, target, expect?}.",
114
- properties: {
115
- type: { type: "string", enum: PROOF_TYPES },
116
- target: { type: "string", minLength: 1 },
117
- expect: { type: "string" },
133
+ patternProperties: {
134
+ "^.*$": {
135
+ type: "object",
136
+ properties: {
137
+ proof: {
138
+ type: "array",
139
+ minItems: 1,
140
+ items: {
141
+ type: "object",
142
+ description: "Accepted proof shape: {type, target, expect?}.",
143
+ properties: {
144
+ type: { type: "string", enum: PROOF_TYPES },
145
+ target: { type: "string", minLength: 1 },
146
+ expect: { type: "string" },
147
+ },
148
+ required: ["type", "target"],
149
+ additionalProperties: false,
118
150
  },
119
- required: ["type", "target"],
120
- additionalProperties: false,
121
151
  },
122
152
  },
153
+ required: ["proof"],
154
+ additionalProperties: false,
123
155
  },
124
- required: ["proof"],
125
- additionalProperties: false,
126
156
  },
157
+ additionalProperties: false,
127
158
  examples: [
128
159
  {
129
160
  "tests pass": { proof: [{ type: "test", target: "bun test", expect: "0 failures" }] },
@@ -251,13 +282,58 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
251
282
  * vehicle-registry's own secure-by-default handler-failed opacity still applies to a genuine
252
283
  * unexpected crash (see artifact-vehicle-shared.ts's classify* helpers).
253
284
  */
285
+ const throwLifecycleError = (error: unknown): never => {
286
+ if (error instanceof TaskInvalidTransitionError) {
287
+ throw new VehicleError("invalid-transition", error.message, {
288
+ category: "conflict",
289
+ details: {
290
+ operation: error.operation,
291
+ currentStatus: error.currentStatus,
292
+ intendedStatus: error.intendedStatus,
293
+ allowedActions: [...error.allowedActions],
294
+ recovery: error.recovery,
295
+ },
296
+ });
297
+ }
298
+ if (error instanceof TaskMutationIdempotencyConflictError) {
299
+ throw new VehicleError("idempotency-key-conflict", error.message, { category: "conflict" });
300
+ }
301
+ if (error instanceof TaskMutationPendingError) {
302
+ throw new VehicleError("mutation-pending", error.message, {
303
+ category: "conflict",
304
+ details: { receiptId: error.receiptId, operation: error.operation },
305
+ });
306
+ }
307
+ if (error instanceof TaskMutationReceiptNotFoundError) {
308
+ throw new VehicleError("mutation-receipt-not-found", error.message, { category: "not_found" });
309
+ }
310
+ throw error;
311
+ };
312
+ const classifyLifecycle = <T>(run: () => T): T => {
313
+ try {
314
+ const result = run();
315
+ return result instanceof Promise ? (result.catch(throwLifecycleError) as T) : result;
316
+ } catch (error) {
317
+ return throwLifecycleError(error);
318
+ }
319
+ };
254
320
  const call = (name: string, input: Record<string, unknown>): unknown =>
255
321
  classifySessionAuthorization(() =>
256
322
  classifyTaskCreateIdempotency(() =>
257
- classifyTaskExecutionBounds(() => classifyTaskDependencyCycles(() => moduleOperations.get(name)!.execute(input))),
323
+ classifyTaskExecutionBounds(() =>
324
+ classifyTaskDependencyCycles(() => classifyLifecycle(() => moduleOperations.get(name)!.execute(input))),
325
+ ),
258
326
  ),
259
327
  );
260
328
  const define = createOperationDefiner(registry, OWNER, "tasks", ["tasks:read", "tasks:write"], call);
329
+ const mutationInput = (
330
+ input: Record<string, unknown>,
331
+ context: VehicleOperationContext<Record<string, unknown>>,
332
+ ): Record<string, unknown> => ({
333
+ ...input,
334
+ idempotency_key: input.idempotency_key ?? context.idempotencyKey,
335
+ idempotency_caller: context.principal?.id ?? "anonymous",
336
+ });
261
337
 
262
338
  const resolveProject = (reference: string) => {
263
339
  try {
@@ -522,7 +598,8 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
522
598
  ): void => {
523
599
  define(action, description, "local-write", properties, required, resolve, (resolvedInput, context) => {
524
600
  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 });
601
+ const operationInput = action === "pause" || action === "unpause" ? mutationInput(resolvedInput, context) : resolvedInput;
602
+ return call(`tasks.${action}`, { ...operationInput, session_id: claims?.sessionId, session_secret: claims?.sessionSecret });
526
603
  });
527
604
  };
528
605
 
@@ -536,63 +613,67 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
536
613
  id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
537
614
  }),
538
615
  );
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 },
616
+ focusOperation(
617
+ "pause",
618
+ "Pauses Task Focus. Destination-state idempotent: replaying after success returns changed=false. Reuse idempotency_key after an unknown outcome.",
619
+ { reason: stringProp, idempotency_key: mutationIdempotencyProp },
572
620
  [],
573
- resolveIdAndScope,
621
+ (input) => input,
574
622
  );
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 },
623
+ focusOperation(
624
+ "unpause",
625
+ "Resumes paused Task Focus. Destination-state idempotent: replaying after success returns changed=false. Reuse idempotency_key after an unknown outcome.",
626
+ { idempotency_key: mutationIdempotencyProp },
580
627
  [],
581
- resolveIdAndScope,
628
+ (input) => input,
582
629
  );
630
+ focusOperation("clear_focus", "Clears the active Task Focus.", {}, [], (input) => input);
583
631
 
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,
632
+ const transitionOperation = (action: "start" | "submit" | "reject" | "retry" | "cancel" | "reopen", description: string): void =>
633
+ define(
634
+ action,
635
+ `${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.`,
636
+ "local-write",
637
+ {
638
+ id: stringProp,
639
+ name: stringProp,
640
+ reason: stringProp,
641
+ session_id: stringProp,
642
+ project_root: stringProp,
643
+ idempotency_key: mutationIdempotencyProp,
644
+ },
645
+ [],
646
+ resolveIdAndScope,
647
+ (input, context) => {
648
+ const result = call(`tasks.${action}`, mutationInput(input, context)) as {
649
+ title: string;
650
+ status: string;
651
+ changed: boolean;
652
+ receiptId?: string;
653
+ replayed?: boolean;
654
+ };
655
+ const text = result.changed
656
+ ? `${result.title} transitioned to ${result.status}.`
657
+ : result.replayed
658
+ ? `Recovered the prior ${action} receipt for ${result.title}; call tasks.show to confirm its current status before the next action.`
659
+ : `${result.title} was already ${result.status}; replay was a safe no-op.`;
660
+ return { ...result, content: [{ type: "text" as const, text }] };
661
+ },
662
+ );
663
+
664
+ transitionOperation("start", "Lifecycle transition: todo -> in-progress.");
665
+ transitionOperation("submit", "Lifecycle transition: in-progress -> review.");
666
+ transitionOperation("reject", "Lifecycle transition: review -> rejected.");
667
+ transitionOperation("retry", "Lifecycle transition: rejected -> in-progress.");
668
+ transitionOperation(
669
+ "cancel",
670
+ "Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected. Reversible via tasks.reopen if premature.",
591
671
  );
672
+ transitionOperation("reopen", "Lifecycle transition: canceled -> todo for work that should resume.");
592
673
 
593
674
  define(
594
675
  "complete",
595
- "Runs gates + checklist-proof review, then focuses one deterministic ready successor without claiming effort. Rejects (not completes) on gate/checklist failure.",
676
+ "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
677
  "local-write",
597
678
  {
598
679
  id: stringProp,
@@ -603,11 +684,12 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
603
684
  scope: { type: "string", enum: ["project", "graph", "all"] },
604
685
  root_task_id: stringProp,
605
686
  root_task_name: stringProp,
687
+ idempotency_key: mutationIdempotencyProp,
606
688
  },
607
689
  [],
608
690
  resolveIdAndScope,
609
- async (input) => {
610
- const result = (await call("tasks.complete", input)) as TaskCompletion;
691
+ async (input, context) => {
692
+ const result = (await call("tasks.complete", mutationInput(input, context))) as TaskCompletion;
611
693
  const dependencyIds = result.blocked.flatMap((entry) => entry.dependencyIds);
612
694
  const labels = labelsById(artifacts, dependencyIds);
613
695
  return { ...result, content: [{ type: "text" as const, text: completionContentText(labels, result) }] };
@@ -615,6 +697,16 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
615
697
  GATE_OPERATION_LIMITS,
616
698
  );
617
699
 
700
+ define(
701
+ "mutation_status",
702
+ "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.",
703
+ "read",
704
+ { idempotency_key: mutationIdempotencyProp },
705
+ ["idempotency_key"],
706
+ (input) => input,
707
+ (input, context) => call("tasks.mutation_status", mutationInput(input, context)),
708
+ );
709
+
618
710
  define(
619
711
  "run_gates",
620
712
  "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. */