@brainervirus/workit-core 0.8.8 → 0.8.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.
- package/package.json +1 -1
- package/scripts/sync-release-manifests.ts +72 -0
- package/skills/wk-changelog/SKILL.md +2 -2
- package/skills/wk-commit/SKILL.md +3 -3
- package/skills/wk-docs-refresh/SKILL.md +2 -2
- package/skills/wk-handoff/SKILL.md +5 -3
- package/skills/wk-implement/SKILL.md +13 -11
- package/skills/wk-issue-update/SKILL.md +5 -5
- package/skills/wk-issue-update/references/youtrack-update-style.md +1 -1
- package/skills/wk-meetings/SKILL.md +5 -5
- package/skills/wk-pr/SKILL.md +4 -4
- package/skills/wk-release-notes/SKILL.md +1 -1
- package/skills/wk-verify/SKILL.md +3 -3
- package/src/core/branch.ts +1 -1
- package/src/core/detector.ts +1 -1
- package/src/core/docs-repo.ts +1 -1
- package/src/core/flow-state.ts +443 -77
- package/src/core/menu.ts +3 -1
- package/src/core/reminder.ts +14 -3
- package/src/core/repo-context.ts +1 -1
- package/src/core/sdd.ts +60 -1
- package/src/core/youtrack-tools.ts +4 -4
- package/src/core/youtrack.ts +1 -1
- package/templates/execution-contract.md +20 -18
- package/templates/plan-template.md +1 -1
- package/templates/spec-template.md +2 -2
- package/templates/superpowers-doc-contract.md +13 -10
- package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +4 -4
package/src/core/flow-state.ts
CHANGED
|
@@ -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
|
|
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(),
|
|
@@ -474,7 +501,7 @@ const readFlowStrict = (root: string, slug: string): StrictRead => {
|
|
|
474
501
|
if (!existsSync(file)) {
|
|
475
502
|
return err(
|
|
476
503
|
"flow_not_activated",
|
|
477
|
-
`flow not activated for ${slug} — run
|
|
504
|
+
`flow not activated for ${slug} — run workit_flow_status first`,
|
|
478
505
|
);
|
|
479
506
|
}
|
|
480
507
|
let text: string;
|
|
@@ -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
|
-
|
|
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
|
|
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"
|
|
1058
|
-
|
|
1059
|
-
"
|
|
1060
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
1301
|
+
question: string = "",
|
|
1302
|
+
purpose?: ReceiptPurpose,
|
|
1189
1303
|
): void {
|
|
1190
|
-
const
|
|
1191
|
-
if (!
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
? {
|
|
1662
|
-
|
|
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
|
-
|
|
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 workit_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 workit_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,
|
|
@@ -1923,19 +2143,19 @@ export const assertFlowGates = (
|
|
|
1923
2143
|
if (state.spec.status !== "approved") {
|
|
1924
2144
|
return err(
|
|
1925
2145
|
"spec_not_approved",
|
|
1926
|
-
`spec not approved (status: ${state.spec.status}). Run
|
|
2146
|
+
`spec not approved (status: ${state.spec.status}). Run workit_spec_approve after the user's approval.`,
|
|
1927
2147
|
);
|
|
1928
2148
|
}
|
|
1929
2149
|
if (state.plan.status !== "approved") {
|
|
1930
2150
|
return err(
|
|
1931
2151
|
"plan_not_approved",
|
|
1932
|
-
`plan not approved (status: ${state.plan.status}). Run
|
|
2152
|
+
`plan not approved (status: ${state.plan.status}). Run workit_plan_approve after the user's approval.`,
|
|
1933
2153
|
);
|
|
1934
2154
|
}
|
|
1935
2155
|
if (opts.requireMenu && !state.menu.presented) {
|
|
1936
2156
|
return err(
|
|
1937
2157
|
"menu_not_presented",
|
|
1938
|
-
"post-plan menu not presented. Ask the native question menu (Subagent-driven/Inline/Handoff/Review spec/Review plan) and record the answer with
|
|
2158
|
+
"post-plan menu not presented. Ask the native question menu (Subagent-driven/Inline/Handoff/Review spec/Review plan) and record the answer with workit_plan_menu.",
|
|
1939
2159
|
);
|
|
1940
2160
|
}
|
|
1941
2161
|
return { ok: true };
|
|
@@ -1967,19 +2187,19 @@ export const assertProductGates = (
|
|
|
1967
2187
|
if (state.spec.status !== "approved") {
|
|
1968
2188
|
return err(
|
|
1969
2189
|
"spec_not_approved",
|
|
1970
|
-
`spec not approved (status: ${state.spec.status}). Run
|
|
2190
|
+
`spec not approved (status: ${state.spec.status}). Run workit_spec_approve after the user's approval.`,
|
|
1971
2191
|
);
|
|
1972
2192
|
}
|
|
1973
2193
|
if (state.plan.status !== "approved") {
|
|
1974
2194
|
return err(
|
|
1975
2195
|
"plan_not_approved",
|
|
1976
|
-
`plan not approved (status: ${state.plan.status}). Run
|
|
2196
|
+
`plan not approved (status: ${state.plan.status}). Run workit_plan_approve after the user's approval.`,
|
|
1977
2197
|
);
|
|
1978
2198
|
}
|
|
1979
2199
|
if (opts.requireMenu && !state.menu.presented) {
|
|
1980
2200
|
return err(
|
|
1981
2201
|
"menu_not_presented",
|
|
1982
|
-
"post-plan menu not presented. Record the native question answer with
|
|
2202
|
+
"post-plan menu not presented. Record the native question answer with workit_plan_menu.",
|
|
1983
2203
|
);
|
|
1984
2204
|
}
|
|
1985
2205
|
if (opts.requireDocs) {
|
|
@@ -1996,13 +2216,81 @@ export const assertProductGates = (
|
|
|
1996
2216
|
};
|
|
1997
2217
|
|
|
1998
2218
|
/**
|
|
1999
|
-
*
|
|
2000
|
-
*
|
|
2001
|
-
*
|
|
2002
|
-
*
|
|
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
|
|
2005
|
-
|
|
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 workit_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 workit_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 workit_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,22 +2316,20 @@ export const COORDINATOR_WRITE_TOOLS: readonly string[] = [
|
|
|
2028
2316
|
"touch",
|
|
2029
2317
|
"chmod",
|
|
2030
2318
|
"chown",
|
|
2031
|
-
// workit product/config/external mutation tools
|
|
2032
|
-
|
|
2033
|
-
"
|
|
2034
|
-
"
|
|
2035
|
-
"
|
|
2036
|
-
"
|
|
2037
|
-
"
|
|
2319
|
+
// workit product/config/external mutation tools (SDD control tools are
|
|
2320
|
+
// coordinator-owned and routed through assertSddControlGates, not this set)
|
|
2321
|
+
"workit_commit",
|
|
2322
|
+
"workit_pr_create",
|
|
2323
|
+
"workit_rule_edit",
|
|
2324
|
+
"workit_template_edit",
|
|
2325
|
+
"workit_changelog_apply",
|
|
2326
|
+
"workit_branch_setup",
|
|
2038
2327
|
"workit_init_apply",
|
|
2039
|
-
"
|
|
2040
|
-
"
|
|
2041
|
-
"
|
|
2042
|
-
"
|
|
2043
|
-
"
|
|
2044
|
-
"workflow_sdd_append_progress",
|
|
2045
|
-
"workflow_youtrack_post",
|
|
2046
|
-
"workflow_youtrack_log_time",
|
|
2328
|
+
"workit_docs_promote",
|
|
2329
|
+
"workit_docs_layout",
|
|
2330
|
+
"workit_docs_repo_link",
|
|
2331
|
+
"workit_youtrack_post",
|
|
2332
|
+
"workit_youtrack_log_time",
|
|
2047
2333
|
];
|
|
2048
2334
|
|
|
2049
2335
|
/**
|
|
@@ -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):
|
|
2661
|
-
*
|
|
2662
|
-
*
|
|
2663
|
-
* the
|
|
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
|
-
|
|
2956
|
+
activeCoordinatorIds?: string[];
|
|
2957
|
+
active?: boolean;
|
|
2670
2958
|
}): FlowGateResult => {
|
|
2671
|
-
|
|
2672
|
-
|
|
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
|
+
"workit_sdd_task_brief",
|
|
2991
|
+
"workit_sdd_review_package",
|
|
2992
|
+
"workit_sdd_append_progress",
|
|
2993
|
+
"workit_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
|
+
};
|