@brainervirus/workit-core 0.8.8 → 0.8.9

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": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "private": false,
5
5
  "description": "Workit — workflow rails for agentic coding: specs, plans, YouTrack, CI-gated commits (shared core)",
6
6
  "keywords": [
@@ -15,4 +15,6 @@ disable-model-invocation: true
15
15
  7. After any `workflow_handoff_session` result—success, partial, or failure—end the originating turn immediately after one status message. Never create todos, execute the plan inline, modify files, retry handoff, or call another tool.
16
16
  8. A destination run that executes the plan must still end with `workflow_plan_complete` (or the CLI `workit flow complete`) once the SDD ledger is complete and repository verification passes, and never finish the run while the plan is still `active`.
17
17
 
18
+ Handoff titles continuation sessions `Workit: <slug>` (never `Continue <slug>`). OpenCode's native `Continue opencode -s <session-id>` epilogue is the valid manual recovery command when host selection is unavailable (`stage: "select"`): `selected: false` with a `sessionID` is a partial success — use `opencode -s <session-id>` to resume, not a Workit bug.
19
+
18
20
  Never emit a continuation prompt, use the clipboard, or ask the user to copy text. If selected, report the session ID only if the current session remains visible. If staying, report the seeded session ID. On failure, report `stage` and `error`; preserve any returned session for the session picker and never recreate it automatically. `todowrite` and `task` are unnecessary here.
@@ -31,13 +31,15 @@ For every plan task whose ID is absent from `completed_task_ids`:
31
31
  3. Use `task` with the built-in `explore` agent for read-only discovery when needed, then a fresh built-in `general` agent to implement from the brief. The parent remains coordinator-only.
32
32
  4. Require product changes to follow TDD: failing check first, minimal implementation, passing focused check.
33
33
  5. Create the review package with `workflow_sdd_review_package` using `confirmed: true`.
34
- 6. Dispatch separate `general` agents for spec-compliance review and code-quality review. **Blocking findings** (Critical, Important, or spec-compliance) may trigger at most **two** fix+re-review rounds per task. **Advisory** findings (Minor, style, YAGNI, taste) never pause the loop — append them to `<SDD_DIR>/advisories.md` with the task id.
34
+ 6. Dispatch separate `general` agents for spec-compliance review and code-quality review. **Blocking findings** (Critical, Important, or spec-compliance) may trigger at most **two** fix+re-review rounds per task. **Advisory** findings (Minor, style, YAGNI, taste) never pause the loop — append them with `workflow_sdd_append_advisory` (`--task <id> --text <text>`) using `confirmed: true`.
35
35
  7. Append the validated ledger line with `workflow_sdd_append_progress` using `confirmed: true`, then mark the task completed with `todowrite`.
36
36
 
37
37
  Each task lands exactly one contiguous non-empty commit range (`base..head`): fix rounds append commits to that range and never rewrite/amend an active review range; each progress line records the task's real base..head shas.
38
38
 
39
39
  Never redispatch completed task IDs. Pass task briefs and review diffs to agents; do not make them reparse the plan. Keep commits on the in-place feature/bugfix branch.
40
40
 
41
+ Delegated authority is direct-child-only: a worker is the session whose host `parentID` exactly equals the activating coordinator's recorded `coordinator_session_id`; missing, mismatched, or multi-owner lineage fails closed with `delegation_lineage_denied`, and nested `opencode` launches are denied during active delegated work. An authorized child receives only the compact worker contract — execute the supplied brief, follow TDD, land one contiguous non-empty commit range, report results — never coordinator guidance, `wk-implement`, or ledger management. Coordinator bookkeeping (briefs, review packages, progress, advisories via `workflow_sdd_*`) stays with the coordinator session.
42
+
41
43
  ## Final gate
42
44
 
43
45
  After all remaining tasks, dispatch a final full-branch code review, run `workflow_verify`, and report exact per-check results. Present the full `<SDD_DIR>/advisories.md` roll-up once, then use native `question` so the user can choose which advisory items to fix, discuss, or discard. Only then may advisory fixes run. Use `workflow_git_context` for the final commit preview and the `wk-commit` skill for any approved commit. If a tracked stash reference exists, preview reapplication with `question`, then call `workflow_branch_setup` with `confirmed: true` only after approval.
@@ -5,6 +5,7 @@ import {
5
5
  fsyncSync,
6
6
  mkdirSync,
7
7
  openSync,
8
+ readdirSync,
8
9
  readFileSync,
9
10
  renameSync,
10
11
  rmSync,
@@ -56,6 +57,13 @@ export type FlowExecutionState = {
56
57
  status: ExecutionStatus;
57
58
  mode: ExecutionMode | null;
58
59
  evidence: LifecycleEvidence | null;
60
+ /**
61
+ * The activating OpenCode coordinator session (CA-12): recorded when an
62
+ * accepted `subagent-driven` menu choice starts the execution; preserved
63
+ * across pause/resume; cleared on completion and approval drift; null for
64
+ * every non-subagent-driven path and for legacy states without the field.
65
+ */
66
+ coordinator_session_id: string | null;
59
67
  };
60
68
 
61
69
  /**
@@ -70,14 +78,20 @@ export type MutationContext = {
70
78
  hostWorkspace: string;
71
79
  role: FlowRole;
72
80
  sessionId: string;
81
+ /**
82
+ * The host-attested parent session id (OpenCode only): present exactly when
83
+ * the host reports a parent for this session, i.e. the session is a child.
84
+ * Delegated authority requires this to equal the persisted
85
+ * `execution.coordinator_session_id` (CA-13) — fail closed otherwise.
86
+ */
87
+ parentSessionId?: string;
73
88
  taskIdentity?: string;
74
89
  };
75
90
 
76
91
  /** Recovery guidance surfaced on a blocked coordinator mutation (FG-07). */
77
92
  export const COORDINATOR_RECOVERY_TEXT =
78
93
  "A subagent-driven plan is active: coordinator product edits are blocked. " +
79
- "Delegate product mutations (task briefs, progress, review packages) to an " +
80
- "authenticated delegated worker via `task` / `wk-implement` instead of " +
94
+ "Delegate product mutations to an authenticated delegated worker via `task` / `wk-implement` instead of " +
81
95
  "editing in the coordinator session.";
82
96
 
83
97
  /**
@@ -263,6 +277,7 @@ const normalizeState = (parsed: unknown, slug: string): FlowState => {
263
277
  status: (execution.status ?? "pending") as ExecutionStatus,
264
278
  mode: (execution.mode ?? null) as ExecutionMode | null,
265
279
  evidence: (execution.evidence ?? null) as LifecycleEvidence | null,
280
+ coordinator_session_id: execution.coordinator_session_id ?? null,
266
281
  },
267
282
  handoff_destination: p.handoff_destination ?? false,
268
283
  updated_at: p.updated_at ?? Date.now(),
@@ -275,7 +290,7 @@ const emptyState = (slug: string): FlowState => ({
275
290
  spec: { path: "", status: "draft", evidence: null, approved_digest: null },
276
291
  plan: { path: "", status: "draft", evidence: null, approved_digest: null },
277
292
  menu: { presented: false, chosen: "", evidence: null },
278
- execution: { status: "pending", mode: null, evidence: null },
293
+ execution: { status: "pending", mode: null, evidence: null, coordinator_session_id: null },
279
294
  handoff_destination: false,
280
295
  updated_at: Date.now(),
281
296
  });
@@ -434,6 +449,16 @@ const validateState = (
434
449
  if (execRaw?.evidence !== undefined && !validateEvidenceValue(execRaw.evidence, true)) {
435
450
  return { ok: false, error: "flow state execution.evidence has an unsupported shape" };
436
451
  }
452
+ if (
453
+ execRaw?.coordinator_session_id !== undefined &&
454
+ execRaw.coordinator_session_id !== null &&
455
+ typeof execRaw.coordinator_session_id !== "string"
456
+ ) {
457
+ return {
458
+ ok: false,
459
+ error: "flow state execution.coordinator_session_id must be a string or null",
460
+ };
461
+ }
437
462
 
438
463
  return {
439
464
  ok: true,
@@ -451,6 +476,8 @@ const validateState = (
451
476
  status: (execRaw?.status as ExecutionStatus | undefined) ?? "pending",
452
477
  mode: (execRaw?.mode as ExecutionMode | null | undefined) ?? null,
453
478
  evidence: (execRaw?.evidence as LifecycleEvidence | null | undefined) ?? null,
479
+ coordinator_session_id:
480
+ (execRaw?.coordinator_session_id as string | null | undefined) ?? null,
454
481
  },
455
482
  handoff_destination: parsed.handoff_destination ?? false,
456
483
  updated_at: parsed.updated_at ?? Date.now(),
@@ -704,7 +731,7 @@ const resetForSpecDrift = (state: FlowState): FlowState => ({
704
731
  spec: { ...state.spec, status: "draft", evidence: null, approved_digest: null },
705
732
  plan: { ...state.plan, status: "draft", evidence: null, approved_digest: null },
706
733
  menu: { presented: false, chosen: "", evidence: null },
707
- execution: { status: "pending", mode: null, evidence: null },
734
+ execution: { status: "pending", mode: null, evidence: null, coordinator_session_id: null },
708
735
  handoff_destination: false,
709
736
  updated_at: Date.now(),
710
737
  });
@@ -716,7 +743,10 @@ const resetForPlanDrift = (state: FlowState): FlowState => ({
716
743
  // required before any plan-gated transition). The execution lifecycle, the
717
744
  // recorded menu choice, and the handoff context are lifecycle facts, not
718
745
  // plan-approval facts: an in-progress or completed run must not be rewound
719
- // to pending by a doc edit made during or after implementation.
746
+ // to pending by a doc edit made during or after implementation. The
747
+ // coordinator identity is likewise a lifecycle fact (CA-12): plan drift
748
+ // preserves it alongside the active status; only completion and spec
749
+ // approval drift clear it.
720
750
  updated_at: Date.now(),
721
751
  });
722
752
 
@@ -782,9 +812,16 @@ const deriveLegacyExecution = (
782
812
  ledger.started &&
783
813
  !ledger.complete
784
814
  ) {
785
- return { status: "active", mode: "subagent-driven", evidence: null };
815
+ // A legacy flow has no persisted coordinator identity: the field stays
816
+ // null and every lineage check fails closed (CA-12/CA-13).
817
+ return {
818
+ status: "active",
819
+ mode: "subagent-driven",
820
+ evidence: null,
821
+ coordinator_session_id: null,
822
+ };
786
823
  }
787
- return { status: "pending", mode: null, evidence: null };
824
+ return { status: "pending", mode: null, evidence: null, coordinator_session_id: null };
788
825
  };
789
826
 
790
827
  type CompatibilityResult = { state: FlowState; changed: boolean };
@@ -801,6 +838,15 @@ const normalizeCompatibility = (
801
838
  if (derived.status !== current.status || derived.mode !== current.mode) {
802
839
  return { state: { ...state, execution: derived, updated_at: Date.now() }, changed: true };
803
840
  }
841
+ return { state, changed: false };
842
+ }
843
+ // Legacy states written before coordinator_session_id (CA-12) carry an
844
+ // execution object without the key; validation defaults it to null, so the
845
+ // migration must be persisted under the lock or every read-modify-write
846
+ // would CAS-conflict forever (baseline bytes would never match disk).
847
+ const execRaw = isRecord(parsed.execution) ? parsed.execution : undefined;
848
+ if (execRaw && !("coordinator_session_id" in execRaw)) {
849
+ return { state: { ...state, updated_at: Date.now() }, changed: true };
804
850
  }
805
851
  return { state, changed: false };
806
852
  };
@@ -1040,7 +1086,8 @@ const assertMutationWorkspace = (root: string, ctx?: MutationContext): FlowGateR
1040
1086
  * subagent-driven, the coordinator session cannot mutate product state — only
1041
1087
  * authenticated delegated workers can. A historical subagent-driven menu choice
1042
1088
  * alone is not a boundary: a pending/paused/completed/inline execution leaves
1043
- * the coordinator unblocked. A delegated worker without a task identity is
1089
+ * the coordinator unblocked. A delegated worker is bound to the recorded
1090
+ * activating coordinator lineage (CA-13) and without a task identity is
1044
1091
  * blocked.
1045
1092
  */
1046
1093
  export const assertCoordinatorBoundary = (
@@ -1054,11 +1101,44 @@ export const assertCoordinatorBoundary = (
1054
1101
  ) {
1055
1102
  return err("coordinator_blocked", COORDINATOR_RECOVERY_TEXT);
1056
1103
  }
1057
- if (ctx?.role === "delegated" && !ctx.taskIdentity) {
1058
- return err(
1059
- "delegated_unauthenticated",
1060
- "delegated mutations require an authenticated task identity (taskIdentity) — re-run inside the delegated worker session",
1061
- );
1104
+ if (ctx?.role === "delegated") {
1105
+ const activeSubagent =
1106
+ state.execution.status === "active" && state.execution.mode === "subagent-driven";
1107
+ if (activeSubagent) {
1108
+ const parent =
1109
+ typeof ctx.parentSessionId === "string" && ctx.parentSessionId !== ""
1110
+ ? ctx.parentSessionId
1111
+ : null;
1112
+ const recorded = state.execution.coordinator_session_id;
1113
+ // A present-but-mismatched lineage fails closed (CA-13): re-rooted
1114
+ // lineage laundering denied.
1115
+ if (parent !== null && recorded !== null && parent !== recorded) {
1116
+ return err(
1117
+ "delegation_lineage_denied",
1118
+ "delegated mutations require an exact direct-parent match to the activating coordinator session",
1119
+ );
1120
+ }
1121
+ if (!ctx.taskIdentity) {
1122
+ // Unverifiable lineage (no parent reported, or no recorded coordinator
1123
+ // id — CA-12/CA-13) fails closed; a verified lineage without a task
1124
+ // identity stays unauthenticated.
1125
+ if (parent === null || recorded === null) {
1126
+ return err(
1127
+ "delegation_lineage_denied",
1128
+ "delegated mutations require an exact direct-parent match to the activating coordinator session",
1129
+ );
1130
+ }
1131
+ return err(
1132
+ "delegated_unauthenticated",
1133
+ "delegated mutations require an authenticated task identity (taskIdentity) — re-run inside the delegated worker session",
1134
+ );
1135
+ }
1136
+ } else if (!ctx.taskIdentity) {
1137
+ return err(
1138
+ "delegated_unauthenticated",
1139
+ "delegated mutations require an authenticated task identity (taskIdentity) — re-run inside the delegated worker session",
1140
+ );
1141
+ }
1062
1142
  }
1063
1143
  return { ok: true };
1064
1144
  };
@@ -1117,7 +1197,7 @@ const NEGATIVE_ANSWER_LABELS = [
1117
1197
  "deny",
1118
1198
  ];
1119
1199
 
1120
- const isNegativeLabel = (label: string): boolean => {
1200
+ export const isNegativeLabel = (label: string): boolean => {
1121
1201
  const normalized = label.trim().toLowerCase();
1122
1202
  return NEGATIVE_ANSWER_LABELS.some((entry) => {
1123
1203
  const firstWord = normalized.split(/\s+/)[0] ?? "";
@@ -1133,10 +1213,41 @@ const isNegativeLabel = (label: string): boolean => {
1133
1213
  });
1134
1214
  };
1135
1215
 
1216
+ export type ReceiptPurpose =
1217
+ | "spec-approval"
1218
+ | "plan-approval"
1219
+ | "execution-menu"
1220
+ | "plan-pause"
1221
+ | "plan-resume"
1222
+ | "plan-complete";
1223
+
1224
+ export const receiptPurposeForLabel = (label: string): ReceiptPurpose | undefined => {
1225
+ const n = normalizeLabel(label);
1226
+ if (n === "approve spec" || n === "approve spec recommended") return "spec-approval";
1227
+ if (n === "approve plan" || n === "approve plan recommended") return "plan-approval";
1228
+ if (n === "approve") return undefined;
1229
+ if (n === "pause plan") return "plan-pause";
1230
+ if (n === "resume plan") return "plan-resume";
1231
+ if (n === "complete plan") return "plan-complete";
1232
+ const exec = new Set([
1233
+ "subagent driven",
1234
+ "inline",
1235
+ "handoff",
1236
+ "review spec",
1237
+ "review plan",
1238
+ "change model",
1239
+ ]);
1240
+ // Decorated execution labels: "(Recommended)" is stripped by normalizeLabel,
1241
+ // so "Subagent-driven (Recommended)" normalizes to "subagent driven".
1242
+ if (exec.has(n)) return "execution-menu";
1243
+ return undefined;
1244
+ };
1245
+
1136
1246
  /**
1137
1247
  * One-use host-observed receipt (AR-12, CA-41): recorded by the OpenCode
1138
1248
  * plugin when the answered `question` tool completes, bound to the session,
1139
- * the question tool call id, the exact selected label, and the timestamp.
1249
+ * the question tool call id, the exact selected label, the timestamp, and
1250
+ * the workflow purpose.
1140
1251
  * The model has no way to inject a receipt — `record` is only reachable from
1141
1252
  * the plugin's `tool.execute.after` hook.
1142
1253
  */
@@ -1148,7 +1259,8 @@ export type HostReceipt = {
1148
1259
  /** The question text the user answered (plugin-observed, best effort), so
1149
1260
  * the consuming tool can report WHICH question authorized a transition
1150
1261
  * (FINDING 2). */
1151
- question?: string;
1262
+ question: string;
1263
+ purpose: ReceiptPurpose;
1152
1264
  };
1153
1265
 
1154
1266
  export type ReceiptConsumeResult = { ok: true; receipt: HostReceipt } | FlowError;
@@ -1163,15 +1275,16 @@ export type ReceiptConsumeResult = { ok: true; receipt: HostReceipt } | FlowErro
1163
1275
  * `question` (user answers), THEN calls the approval/menu tool — the tools
1164
1276
  * never run a question internally, so a before/after execution window can
1165
1277
  * never capture the answer. Consumption therefore takes the session's MOST
1166
- * RECENT unconsumed receipt and verifies: one-use (atomic take), freshness
1278
+ * RECENT unconsumed receipt FOR THE EXACT PURPOSE and verifies: one-use (atomic take), freshness
1167
1279
  * (RECEIPT_FRESHNESS_MS), NOT a negative label (isNegativeLabel), and session
1168
1280
  * match. Menu tools additionally pin the expected choice label. CallID and
1169
- * the exact selected label stay bound at record time.
1281
+ * the exact selected label stay bound at record time. Unrelated purpose
1282
+ * receipts never mask the target purpose.
1170
1283
  *
1171
- * Residual risk (honest boundary): any recent POSITIVE host answer (e.g. a
1172
- * "proceed with stash?" -> "yes, proceed") plus the model's choice to call an
1284
+ * Residual risk (honest boundary): any recent POSITIVE host answer FOR THAT PURPOSE plus the model's choice to call an
1173
1285
  * approval tool authorizes the transition. The laundering case — a negative
1174
- * answer recorded as an approval — is closed by the negative-label denylist.
1286
+ * answer recorded as an approval — is closed by the negative-label denylist
1287
+ * per purpose.
1175
1288
  *
1176
1289
  * ponytail: in-memory only — receipts die with the plugin process, which is
1177
1290
  * correct: a host-observed answer cannot survive a restart. Upgrade path:
@@ -1185,14 +1298,18 @@ export class HostReceiptStore {
1185
1298
  callID: string,
1186
1299
  selectedLabel: string,
1187
1300
  recordedAt: number = Date.now(),
1188
- question?: string,
1301
+ question: string = "",
1302
+ purpose?: ReceiptPurpose,
1189
1303
  ): void {
1190
- const label = selectedLabel.trim();
1191
- if (!label) return;
1304
+ const trimmed = selectedLabel.trim();
1305
+ if (!trimmed) return;
1192
1306
  if (recordedAt > Date.now() + MAX_CLOCK_SKEW_MS) return; // forged future receipt
1307
+ const derived = purpose ?? receiptPurposeForLabel(selectedLabel);
1308
+ if (derived === undefined) return; // unrelated question produces no flow receipt (CA-01)
1309
+ const q = question ?? "";
1193
1310
  const queue = this.#bySession.get(sessionId) ?? [];
1194
1311
  if (queue.length >= MAX_RECEIPTS_PER_SESSION) queue.shift();
1195
- queue.push({ sessionId, callID, selectedLabel: label, recordedAt, question });
1312
+ queue.push({ sessionId, callID, selectedLabel, recordedAt, question: q, purpose: derived });
1196
1313
  this.#bySession.set(sessionId, queue);
1197
1314
  }
1198
1315
 
@@ -1207,25 +1324,35 @@ export class HostReceiptStore {
1207
1324
  * the transition and is spent on any attempt, closing the concurrent-call
1208
1325
  * race). Peek remains for tests and read-only callers. A NEGATIVE receipt is
1209
1326
  * the exception: it is spent by peek too (consumed-and-rejected, FINDING 3)
1210
- * so it cannot poison the top of the queue.
1327
+ * so it cannot poison the top of the queue. Negative revocation is per-purpose.
1211
1328
  */
1212
- peek(sessionId: string, opts: { label?: string } = {}): ReceiptConsumeResult {
1329
+ peek(
1330
+ sessionId: string,
1331
+ opts: { purpose?: ReceiptPurpose; label?: string } = {},
1332
+ ): ReceiptConsumeResult {
1213
1333
  return this.#take(sessionId, opts, false);
1214
1334
  }
1215
1335
 
1216
1336
  /**
1217
- * One-use consumption of the session's most recent receipt (FINDING 2).
1337
+ * One-use consumption of the session's most recent receipt FOR THE EXACT PURPOSE (FINDING 2).
1218
1338
  * The atomic take gates the transition at the tool layer (FINDING 5, round
1219
1339
  * 3): a stale receipt, a wrong pinned label (menu), or a negative label
1220
1340
  * fails the transition; the receipt is removed on take, staleness, or
1221
1341
  * negativity (fail-closed). A wrong label (menu) is NOT spent — it stays
1222
- * queued for the choice it actually matched.
1342
+ * queued for the choice it actually matched. Negative revocation removes only older receipts of that purpose.
1223
1343
  */
1224
- consume(sessionId: string, opts: { label?: string } = {}): ReceiptConsumeResult {
1344
+ consume(
1345
+ sessionId: string,
1346
+ opts: { purpose?: ReceiptPurpose; label?: string } = {},
1347
+ ): ReceiptConsumeResult {
1225
1348
  return this.#take(sessionId, opts, true);
1226
1349
  }
1227
1350
 
1228
- #take(sessionId: string, opts: { label?: string }, remove: boolean): ReceiptConsumeResult {
1351
+ #take(
1352
+ sessionId: string,
1353
+ opts: { purpose?: ReceiptPurpose; label?: string },
1354
+ remove: boolean,
1355
+ ): ReceiptConsumeResult {
1229
1356
  const queue = this.#bySession.get(sessionId);
1230
1357
  if (!queue || queue.length === 0) {
1231
1358
  return err(
@@ -1234,14 +1361,47 @@ export class HostReceiptStore {
1234
1361
  "`question` tool and have the user answer before calling this tool",
1235
1362
  );
1236
1363
  }
1237
- const index = queue.length - 1;
1364
+ let index = -1;
1365
+ if (opts.purpose !== undefined) {
1366
+ // A top negative blocks only its own purpose; an unrelated purpose's
1367
+ // typed receipt is untouched (CA-02 per-purpose revocation). Purposeless
1368
+ // negatives no longer exist: `record` drops unclassified questions, so
1369
+ // branch/stash/No/Cancel never block typed purposes globally.
1370
+ const top = queue[queue.length - 1];
1371
+ if (top && top.purpose === opts.purpose && isNegativeLabel(top.selectedLabel)) {
1372
+ const filtered = queue.filter((r) => r.purpose !== opts.purpose);
1373
+ if (filtered.length === 0) this.#bySession.delete(sessionId);
1374
+ else this.#bySession.set(sessionId, filtered);
1375
+ return err(
1376
+ "receipt_rejected",
1377
+ `the user's most recent answer (${JSON.stringify(top.selectedLabel)}) is a ` +
1378
+ "negative answer — it cannot authorize an approval; ask the native question again",
1379
+ );
1380
+ }
1381
+ for (let i = queue.length - 1; i >= 0; i--) {
1382
+ if (queue[i].purpose === opts.purpose) {
1383
+ index = i;
1384
+ break;
1385
+ }
1386
+ }
1387
+ if (index === -1) {
1388
+ return err(
1389
+ "receipt_missing",
1390
+ `no host-observed receipt for purpose ${JSON.stringify(opts.purpose)} — ask the native question for that purpose`,
1391
+ );
1392
+ }
1393
+ } else {
1394
+ index = queue.length - 1;
1395
+ }
1238
1396
  const receipt = queue[index];
1239
1397
  if (isNegativeLabel(receipt.selectedLabel)) {
1240
- // Consumed-and-rejected: a negative answer is still an answer, and it
1241
- // can never authorize a transition (FINDING 3). The whole session queue
1242
- // is revoked too: the user's most recent intent is negative, so an
1243
- // older positive answer must not come back to life on a retry.
1244
- this.#bySession.delete(sessionId);
1398
+ if (opts.purpose !== undefined) {
1399
+ const filtered = queue.filter((r) => r.purpose !== opts.purpose);
1400
+ if (filtered.length === 0) this.#bySession.delete(sessionId);
1401
+ else this.#bySession.set(sessionId, filtered);
1402
+ } else {
1403
+ this.#bySession.delete(sessionId);
1404
+ }
1245
1405
  return err(
1246
1406
  "receipt_rejected",
1247
1407
  `the user's most recent answer (${JSON.stringify(receipt.selectedLabel)}) is a ` +
@@ -1445,7 +1605,7 @@ export const prepareFlowState = (
1445
1605
  spec: { path: specPath, status: "draft", evidence: null, approved_digest: null },
1446
1606
  plan: { path: planPath, status: "draft", evidence: null, approved_digest: null },
1447
1607
  menu: { presented: false, chosen: "", evidence: null },
1448
- execution: { status: "pending", mode: null, evidence: null },
1608
+ execution: { status: "pending", mode: null, evidence: null, coordinator_session_id: null },
1449
1609
  handoff_destination: false,
1450
1610
  updated_at: Date.now(),
1451
1611
  });
@@ -1646,8 +1806,15 @@ export const recordMenuChoice = (
1646
1806
  // Lifecycle is set ATOMICALLY with the menu evidence (CA-11/CA-13): an
1647
1807
  // executing choice starts the plan; a review/handoff choice leaves it
1648
1808
  // pending. The menu evidence IS the lifecycle evidence — the choice the
1649
- // user selected on the native question.
1809
+ // user selected on the native question. The activating OpenCode
1810
+ // coordinator session (CA-12) is persisted ONLY for an accepted
1811
+ // subagent-driven activation; inline/handoff/review choices and Cursor's
1812
+ // rejected subagent path keep it null.
1650
1813
  const executing = choice === "subagent-driven" || choice === "inline";
1814
+ const coordinatorSessionId =
1815
+ choice === "subagent-driven" && recorded.evidence.host === "opencode"
1816
+ ? (ctx?.sessionId ?? null)
1817
+ : null;
1651
1818
  return {
1652
1819
  ok: true,
1653
1820
  next: {
@@ -1658,8 +1825,18 @@ export const recordMenuChoice = (
1658
1825
  plan: { ...state.plan, path: state.plan.path || `docs/${slug}/plan.md` },
1659
1826
  menu: { presented: true, chosen: choice, evidence: recorded.evidence },
1660
1827
  execution: executing
1661
- ? { status: "active", mode: choice as ExecutionMode, evidence: recorded.evidence }
1662
- : { status: "pending", mode: null, evidence: recorded.evidence },
1828
+ ? {
1829
+ status: "active",
1830
+ mode: choice as ExecutionMode,
1831
+ evidence: recorded.evidence,
1832
+ coordinator_session_id: coordinatorSessionId,
1833
+ }
1834
+ : {
1835
+ status: "pending",
1836
+ mode: null,
1837
+ evidence: recorded.evidence,
1838
+ coordinator_session_id: null,
1839
+ },
1663
1840
  updated_at: Date.now(),
1664
1841
  },
1665
1842
  };
@@ -1811,7 +1988,9 @@ const completeExecution = (
1811
1988
  }
1812
1989
  const next: FlowState = {
1813
1990
  ...reconciled.state,
1814
- execution: { ...exec, status: "completed" },
1991
+ // Completion clears the activating coordinator identity (CA-12): a
1992
+ // completed flow has no delegated workers left to authorize.
1993
+ execution: { ...exec, status: "completed", coordinator_session_id: null },
1815
1994
  // A completed flow is never a destination: clear the context so the next
1816
1995
  // ordinary session gets the source five-choice reminder, not the stale
1817
1996
  // four-choice destination wording (CA-08). Both approval-drift resets
@@ -1907,6 +2086,47 @@ export const slugFromSddPath = (p: string): string => {
1907
2086
  return match?.[1] ?? "";
1908
2087
  };
1909
2088
 
2089
+ /**
2090
+ * Handoff readiness (CA-06..CA-08): the source flow must be approved, valid,
2091
+ * not already a destination, and have menu.presented === true with
2092
+ * menu.chosen === "handoff" before ANY session is created. A logical preflight
2093
+ * failure creates no session (orphan-free). Uses the effective reconciled
2094
+ * state so digest drift is observed.
2095
+ */
2096
+ export const assertHandoffReady = (root: string, planPath: string): FlowGateResult => {
2097
+ const doc = resolveDoc(root, "", planPath, "plan");
2098
+ if (!doc.ok) return err("path_invalid", doc.error);
2099
+ const slug = slugFromPath(planPath);
2100
+ const effective = readEffectiveFlowState(root, slug);
2101
+ if (!effective.ok) return effective;
2102
+ const state = effective.state;
2103
+ if (state.spec.status !== "approved") {
2104
+ return err(
2105
+ "spec_not_approved",
2106
+ `spec not approved (status: ${state.spec.status}). Run workflow_spec_approve after the user's approval.`,
2107
+ );
2108
+ }
2109
+ if (state.plan.status !== "approved") {
2110
+ return err(
2111
+ "plan_not_approved",
2112
+ `plan not approved (status: ${state.plan.status}). Run workflow_plan_approve after the user's approval.`,
2113
+ );
2114
+ }
2115
+ if (state.handoff_destination) {
2116
+ return err(
2117
+ "recursive_handoff",
2118
+ "this flow is already a handoff destination — a second handoff is rejected",
2119
+ );
2120
+ }
2121
+ if (!state.menu.presented || state.menu.chosen !== "handoff") {
2122
+ return err(
2123
+ "handoff_not_chosen",
2124
+ `handoff requires the execution menu choice "handoff" (chosen: ${JSON.stringify(state.menu.chosen)}, presented: ${state.menu.presented})`,
2125
+ );
2126
+ }
2127
+ return { ok: true };
2128
+ };
2129
+
1910
2130
  export const assertFlowGates = (
1911
2131
  root: string,
1912
2132
  planPath: string,
@@ -1996,13 +2216,81 @@ export const assertProductGates = (
1996
2216
  };
1997
2217
 
1998
2218
  /**
1999
- * Delegated status derives from host session parentage (AR-12, CA-20): a
2000
- * session whose host record has a parent is a child (delegated worker); a root
2001
- * session (no parent) is the coordinator. Caller-supplied role fields are
2002
- * removed from every tool schema this pure function is the only source.
2219
+ * Coordinator-only SDD control gate (CA-10): validated gitignored control
2220
+ * metadata under docs/<slug>/sdd/ task briefs, review packages, progress,
2221
+ * and advisories. Requirements match assertProductGates' workspace/approval/
2222
+ * menu/docs/path checks, but when execution is active subagent-driven the
2223
+ * call must be the coordinator (root session); a delegated worker cannot
2224
+ * mutate coordinator bookkeeping. Inactive flows are not gated on role.
2003
2225
  */
2004
- export const roleFromParentage = (parentID?: string | null): FlowRole =>
2005
- parentID ? "delegated" : "coordinator";
2226
+ export const assertSddControlGates = (
2227
+ root: string,
2228
+ slug: string,
2229
+ opts: { requireMenu?: boolean; requireDocs?: boolean } = {},
2230
+ ctx?: MutationContext,
2231
+ ): FlowGateResult => {
2232
+ const bound = assertMutationWorkspace(root, ctx);
2233
+ if (!bound.ok) return bound;
2234
+ const effective = readEffectiveFlowState(root, slug);
2235
+ if (!effective.ok) return effective;
2236
+ const state = effective.state;
2237
+ if (state.spec.status !== "approved") {
2238
+ return err(
2239
+ "spec_not_approved",
2240
+ `spec not approved (status: ${state.spec.status}). Run workflow_spec_approve after the user's approval.`,
2241
+ );
2242
+ }
2243
+ if (state.plan.status !== "approved") {
2244
+ return err(
2245
+ "plan_not_approved",
2246
+ `plan not approved (status: ${state.plan.status}). Run workflow_plan_approve after the user's approval.`,
2247
+ );
2248
+ }
2249
+ if (opts.requireMenu && !state.menu.presented) {
2250
+ return err(
2251
+ "menu_not_presented",
2252
+ "post-plan menu not presented. Record the native question answer with workflow_plan_menu.",
2253
+ );
2254
+ }
2255
+ if (opts.requireDocs) {
2256
+ const validated = docsValidate({
2257
+ spec_path: path.posix.join("docs", slug, "spec.md"),
2258
+ plan_path: path.posix.join("docs", slug, "plan.md"),
2259
+ workspace_root: root,
2260
+ });
2261
+ if (validated.ok === false) return err("docs_invalid", validated.error);
2262
+ }
2263
+ if (
2264
+ state.execution.status === "active" &&
2265
+ state.execution.mode === "subagent-driven" &&
2266
+ // Lineage binding (CA-13): the adapter derives the role before the slug
2267
+ // resolves, so delegation is re-derived here from the host-attested parent
2268
+ // against the persisted activating coordinator id.
2269
+ roleFromParentage(ctx?.parentSessionId, state.execution.coordinator_session_id) === "delegated"
2270
+ ) {
2271
+ return err(
2272
+ "sdd_control_denied",
2273
+ "SDD control metadata is coordinator-owned while a subagent-driven plan is active — delegated workers cannot mutate task briefs, review packages, progress, or advisories",
2274
+ );
2275
+ }
2276
+ return { ok: true };
2277
+ };
2278
+
2279
+ /**
2280
+ * Delegated status derives from host session parentage bound to the persisted
2281
+ * coordinator identity (AR-12, CA-13): a session with a parent is delegated
2282
+ * ONLY when that parent id equals the flow's recorded activating coordinator
2283
+ * session; any other parentage (or a missing/null coordinator id) is a
2284
+ * coordinator. Caller-supplied role fields are removed from every tool schema
2285
+ * — this pure function is the only source.
2286
+ */
2287
+ export const roleFromParentage = (
2288
+ parentID?: string | null,
2289
+ coordinatorSessionId?: string | null,
2290
+ ): FlowRole =>
2291
+ typeof parentID === "string" && parentID !== "" && parentID === coordinatorSessionId
2292
+ ? "delegated"
2293
+ : "coordinator";
2006
2294
 
2007
2295
  /**
2008
2296
  * Root-session write interception while a subagent-driven plan is active
@@ -2028,7 +2316,8 @@ export const COORDINATOR_WRITE_TOOLS: readonly string[] = [
2028
2316
  "touch",
2029
2317
  "chmod",
2030
2318
  "chown",
2031
- // workit product/config/external mutation tools
2319
+ // workit product/config/external mutation tools (SDD control tools are
2320
+ // coordinator-owned and routed through assertSddControlGates, not this set)
2032
2321
  "workflow_commit",
2033
2322
  "workflow_pr_create",
2034
2323
  "workflow_rule_edit",
@@ -2039,9 +2328,6 @@ export const COORDINATOR_WRITE_TOOLS: readonly string[] = [
2039
2328
  "workflow_docs_promote",
2040
2329
  "workflow_docs_layout",
2041
2330
  "workflow_docs_repo_link",
2042
- "workflow_sdd_task_brief",
2043
- "workflow_sdd_review_package",
2044
- "workflow_sdd_append_progress",
2045
2331
  "workflow_youtrack_post",
2046
2332
  "workflow_youtrack_log_time",
2047
2333
  ];
@@ -2657,19 +2943,74 @@ export const COORDINATOR_SHELL_DENIED_TEXT =
2657
2943
  COORDINATOR_RECOVERY_TEXT;
2658
2944
 
2659
2945
  /**
2660
- * The plugin hook's decision function (AR-13): a delegated child session
2661
- * (host parentage) is never intercepted; the root session is intercepted only
2662
- * while at least one subagent-driven plan is active in its workspace. Returns
2663
- * the denial error to throw from `tool.execute.before`, or `{ ok: true }`.
2946
+ * The plugin hook's decision function (AR-13): only the exact direct child of
2947
+ * the single recorded activating coordinator escapes interception while a
2948
+ * subagent-driven plan is active; a re-rooted lineage, an unrelated child, or
2949
+ * the root coordinator itself is intercepted. Returns the denial error to
2950
+ * throw from `tool.execute.before`, or `{ ok: true }`.
2664
2951
  */
2665
2952
  export const subagentDrivenInterception = (input: {
2666
2953
  tool: string;
2667
2954
  command?: string;
2668
2955
  parentID?: string | null;
2669
- active: boolean;
2956
+ activeCoordinatorIds?: string[];
2957
+ active?: boolean;
2670
2958
  }): FlowGateResult => {
2671
- if (input.parentID) return { ok: true }; // delegated child the worker
2672
- if (!input.active) return { ok: true };
2959
+ // Distinct owners only: the same coordinator recorded on several active
2960
+ // plans is still ONE owner (CA-13 denies multiple DISTINCT owners).
2961
+ const ids = Array.from(
2962
+ new Set((input.activeCoordinatorIds ?? []).filter((id) => typeof id === "string" && id !== "")),
2963
+ );
2964
+ const parent =
2965
+ typeof input.parentID === "string" && input.parentID !== "" ? input.parentID : null;
2966
+ const legacyActive = input.active === true;
2967
+ if (!legacyActive && ids.length === 0) return { ok: true };
2968
+ // Authorized direct child: exactly one recorded coordinator and this session
2969
+ // is its exact direct child.
2970
+ if (parent !== null && ids.length === 1 && ids[0] === parent) {
2971
+ if (input.tool === "bash") {
2972
+ // Nested-launch denial (CA-14): an authorized worker cannot launch
2973
+ // opencode recursively while the plan is active. Any token whose
2974
+ // basename is exactly `opencode` denies — head, path-suffixed
2975
+ // (`./node_modules/.bin/opencode`), or runner-carried
2976
+ // (`bun x opencode`). ponytail: argument text containing the bare word
2977
+ // (`grep opencode file`) is over-denied — a documented ceiling; a
2978
+ // parser that distinguishes argument positions is the upgrade path.
2979
+ const tokens = (input.command ?? "").split(/[\s'"]+/).filter(Boolean);
2980
+ const launchesOpencode = tokens.some((t) => t.split("/").pop() === "opencode");
2981
+ if (launchesOpencode) {
2982
+ return err(
2983
+ "delegation_lineage_denied",
2984
+ "nested opencode launch is denied while a subagent-driven plan is active",
2985
+ );
2986
+ }
2987
+ }
2988
+ if (
2989
+ [
2990
+ "workflow_sdd_task_brief",
2991
+ "workflow_sdd_review_package",
2992
+ "workflow_sdd_append_progress",
2993
+ "workflow_sdd_append_advisory",
2994
+ ].includes(input.tool)
2995
+ ) {
2996
+ return err(
2997
+ "delegation_lineage_denied",
2998
+ "SDD control metadata is coordinator-owned — workers execute briefs, not bookkeeping",
2999
+ );
3000
+ }
3001
+ return { ok: true };
3002
+ }
3003
+ if (parent !== null) {
3004
+ // A non-empty parentID that does not exactly match the single recorded
3005
+ // coordinator fails closed (CA-13): re-rooted lineage laundering denied.
3006
+ if (!legacyActive || ids.length > 0) {
3007
+ return err(
3008
+ "delegation_lineage_denied",
3009
+ "delegated writes require an exact direct-parent match to the activating coordinator",
3010
+ );
3011
+ }
3012
+ }
3013
+ // Coordinator (root) path: existing restrictions while active.
2673
3014
  if (COORDINATOR_WRITE_TOOLS.includes(input.tool)) {
2674
3015
  return err("coordinator_write_denied", COORDINATOR_RECOVERY_TEXT);
2675
3016
  }
@@ -2680,3 +3021,28 @@ export const subagentDrivenInterception = (input: {
2680
3021
  }
2681
3022
  return { ok: true };
2682
3023
  };
3024
+
3025
+ export const findActiveSubagentDrivenContexts = (
3026
+ root: string,
3027
+ ): Array<{ slug: string; coordinator_session_id: string | null }> => {
3028
+ let entries: string[] = [];
3029
+ try {
3030
+ entries = readdirSync(path.join(root, "docs"), { withFileTypes: true })
3031
+ .filter((e) => e.isDirectory())
3032
+ .map((e) => e.name);
3033
+ } catch {
3034
+ return [];
3035
+ }
3036
+ const out: Array<{ slug: string; coordinator_session_id: string | null }> = [];
3037
+ for (const slug of entries) {
3038
+ try {
3039
+ const state = readFlowState(root, slug);
3040
+ if (state.execution.status === "active" && state.execution.mode === "subagent-driven") {
3041
+ out.push({ slug, coordinator_session_id: state.execution.coordinator_session_id });
3042
+ }
3043
+ } catch {
3044
+ // unreadable flow state: skip, never throw from discovery
3045
+ }
3046
+ }
3047
+ return out;
3048
+ };
package/src/core/menu.ts CHANGED
@@ -16,14 +16,16 @@ export const SOURCE_MENU_LABELS = [
16
16
  "Handoff",
17
17
  "Review spec first",
18
18
  "Review plan first",
19
+ "Change model first",
19
20
  ] as const;
20
21
 
21
- /** Display labels a marked destination presents — exactly four, no Handoff (CA-08). */
22
+ /** Display labels a marked destination presents — exactly five, no Handoff (CA-08). */
22
23
  export const DESTINATION_MENU_LABELS = [
23
24
  "Subagent-driven",
24
25
  "Inline",
25
26
  "Review spec first",
26
27
  "Review plan first",
28
+ "Change model first",
27
29
  ] as const;
28
30
 
29
31
  /**
@@ -51,6 +51,17 @@ export const SDD_REMINDER_TEXT = `<workflow-sdd-reminder>
51
51
  An approved plan is subagent-driven — execute it via \`wk-implement\` / \`task\` delegation. Never implement the approved plan inline in the main session.
52
52
  </workflow-sdd-reminder>`;
53
53
 
54
+ /**
55
+ * Worker-only context (CA-16): an authorized direct child of the activating
56
+ * coordinator receives ONLY this compact contract — never the coordinator
57
+ * bootstrap or SDD_REMINDER_TEXT. It carries the worker duties (brief, TDD,
58
+ * commit range, report) and no coordination instructions.
59
+ */
60
+ export const SDD_WORKER_REMINDER_TEXT = `<workflow-sdd-worker>
61
+ You are an authorized delegated worker for an active subagent-driven plan.
62
+ Execute only the supplied task brief: follow TDD (failing test first), land exactly one contiguous non-empty commit range for your task, then report status, commits, and test results to the coordinator. Do not manage coordinator bookkeeping or launch another agent harness.
63
+ </workflow-sdd-worker>`;
64
+
54
65
  export const DOC_RENDER_TEXT = `<workflow-doc-render>
55
66
  When delivering a spec or plan, by default render the full markdown content of the doc in chat (headings, tables, mermaid fences preserved) — NOT a backtick-wrapped raw block.
56
67
  If the doc exceeds the render threshold (more than 150 lines, over 8KB, or more than 3 mermaid diagrams), deliver only the clickable link \`[spec.md](docs/<slug>/spec.md)\` + a 3-5 bullet summary.
package/src/core/sdd.ts CHANGED
@@ -292,3 +292,62 @@ export function sddAppendProgress({
292
292
  const rel = posix(path.relative(contained.base, path_));
293
293
  return { ok: true, line: trimmed, progress_path: rel };
294
294
  }
295
+
296
+ export type AdvisoryResult =
297
+ | { ok: true; advisory: string; advisories_path: string }
298
+ | { error: string; code: string };
299
+
300
+ export function sddAppendAdvisory({
301
+ advisories_path,
302
+ task_id,
303
+ text,
304
+ workspace_root,
305
+ }: {
306
+ advisories_path: string;
307
+ task_id: unknown;
308
+ text: unknown;
309
+ workspace_root: string;
310
+ }): AdvisoryResult {
311
+ if (typeof task_id !== "number" || !Number.isSafeInteger(task_id) || task_id <= 0) {
312
+ return { error: "task_id must be a positive safe integer", code: "advisory_task_invalid" };
313
+ }
314
+ if (typeof text !== "string") {
315
+ return {
316
+ error: "advisory text must be a string of 1-1000 characters after normalization",
317
+ code: "advisory_text_invalid",
318
+ };
319
+ }
320
+ if (text.includes("\r") || text.includes("\n")) {
321
+ return {
322
+ error: "advisory text must be a single line (no CR/LF)",
323
+ code: "advisory_text_invalid",
324
+ };
325
+ }
326
+ const collapsed = text.trim().replace(/[ \t]+/g, " ");
327
+ if (collapsed.length === 0 || collapsed.length > 1000) {
328
+ return {
329
+ error: "advisory text must be 1-1000 characters after trim and horizontal-space collapse",
330
+ code: "advisory_text_invalid",
331
+ };
332
+ }
333
+ if (!/^docs\/[^/]+\/sdd\/advisories\.md$/.test(advisories_path)) {
334
+ return {
335
+ error: `advisories_path must be docs/<slug>/sdd/advisories.md: ${advisories_path}`,
336
+ code: "advisory_path_invalid",
337
+ };
338
+ }
339
+ const contained = resolveDocsPath({ workspace_root, path: advisories_path });
340
+ if (!contained.ok) return { error: contained.error, code: "advisory_path_invalid" };
341
+ const abs = contained.path;
342
+ if (existsSync(abs) && statSync(abs).isDirectory()) {
343
+ return {
344
+ error: `advisory target is a directory: ${advisories_path}`,
345
+ code: "advisory_target_invalid",
346
+ };
347
+ }
348
+ mkdirSync(path.dirname(abs), { recursive: true });
349
+ const line = `- Task ${task_id}: ${collapsed}\n`;
350
+ appendFileSync(abs, line, "utf8");
351
+ const rel = posix(path.relative(contained.base, abs));
352
+ return { ok: true, advisory: collapsed, advisories_path: rel };
353
+ }
@@ -7,12 +7,13 @@ Load `using-superpowers`, `subagent-driven-development`, `test-driven-developmen
7
7
 
8
8
  ## Handoff destination
9
9
 
10
- This session is a handoff destination for a continued plan. The originating session already recorded the post-plan menu choice; present exactly these four choices and never re-offer the originating handoff option:
10
+ This session is a handoff destination for a continued plan. The originating session already recorded the post-plan menu choice; present exactly these four choices plus model deferral and never re-offer the originating handoff option:
11
11
 
12
12
  - Subagent-driven
13
13
  - Inline
14
14
  - Review spec first
15
15
  - Review plan first
16
+ - Change model first
16
17
 
17
18
  <workflow-handoff-destination>true</workflow-handoff-destination>
18
19
 
@@ -24,6 +25,7 @@ This session is a handoff destination for a continued plan. The originating sess
24
25
  - Use native `todowrite` for visible task state as well as the gitignored ledger.
25
26
  - Use native `question` for branch/stash choices and guarded external mutations; call mutation tools only after approval with `confirmed: true` (grounded in the recorded NativeChoiceEvidence).
26
27
  - Flow-tool confirmations are never agent-typed booleans and never caller-supplied evidence objects: on OpenCode the plugin records the user's native-`question` answer as a host-observed one-use receipt (`attested: true`, `callID`, `selectedLabel`, `recordedAt`) consumed by `workflow_spec_approve` / `workflow_plan_approve` / `workflow_plan_menu` — no evidence argument exists, and delegated worker status comes from host session parentage (`parentID`), never a caller `role` field. On Cursor, confirmations are policy-only (`attested: false`) and subagent-driven execution is rejected as unsupported.
28
+ - Delegated authority is direct-child-only: a worker is the session whose host `parentID` exactly equals the activating coordinator's recorded `coordinator_session_id`; missing, mismatched, or multi-owner lineage fails closed with `delegation_lineage_denied`, and nested `opencode` launches are denied during active delegated work. An authorized child receives only the compact worker contract (execute the supplied brief, follow TDD, land one contiguous non-empty commit range, report results) — never coordinator guidance, `wk-implement`, or ledger management; coordinator bookkeeping via `workflow_sdd_*` stays with the coordinator session.
27
29
  - On Cursor, for every repository-scoped `workflow_*` call, pass the active Cursor workspace as `workspace_root`; never rely on the MCP process default.
28
30
  - Use native `task` with only the built-in `explore` and `general` agents.
29
31
 
@@ -52,7 +54,7 @@ For each top-level task absent from `completed_task_ids`:
52
54
  3. Delegate read-only discovery, when needed, to an `explore` agent. Delegate implementation to a fresh `general` agent. Product changes follow TDD.
53
55
  4. Create a working-state diff with `workflow_sdd_review_package` and `confirmed: true`.
54
56
  5. Delegate spec-compliance review and code-quality review to separate `general` agents.
55
- 6. **Blocking** findings (Critical, Important, or spec-compliance) may trigger at most **two** fix+re-review rounds per task. **Advisory** findings (Minor, style, YAGNI, taste) never pause the loop — append them to `<SDD_DIR>/advisories.md`.
57
+ 6. **Blocking** findings (Critical, Important, or spec-compliance) may trigger at most **two** fix+re-review rounds per task. **Advisory** findings (Minor, style, YAGNI, taste) never pause the loop — append them with `workflow_sdd_append_advisory` (`--task <id> --text <text>`, `confirmed: true`) instead of an unrestricted file edit.
56
58
  7. Append the validated ledger entry with `workflow_sdd_append_progress` and `confirmed: true`; mark the todo completed.
57
59
 
58
60
  ## Final gate
@@ -60,10 +60,13 @@ On success, use native `question` / Cursor `AskQuestion` with exactly these opti
60
60
  3. Handoff → load `wk-handoff` (new session only)
61
61
  4. Review spec first
62
62
  5. Review plan first
63
+ 6. Change model first
64
+
65
+ `Change model first` is display-only deferral: it ends the turn without calling `workflow_plan_menu` and re-presents the menu on the next turn. Every other choice must call `workflow_plan_menu` immediately after the answer and before any skill, branch question, mutation, or handoff.
63
66
 
64
67
  Never emit Superpowers text beginning “Two execution options”.
65
68
 
66
- A handoff destination session (the seeded contract carries `<workflow-handoff-destination>true</workflow-handoff-destination>`) presents exactly four choices — Subagent-driven, Inline, Review spec first, Review plan first — and never re-offers the originating handoff option.
69
+ A handoff destination session (the seeded contract carries `<workflow-handoff-destination>true</workflow-handoff-destination>`) presents exactly five choices — Subagent-driven, Inline, Review spec first, Review plan first, Change model first — and never re-offers the originating handoff option.
67
70
 
68
71
  - Specs/plans must follow `templates/spec-template.md` / `templates/plan-template.md` (mandated diagrams, tables, CA-XX).
69
72