@brainervirus/workit-core 1.0.8 → 1.0.10

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.
@@ -7,12 +7,15 @@ import {
7
7
  type Result,
8
8
  type Revision,
9
9
  type Decision,
10
+ type Entry,
11
+ type TaskRecord,
10
12
  refSchema,
11
13
  sha256,
12
14
  } from "./task-contract";
13
15
  import { WorkitCore } from "./task-engine";
14
16
  import { TaskStore } from "./task-store";
15
17
  import * as z from "zod";
18
+ import { execFileSync } from "node:child_process";
16
19
 
17
20
  export type AuthorizedActionInput = {
18
21
  core: WorkitCore;
@@ -53,7 +56,13 @@ const externalActionSchema = z.discriminatedUnion("operation", [
53
56
  z
54
57
  .object({
55
58
  operation: z.literal("git.commit"),
56
- payload: z.object({ message: z.string().min(1) }).strict(),
59
+ payload: z
60
+ .object({
61
+ message: z.string().min(1).optional(),
62
+ plan_steps: z.array(z.string().min(1)).optional(),
63
+ plan_branch: z.string().min(1).optional(),
64
+ })
65
+ .strict(),
57
66
  })
58
67
  .strict(),
59
68
  z
@@ -206,7 +215,7 @@ export const externalActionDescriptor = (operation: string, payload: unknown): s
206
215
 
207
216
  /** Compact host-facing help for the fixed optional-action surface. */
208
217
  export const externalActionHelp =
209
- "Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch?,stash?}; git.commit {message}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
218
+ "Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch(required unless reapply_stash; the working branch to create or switch to),stash?}; git.commit {message}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
210
219
 
211
220
  export const externalActionRef = (
212
221
  host: "opencode" | "pi" | "workit_cli",
@@ -311,6 +320,151 @@ export const priorExternalAction = (
311
320
  return success(workspace.data.revision, null, { ...candidates[0], workspace: workspace.data });
312
321
  };
313
322
 
323
+ /** Parse a plan-commit authorization descriptor (one approved list per plan). */
324
+ export const planCommitDescriptor = (
325
+ content: string,
326
+ ): { steps: string[]; branch: string } | null => {
327
+ try {
328
+ const value = JSON.parse(content) as {
329
+ operation?: unknown;
330
+ payload?: { plan_steps?: unknown; plan_branch?: unknown };
331
+ };
332
+ if (value.operation !== "git.commit" || !value.payload) return null;
333
+ const steps = value.payload.plan_steps;
334
+ const branch = value.payload.plan_branch;
335
+ if (
336
+ !Array.isArray(steps) ||
337
+ steps.some((step) => typeof step !== "string" || !step) ||
338
+ typeof branch !== "string" ||
339
+ !branch
340
+ )
341
+ return null;
342
+ return { steps: steps as string[], branch };
343
+ } catch {
344
+ return null;
345
+ }
346
+ };
347
+
348
+ export type PlanCommitAuthorization = {
349
+ task: TaskRecord;
350
+ entry: Entry<Decision>;
351
+ steps: string[];
352
+ branch: string;
353
+ completed: string[];
354
+ };
355
+
356
+ /**
357
+ * Find the single active plan-commit authorization whose next unconsumed step
358
+ * is this exact commit message. Consumption order is enforced so each listed
359
+ * commit executes once, in plan order; unlisted or replayed messages find no
360
+ * authorization and require a fresh exact approval.
361
+ */
362
+ export const approvedPlanCommit = (
363
+ store: TaskStore,
364
+ host: string,
365
+ actor: string,
366
+ message: string,
367
+ ): Result<PlanCommitAuthorization> => {
368
+ if (typeof message !== "string" || !message)
369
+ return failure("invalid_input", "commit message is required");
370
+ const listed = store.listTasks();
371
+ const workspace = store.readWorkspace();
372
+ if (!listed.ok || !workspace.ok)
373
+ return failure("storage_error", "external action state is unavailable");
374
+ if (!workspace.data) return failure("not_found", "workspace not found");
375
+ const matches = listed.data.flatMap((task) => {
376
+ if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
377
+ if (
378
+ task.intent.provenance.session?.kind !== "host" ||
379
+ task.intent.provenance.session.host !== host ||
380
+ task.intent.provenance.session.handle !== actor
381
+ )
382
+ return [];
383
+ return task.decisions.flatMap((entry) => {
384
+ const decision = entry.data;
385
+ if (
386
+ decision.purpose !== "action" ||
387
+ decision.response !== "approved" ||
388
+ decision.revoked !== null
389
+ )
390
+ return [];
391
+ if (entry.provenance.kind !== "host_observed" || entry.provenance.receipts.length === 0)
392
+ return [];
393
+ const plan = planCommitDescriptor(decision.binding.approvedContent);
394
+ if (!plan) return [];
395
+ const completed =
396
+ task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
397
+ [];
398
+ if (plan.steps[completed.length] !== message) return [];
399
+ return [{ task, entry, steps: plan.steps, branch: plan.branch, completed: [...completed] }];
400
+ });
401
+ });
402
+ if (matches.length !== 1)
403
+ return failure(
404
+ "permission_denied",
405
+ matches.length === 0
406
+ ? "no plan commit authorization matches this message"
407
+ : "multiple plan authorizations match this message",
408
+ );
409
+ return success(workspace.data.revision, null, matches[0]);
410
+ };
411
+
412
+ export const commitMessageFromDescriptor = (operation: string): string | undefined => {
413
+ try {
414
+ const descriptor = JSON.parse(operation) as {
415
+ operation?: unknown;
416
+ payload?: { message?: unknown };
417
+ };
418
+ return descriptor.operation === "git.commit" && typeof descriptor.payload?.message === "string"
419
+ ? descriptor.payload.message
420
+ : undefined;
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ };
425
+
426
+ /** Resolve a plan-commit authorization into the exact binding an adapter needs
427
+ * to run one listed commit under the approved list. */
428
+ export const planCommitBinding = (
429
+ store: TaskStore,
430
+ host: string,
431
+ actor: string,
432
+ operation: string,
433
+ ): {
434
+ taskId: Id;
435
+ decisionId: Id;
436
+ binding: Decision["binding"];
437
+ expectedRevision: Revision;
438
+ expectedWorkspaceRevision: Revision;
439
+ step: string;
440
+ } | null => {
441
+ const message = commitMessageFromDescriptor(operation);
442
+ if (!message) return null;
443
+ const plan = approvedPlanCommit(store, host, actor, message);
444
+ if (!plan.ok) return null;
445
+ let branch: string;
446
+ try {
447
+ branch = execFileSync("git", ["branch", "--show-current"], {
448
+ cwd: store.root,
449
+ encoding: "utf8",
450
+ stdio: ["pipe", "pipe", "pipe"],
451
+ }).trim();
452
+ } catch {
453
+ return null;
454
+ }
455
+ if (!branch || branch !== plan.data.branch) return null;
456
+ const workspace = store.readWorkspace();
457
+ if (!workspace.ok || !workspace.data) return null;
458
+ return {
459
+ taskId: plan.data.task.id,
460
+ decisionId: plan.data.entry.id,
461
+ binding: plan.data.entry.data.binding,
462
+ expectedRevision: plan.data.task.revision,
463
+ expectedWorkspaceRevision: workspace.data.revision,
464
+ step: message,
465
+ };
466
+ };
467
+
314
468
  export const approvedExternalAction = (
315
469
  store: TaskStore,
316
470
  host: string,
@@ -331,6 +485,40 @@ export const approvedExternalAction = (
331
485
  return selected;
332
486
  };
333
487
 
488
+ /**
489
+ * Re-resolve guard: when a prior approval exists for the same requested
490
+ * operation/payload, the freshly resolved baseline (source state and remote
491
+ * base) must still match the approved descriptor. Drift returns not_started so
492
+ * the agent re-presents the current state instead of executing a stale effect.
493
+ */
494
+ export const priorResolvedDrift = (
495
+ store: TaskStore,
496
+ host: string,
497
+ actor: string,
498
+ operation: string,
499
+ payload: unknown,
500
+ currentResolved: unknown,
501
+ ): Result<null> => {
502
+ const prior = priorExternalAction(store, host, actor, operation, payload);
503
+ if (!prior.ok) return success(null, null, null);
504
+ let baseline: unknown;
505
+ try {
506
+ const parsed = JSON.parse(prior.data.entry.data.binding.approvedContent) as {
507
+ payload?: { resolved?: unknown };
508
+ };
509
+ baseline = parsed.payload?.resolved;
510
+ } catch {
511
+ return success(null, null, null);
512
+ }
513
+ if (baseline === undefined || baseline === null) return success(null, null, null);
514
+ if (canonicalJson(baseline) === canonicalJson(currentResolved)) return success(null, null, null);
515
+ return failure(
516
+ "invalid_input",
517
+ "repository state changed since approval; show the current state and approve the action again",
518
+ { outcome: "not_started", operation },
519
+ );
520
+ };
521
+
334
522
  export const createAuthorizedExternalActionRunner =
335
523
  (core: WorkitCore, bind: ExternalActionBinding): ExternalActionEffect =>
336
524
  async <T>(
@@ -18,7 +18,7 @@ type MethodDefinition = {
18
18
  export const METHODS: Readonly<Record<MethodId, MethodDefinition>> = {
19
19
  "workit-challenge": { dimensions: ["challenge", "decisions"] },
20
20
  "workit-behavioral-tdd": { dimensions: ["testing"] },
21
- "workit-review": { dimensions: ["review"] },
21
+ "workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review"] },
22
22
  "workit-plan": { dimensions: ["artifacts", "continuity"] },
23
23
  "workit-implement": { dimensions: ["delegation"] },
24
24
  "workit-debug": { ruleIds: ["root-cause-investigation"] },
@@ -107,7 +107,18 @@ decision.record {binding with taskId/workspaceId from inspect plus presented and
107
107
  approvedContent} only through a native approval question asked receipt-shaped:
108
108
  header Workit decision: <purpose>, the same label repeated in the question
109
109
  text (some host UIs do not render headers), exactly two options approved and
110
- rejected; ask once, never re-ask to mint a receipt. task.close {outcome,
110
+ rejected; ask once, never re-ask to mint a receipt. Before asking, present the
111
+ item in the conversation (durable artifacts: complete digest plus exact path;
112
+ inline plans: the content) and keep the question to one short scoped sentence;
113
+ never ask to approve something unseen. An answer with custom text is steering,
114
+ not approval: adjust, re-present, then ask a new scoped question. After the user
115
+ approves a spec or plan, execute the remaining tasks continuously with one
116
+ atomic commit per task and do not ask whether to continue; stop only for a new
117
+ product decision, a failed safety or verification gate, a conflicting edit, or
118
+ missing authority. Record the plan's commit list once through the plan-scoped
119
+ action approval (git.commit with plan_steps and plan_branch) so each listed
120
+ commit executes without a new question; an unlisted message needs a fresh
121
+ approval. task.close {outcome,
111
122
  summary, decisionIds}. A completed native subagent run stops its bound worker
112
123
  by itself; if a worker strands in cancelling with its run verifiably over,
113
124
  repeat worker.cancel {reason} to confirm the stop. Check and review evidence
@@ -306,7 +306,7 @@ function resolveRequirements(input: NormalizedResolverInput): Requirement[] {
306
306
  satisfaction:
307
307
  "An observed or inferred fact resolves the uncertainty and records its supporting references.",
308
308
  before: "dependent_action",
309
- dependentAction: "dependent-action",
309
+ dependentAction: null,
310
310
  acceptanceAllowed: false,
311
311
  }),
312
312
  );
@@ -321,7 +321,7 @@ function resolveRequirements(input: NormalizedResolverInput): Requirement[] {
321
321
  satisfaction:
322
322
  "Present the material alternatives and record the user's decision before the dependent action.",
323
323
  before: "dependent_action",
324
- dependentAction: "dependent-action",
324
+ dependentAction: null,
325
325
  acceptanceAllowed: false,
326
326
  }),
327
327
  );
@@ -0,0 +1,51 @@
1
+ export type ShellRouteIntent = {
2
+ route: "git.branch_setup" | "hosting.pull_request";
3
+ guidance: string;
4
+ };
5
+
6
+ const GUIDANCE: Record<ShellRouteIntent["route"], string> = {
7
+ "git.branch_setup":
8
+ "use the Workit git.branch_setup action (workit_external_action in hosts, or `workit action git.branch_setup --payload '{}' --confirm --json` in the CLI)",
9
+ "hosting.pull_request":
10
+ "use the Workit hosting.pull_request action (workit_external_action in hosts, or `workit action hosting.pull_request --payload '{...}' --confirm --json` in the CLI), then drive the created PR through workit-babysit",
11
+ };
12
+
13
+ const unbalancedQuotes = (segment: string): boolean => {
14
+ const text = segment.replace(/\\./gu, "");
15
+ return (text.match(/"/gu)?.length ?? 0) % 2 !== 0 || (text.match(/'/gu)?.length ?? 0) % 2 !== 0;
16
+ };
17
+
18
+ const segmentTokens = (segment: string): string[] | null => {
19
+ if (segment.includes("`") || segment.includes("$(")) return null;
20
+ if (unbalancedQuotes(segment)) return null;
21
+ const parts = segment.trim().split(/\s+/u);
22
+ while (parts.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(parts[0])) parts.shift();
23
+ return parts.length > 0 ? parts : null;
24
+ };
25
+
26
+ const unquote = (token: string): string => token.replace(/^['"]|['"]$/gu, "");
27
+
28
+ /**
29
+ * Narrow recognizer for direct branch and PR creation commands. It returns null
30
+ * for everything it cannot classify unambiguously, so hosts deny only the exact
31
+ * routes that must go through the shared action reservation.
32
+ */
33
+ export function shellRouteIntent(command: string): ShellRouteIntent | null {
34
+ if (typeof command !== "string" || command.trim() === "") return null;
35
+ for (const raw of command.split(/&&|\|\||;|\n/u)) {
36
+ const parts = segmentTokens(raw);
37
+ if (!parts) continue;
38
+ const head = unquote(parts[0]);
39
+ const second = unquote(parts[1] ?? "");
40
+ const third = unquote(parts[2] ?? "");
41
+ if (head === "git" && second === "switch" && (third === "-c" || third === "--create"))
42
+ return { route: "git.branch_setup", guidance: GUIDANCE["git.branch_setup"] };
43
+ if (head === "git" && second === "checkout" && (third === "-b" || third === "-B"))
44
+ return { route: "git.branch_setup", guidance: GUIDANCE["git.branch_setup"] };
45
+ if (head === "gh" && second === "pr" && third === "create")
46
+ return { route: "hosting.pull_request", guidance: GUIDANCE["hosting.pull_request"] };
47
+ if (head === "glab" && second === "mr" && third === "create")
48
+ return { route: "hosting.pull_request", guidance: GUIDANCE["hosting.pull_request"] };
49
+ }
50
+ return null;
51
+ }
@@ -1,3 +1,4 @@
1
+ import { isUncertainWorker } from "./workers";
1
2
  import {
2
3
  POLICY_VERSION,
3
4
  failure,
@@ -13,6 +14,7 @@ import {
13
14
  type Utc,
14
15
  } from "./task-contract";
15
16
  import { captureCandidate, evaluateEvidence } from "./task-evaluation";
17
+ import { selectMethods, type SelectedMethod } from "./methods";
16
18
  import type { NativeWorkerObservation } from "./workers";
17
19
 
18
20
  export type ResumeObservation = {
@@ -41,11 +43,7 @@ export function reconcileResume(
41
43
  .filter((entry) => entry.status === "stale")
42
44
  .map((entry) => entry.evidenceId);
43
45
  const blockers = [...view.task.progress.blockers];
44
- if (
45
- view.task.workers.some((entry) =>
46
- ["running", "cancelling", "unknown"].includes(entry.data.state),
47
- )
48
- )
46
+ if (view.task.workers.some((entry) => isUncertainWorker(entry.data.state)))
49
47
  blockers.push({
50
48
  reason: "worker state requires reconciliation",
51
49
  dependentAction: "resume",
@@ -71,10 +69,12 @@ type CompactDecision = {
71
69
  digest: string;
72
70
  references: CompactReference[];
73
71
  };
72
+ type CompactMethod = Pick<SelectedMethod, "id" | "assurance"> & { reason: string };
74
73
  export type CompactTaskContext = {
75
74
  objective: string;
76
75
  status: TaskView["task"]["status"];
77
76
  decisions: CompactDecision[];
77
+ methods: CompactMethod[];
78
78
  gaps: string[];
79
79
  nextAction: string | null;
80
80
  };
@@ -127,6 +127,13 @@ export function compactTaskContext(view: TaskView): string {
127
127
  digest: entry.data.digest,
128
128
  references: entry.data.binding.contentRefs.slice(0, 1).map(compactReference),
129
129
  }));
130
+ const methods: CompactMethod[] = view.task.policy
131
+ ? selectMethods(view.task.policy, view.capabilities).map((method) => ({
132
+ id: method.id,
133
+ assurance: method.assurance,
134
+ reason: compactText(method.reason, COMPACT_TEXT_BYTES) ?? "",
135
+ }))
136
+ : [];
130
137
  const gaps = Array.from(
131
138
  new Set([
132
139
  ...view.evidence
@@ -145,16 +152,18 @@ export function compactTaskContext(view: TaskView): string {
145
152
  objective: compactText(view.task.intent.data.objective, COMPACT_TEXT_BYTES) ?? "",
146
153
  status: view.task.status,
147
154
  decisions,
155
+ methods,
148
156
  gaps,
149
157
  nextAction: compactText(view.task.progress.nextAction, COMPACT_TEXT_BYTES),
150
158
  };
151
159
  let encoded = JSON.stringify(context);
152
160
  while (
153
161
  compactBytes(encoded) > COMPACT_MAX_BYTES &&
154
- (context.decisions.length || context.gaps.length)
162
+ (context.decisions.length || context.methods.length || context.gaps.length)
155
163
  ) {
156
164
  if (context.gaps.length) context.gaps.pop();
157
- else context.decisions.pop();
165
+ else if (context.decisions.length) context.decisions.pop();
166
+ else context.methods.pop();
158
167
  encoded = JSON.stringify(context);
159
168
  }
160
169
  if (compactBytes(encoded) > COMPACT_MAX_BYTES) {
@@ -453,6 +453,7 @@ export const decisionSchema = z
453
453
  scope: scopeSchema,
454
454
  presented: text,
455
455
  approvedContent: text,
456
+ displayed: text.optional(),
456
457
  contentRefs: z.array(refSchema),
457
458
  })
458
459
  .strict(),
@@ -471,6 +472,29 @@ export const decisionSchema = z
471
472
  })
472
473
  .strict();
473
474
  export type Decision = z.infer<typeof decisionSchema>;
475
+
476
+ /** Show-before-ask guard: binding questions are single-sentence and never
477
+ * carry the artifact, descriptor, or payload body in the question UI. */
478
+ export const BINDING_QUESTION_BUDGET = 300;
479
+ const bindingQuestionHeader = /^Workit decision: (design|action|limitation|preference)$/;
480
+ export const workitBindingQuestionIssue = (questions: unknown): string | null => {
481
+ if (!Array.isArray(questions) || questions.length !== 1) return null;
482
+ const question = questions[0] as { header?: unknown; question?: unknown; options?: unknown };
483
+ if (!question || typeof question !== "object") return null;
484
+ if (typeof question.header !== "string" || !bindingQuestionHeader.test(question.header.trim()))
485
+ return null;
486
+ const over = (label: string, value: string) =>
487
+ `invalid_input: ${label} is ${value.length} characters; Workit binding questions must stay within ${BINDING_QUESTION_BUDGET}. present the item in the conversation first, then ask a short scoped question.`;
488
+ if (typeof question.question === "string" && question.question.length > BINDING_QUESTION_BUDGET)
489
+ return over("the question text", question.question);
490
+ if (Array.isArray(question.options))
491
+ for (const option of question.options) {
492
+ const description = (option as { description?: unknown }).description;
493
+ if (typeof description === "string" && description.length > BINDING_QUESTION_BUDGET)
494
+ return over("an option description", description);
495
+ }
496
+ return null;
497
+ };
474
498
  export const findingSchema = z
475
499
  .object({
476
500
  claim: text,
@@ -510,7 +534,7 @@ export type WorkerReport = z.infer<typeof workerReportSchema>;
510
534
  export const workerSchema = z
511
535
  .object({
512
536
  assignment: assignmentSchema,
513
- state: z.enum(["assigned", "running", "cancelling", "stopped", "unknown"]),
537
+ state: z.enum(["assigned", "dispatching", "running", "cancelling", "stopped", "unknown"]),
514
538
  session: refSchema.nullable(),
515
539
  report: workerReportSchema.nullable(),
516
540
  })
@@ -565,6 +589,13 @@ export const actionProgressListSchema = z.array(actionProgressSchema).check((ctx
565
589
  path: ["decisionId"],
566
590
  });
567
591
  });
592
+ export const runtimeSchema = z
593
+ .object({
594
+ createdWith: text.nullable(),
595
+ updatedWith: text,
596
+ })
597
+ .strict();
598
+ export type RuntimeInfo = z.infer<typeof runtimeSchema>;
568
599
  export const taskRecordSchema = z
569
600
  .object({
570
601
  schemaVersion: z.literal(1),
@@ -579,6 +610,8 @@ export const taskRecordSchema = z
579
610
  status: z.enum(["active", "paused", "closed"]),
580
611
  closure: closureSchema.nullable(),
581
612
  progress: progressSchema,
613
+ pauseReason: text.nullable().optional(),
614
+ runtime: runtimeSchema.optional(),
582
615
  assessments: z.array(entrySchema(assessmentSchema)),
583
616
  policy: policySchema.nullable(),
584
617
  policyChanges: z.array(policyChangeSchema),
@@ -597,6 +630,7 @@ export const workspaceRecordSchema = z
597
630
  id,
598
631
  revision,
599
632
  root: nonEmpty,
633
+ runtime: runtimeSchema.optional(),
600
634
  writer: z
601
635
  .object({ state: z.enum(["held", "uncertain"]), owner: ownerSchema, acquiredAt: utc })
602
636
  .strict()
@@ -639,6 +673,9 @@ export const taskSummarySchema = z
639
673
  id,
640
674
  revision,
641
675
  workspaceRevision: revision,
676
+ createdAt: utc,
677
+ updatedAt: utc,
678
+ runtime: runtimeSchema.nullable(),
642
679
  objective: text,
643
680
  status: z.enum(["active", "paused", "closed"]),
644
681
  closure: closureSchema.nullable(),
@@ -720,7 +757,7 @@ const taskOperations = {
720
757
  expectedWorkspaceRevision: revision.optional(),
721
758
  outcome: outcomeSchema,
722
759
  summary: text,
723
- decisionIds: z.array(id),
760
+ decisionIds: z.array(id).optional(),
724
761
  }),
725
762
  };
726
763
  const policyOperations = {
@@ -813,13 +850,14 @@ const writerOperations = {
813
850
  expectedRevision: revision.optional(),
814
851
  expectedWorkspaceRevision: revision.optional(),
815
852
  workerId: nullableId.optional(),
853
+ reason: text.optional(),
816
854
  }),
817
855
  release: operation({
818
856
  action: z.literal("release"),
819
857
  ...taskId,
820
858
  expectedRevision: revision.optional(),
821
859
  expectedWorkspaceRevision: revision.optional(),
822
- reason: text,
860
+ reason: text.optional(),
823
861
  }),
824
862
  };
825
863
  const stateOperations = {
@@ -891,6 +929,22 @@ export type ErrorDetails = {
891
929
  path?: string;
892
930
  operation?: string;
893
931
  outcome?: "not_started" | "pending" | "unknown";
932
+ /** Present when a native approval must be asked from a concise proposal. */
933
+ proposal?: {
934
+ presented: string;
935
+ approvedContent: string;
936
+ descriptorDigest: string;
937
+ };
938
+ guidance?: string;
939
+ /** Structured remedy for unsatisfied requirements: rule, reason, and
940
+ * satisfaction text instead of opaque requirement hashes alone. */
941
+ requirements?: {
942
+ requirementId: string;
943
+ ruleId: string;
944
+ reason: string;
945
+ satisfaction: string;
946
+ dependentAction: string | null;
947
+ }[];
894
948
  };
895
949
  export type Result<T> =
896
950
  | {