immune-brain 5.1.1 → 5.1.2
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/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +20 -0
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +66 -11
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +15 -0
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8256 -7417
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
- package/plugins/immune-brain/dist/imm-planner.md +37 -4
- package/plugins/immune-brain/dist/imm-run.md +123 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
- package/plugins/immune-brain/hooks/hooks.json +22 -1
- package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
- package/plugins/immune-brain/runtime/claude/interaction.ts +5 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +66 -6
- package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
- package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
- package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Claude Code hook decisions that are not Review observation. PreToolUse guard for batch Lanes: an Edit, Write, MultiEdit or
|
|
2
|
+
// NotebookEdit whose target resolves outside the Lane is denied before it
|
|
3
|
+
// runs, the same rule the Pi extension applies to `edit`/`write`. Bash is not
|
|
4
|
+
// intercepted. Outside a Lane the hook answers nothing and the tool runs as
|
|
5
|
+
// configured.
|
|
6
|
+
import { laneWriteRefusal } from "../unattended/lane_workspace";
|
|
7
|
+
import { activeTaskNotice } from "../assurance/active_task_notice";
|
|
8
|
+
|
|
9
|
+
const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
10
|
+
|
|
11
|
+
/** The hook's stdout for one PreToolUse payload, or null to stay silent. */
|
|
12
|
+
export function laneGuardHookOutput(payload: Record<string, unknown>): string | null {
|
|
13
|
+
if (payload.hook_event_name !== "PreToolUse" || !FILE_TOOLS.has(String(payload.tool_name))) return null;
|
|
14
|
+
const input = (payload.tool_input ?? {}) as { file_path?: unknown; notebook_path?: unknown };
|
|
15
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
16
|
+
const refusal = laneWriteRefusal(cwd, input.file_path ?? input.notebook_path);
|
|
17
|
+
if (!refusal) return null;
|
|
18
|
+
return JSON.stringify({
|
|
19
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* UserPromptSubmit: when the workspace has an active Managed task, add the
|
|
25
|
+
* advisory notice as context. It never resumes the task.
|
|
26
|
+
*/
|
|
27
|
+
export async function activeTaskHookOutput(payload: Record<string, unknown>): Promise<string | null> {
|
|
28
|
+
if (payload.hook_event_name !== "UserPromptSubmit") return null;
|
|
29
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
30
|
+
const notice = await activeTaskNotice(cwd);
|
|
31
|
+
if (!notice) return null;
|
|
32
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: notice } });
|
|
33
|
+
}
|
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
type NativeConfirmationPort,
|
|
13
13
|
} from "./interaction";
|
|
14
14
|
import { ClaudeReviewHost, FileHookEventLog, parseHookStdin } from "./review_host";
|
|
15
|
+
import { activeTaskHookOutput, laneGuardHookOutput } from "./lane_guard";
|
|
15
16
|
import { ClaudeRuntime, type ToolMeta } from "./kernel_ports";
|
|
16
|
-
import { parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
|
|
17
|
+
import { parseLaneInstruction, parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
|
|
18
|
+
import { parseFinalVerification } from "../unattended/batch_final_verification";
|
|
19
|
+
import { parseLaneRevision, REVISION_DELEGATION_TEXT } from "../unattended/batch_delegation";
|
|
17
20
|
import type { AssuranceCoordinatorPorts } from "../assurance/coordinator";
|
|
18
21
|
|
|
19
22
|
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
@@ -56,6 +59,24 @@ export function listMcpTools() {
|
|
|
56
59
|
additionalProperties: false,
|
|
57
60
|
},
|
|
58
61
|
},
|
|
62
|
+
final_verification: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 8 },
|
|
63
|
+
lane_instruction: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
task_id: { type: "string" },
|
|
67
|
+
kind: { type: "string", enum: ["instruction", "correction"] },
|
|
68
|
+
text: { type: "string" },
|
|
69
|
+
session_state: { type: "string", enum: ["idle", "working", "blocked", "done"] },
|
|
70
|
+
},
|
|
71
|
+
required: ["task_id", "text", "session_state"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
lane_revision: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: { task_id: { type: "string" }, next_intent: { type: "object" } },
|
|
77
|
+
required: ["task_id", "next_intent"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
59
80
|
}
|
|
60
81
|
: {}),
|
|
61
82
|
}
|
|
@@ -153,10 +174,16 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
153
174
|
};
|
|
154
175
|
const laneOffers = parseLaneOffers(args.lane_offers);
|
|
155
176
|
// A lane_offers-only call resumes the recorded lane batch; refused before any gate otherwise.
|
|
156
|
-
const
|
|
177
|
+
const laneInstruction = parseLaneInstruction(args.lane_instruction);
|
|
178
|
+
const laneRevision = parseLaneRevision(args.lane_revision);
|
|
179
|
+
const maxParallel = resolveLaneParallel(options.cwd ?? process.cwd(), initiativeSlug, parseMaxParallel(args.max_parallel), laneOffers, laneInstruction ?? laneRevision);
|
|
180
|
+
const finalVerification = parseFinalVerification(args.final_verification);
|
|
157
181
|
return runtime.startUnattendedBatch(initiativeSlug, toolMeta, {
|
|
158
182
|
...(maxParallel !== undefined ? { max_parallel: maxParallel } : {}),
|
|
159
183
|
...(laneOffers !== undefined ? { lane_offers: laneOffers } : {}),
|
|
184
|
+
...(finalVerification !== undefined ? { final_verification: finalVerification } : {}),
|
|
185
|
+
...(laneInstruction !== undefined ? { lane_instruction: laneInstruction } : {}),
|
|
186
|
+
...(laneRevision !== undefined ? { lane_revision: laneRevision } : {}),
|
|
160
187
|
});
|
|
161
188
|
}
|
|
162
189
|
if (name === "retire_stale_batch") {
|
|
@@ -445,11 +472,25 @@ export function elicitationParams(input: NativeConfirmationInput) {
|
|
|
445
472
|
b?.lane_mode ? `Lane mode: max_parallel=${b.lane_mode.max_parallel}` : null,
|
|
446
473
|
b?.lane_mode ? `Parallel groups (${b.lane_mode.parallel_groups.length}):\n${b.lane_mode.parallel_groups.map((group) => ` - ${group.join(", ")}`).join("\n")}` : null,
|
|
447
474
|
b?.lane_mode && b.lane_mode.serialized.length > 0 ? `Serialized by overlapping scope (${b.lane_mode.serialized.length}):\n${b.lane_mode.serialized.map((c) => ` - ${c.task_id} after ${c.overlaps_with.join(", ")}`).join("\n")}` : null,
|
|
475
|
+
b?.lane_mode?.final_verification ? `Final verification: ${b.lane_mode.final_verification.join("; ")}` : null,
|
|
476
|
+
b?.lane_mode?.offer_revision_delegation ? REVISION_DELEGATION_TEXT : null,
|
|
448
477
|
].filter(Boolean);
|
|
449
478
|
return {
|
|
450
479
|
mode: "form",
|
|
451
480
|
message: `Authorize this unattended batch run for ${input.initiativeSlug}?\n\n${details.join("\n")}`,
|
|
452
|
-
requestedSchema:
|
|
481
|
+
requestedSchema: b?.lane_mode?.offer_revision_delegation
|
|
482
|
+
? {
|
|
483
|
+
type: "object",
|
|
484
|
+
properties: {
|
|
485
|
+
delegate_in_lane_revisions: {
|
|
486
|
+
type: "boolean",
|
|
487
|
+
title: "Delegate in-envelope Lane Intent revisions to the batch coordinator",
|
|
488
|
+
description: REVISION_DELEGATION_TEXT,
|
|
489
|
+
default: false,
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
: { type: "object", properties: {} },
|
|
453
494
|
};
|
|
454
495
|
}
|
|
455
496
|
const details = [
|
|
@@ -527,13 +568,19 @@ export async function serveStdio(options: {
|
|
|
527
568
|
if (action !== "accept" && action !== "decline" && action !== "cancel") {
|
|
528
569
|
throw new NativeAuthorityError("correlation_missing", "MCP elicitation returned an invalid action");
|
|
529
570
|
}
|
|
571
|
+
let delegateRevisions = false;
|
|
530
572
|
if (action === "accept") {
|
|
531
573
|
const content = (result as { content?: unknown }).content;
|
|
532
|
-
|
|
574
|
+
const offered = input.operation === "start_unattended_batch" && input.batchDetails?.lane_mode?.offer_revision_delegation === true;
|
|
575
|
+
const keys = typeof content === "object" && content !== null && !Array.isArray(content) ? Object.keys(content) : null;
|
|
576
|
+
const valid = keys !== null && (keys.length === 0 ||
|
|
577
|
+
(offered && keys.length === 1 && keys[0] === "delegate_in_lane_revisions" && typeof (content as Record<string, unknown>).delegate_in_lane_revisions === "boolean"));
|
|
578
|
+
if (!valid) {
|
|
533
579
|
throw new NativeAuthorityError("correlation_missing", "MCP elicitation accept content did not match the requested schema");
|
|
534
580
|
}
|
|
581
|
+
delegateRevisions = offered && (content as Record<string, unknown>).delegate_in_lane_revisions === true;
|
|
535
582
|
}
|
|
536
|
-
return { decision: action, requestId };
|
|
583
|
+
return { decision: action, requestId, ...(delegateRevisions ? { delegateRevisions: true } : {}) };
|
|
537
584
|
} finally {
|
|
538
585
|
pending.delete(requestId);
|
|
539
586
|
if (abortListener) input.signal?.removeEventListener("abort", abortListener);
|
|
@@ -686,7 +733,20 @@ async function runHook(): Promise<void> {
|
|
|
686
733
|
const lines: string[] = [];
|
|
687
734
|
const rl = createInterface({ input: stdin });
|
|
688
735
|
for await (const line of rl) lines.push(line);
|
|
689
|
-
const
|
|
736
|
+
const raw = lines.join("\n");
|
|
737
|
+
let payload: Record<string, unknown> | null = null;
|
|
738
|
+
try { payload = JSON.parse(raw) as Record<string, unknown>; } catch { payload = null; }
|
|
739
|
+
if (payload?.hook_event_name === "PreToolUse") {
|
|
740
|
+
const decision = laneGuardHookOutput(payload);
|
|
741
|
+
if (decision) process.stdout.write(`${decision}\n`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (payload?.hook_event_name === "UserPromptSubmit") {
|
|
745
|
+
const notice = await activeTaskHookOutput(payload);
|
|
746
|
+
if (notice) process.stdout.write(`${notice}\n`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const event = parseHookStdin(raw);
|
|
690
750
|
if (!event) return;
|
|
691
751
|
new FileHookEventLog().append(event);
|
|
692
752
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
|
+
import { projectScopeOverlap } from "../unattended/batch_schedule";
|
|
11
12
|
import process from "node:process";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
import {
|
|
@@ -858,6 +859,57 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
|
|
|
858
859
|
* TaskRecord, claim, Ledger, Git index, or session write. Returns a stable
|
|
859
860
|
* bounded projection.
|
|
860
861
|
*/
|
|
862
|
+
/**
|
|
863
|
+
* Read-only: compare the `scope_hint` of two or more TaskIntents of one
|
|
864
|
+
* Initiative and report every overlapping entry pair plus the start waves the
|
|
865
|
+
* Batch Plan would use. Optional `--blocked-by a=b,c` edges order the waves the
|
|
866
|
+
* way the Initiative's dependencies do. Writes nothing.
|
|
867
|
+
*/
|
|
868
|
+
function runIntentOverlap(args: string[], root: string): KernelExecution {
|
|
869
|
+
const usage = "Run imm-kernel intent overlap <path> <path>... [--blocked-by <task-id>=<task-id>,...] --json.";
|
|
870
|
+
const reject = (message: string): KernelExecution => ({
|
|
871
|
+
result: errorResult("invalid_command", message, 2),
|
|
872
|
+
journal: journalFor("intent", null, "rejected", "invalid_command", null, usage),
|
|
873
|
+
});
|
|
874
|
+
const paths: string[] = [];
|
|
875
|
+
const blockedBy = new Map<string, string[]>();
|
|
876
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
877
|
+
const arg = args[index]!;
|
|
878
|
+
if (arg === "--json") continue;
|
|
879
|
+
if (arg === "--blocked-by") {
|
|
880
|
+
const edge = args[++index] ?? "";
|
|
881
|
+
const match = /^([A-Za-z0-9][A-Za-z0-9._-]*)=([A-Za-z0-9._,-]+)$/.exec(edge);
|
|
882
|
+
if (!match) return reject(`invalid --blocked-by edge: ${edge}`);
|
|
883
|
+
blockedBy.set(match[1]!, match[2]!.split(",").filter(Boolean));
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (arg.startsWith("-")) return reject(`unsupported intent overlap option: ${arg}`);
|
|
887
|
+
paths.push(arg);
|
|
888
|
+
}
|
|
889
|
+
if (paths.length < 2) return reject("imm-kernel intent overlap requires at least two paths");
|
|
890
|
+
const children: Array<{ task_id: string; blocked_by: string[]; scope_hint: string[] }> = [];
|
|
891
|
+
for (const path of paths) {
|
|
892
|
+
let intent: { task_id?: unknown; scope_hint?: unknown };
|
|
893
|
+
try {
|
|
894
|
+
intent = JSON.parse(readFileSync(resolve(root, path), "utf8"));
|
|
895
|
+
} catch (error) {
|
|
896
|
+
return { result: errorResult("source_invalid", `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`, 1), journal: journalFor("intent", null, "rejected", "source_read_failed", null, usage) };
|
|
897
|
+
}
|
|
898
|
+
if (typeof intent.task_id !== "string" || !Array.isArray(intent.scope_hint) || intent.scope_hint.some((entry) => typeof entry !== "string"))
|
|
899
|
+
return { result: errorResult("source_invalid", `${path} has no task_id and string scope_hint`, 1), journal: journalFor("intent", null, "rejected", "source_invalid", null, usage) };
|
|
900
|
+
children.push({ task_id: intent.task_id, blocked_by: [], scope_hint: intent.scope_hint as string[] });
|
|
901
|
+
}
|
|
902
|
+
const known = new Set(children.map((child) => child.task_id));
|
|
903
|
+
for (const child of children) child.blocked_by = (blockedBy.get(child.task_id) ?? []).filter((id) => known.has(id));
|
|
904
|
+
let report: ReturnType<typeof projectScopeOverlap>;
|
|
905
|
+
try {
|
|
906
|
+
report = projectScopeOverlap(children);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
return reject(error instanceof Error ? error.message : String(error));
|
|
909
|
+
}
|
|
910
|
+
return { result: jsonResult(report, 0), journal: journalFor("intent", null, "ok", "command_ok", null, null) };
|
|
911
|
+
}
|
|
912
|
+
|
|
861
913
|
function runIntentValidate(args: string[], root: string): KernelExecution {
|
|
862
914
|
const nonFlags = args.filter((arg) => !arg.startsWith("-"));
|
|
863
915
|
if (nonFlags.length !== 1)
|
|
@@ -1173,10 +1225,11 @@ function executeKernelCommand(args: string[], root: string): KernelExecution {
|
|
|
1173
1225
|
const sub = args[1] ?? "";
|
|
1174
1226
|
if (sub === "author") return runIntentAuthor(args.slice(2), root);
|
|
1175
1227
|
if (sub === "validate") return runIntentValidate(args.slice(2), root);
|
|
1228
|
+
if (sub === "overlap") return runIntentOverlap(args.slice(2), root);
|
|
1176
1229
|
return {
|
|
1177
1230
|
result: errorResult(
|
|
1178
1231
|
"invalid_command",
|
|
1179
|
-
"usage: imm-kernel intent author <path> --stdin --json | validate <path> --json",
|
|
1232
|
+
"usage: imm-kernel intent author <path> --stdin --json | validate <path> --json | overlap <path> <path>... [--blocked-by <task-id>=<task-id>,...] --json",
|
|
1180
1233
|
2,
|
|
1181
1234
|
),
|
|
1182
1235
|
journal: journalFor(
|
|
@@ -8,6 +8,8 @@ const PROTOCOL_MARKER = "<!-- immune-brain-tracker:v1 -->";
|
|
|
8
8
|
const KIND_INITIATIVE_MARKER = "<!-- immune-brain:kind=initiative -->";
|
|
9
9
|
const KIND_TASK_MARKER = "<!-- immune-brain:kind=task -->";
|
|
10
10
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
11
|
+
/** Public scenario identity: an upstream Brainstorm id or a Spec-local id. */
|
|
12
|
+
const SCENARIO_ID_PATTERN = /^(?:BR-SCN|SCN)-[1-9][0-9]{0,8}$/;
|
|
11
13
|
const MAX_GH_OUTPUT = 8 * 1024 * 1024;
|
|
12
14
|
const MAX_DIAGNOSTIC = 512;
|
|
13
15
|
const GH_TIMEOUT_MS = 20_000;
|
|
@@ -62,6 +64,23 @@ export interface InitiativeProjection {
|
|
|
62
64
|
out_of_scope?: string[];
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* One user scenario projected onto the Task that first makes it observable.
|
|
69
|
+
* Public projection only: it never widens TaskIntent scope or authority.
|
|
70
|
+
*/
|
|
71
|
+
export interface InitiativeTaskScenario {
|
|
72
|
+
id: string;
|
|
73
|
+
actor: string;
|
|
74
|
+
given: string;
|
|
75
|
+
when: string;
|
|
76
|
+
then: string;
|
|
77
|
+
mode: "automated" | "manual";
|
|
78
|
+
/** Canonical acceptance IDs this scenario maps to. Required for automated; optional for manual. */
|
|
79
|
+
acceptance?: string[];
|
|
80
|
+
/** Why a deterministic check cannot prove this scenario. Required for manual. */
|
|
81
|
+
manual_reason?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
export interface InitiativePublicationInput {
|
|
66
85
|
initiative_id: string;
|
|
67
86
|
goal: string;
|
|
@@ -71,6 +90,8 @@ export interface InitiativePublicationInput {
|
|
|
71
90
|
intent: string;
|
|
72
91
|
acceptance: Array<{ id: string; summary: string }>;
|
|
73
92
|
projection?: TaskProjection;
|
|
93
|
+
/** Optional. Omitted or empty renders no User scenarios section for this Task. */
|
|
94
|
+
scenarios?: InitiativeTaskScenario[];
|
|
74
95
|
}>;
|
|
75
96
|
/**
|
|
76
97
|
* Explicit amendment input: binds the caller's desired pending frontier to
|
|
@@ -179,6 +200,15 @@ export type TrackerOperation =
|
|
|
179
200
|
goal: string;
|
|
180
201
|
slices: InitiativeSlice[];
|
|
181
202
|
projection?: InitiativeProjection;
|
|
203
|
+
/** Derived Parent listing. Absent or empty renders no User scenarios section. */
|
|
204
|
+
scenarios?: Array<{
|
|
205
|
+
slice_id: string;
|
|
206
|
+
id: string;
|
|
207
|
+
mode: "automated" | "manual";
|
|
208
|
+
when: string;
|
|
209
|
+
then: string;
|
|
210
|
+
manual_reason?: string;
|
|
211
|
+
}>;
|
|
182
212
|
}
|
|
183
213
|
| {
|
|
184
214
|
op: "upsert-task";
|
|
@@ -191,6 +221,8 @@ export type TrackerOperation =
|
|
|
191
221
|
projection?: TaskProjection;
|
|
192
222
|
/** The canonical TaskIntent content hash this Task was prepared from. */
|
|
193
223
|
intent_hash?: string;
|
|
224
|
+
/** Public scenarios owned by this Task. Absent renders no User scenarios section. */
|
|
225
|
+
scenarios?: InitiativeTaskScenario[];
|
|
194
226
|
}
|
|
195
227
|
| {
|
|
196
228
|
op: "mark-terminal";
|
|
@@ -270,7 +302,7 @@ function countLiteral(value: string, needle: string): number {
|
|
|
270
302
|
return count;
|
|
271
303
|
}
|
|
272
304
|
|
|
273
|
-
function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id" | "intent-hash", value: string | number): string {
|
|
305
|
+
function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id" | "intent-hash" | "scenarios", value: string | number): string {
|
|
274
306
|
return `<!-- immune-brain:${name}=${value} -->`;
|
|
275
307
|
}
|
|
276
308
|
|
|
@@ -730,8 +762,9 @@ function stripPublishedIntentHash(body: string): string {
|
|
|
730
762
|
return body.replace(/<!-- immune-brain:intent-hash=[A-Za-z0-9][A-Za-z0-9._:-]{0,127} -->\n?/g, "");
|
|
731
763
|
}
|
|
732
764
|
|
|
733
|
-
function ownershipMarkerValue(body: string, name: "initiative-id" | "slice-id" | "task-id"): string | null {
|
|
734
|
-
const
|
|
765
|
+
function ownershipMarkerValue(body: string, name: "initiative-id" | "slice-id" | "task-id" | "scenarios"): string | null {
|
|
766
|
+
const pattern = name === "scenarios" ? "[A-Za-z0-9%_!*'()~.-]+" : "[A-Za-z0-9][A-Za-z0-9._-]{0,127}";
|
|
767
|
+
const values = [...body.matchAll(new RegExp(`<!-- immune-brain:${name}=(${pattern}) -->`, "g"))];
|
|
735
768
|
return values.length === 1 ? values[0][1] : null;
|
|
736
769
|
}
|
|
737
770
|
|
|
@@ -953,6 +986,160 @@ function listText(values: string[] | undefined, fallback: string): string {
|
|
|
953
986
|
return values?.length ? values.map((value) => `- ${value}`).join("\n") : fallback;
|
|
954
987
|
}
|
|
955
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Child section after Acceptance criteria. Empty input returns "" so a Task
|
|
991
|
+
* without scenarios, and a publication with none at all, stays byte-identical.
|
|
992
|
+
*/
|
|
993
|
+
function childScenarioSection(scenarios: InitiativeTaskScenario[] | undefined): string {
|
|
994
|
+
if (!scenarios?.length) return "";
|
|
995
|
+
const items = scenarios.map((scenario) => {
|
|
996
|
+
const mapped = scenario.acceptance?.length ? scenario.acceptance.map((id) => `\`${id}\``).join(", ") : "none";
|
|
997
|
+
const visible = `Actor: ${scenario.actor}. Given: ${scenario.given}. When: ${scenario.when}. Then: ${scenario.then}.`;
|
|
998
|
+
if (scenario.mode === "manual")
|
|
999
|
+
return `- [ ] \`${scenario.id}\` (manual, ${mapped}): ${visible} Reason: ${scenario.manual_reason}.`;
|
|
1000
|
+
return `- \`${scenario.id}\` (automated, ${mapped}): ${visible}`;
|
|
1001
|
+
});
|
|
1002
|
+
return `\n## User scenarios\n\n${items.join("\n")}\n`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Parent listing after Testing strategy, derived from the Tasks. One plain
|
|
1007
|
+
* bullet per scenario; no checkboxes. Empty input returns "".
|
|
1008
|
+
*/
|
|
1009
|
+
function parentScenarioSection(scenarios: Array<{ slice_id: string; id: string; mode: "automated" | "manual"; actor?: string; given?: string; when: string; then: string; manual_reason?: string }> | undefined): string {
|
|
1010
|
+
if (!scenarios?.length) return "";
|
|
1011
|
+
const items = scenarios.map((scenario) => {
|
|
1012
|
+
const prefix = scenario.actor && scenario.given ? `Actor: ${scenario.actor}. Given: ${scenario.given}. ` : "";
|
|
1013
|
+
const reason = scenario.manual_reason ? ` Reason: ${scenario.manual_reason}.` : "";
|
|
1014
|
+
return `- \`${scenario.id}\` (Slice \`${scenario.slice_id}\`, ${scenario.mode}): ${prefix}When: ${scenario.when}. Then: ${scenario.then}.${reason}`;
|
|
1015
|
+
});
|
|
1016
|
+
return `\n## User scenarios\n\n${items.join("\n")}\n`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
type HistoricalScenarioListing = InitiativeTaskScenario & { slice_id: string };
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Read the Parent listings already published by historical Children. Their
|
|
1024
|
+
* Child bytes stay untouched, but an amendment must keep those listings and
|
|
1025
|
+
* reserve their IDs before it rebuilds the Parent or accepts pending input.
|
|
1026
|
+
*/
|
|
1027
|
+
function historicalScenarioListings(children: Iterable<{ body?: unknown }>): HistoricalScenarioListing[] | string {
|
|
1028
|
+
const listings: HistoricalScenarioListing[] = [];
|
|
1029
|
+
for (const child of children) {
|
|
1030
|
+
if (typeof child.body !== "string") continue;
|
|
1031
|
+
const sliceId = ownershipMarkerValue(child.body, "slice-id");
|
|
1032
|
+
if (!sliceId) continue;
|
|
1033
|
+
const markerStarts = [...child.body.matchAll(/<!-- immune-brain:scenarios=/g)].length;
|
|
1034
|
+
if (markerStarts > 1) return `historical Slice ${sliceId} has duplicate scenarios markers`;
|
|
1035
|
+
const encoded = ownershipMarkerValue(child.body, "scenarios");
|
|
1036
|
+
if (encoded === null) {
|
|
1037
|
+
if (markerStarts === 0) continue;
|
|
1038
|
+
return `historical Slice ${sliceId} has a malformed scenarios marker`;
|
|
1039
|
+
}
|
|
1040
|
+
const malformed = `historical Slice ${sliceId} has a malformed scenarios marker`;
|
|
1041
|
+
let decoded: unknown;
|
|
1042
|
+
try { decoded = JSON.parse(decodeURIComponent(encoded)); }
|
|
1043
|
+
catch { return malformed; }
|
|
1044
|
+
let scenarios: InitiativeTaskScenario[] | undefined;
|
|
1045
|
+
try { scenarios = normalizeTaskScenarios(decoded, `historical Slice ${sliceId}`, undefined, new Set()); }
|
|
1046
|
+
catch { return malformed; }
|
|
1047
|
+
// The marker is the only source of historical scenarios, so it must hold
|
|
1048
|
+
// exactly what the publish path writes for these values: the same
|
|
1049
|
+
// validator, re-encoded byte for byte. Anything else is an unamendable
|
|
1050
|
+
// Child, and the batch is rejected before any remote write.
|
|
1051
|
+
if (scenarios === undefined || encodeURIComponent(JSON.stringify(scenarios)) !== encoded) return malformed;
|
|
1052
|
+
for (const scenario of scenarios) listings.push({ ...scenario, slice_id: sliceId });
|
|
1053
|
+
}
|
|
1054
|
+
return listings;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function retainHistoricalScenarioIds(children: Iterable<{ body?: unknown }>, seenScenarioIds = new Set<string>()): string | null {
|
|
1058
|
+
const listings = historicalScenarioListings(children);
|
|
1059
|
+
if (typeof listings === "string") return listings;
|
|
1060
|
+
for (const listing of listings) {
|
|
1061
|
+
if (seenScenarioIds.has(listing.id)) return `historical Slice ${listing.slice_id} repeats scenario id ${listing.id}`;
|
|
1062
|
+
seenScenarioIds.add(listing.id);
|
|
1063
|
+
}
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Reject the whole batch, before any remote write, when a scenario is malformed
|
|
1069
|
+
* or names an acceptance ID its TaskIntent lacks. Text uses the same public
|
|
1070
|
+
* projection rules as every other projected field. When `acceptanceIds` is
|
|
1071
|
+
* omitted the TaskIntent-bound existence check is skipped, which is what the
|
|
1072
|
+
* historical read-back needs: a published marker already passed it once.
|
|
1073
|
+
*/
|
|
1074
|
+
function normalizeTaskScenarios(
|
|
1075
|
+
value: unknown,
|
|
1076
|
+
taskId: string,
|
|
1077
|
+
acceptanceIds: Set<string> | undefined,
|
|
1078
|
+
seenScenarioIds: Set<string>,
|
|
1079
|
+
): InitiativeTaskScenario[] | undefined {
|
|
1080
|
+
if (value === undefined) return undefined;
|
|
1081
|
+
if (!Array.isArray(value)) throw new Error(`Task ${taskId} scenarios must be an array`);
|
|
1082
|
+
if (value.length === 0) return undefined;
|
|
1083
|
+
return value.map((item, index) => {
|
|
1084
|
+
const name = `Task ${taskId} scenarios[${index}]`;
|
|
1085
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`${name} must be an object`);
|
|
1086
|
+
const raw = item as Record<string, unknown>;
|
|
1087
|
+
const required = ["id", "actor", "given", "when", "then", "mode"] as const;
|
|
1088
|
+
for (const field of required) {
|
|
1089
|
+
if (raw[field] === undefined) throw new Error(`${name} is missing ${field}`);
|
|
1090
|
+
}
|
|
1091
|
+
const id = typeof raw.id === "string" && SCENARIO_ID_PATTERN.test(raw.id) ? raw.id : null;
|
|
1092
|
+
if (!id) throw new Error(`${name} id must match BR-SCN-<n> or SCN-<n>`);
|
|
1093
|
+
if (seenScenarioIds.has(id)) throw new Error(`Task ${taskId} repeats scenario id ${id}`);
|
|
1094
|
+
seenScenarioIds.add(id);
|
|
1095
|
+
if (raw.mode !== "automated" && raw.mode !== "manual")
|
|
1096
|
+
throw new Error(`${name} mode must be automated or manual`);
|
|
1097
|
+
const acceptance = raw.acceptance === undefined
|
|
1098
|
+
? undefined
|
|
1099
|
+
: normalizedList(raw.acceptance, `${name}.acceptance`, 128)?.map((acceptanceId) => identifier(acceptanceId, `${name}.acceptance`));
|
|
1100
|
+
if (acceptance && new Set(acceptance).size !== acceptance.length)
|
|
1101
|
+
throw new Error(`${name} repeats an acceptance id`);
|
|
1102
|
+
for (const acceptanceId of acceptance ?? []) {
|
|
1103
|
+
if (acceptanceIds && !acceptanceIds.has(acceptanceId))
|
|
1104
|
+
throw new Error(`Task ${taskId} scenario ${id} names unknown acceptance id ${acceptanceId}`);
|
|
1105
|
+
}
|
|
1106
|
+
if (raw.mode === "automated" && !acceptance?.length)
|
|
1107
|
+
throw new Error(`Task ${taskId} scenario ${id} is automated with no acceptance id`);
|
|
1108
|
+
if (raw.mode === "manual" && raw.manual_reason === undefined)
|
|
1109
|
+
throw new Error(`Task ${taskId} scenario ${id} is manual without manual_reason`);
|
|
1110
|
+
const scenario: InitiativeTaskScenario = {
|
|
1111
|
+
id,
|
|
1112
|
+
actor: projectionText(raw.actor, `${name}.actor`, 500),
|
|
1113
|
+
given: projectionText(raw.given, `${name}.given`, 500),
|
|
1114
|
+
when: projectionText(raw.when, `${name}.when`, 500),
|
|
1115
|
+
then: projectionText(raw.then, `${name}.then`, 500),
|
|
1116
|
+
mode: raw.mode,
|
|
1117
|
+
...(acceptance ? { acceptance } : {}),
|
|
1118
|
+
...(raw.manual_reason === undefined ? {} : { manual_reason: projectionText(raw.manual_reason, `${name}.manual_reason`, 500) }),
|
|
1119
|
+
};
|
|
1120
|
+
// The historical read-back re-applies these rules to the stored values, so
|
|
1121
|
+
// each one must survive its own projection a second time. Escaping can grow
|
|
1122
|
+
// a value past its bound and a restricted-context acceptance ID passes the
|
|
1123
|
+
// pending path; either would make a published Child unamendable.
|
|
1124
|
+
const roundTrip: Array<[string, string]> = [
|
|
1125
|
+
["actor", scenario.actor],
|
|
1126
|
+
["given", scenario.given],
|
|
1127
|
+
["when", scenario.when],
|
|
1128
|
+
["then", scenario.then],
|
|
1129
|
+
...(scenario.manual_reason === undefined ? [] : [["manual_reason", scenario.manual_reason] as [string, string]]),
|
|
1130
|
+
];
|
|
1131
|
+
for (const [label, value] of roundTrip) {
|
|
1132
|
+
if (projectionText(value, `${name}.${label}`, 500) !== value)
|
|
1133
|
+
throw new Error(`${name}.${label} fails the public-projection round-trip`);
|
|
1134
|
+
}
|
|
1135
|
+
for (const acceptanceId of acceptance ?? []) {
|
|
1136
|
+
if (projectionText(acceptanceId, `${name}.acceptance`, 128) !== acceptanceId || identifier(acceptanceId, `${name}.acceptance`) !== acceptanceId)
|
|
1137
|
+
throw new Error(`${name}.acceptance fails the public-projection round-trip`);
|
|
1138
|
+
}
|
|
1139
|
+
return scenario;
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
956
1143
|
function bodyLimitFailure(operation: TrackerOperation["op"], body: string, reserve = 0): GithubTrackerResult | null {
|
|
957
1144
|
return Buffer.byteLength(body, "utf8") + reserve <= GITHUB_ISSUE_BODY_LIMIT
|
|
958
1145
|
? null
|
|
@@ -973,7 +1160,7 @@ function createInitiativeBody(
|
|
|
973
1160
|
KIND_INITIATIVE_MARKER,
|
|
974
1161
|
marker("repo-id", repository.id),
|
|
975
1162
|
marker("initiative-id", operation.initiative_id),
|
|
976
|
-
].join("\n")}\n\n${provenance}## How to use this Issue\n\n- Edit planning prose and Slice ordering directly after creation.\n- Keep each Slice marker attached to exactly one stable Slice entry.\n- The tracker never rewrites this Parent after creation; the tracker closes it as completed once every Slice Child is completed, and never closes it otherwise.\n\n## Problem\n\n${publicText(projection.problem ?? "The Initiative addresses the bounded delivery described below.", "projection.problem")}\n\n## Result\n\n${publicText(projection.result ?? operation.goal, "projection.result")}\n\n## Initiative design\n\n${publicText(projection.design ?? "Each Child preserves the shared Initiative decisions and boundaries recorded here.", "projection.design")}\n\n## Decisions\n\n${listText(projection.decisions, "- No additional Initiative decisions recorded.")}\n\n## Testing strategy\n\n${publicText(projection.testing_strategy ?? "Each Child closes from its focused acceptance verification.", "projection.testing_strategy")}\n\n## Out of scope\n\n${listText(projection.out_of_scope, "- Unrelated work outside this Initiative.")}\n\n## Slices\n\n${operation.slices.length + historicalSlices.length === 0 ? "No Slices recorded yet." : [...historicalSlices, ...operation.slices.map((slice) => `- [ ] ${marker("slice-id", slice.id)} **${slice.id}**: ${slice.result ?? slice.goal}${slice.blocked_by?.length ? ` (blocked by: ${slice.blocked_by.join(", ")})` : ""}`)].join("\n")}\n\n${ISSUE_FOOTER}\n`;
|
|
1163
|
+
].join("\n")}\n\n${provenance}## How to use this Issue\n\n- Edit planning prose and Slice ordering directly after creation.\n- Keep each Slice marker attached to exactly one stable Slice entry.\n- The tracker never rewrites this Parent after creation; the tracker closes it as completed once every Slice Child is completed, and never closes it otherwise.\n\n## Problem\n\n${publicText(projection.problem ?? "The Initiative addresses the bounded delivery described below.", "projection.problem")}\n\n## Result\n\n${publicText(projection.result ?? operation.goal, "projection.result")}\n\n## Initiative design\n\n${publicText(projection.design ?? "Each Child preserves the shared Initiative decisions and boundaries recorded here.", "projection.design")}\n\n## Decisions\n\n${listText(projection.decisions, "- No additional Initiative decisions recorded.")}\n\n## Testing strategy\n\n${publicText(projection.testing_strategy ?? "Each Child closes from its focused acceptance verification.", "projection.testing_strategy")}\n${parentScenarioSection(operation.scenarios)}\n## Out of scope\n\n${listText(projection.out_of_scope, "- Unrelated work outside this Initiative.")}\n\n## Slices\n\n${operation.slices.length + historicalSlices.length === 0 ? "No Slices recorded yet." : [...historicalSlices, ...operation.slices.map((slice) => `- [ ] ${marker("slice-id", slice.id)} **${slice.id}**: ${slice.result ?? slice.goal}${slice.blocked_by?.length ? ` (blocked by: ${slice.blocked_by.join(", ")})` : ""}`)].join("\n")}\n\n${ISSUE_FOOTER}\n`;
|
|
977
1164
|
}
|
|
978
1165
|
|
|
979
1166
|
async function createInitiative(
|
|
@@ -1056,7 +1243,8 @@ function childBody(
|
|
|
1056
1243
|
// idempotent replay can compare against what was actually published
|
|
1057
1244
|
// instead of against whatever bytes it happens to read this time.
|
|
1058
1245
|
...(operation.intent_hash ? [marker("intent-hash", operation.intent_hash)] : []),
|
|
1059
|
-
|
|
1246
|
+
...(operation.scenarios?.length ? [marker("scenarios", encodeURIComponent(JSON.stringify(operation.scenarios)))] : []),
|
|
1247
|
+
].join("\n")}\n\n## Parent\n\n| Initiative | \`${operation.initiative_id}\` |\n| Parent Issue | [#${parent.number}](${parent.url}) |\n| Slice | \`${operation.slice_id}\` |\n| Risk | \`${operation.risk}\` |\n\n## Current behavior\n\n${publicText(projection.current_behavior ?? "The current behavior is defined by the repository's existing contract.", "projection.current_behavior")}\n\n## Desired behavior\n\n${publicText(projection.desired_behavior ?? projection.result ?? operation.goal, "projection.desired_behavior")}\n\n## Key interfaces\n\n${listText(projection.key_interfaces, "- Canonical TaskIntent acceptance and Kernel lifecycle remain authoritative.")}\n\n## Acceptance criteria\n\n${acceptance}\n${childScenarioSection(operation.scenarios)}\n## Verification\n\n${publicText(projection.verification ?? "Run the focused acceptance verification declared by the TaskIntent.", "projection.verification")}\n\n## Blocked by\n\n${projection.blocked_by?.length ? projection.blocked_by.map((id) => `- \`${identifier(id, "blocked_by task_id")}\``).join("\n") : "None"}\n\n## Out of scope\n\n${listText(projection.out_of_scope, "- Scope not declared by the validated TaskIntent.")}\n\n## Agent handoff\n\n${publicText(projection.agent_handoff ?? "Implement only the bounded TaskIntent result and run the focused checks. Do not widen scope or treat GitHub as authorization.", "projection.agent_handoff")}\n\n${ISSUE_FOOTER}\n`;
|
|
1060
1248
|
}
|
|
1061
1249
|
|
|
1062
1250
|
/** Derived approved-final content and baseline-derived historical evidence for an amendment. */
|
|
@@ -1094,6 +1282,26 @@ function approvedAmendmentContent(
|
|
|
1094
1282
|
blocked_by: operation.projection?.blocked_by,
|
|
1095
1283
|
})),
|
|
1096
1284
|
}) as Extract<TrackerOperation, { op: "create-initiative" }>;
|
|
1285
|
+
const historicalScenarios = historicalScenarioListings([...amendment.historical.values()].map((binding) => ({ body: binding.body })));
|
|
1286
|
+
if (typeof historicalScenarios === "string") return historicalScenarios;
|
|
1287
|
+
parent.scenarios = [
|
|
1288
|
+
...historicalScenarios.map((scenario) => ({
|
|
1289
|
+
slice_id: scenario.slice_id,
|
|
1290
|
+
id: scenario.id,
|
|
1291
|
+
mode: scenario.mode,
|
|
1292
|
+
when: scenario.when,
|
|
1293
|
+
then: scenario.then,
|
|
1294
|
+
...(typeof scenario.manual_reason === "string" ? { manual_reason: scenario.manual_reason } : {}),
|
|
1295
|
+
})),
|
|
1296
|
+
...prepared.order.flatMap((operation) =>
|
|
1297
|
+
(operation.scenarios ?? []).map((scenario) => ({
|
|
1298
|
+
slice_id: operation.slice_id,
|
|
1299
|
+
id: scenario.id,
|
|
1300
|
+
mode: scenario.mode,
|
|
1301
|
+
when: scenario.when,
|
|
1302
|
+
then: scenario.then,
|
|
1303
|
+
}))),
|
|
1304
|
+
];
|
|
1097
1305
|
const historicalSliceIds: Set<string> = new Set();
|
|
1098
1306
|
for (const child of amendment.historical.values()) {
|
|
1099
1307
|
const sliceId = ownershipMarkerValue(child.body, "slice-id");
|
|
@@ -1120,7 +1328,7 @@ function approvedAmendmentContent(
|
|
|
1120
1328
|
void sourceStub;
|
|
1121
1329
|
// The approved final Parent body fixes the Slices checklist, so every Child
|
|
1122
1330
|
// title is numbered from the Initiative order rather than from this batch.
|
|
1123
|
-
const parentBody = createInitiativeBody(sourceStub.repository, parent, historicalSlices);
|
|
1331
|
+
const parentBody = createInitiativeBody(sourceStub.repository, { ...parent, scenarios: parent.scenarios }, historicalSlices);
|
|
1124
1332
|
const oversizedParent = bodyLimitFailure("create-initiative", parentBody);
|
|
1125
1333
|
if (oversizedParent) return `${oversizedParent.status}: ${oversizedParent.message}`;
|
|
1126
1334
|
const pendingContent = new Map<string, { title: string; body: string }>();
|
|
@@ -1587,6 +1795,7 @@ function validateOperation(operation: TrackerOperation): TrackerOperation {
|
|
|
1587
1795
|
id: identifier(item.id, `acceptance[${index}].id`),
|
|
1588
1796
|
summary: projectionText(item.summary, `acceptance[${index}].summary`, 500),
|
|
1589
1797
|
})),
|
|
1798
|
+
scenarios: operation.scenarios,
|
|
1590
1799
|
};
|
|
1591
1800
|
taskIssueTitle(normalized);
|
|
1592
1801
|
return normalized;
|
|
@@ -1727,10 +1936,16 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1727
1936
|
// projection is stamped with them, so a missing or oversized display name
|
|
1728
1937
|
// fails the batch closed with zero remote writes.
|
|
1729
1938
|
const initiativeShortName = initiativeDisplayNames(input.projection).shortName;
|
|
1939
|
+
const seenScenarioIds = new Set<string>();
|
|
1940
|
+
if (input.amendment) {
|
|
1941
|
+
const historicalBindings = (input.amendment.historical ?? []).map((child) => (child as { binding?: { body?: unknown } }).binding ?? {});
|
|
1942
|
+
const historicalScenarioError = retainHistoricalScenarioIds(historicalBindings, seenScenarioIds);
|
|
1943
|
+
if (historicalScenarioError) throw new Error(historicalScenarioError);
|
|
1944
|
+
}
|
|
1730
1945
|
const publications = input.tasks.map((task, index) => {
|
|
1731
1946
|
if (!task || typeof task !== "object" || Array.isArray(task)) throw new Error(`tasks[${index}] must be an object`);
|
|
1732
1947
|
if (typeof task.intent !== "string") throw new Error(`tasks[${index}].intent must be a string`);
|
|
1733
|
-
return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.acceptance, task.projection, index + 1, initiativeShortName);
|
|
1948
|
+
return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.acceptance, task.projection, task.scenarios, index + 1, initiativeShortName, seenScenarioIds);
|
|
1734
1949
|
});
|
|
1735
1950
|
const historicalIds = new Set<string>();
|
|
1736
1951
|
if (input.amendment) {
|
|
@@ -1772,6 +1987,15 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1772
1987
|
blocked_by: operation.projection?.blocked_by,
|
|
1773
1988
|
})),
|
|
1774
1989
|
}) as Extract<TrackerOperation, { op: "create-initiative" }>;
|
|
1990
|
+
initiative.scenarios = operations.flatMap((operation) =>
|
|
1991
|
+
(operation.scenarios ?? []).map((scenario) => ({
|
|
1992
|
+
slice_id: operation.slice_id,
|
|
1993
|
+
id: scenario.id,
|
|
1994
|
+
mode: scenario.mode,
|
|
1995
|
+
when: scenario.when,
|
|
1996
|
+
then: scenario.then,
|
|
1997
|
+
})),
|
|
1998
|
+
);
|
|
1775
1999
|
const intentBindings = new Map(publications.map((publication) => [publication.operation.task_id, {
|
|
1776
2000
|
intent_path: publication.intent_path,
|
|
1777
2001
|
intent_content_hash: publication.intent_content_hash,
|
|
@@ -2874,8 +3098,10 @@ function taskPublication(
|
|
|
2874
3098
|
intentPath: string,
|
|
2875
3099
|
acceptance: unknown,
|
|
2876
3100
|
projection: TaskProjection | undefined,
|
|
3101
|
+
scenarios: unknown,
|
|
2877
3102
|
ordinal: number,
|
|
2878
3103
|
initiativeShortName: string,
|
|
3104
|
+
seenScenarioIds: Set<string>,
|
|
2879
3105
|
): PreparedPublicationTask {
|
|
2880
3106
|
const absoluteRoot = resolve(root);
|
|
2881
3107
|
const absolutePath = resolve(absoluteRoot, intentPath);
|
|
@@ -2901,6 +3127,7 @@ function taskPublication(
|
|
|
2901
3127
|
});
|
|
2902
3128
|
const missingIds = [...expectedIds].filter((id) => !publicById.has(id));
|
|
2903
3129
|
if (missingIds.length) throw new Error(`Task ${taskId} is missing public acceptance ids: ${missingIds.join(", ")}`);
|
|
3130
|
+
const normalizedScenarios = normalizeTaskScenarios(scenarios, taskId, expectedIds, seenScenarioIds);
|
|
2904
3131
|
// Display names are stamped from the Initiative so every Child title carries
|
|
2905
3132
|
// the same short name and its declared Slice position; an explicitly
|
|
2906
3133
|
// supplied value must agree, never silently override.
|
|
@@ -2924,6 +3151,7 @@ function taskPublication(
|
|
|
2924
3151
|
acceptance: intent.acceptance.map((item) => publicById.get(item.id)!),
|
|
2925
3152
|
projection: stampedProjection,
|
|
2926
3153
|
intent_hash: read.content_hash,
|
|
3154
|
+
scenarios: normalizedScenarios,
|
|
2927
3155
|
}) as Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
2928
3156
|
intent_path: read.intent_ref.path,
|
|
2929
3157
|
intent_content_hash: read.content_hash,
|