@brainervirus/workit-core 1.0.11 → 1.0.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -181,12 +181,57 @@ export type ReconcileActionInput = {
181
181
 
182
182
  type Workflow = { steps: string[]; completed: string[] };
183
183
 
184
+ /** One typed step of a chain reservation. Plain strings are commit
185
+ * messages (the historical plan_steps shape); branch and pr steps carry
186
+ * their match key. Keys are the canonical consumption alphabet. */
187
+ export type ChainStep =
188
+ | { kind: "commit"; message: string }
189
+ | { kind: "branch"; target: string }
190
+ | { kind: "pr" };
191
+
192
+ export const chainStepKey = (step: ChainStep): string =>
193
+ step.kind === "commit" ? step.message : step.kind === "branch" ? `branch:${step.target}` : "pr";
194
+
195
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
196
+ typeof value === "object" && value !== null && !Array.isArray(value);
197
+
198
+ /** Normalize mixed plan steps to typed entries. Returns null for empty,
199
+ * over-long, malformed, or key-colliding lists (a commit message that
200
+ * spells exactly like a typed key is rejected rather than misrouted). */
201
+ export const normalizeChainSteps = (steps: unknown): ChainStep[] | null => {
202
+ if (!Array.isArray(steps) || steps.length === 0 || steps.length > 32) return null;
203
+ const normalized: ChainStep[] = [];
204
+ for (const step of steps) {
205
+ if (typeof step === "string") {
206
+ if (!step.trim()) return null;
207
+ normalized.push({ kind: "commit", message: step });
208
+ continue;
209
+ }
210
+ if (!isRecord(step)) return null;
211
+ if (typeof step.branch === "string" && step.branch.trim() && Object.keys(step).length === 1) {
212
+ normalized.push({ kind: "branch", target: step.branch });
213
+ continue;
214
+ }
215
+ if (step.pr === true && Object.keys(step).length === 1) {
216
+ normalized.push({ kind: "pr" });
217
+ continue;
218
+ }
219
+ return null;
220
+ }
221
+ const keys = normalized.map(chainStepKey);
222
+ if (new Set(keys).size !== keys.length) return null;
223
+ return normalized;
224
+ };
225
+
184
226
  const scopeEqual = (
185
227
  left: Decision["binding"]["scope"],
186
228
  right: Decision["binding"]["scope"],
187
229
  ): boolean => canonicalJson(left) === canonicalJson(right);
188
230
 
189
- const parseSteps = (content: string): string[] | null => {
231
+ /** Parse the durable approved text into typed chain steps. Single commits
232
+ * and branch/PR operations carry no step list (empty, not an invalid
233
+ * chain); an invalid list is null and rejected by callers. */
234
+ const parseSteps = (content: string): ChainStep[] | null => {
190
235
  let value: unknown;
191
236
  try {
192
237
  value = JSON.parse(content) as unknown;
@@ -195,7 +240,9 @@ const parseSteps = (content: string): string[] | null => {
195
240
  }
196
241
  if (typeof value !== "object" || value === null || !("steps" in value)) return [];
197
242
  const steps = (value as { steps?: unknown }).steps;
198
- return Array.isArray(steps) && steps.every((step) => typeof step === "string") ? steps : null;
243
+ if (!Array.isArray(steps)) return null;
244
+ const normalized = normalizeChainSteps(steps);
245
+ return normalized;
199
246
  };
200
247
 
201
248
  const decisionMatches = (
@@ -543,22 +590,17 @@ const validateAction = (
543
590
  if (!content.ok) return content as Result<never>;
544
591
  const requestedSteps = parseSteps(decision.binding.approvedContent);
545
592
  if (requestedSteps === null)
546
- return failure("invalid_input", "bounded action steps must be a string array");
547
- if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps))
593
+ return failure("invalid_input", "bounded action steps must be a unique non-empty array");
594
+ if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps.map(chainStepKey)))
548
595
  return failure("permission_denied", "bounded action steps do not match approved content");
549
- if (
550
- requestedSteps.some((step) => !step || typeof step !== "string") ||
551
- new Set(requestedSteps).size !== requestedSteps.length
552
- )
553
- return failure("invalid_input", "bounded action steps must be unique and non-empty");
554
596
  const stored = task.actionProgress?.find((item) => item.decisionId === input.decisionId);
555
597
  const workflow = stored
556
598
  ? { steps: stored.steps, completed: stored.completedSteps }
557
- : { steps: requestedSteps, completed: [] };
599
+ : { steps: requestedSteps.map(chainStepKey), completed: [] };
558
600
  if (
559
601
  workflow.steps.length &&
560
602
  requestedSteps.length &&
561
- canonicalJson(workflow.steps) !== canonicalJson(requestedSteps)
603
+ canonicalJson(workflow.steps) !== canonicalJson(requestedSteps.map(chainStepKey))
562
604
  )
563
605
  return failure("permission_denied", "bounded action scope changed");
564
606
  return success(null, null, { entry, workflow });
@@ -281,6 +281,43 @@ export type BranchSetupResult =
281
281
  | { error: string; phase: "preflight" | "post_mutation" };
282
282
 
283
283
  // Port of scripts/branch/setup-branch.sh
284
+ export type BranchDirt = "clean" | "carry" | "stash-required";
285
+
286
+ /**
287
+ * Classify working-tree dirt for branch setup. Untracked files ride along
288
+ * (`git switch -c` never touches them), and tracked modifications confined
289
+ * to `docs/` are branch-owned by construction in this workflow — both
290
+ * auto-carry with no stash question. Anything else keeps the stash-bound
291
+ * proposal, since the base-checkout sequence could disturb it. Any
292
+ * inspection failure reads as clean so a broken git surfaces at its own
293
+ * error instead of inventing a stash demand.
294
+ */
295
+ export const classifyBranchDirt = (workspaceRoot: string): BranchDirt => {
296
+ let raw: string;
297
+ try {
298
+ raw = execFileSync("git", ["status", "--porcelain=v1"], {
299
+ cwd: path.resolve(workspaceRoot),
300
+ encoding: "utf8",
301
+ stdio: ["pipe", "pipe", "pipe"],
302
+ });
303
+ } catch {
304
+ return "clean";
305
+ }
306
+ let dirty = false;
307
+ for (const line of raw.split("\n")) {
308
+ if (!line.trim()) continue;
309
+ dirty = true;
310
+ const code = line.slice(0, 2);
311
+ if (code === "??") continue;
312
+ let entry = line.slice(3);
313
+ const arrow = entry.indexOf(" -> ");
314
+ if (arrow >= 0) entry = entry.slice(arrow + 4);
315
+ const unquoted = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry;
316
+ if (unquoted !== "docs" && !unquoted.startsWith("docs/")) return "stash-required";
317
+ }
318
+ return dirty ? "carry" : "clean";
319
+ };
320
+
284
321
  export const branchSetup = ({
285
322
  action,
286
323
  sdd_dir,
@@ -398,7 +435,7 @@ export const branchSetup = ({
398
435
  };
399
436
  if (current !== target) {
400
437
  const dirty = Boolean(gitContext(cwd).status_short.trim());
401
- if (dirty && stash !== "yes") {
438
+ if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") {
402
439
  return {
403
440
  error:
404
441
  "dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
@@ -416,7 +453,7 @@ export const branchSetup = ({
416
453
  return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
417
454
  validatedBase = base;
418
455
  }
419
- if (dirty) {
456
+ if (dirty && stash === "yes") {
420
457
  try {
421
458
  exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
422
459
  } catch (error) {
@@ -22,6 +22,7 @@ import {
22
22
  import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { SUPPORT_MATRIX } from "./support-matrix";
25
+ import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
25
26
  import { EVENT } from "./boundary";
26
27
  import { getDiagnosticLogger, isConfigObject } from "./config";
27
28
  import { packageRoot } from "./package-root";
@@ -682,12 +683,7 @@ const checkUtility = (res: Resolved): DoctorCheck => {
682
683
  const isFragileCachePin = (entry: string): boolean => {
683
684
  const n = entry.replaceAll("\\", "/").toLowerCase();
684
685
  if (!n.startsWith("file:")) return false;
685
- return (
686
- n.includes("/caches/pnpm/dlx/") ||
687
- n.includes("/_npx/") ||
688
- n.includes("/.pacquet/") ||
689
- n.includes("/library/caches/pnpm/")
690
- );
686
+ return isEphemeralCachePath(entry);
691
687
  };
692
688
 
693
689
  const staleEntry = (entry: string): "ok" | "stale" | "missing-file" | "fragile-cache" => {
@@ -936,12 +932,7 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
936
932
  } catch {
937
933
  target = "(unreadable)";
938
934
  }
939
- const n = target.replaceAll("\\", "/").toLowerCase();
940
- const fragile =
941
- n.includes("/caches/pnpm/dlx/") ||
942
- n.includes("/_npx/") ||
943
- n.includes("/.pacquet/") ||
944
- n.includes("/library/caches/pnpm/");
935
+ const fragile = isEphemeralCachePath(target);
945
936
  return {
946
937
  id: "stale_install",
947
938
  status: "fail",
@@ -1013,6 +1004,28 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
1013
1004
  fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory and rewrites the workit MCP/hook entries",
1014
1005
  };
1015
1006
  }
1007
+ // Same version, different bytes: the install was hand-edited or built from
1008
+ // a dirty tree. Version strings cannot see that; the bundle hash can. The
1009
+ // dev dist is byte-deterministic, so a mismatch heals with reinstall.
1010
+ if (localDist && installed !== null && source !== null && res.dev) {
1011
+ const entry = /^node\s+(.+)$/.exec((hookCmd ?? "").trim())?.[1]?.trim() ?? "";
1012
+ const sourceBundle =
1013
+ entry && path.isAbsolute(entry)
1014
+ ? path.join(res.dev, "packages", "workit-cursor", "dist", path.basename(entry))
1015
+ : "";
1016
+ if (entry && sourceBundle) {
1017
+ const installedHash = bundleHashOfFile(entry);
1018
+ const sourceHash = bundleHashOfFile(sourceBundle);
1019
+ if (installedHash !== null && sourceHash !== null && installedHash !== sourceHash) {
1020
+ return {
1021
+ id: "stale_install",
1022
+ status: "fail",
1023
+ detail: `stale_install: installed workit-cursor ${installed} runs different bytes than the current runtime build (${path.basename(entry)} hash mismatch)`,
1024
+ fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
1025
+ };
1026
+ }
1027
+ }
1028
+ }
1016
1029
  if (installed !== null && localDist) {
1017
1030
  const expected = registryLatestVersion(res);
1018
1031
  if (expected === null) {
@@ -1,4 +1,9 @@
1
- import { branchSetup, resolveBranchPolicyFor, resolveCommitPolicyFor } from "./branch";
1
+ import {
2
+ branchSetup,
3
+ classifyBranchDirt,
4
+ resolveBranchPolicyFor,
5
+ resolveCommitPolicyFor,
6
+ } from "./branch";
2
7
  import { hostingCliAvailable, prCreate } from "./pr-create";
3
8
  import {
4
9
  changelogContext,
@@ -24,6 +29,7 @@ import { failure, success, sha256, type Caller, type Result, type Ref } from "./
24
29
  import { changelogApply, changelogApplyPreview } from "./changelog";
25
30
  import type { ExternalActionRequest } from "./external-action";
26
31
  import { externalActionDescriptor, externalActionRequest } from "./external-action";
32
+ import { normalizeChainSteps } from "./authority";
27
33
  import { resolveInside, run as coreRun } from "../core";
28
34
  import { vcsConfig } from "./vcs-config";
29
35
  import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
@@ -271,10 +277,33 @@ export const actionProposalQuestion = (
271
277
  const resolved = (payload.resolved ?? {}) as Record<string, unknown>;
272
278
  const payloadTarget = String(payload.target_branch ?? "");
273
279
  if (request.operation === "git.commit" && Array.isArray(payload.plan_steps)) {
274
- const steps = payload.plan_steps as string[];
280
+ const steps = payload.plan_steps as unknown[];
281
+ const chain = normalizeChainSteps(steps);
282
+ if (!chain)
283
+ return {
284
+ presented: `Workit decision: action — Approve committing the plan's listed tasks?`,
285
+ approvedText: `Approve the listed plan commits.`,
286
+ };
287
+ const kinds = chain.map((step) => step.kind);
288
+ if (kinds.every((kind) => kind === "commit"))
289
+ return {
290
+ presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
291
+ approvedText: `Approve the ${steps.length} listed plan commits.`,
292
+ };
293
+ const summary = [
294
+ kinds.filter((kind) => kind === "branch").length
295
+ ? `${kinds.filter((kind) => kind === "branch").length} branch`
296
+ : null,
297
+ kinds.filter((kind) => kind === "commit").length
298
+ ? `${kinds.filter((kind) => kind === "commit").length} commits`
299
+ : null,
300
+ kinds.includes("pr") ? "PR" : null,
301
+ ]
302
+ .filter(Boolean)
303
+ .join(", ");
275
304
  return {
276
- presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
277
- approvedText: `Approve the ${steps.length} listed plan commits.`,
305
+ presented: `Workit decision: action — Approve the ${steps.length}-step chain on \`${String(payload.plan_branch ?? "")}\` (${summary})?`,
306
+ approvedText: `Approve the ${steps.length}-step chain (${summary}).`,
278
307
  };
279
308
  }
280
309
  switch (request.operation) {
@@ -806,6 +835,32 @@ const youTrackWritePreflight = (): Result<null> => {
806
835
  return success(null, null, null);
807
836
  };
808
837
 
838
+ /**
839
+ * A dirty tree binds stash up front so the proposal names it and one
840
+ * approval carries the whole effect instead of failing at preflight.
841
+ * Carry-class dirt (untracked or docs-confined) rides along silently.
842
+ * Returns the original resolution untouched unless an upgrade applies.
843
+ */
844
+ export const upgradeBranchSetupForStash = (
845
+ root: string,
846
+ parsed: ExternalActionRequest,
847
+ resolved: Result<ResolvedExternalAction>,
848
+ ): Result<ResolvedExternalAction> => {
849
+ if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
850
+ if (
851
+ (resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
852
+ true ||
853
+ (resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
854
+ classifyBranchDirt(root) !== "stash-required"
855
+ )
856
+ return resolved;
857
+ const upgraded = resolveExternalActionRequest(root, {
858
+ ...parsed,
859
+ payload: { ...(parsed.payload as Record<string, unknown>), stash: "yes" },
860
+ } as typeof parsed);
861
+ return upgraded.ok ? upgraded : resolved;
862
+ };
863
+
809
864
  export const resolveExternalActionRequest = (
810
865
  root: string,
811
866
  request: ExternalActionRequest,
@@ -872,26 +927,41 @@ export const resolveExternalActionRequest = (
872
927
  if (!head) return failure("storage_error", "Git state could not be resolved");
873
928
  const branch = gitValue(root, ["branch", "--show-current"]);
874
929
  if (planSteps !== undefined || planBranch !== undefined) {
875
- if (
876
- !Array.isArray(planSteps) ||
877
- planSteps.some((step) => typeof step !== "string" || !step.trim()) ||
878
- new Set(planSteps).size !== planSteps.length ||
879
- planSteps.length === 0 ||
880
- planSteps.length > 32
881
- )
930
+ const chain = Array.isArray(planSteps) ? normalizeChainSteps(planSteps) : null;
931
+ if (!chain)
882
932
  return failure(
883
933
  "invalid_input",
884
- "plan_steps must be 1-32 unique non-empty commit messages",
934
+ "plan_steps must be 1-32 unique non-empty commit messages, branch targets, or pull_request markers",
885
935
  {
886
936
  outcome: "not_started",
887
- fields: [{ path: "plan_steps", reason: "unique strings" }],
937
+ fields: [{ path: "plan_steps", reason: "unique steps" }],
888
938
  },
889
939
  );
890
- if (typeof planBranch !== "string" || !planBranch.trim() || planBranch !== branch)
891
- return failure("invalid_input", "plan_branch must name the current working branch", {
940
+ if (typeof planBranch !== "string" || !planBranch.trim())
941
+ return failure("invalid_input", "plan_branch must name the working branch", {
892
942
  outcome: "not_started",
893
- fields: [{ path: "plan_branch", reason: "current branch" }],
943
+ fields: [{ path: "plan_branch", reason: "required" }],
894
944
  });
945
+ // A chain that creates its own branch names that branch; otherwise
946
+ // the plan runs on the branch already checked out.
947
+ const first = chain[0];
948
+ const createsBranch = first?.kind === "branch";
949
+ if (createsBranch ? planBranch !== first.target : planBranch !== branch)
950
+ return failure(
951
+ "invalid_input",
952
+ createsBranch
953
+ ? "plan_branch must name the branch the chain creates"
954
+ : "plan_branch must name the current working branch",
955
+ {
956
+ outcome: "not_started",
957
+ fields: [
958
+ {
959
+ path: "plan_branch",
960
+ reason: createsBranch ? "chain branch" : "current branch",
961
+ },
962
+ ],
963
+ },
964
+ );
895
965
  if (isProtectedBranch(root, planBranch))
896
966
  return failure("invalid_input", "plan_branch must not be a protected branch", {
897
967
  outcome: "not_started",
@@ -14,6 +14,7 @@ import {
14
14
  } from "./task-contract";
15
15
  import { WorkitCore } from "./task-engine";
16
16
  import { TaskStore } from "./task-store";
17
+ import { chainStepKey, normalizeChainSteps, type ChainStep } from "./authority";
17
18
  import * as z from "zod";
18
19
  import { execFileSync } from "node:child_process";
19
20
 
@@ -323,33 +324,58 @@ export const priorExternalAction = (
323
324
  /** Parse a plan-commit authorization descriptor (one approved list per plan). */
324
325
  export const planCommitDescriptor = (
325
326
  content: string,
326
- ): { steps: string[]; branch: string } | null => {
327
+ ): { steps: ChainStep[]; branch: string; snapshotHead: string | null } | null => {
327
328
  try {
328
329
  const value = JSON.parse(content) as {
329
330
  operation?: unknown;
330
- payload?: { plan_steps?: unknown; plan_branch?: unknown };
331
+ payload?: {
332
+ plan_steps?: unknown;
333
+ plan_branch?: unknown;
334
+ resolved?: { head?: unknown; branch?: unknown };
335
+ };
331
336
  };
332
337
  if (value.operation !== "git.commit" || !value.payload) return null;
333
- const steps = value.payload.plan_steps;
338
+ const steps = normalizeChainSteps(value.payload.plan_steps);
334
339
  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 };
340
+ if (!steps || typeof branch !== "string" || !branch) return null;
341
+ const head = value.payload.resolved?.head;
342
+ return { steps, branch, snapshotHead: typeof head === "string" ? head : null };
343
343
  } catch {
344
344
  return null;
345
345
  }
346
346
  };
347
347
 
348
+ /**
349
+ * Chain lease: the recorded head must still be an ancestor of the current
350
+ * HEAD — history only moved forward. Rebase/reset underneath an approved
351
+ * chain invalidates it instead of executing on rewritten history.
352
+ */
353
+ export const chainLeaseValid = (store: TaskStore, snapshotHead: string | null): boolean => {
354
+ if (!snapshotHead) return true;
355
+ try {
356
+ const head = execFileSync("git", ["rev-parse", "HEAD"], {
357
+ cwd: store.root,
358
+ encoding: "utf8",
359
+ stdio: ["pipe", "pipe", "pipe"],
360
+ }).trim();
361
+ if (head === snapshotHead) return true;
362
+ execFileSync("git", ["merge-base", "--is-ancestor", snapshotHead, head], {
363
+ cwd: store.root,
364
+ encoding: "utf8",
365
+ stdio: ["pipe", "pipe", "pipe"],
366
+ });
367
+ return true;
368
+ } catch {
369
+ return false;
370
+ }
371
+ };
372
+
348
373
  export type PlanCommitAuthorization = {
349
374
  task: TaskRecord;
350
375
  entry: Entry<Decision>;
351
- steps: string[];
376
+ steps: ChainStep[];
352
377
  branch: string;
378
+ snapshotHead: string | null;
353
379
  completed: string[];
354
380
  };
355
381
 
@@ -357,7 +383,10 @@ export type PlanCommitAuthorization = {
357
383
  * Find the single active plan-commit authorization whose next unconsumed step
358
384
  * is this exact commit message. Consumption order is enforced so each listed
359
385
  * commit executes once, in plan order; unlisted or replayed messages find no
360
- * authorization and require a fresh exact approval.
386
+ * authorization and require a fresh exact approval. Typed chain positions
387
+ * never match messages (a pathological message falls through to a fresh
388
+ * approval instead of consuming the wrong step), and a rewritten history
389
+ * breaks the lease.
361
390
  */
362
391
  export const approvedPlanCommit = (
363
392
  store: TaskStore,
@@ -395,8 +424,19 @@ export const approvedPlanCommit = (
395
424
  const completed =
396
425
  task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
397
426
  [];
398
- if (plan.steps[completed.length] !== message) return [];
399
- return [{ task, entry, steps: plan.steps, branch: plan.branch, completed: [...completed] }];
427
+ const next = plan.steps[completed.length];
428
+ if (!next || next.kind !== "commit" || next.message !== message) return [];
429
+ if (!chainLeaseValid(store, plan.snapshotHead)) return [];
430
+ return [
431
+ {
432
+ task,
433
+ entry,
434
+ steps: plan.steps,
435
+ branch: plan.branch,
436
+ snapshotHead: plan.snapshotHead,
437
+ completed: [...completed],
438
+ },
439
+ ];
400
440
  });
401
441
  });
402
442
  if (matches.length !== 1)
@@ -409,6 +449,80 @@ export const approvedPlanCommit = (
409
449
  return success(workspace.data.revision, null, matches[0]);
410
450
  };
411
451
 
452
+ export type ChainStepQuery =
453
+ | { operation: "git.branch_setup"; target: string }
454
+ | { operation: "hosting.pull_request" };
455
+
456
+ /**
457
+ * Match one branch or PR step of a chain authorization: same session-bound
458
+ * search as plan commits, next unconsumed step of the right kind, lease
459
+ * intact. Commit steps stay on the plan path; this covers the steps plans
460
+ * could never express.
461
+ */
462
+ export const approvedChainStep = (
463
+ store: TaskStore,
464
+ host: string,
465
+ actor: string,
466
+ query: ChainStepQuery,
467
+ ): Result<PlanCommitAuthorization> => {
468
+ const listed = store.listTasks();
469
+ const workspace = store.readWorkspace();
470
+ if (!listed.ok || !workspace.ok)
471
+ return failure("storage_error", "external action state is unavailable");
472
+ if (!workspace.data) return failure("not_found", "workspace not found");
473
+ const matches = listed.data.flatMap((task) => {
474
+ if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
475
+ if (
476
+ task.intent.provenance.session?.kind !== "host" ||
477
+ task.intent.provenance.session.host !== host ||
478
+ task.intent.provenance.session.handle !== actor
479
+ )
480
+ return [];
481
+ return task.decisions.flatMap((entry) => {
482
+ const decision = entry.data;
483
+ if (
484
+ decision.purpose !== "action" ||
485
+ decision.response !== "approved" ||
486
+ decision.revoked !== null
487
+ )
488
+ return [];
489
+ if (entry.provenance.kind !== "host_observed" || entry.provenance.receipts.length === 0)
490
+ return [];
491
+ const plan = planCommitDescriptor(decision.binding.approvedContent);
492
+ if (!plan) return [];
493
+ const completed =
494
+ task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
495
+ [];
496
+ const next = plan.steps[completed.length];
497
+ if (!next) return [];
498
+ if (query.operation === "git.branch_setup") {
499
+ if (next.kind !== "branch" || next.target !== query.target) return [];
500
+ } else if (next.kind !== "pr") {
501
+ return [];
502
+ }
503
+ if (!chainLeaseValid(store, plan.snapshotHead)) return [];
504
+ return [
505
+ {
506
+ task,
507
+ entry,
508
+ steps: plan.steps,
509
+ branch: plan.branch,
510
+ snapshotHead: plan.snapshotHead,
511
+ completed: [...completed],
512
+ },
513
+ ];
514
+ });
515
+ });
516
+ if (matches.length !== 1)
517
+ return failure(
518
+ "permission_denied",
519
+ matches.length === 0
520
+ ? "no chain authorization matches this step"
521
+ : "multiple chain authorizations match this step",
522
+ );
523
+ return success(workspace.data.revision, null, matches[0]);
524
+ };
525
+
412
526
  export const commitMessageFromDescriptor = (operation: string): string | undefined => {
413
527
  try {
414
528
  const descriptor = JSON.parse(operation) as {
@@ -423,6 +537,69 @@ export const commitMessageFromDescriptor = (operation: string): string | undefin
423
537
  }
424
538
  };
425
539
 
540
+ /** Resolve a chain authorization into the exact binding an adapter needs
541
+ * to run one branch or PR step under the approved chain. Commit steps stay
542
+ * on the plan path; this covers the steps plans could never express. */
543
+ export const chainStepBinding = (
544
+ store: TaskStore,
545
+ host: string,
546
+ actor: string,
547
+ operation: string,
548
+ ): {
549
+ taskId: Id;
550
+ decisionId: Id;
551
+ binding: Decision["binding"];
552
+ expectedRevision: Revision;
553
+ expectedWorkspaceRevision: Revision;
554
+ step: string;
555
+ } | null => {
556
+ let descriptor: { operation?: unknown; payload?: Record<string, unknown> };
557
+ try {
558
+ descriptor = JSON.parse(operation) as {
559
+ operation?: unknown;
560
+ payload?: Record<string, unknown>;
561
+ };
562
+ } catch {
563
+ return null;
564
+ }
565
+ const query: ChainStepQuery | null =
566
+ descriptor.operation === "git.branch_setup" &&
567
+ typeof descriptor.payload?.target_branch === "string"
568
+ ? { operation: "git.branch_setup", target: descriptor.payload.target_branch as string }
569
+ : descriptor.operation === "hosting.pull_request"
570
+ ? { operation: "hosting.pull_request" }
571
+ : null;
572
+ if (!query) return null;
573
+ const chain = approvedChainStep(store, host, actor, query);
574
+ if (!chain.ok) return null;
575
+ // PR steps run on the chain branch; the branch step itself creates it.
576
+ if (query.operation === "hosting.pull_request") {
577
+ let branch: string;
578
+ try {
579
+ branch = execFileSync("git", ["branch", "--show-current"], {
580
+ cwd: store.root,
581
+ encoding: "utf8",
582
+ stdio: ["pipe", "pipe", "pipe"],
583
+ }).trim();
584
+ } catch {
585
+ return null;
586
+ }
587
+ if (!branch || branch !== chain.data.branch) return null;
588
+ }
589
+ const workspace = store.readWorkspace();
590
+ if (!workspace.ok || !workspace.data) return null;
591
+ const next = chain.data.steps[chain.data.completed.length];
592
+ if (!next) return null;
593
+ return {
594
+ taskId: chain.data.task.id,
595
+ decisionId: chain.data.entry.id,
596
+ binding: chain.data.entry.data.binding,
597
+ expectedRevision: chain.data.task.revision,
598
+ expectedWorkspaceRevision: workspace.data.revision,
599
+ step: chainStepKey(next),
600
+ };
601
+ };
602
+
426
603
  /** Resolve a plan-commit authorization into the exact binding an adapter needs
427
604
  * to run one listed commit under the approved list. */
428
605
  export const planCommitBinding = (
@@ -17,12 +17,14 @@ type MethodDefinition = {
17
17
 
18
18
  export const METHODS: Readonly<Record<MethodId, MethodDefinition>> = {
19
19
  "workit-challenge": { dimensions: ["challenge", "decisions"] },
20
- "workit-behavioral-tdd": { dimensions: ["testing"] },
21
- "workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review"] },
20
+ "workit-behavioral-tdd": { dimensions: ["testing"], ruleIds: ["mechanical-existing-checks"] },
21
+ "workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review", "self-review"] },
22
22
  "workit-plan": { dimensions: ["artifacts", "continuity"] },
23
23
  "workit-implement": { dimensions: ["delegation"] },
24
- "workit-debug": { ruleIds: ["root-cause-investigation"] },
25
- "workit-handoff": { ruleIds: ["durable-handoff"] },
24
+ // workit-debug and workit-handoff stay slash-invoked: no generated rule
25
+ // routes to them, so they never appear spuriously in method selection.
26
+ "workit-debug": {},
27
+ "workit-handoff": {},
26
28
  "workit-deslop": { ruleIds: ["pre-pr-cleanup"] },
27
29
  };
28
30
 
@@ -33,12 +35,10 @@ export type SelectedMethod = {
33
35
  };
34
36
 
35
37
  const methodMatches = (
36
- id: MethodId,
37
38
  definition: MethodDefinition,
38
39
  requirement: Policy["requirements"][number],
39
40
  ): boolean =>
40
- (definition.dimensions?.includes(requirement.dimension) === true &&
41
- !(id === "workit-review" && requirement.ruleId === "self-review")) ||
41
+ definition.dimensions?.includes(requirement.dimension) === true ||
42
42
  definition.ruleIds?.includes(requirement.ruleId) === true;
43
43
 
44
44
  const assuranceFor = (
@@ -71,7 +71,7 @@ const assuranceFor = (
71
71
  export function selectMethods(policy: Policy, capabilities: Capability[]): SelectedMethod[] {
72
72
  return (Object.entries(METHODS) as [MethodId, MethodDefinition][]).flatMap(([id, definition]) => {
73
73
  const requirements = policy.requirements.filter((requirement) =>
74
- methodMatches(id, definition, requirement),
74
+ methodMatches(definition, requirement),
75
75
  );
76
76
  if (requirements.length === 0) return [];
77
77
  const capability = assuranceFor(id, definition, capabilities);