immune-brain 3.6.4 → 3.6.6
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/README.md +45 -0
- 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 +76 -20
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +38 -10
- package/plugins/immune-brain/dist/BASELINE.md +48 -15
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +166 -57
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
- package/plugins/immune-brain/dist/imm-agent-doc-maintain.md +9 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +49 -35
- package/plugins/immune-brain/dist/imm-doc-prune.md +7 -1
- package/plugins/immune-brain/dist/imm-loop.md +31 -13
- package/plugins/immune-brain/dist/imm-planner.md +74 -32
- package/plugins/immune-brain/dist/imm-pr-fix.md +6 -2
- package/plugins/immune-brain/dist/role-prompts/executor.md +18 -10
- package/plugins/immune-brain/dist/role-prompts/pr-fix.md +5 -2
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +18 -0
- package/plugins/immune-brain/runtime/assurance/verification.ts +13 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +31 -9
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +14 -1
- package/plugins/immune-brain/runtime/commands/kernel.ts +15 -13
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1112 -20
- package/plugins/immune-brain/runtime/kernel/application.ts +1 -0
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +407 -0
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +24 -9
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +72 -13
- package/plugins/immune-brain/runtime/kernel/intent.ts +67 -23
- package/plugins/immune-brain/runtime/kernel/reducer.ts +37 -9
- package/plugins/immune-brain/runtime/kernel/types.ts +1 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +10 -6
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +18 -10
- package/plugins/immune-brain/runtime/prompts/pr-fix.md +5 -2
- package/plugins/immune-brain/skills/BASELINE.md +48 -15
- package/plugins/immune-brain/skills/imm-agent-doc-maintain/SKILL.md +20 -4
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +24 -64
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +18 -3
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +20 -6
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +35 -8
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +17 -3
|
@@ -57,8 +57,38 @@ export interface InitiativePublicationInput {
|
|
|
57
57
|
tasks: Array<{
|
|
58
58
|
slice_id: string;
|
|
59
59
|
intent: string;
|
|
60
|
+
acceptance: Array<{ id: string; summary: string }>;
|
|
60
61
|
projection?: TaskProjection;
|
|
61
62
|
}>;
|
|
63
|
+
/**
|
|
64
|
+
* Explicit amendment input: binds the caller's desired pending frontier to
|
|
65
|
+
* the exact observed remote content it was approved against, and declares
|
|
66
|
+
* read-only historical Child identities. When omitted, publication keeps
|
|
67
|
+
* today's strict default semantics (create once, never rewrite).
|
|
68
|
+
*/
|
|
69
|
+
amendment?: {
|
|
70
|
+
parent: InitiativeAmendmentBinding;
|
|
71
|
+
tasks: Array<InitiativeTaskAmendment>;
|
|
72
|
+
historical: InitiativeHistoricalChild[];
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Identity plus expected remote title/body/state for one bound Issue. */
|
|
77
|
+
export interface InitiativeAmendmentBinding {
|
|
78
|
+
issue_number: number;
|
|
79
|
+
title: string;
|
|
80
|
+
body: string;
|
|
81
|
+
state: "open" | "closed";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface InitiativeTaskAmendment {
|
|
85
|
+
task_id: string;
|
|
86
|
+
binding?: InitiativeAmendmentBinding;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface InitiativeHistoricalChild {
|
|
90
|
+
task_id: string;
|
|
91
|
+
binding: InitiativeAmendmentBinding;
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
export interface GithubInitiativePublicationResult {
|
|
@@ -85,6 +115,18 @@ export interface GithubInitiativePublicationResult {
|
|
|
85
115
|
message: string;
|
|
86
116
|
}
|
|
87
117
|
|
|
118
|
+
export interface GithubInitiativeObservation {
|
|
119
|
+
contract: "immune_brain/github_initiative_observation/v1";
|
|
120
|
+
initiative_id: string;
|
|
121
|
+
issue_number: number;
|
|
122
|
+
tasks: Array<{
|
|
123
|
+
task_id: string;
|
|
124
|
+
slice_id: string;
|
|
125
|
+
issue_number: number;
|
|
126
|
+
blocked_by: string[];
|
|
127
|
+
}>;
|
|
128
|
+
}
|
|
129
|
+
|
|
88
130
|
export interface TaskProjection {
|
|
89
131
|
result?: string;
|
|
90
132
|
current_behavior?: string;
|
|
@@ -198,6 +240,12 @@ function terminalEvent(value: unknown): string {
|
|
|
198
240
|
if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,500}$/.test(value))
|
|
199
241
|
throw new Error("terminal_event_id must be a bounded opaque Kernel event id");
|
|
200
242
|
if (/(?:gh[pousr]_|github_pat_)/i.test(value)) throw new Error("terminal_event_id must not contain a token-like value");
|
|
243
|
+
// The suffix parser reports failures through the string sentinels "multiple"
|
|
244
|
+
// and "malformed"; an event id equal to either sentinel would make valid
|
|
245
|
+
// terminal evidence indistinguishable from a parser failure. Reject at the
|
|
246
|
+
// validation boundary so no published marker can ever collide.
|
|
247
|
+
if (value === "multiple" || value === "malformed")
|
|
248
|
+
throw new Error(`terminal_event_id must not be the reserved parser sentinel: ${value}`);
|
|
201
249
|
return value;
|
|
202
250
|
}
|
|
203
251
|
|
|
@@ -552,6 +600,63 @@ async function readBlockedByIds(
|
|
|
552
600
|
}
|
|
553
601
|
}
|
|
554
602
|
|
|
603
|
+
export async function observeGithubInitiative(
|
|
604
|
+
root: string,
|
|
605
|
+
initiativeId: string,
|
|
606
|
+
gh: GhTransport = createGhTransport(),
|
|
607
|
+
): Promise<GithubInitiativeObservation> {
|
|
608
|
+
const id = identifier(initiativeId, "initiative_id");
|
|
609
|
+
const source = await snapshot(resolve(root), gh, "create-initiative");
|
|
610
|
+
if ("contract" in source) throw new Error(source.message);
|
|
611
|
+
const parent = initiativeLookup(source.issues, source.repository.id, id);
|
|
612
|
+
if (parent.kind === "missing") throw new Error(`Initiative ${id} is not published`);
|
|
613
|
+
if (parent.kind === "ambiguous") throw new Error(parent.message);
|
|
614
|
+
const subIssueNumbers = await readSubIssueNumbers(root, gh, "create-initiative", source.repository, parent.issue.number);
|
|
615
|
+
if (!Array.isArray(subIssueNumbers)) throw new Error(subIssueNumbers.message);
|
|
616
|
+
if (new Set(subIssueNumbers).size !== subIssueNumbers.length)
|
|
617
|
+
throw new Error(`Initiative ${id} has duplicate native Sub-issue relations`);
|
|
618
|
+
const tasks = subIssueNumbers.map((issueNumber) => {
|
|
619
|
+
const matches = source.issues.filter((issue) => issue.number === issueNumber);
|
|
620
|
+
if (matches.length !== 1) throw new Error(`Initiative ${id} references an unreadable Sub-issue #${issueNumber}`);
|
|
621
|
+
const issue = matches[0];
|
|
622
|
+
const taskId = ownershipMarkerValue(issue.body, "task-id");
|
|
623
|
+
const sliceId = ownershipMarkerValue(issue.body, "slice-id");
|
|
624
|
+
if (!taskId || !sliceId || ownershipMarkerValue(issue.body, "initiative-id") !== id)
|
|
625
|
+
throw new Error(`Sub-issue #${issueNumber} has invalid Initiative ownership markers`);
|
|
626
|
+
const owned = ownedTaskLookup(source.issues, source.repository.id, taskId, id, sliceId);
|
|
627
|
+
if (owned.kind !== "found" || owned.issue.number !== issueNumber)
|
|
628
|
+
throw new Error(owned.kind === "ambiguous" ? owned.message : `Sub-issue #${issueNumber} has invalid Task ownership`);
|
|
629
|
+
return { task_id: taskId, slice_id: sliceId, issue_number: issueNumber, issue_id: issue.id };
|
|
630
|
+
});
|
|
631
|
+
if (new Set(tasks.map((task) => task.task_id)).size !== tasks.length)
|
|
632
|
+
throw new Error(`Initiative ${id} has duplicate Task identities`);
|
|
633
|
+
if (new Set(tasks.map((task) => task.slice_id)).size !== tasks.length)
|
|
634
|
+
throw new Error(`Initiative ${id} has duplicate Slice identities`);
|
|
635
|
+
const taskByIssueId = new Map(tasks.map((task) => [task.issue_id, task.task_id]));
|
|
636
|
+
const observed: GithubInitiativeObservation["tasks"] = [];
|
|
637
|
+
for (const task of tasks.sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0)) {
|
|
638
|
+
const blockerIds = await readBlockedByIds(root, gh, "create-initiative", source.repository, task.issue_number);
|
|
639
|
+
if (!Array.isArray(blockerIds)) throw new Error(blockerIds.message);
|
|
640
|
+
const blockedBy = blockerIds.map((blockerId) => {
|
|
641
|
+
const blocker = taskByIssueId.get(blockerId);
|
|
642
|
+
if (!blocker) throw new Error(`Task ${task.task_id} depends on an Issue outside Initiative ${id}`);
|
|
643
|
+
return blocker;
|
|
644
|
+
}).sort();
|
|
645
|
+
observed.push({
|
|
646
|
+
task_id: task.task_id,
|
|
647
|
+
slice_id: task.slice_id,
|
|
648
|
+
issue_number: task.issue_number,
|
|
649
|
+
blocked_by: blockedBy,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
contract: "immune_brain/github_initiative_observation/v1",
|
|
654
|
+
initiative_id: id,
|
|
655
|
+
issue_number: parent.issue.number,
|
|
656
|
+
tasks: observed,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
555
660
|
async function confirmBlockedBy(
|
|
556
661
|
root: string,
|
|
557
662
|
gh: GhTransport,
|
|
@@ -610,14 +715,18 @@ function bodyLimitFailure(operation: TrackerOperation["op"], body: string, reser
|
|
|
610
715
|
: result(operation, "permanent_failure", "rendered GitHub Issue body exceeds 65,536 UTF-8 bytes");
|
|
611
716
|
}
|
|
612
717
|
|
|
613
|
-
function createInitiativeBody(
|
|
718
|
+
function createInitiativeBody(
|
|
719
|
+
repository: RepositoryInfo,
|
|
720
|
+
operation: Extract<TrackerOperation, { op: "create-initiative" }>,
|
|
721
|
+
historicalSlices: string[] = [],
|
|
722
|
+
): string {
|
|
614
723
|
const projection = operation.projection ?? {};
|
|
615
724
|
return `${[
|
|
616
725
|
PROTOCOL_MARKER,
|
|
617
726
|
KIND_INITIATIVE_MARKER,
|
|
618
727
|
marker("repo-id", repository.id),
|
|
619
728
|
marker("initiative-id", operation.initiative_id),
|
|
620
|
-
].join("\n")}\n\n# ${titleText(projection.result ?? operation.goal)}\n\nOpt-in, non-authoritative Immune-Brain Initiative planning carrier. Kernel TaskIntent, TaskRecord, and Assurance remain the execution authority.\n\n## 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 or closes this Parent after creation; the tracker never changes or closes it automatically.\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 === 0 ? "No Slices recorded yet." : 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## Authority boundary\n\nThis Issue is outbound visibility only. GitHub state never starts, authorizes, reprioritizes, or settles work. Native Sub-issues identify published Tasks; their state is only an observation.\n`;
|
|
729
|
+
].join("\n")}\n\n# ${titleText(projection.result ?? operation.goal)}\n\nOpt-in, non-authoritative Immune-Brain Initiative planning carrier. Kernel TaskIntent, TaskRecord, and Assurance remain the execution authority.\n\n## 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 or closes this Parent after creation; the tracker never changes or closes it automatically.\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## Authority boundary\n\nThis Issue is outbound visibility only. GitHub state never starts, authorizes, reprioritizes, or settles work. Native Sub-issues identify published Tasks; their state is only an observation.\n`;
|
|
621
730
|
}
|
|
622
731
|
|
|
623
732
|
async function createInitiative(
|
|
@@ -679,11 +788,183 @@ function childBody(
|
|
|
679
788
|
].join("\n")}\n\n# ${titleText(projection.result ?? operation.goal)}\n\nOpt-in, non-authoritative Immune-Brain Task Issue. Kernel TaskIntent, TaskRecord, and Assurance remain the execution authority.\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## What to build\n\n${publicText(projection.result ?? operation.goal, "projection.result")}\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 ?? 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\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## Lifecycle\n\n- **Open** means this Task still needs attention; it does not mean the Task is authorized or executing.\n- Only a fresh claimless terminal projection can close this Issue: \`done\` becomes **Completed**, and \`stopped\` becomes **Not planned**.\n\n## Authority boundary\n\nThis Issue is outbound visibility only. GitHub state never changes TaskIntent, TaskRecord, QA, Review, authorization, or Kernel settlement. Internal role prompts, tool policies, review gates, model reservations, and prompt digests are not part of this external handoff.\n`;
|
|
680
789
|
}
|
|
681
790
|
|
|
791
|
+
/** Derived approved-final content and baseline-derived historical evidence for an amendment. */
|
|
792
|
+
interface AmendmentExecutionContext {
|
|
793
|
+
/** Approved final title/body per pending Task id. */
|
|
794
|
+
pendingContent: Map<string, { title: string; body: string }>;
|
|
795
|
+
/** Approved final Parent title/body. */
|
|
796
|
+
parent: { title: string; body: string };
|
|
797
|
+
/** Approved bound Parent issue number. */
|
|
798
|
+
parentIssueNumber: number;
|
|
799
|
+
/** Historical Slice lines derived from the approved Parent baseline (exact bytes). */
|
|
800
|
+
historicalSlices: string[];
|
|
801
|
+
/** Historical dependency database IDs and terminal state_reason snapshotted before any write. */
|
|
802
|
+
historicalRelations: Map<string, { blocked_by: number[]; state_reason: string | null }>;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Derive the approved-final content for every amendment write from the current
|
|
807
|
+
* TaskIntents and the approved baseline. Historical Slice lines are extracted
|
|
808
|
+
* from the Parent binding's baseline body so the final expectation is fixed
|
|
809
|
+
* before any mutation, never derived from post-write remote state.
|
|
810
|
+
*/
|
|
811
|
+
function approvedAmendmentContent(
|
|
812
|
+
root: string,
|
|
813
|
+
repository: RepositoryInfo,
|
|
814
|
+
parentIssue: GithubIssue,
|
|
815
|
+
prepared: ReturnType<typeof preflightPublication>,
|
|
816
|
+
amendment: ReturnType<typeof validateAmendment>,
|
|
817
|
+
): AmendmentExecutionContext | string {
|
|
818
|
+
const parent = validateOperation({
|
|
819
|
+
op: "create-initiative",
|
|
820
|
+
initiative_id: prepared.initiative.initiative_id,
|
|
821
|
+
goal: prepared.initiative.goal,
|
|
822
|
+
projection: prepared.initiative.projection,
|
|
823
|
+
slices: prepared.order.map((operation) => ({
|
|
824
|
+
id: operation.slice_id,
|
|
825
|
+
goal: operation.goal,
|
|
826
|
+
result: operation.projection?.result,
|
|
827
|
+
blocked_by: operation.projection?.blocked_by,
|
|
828
|
+
})),
|
|
829
|
+
}) as Extract<TrackerOperation, { op: "create-initiative" }>;
|
|
830
|
+
const historicalSliceIds: Set<string> = new Set();
|
|
831
|
+
for (const child of amendment.historical.values()) {
|
|
832
|
+
const sliceId = ownershipMarkerValue(child.body, "slice-id");
|
|
833
|
+
if (!sliceId) throw new Error("historical amendment binding must carry a slice-id marker");
|
|
834
|
+
if (historicalSliceIds.has(sliceId)) throw new Error(`duplicate historical slice-id: ${sliceId}`);
|
|
835
|
+
historicalSliceIds.add(sliceId);
|
|
836
|
+
}
|
|
837
|
+
const amendedSliceIds = new Set(parent.slices.map((slice) => slice.id));
|
|
838
|
+
for (const sliceId of amendedSliceIds) {
|
|
839
|
+
if (historicalSliceIds.has(sliceId))
|
|
840
|
+
return `pending Task Slice ${sliceId} collides with a historical Task Slice of the same id; Slice identities must be unique across historical and pending Children`;
|
|
841
|
+
}
|
|
842
|
+
const boundSliceIds = new Set(
|
|
843
|
+
parent.slices
|
|
844
|
+
.filter((slice) => prepared.order.some((operation) => operation.slice_id === slice.id && amendment.tasks.get(operation.task_id) !== undefined))
|
|
845
|
+
.map((slice) => slice.id),
|
|
846
|
+
);
|
|
847
|
+
const historicalSlices = baselineHistoricalSlices(amendment.parent.body, amendedSliceIds, boundSliceIds);
|
|
848
|
+
if (typeof historicalSlices === "string") return historicalSlices;
|
|
849
|
+
const pendingContent = new Map<string, { title: string; body: string }>();
|
|
850
|
+
const sourceStub = {
|
|
851
|
+
repository,
|
|
852
|
+
issues: [parentIssue],
|
|
853
|
+
};
|
|
854
|
+
void sourceStub;
|
|
855
|
+
for (const operation of prepared.order) {
|
|
856
|
+
const body = childBody(repository, operation, parentIssue);
|
|
857
|
+
const oversized = bodyLimitFailure("upsert-task", body, MAX_TERMINAL_SUFFIX_BYTES);
|
|
858
|
+
if (oversized) return `${oversized.status}: ${oversized.message}`;
|
|
859
|
+
pendingContent.set(operation.task_id, {
|
|
860
|
+
title: issueTitle(`${operation.initiative_id}/${operation.slice_id}`, operation.projection?.result ?? operation.goal),
|
|
861
|
+
body,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
const parentBody = createInitiativeBody(sourceStub.repository, parent, historicalSlices);
|
|
865
|
+
const oversizedParent = bodyLimitFailure("create-initiative", parentBody);
|
|
866
|
+
if (oversizedParent) return `${oversizedParent.status}: ${oversizedParent.message}`;
|
|
867
|
+
return {
|
|
868
|
+
pendingContent,
|
|
869
|
+
parent: {
|
|
870
|
+
title: issueTitle(parent.initiative_id, parent.projection?.result ?? parent.goal),
|
|
871
|
+
body: parentBody,
|
|
872
|
+
},
|
|
873
|
+
parentIssueNumber: parentIssue.number,
|
|
874
|
+
historicalSlices,
|
|
875
|
+
historicalRelations: new Map(),
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Extract historical Slice lines from the approved Parent baseline body.
|
|
881
|
+
* Accepts any exactly-once Slice marker representation and returns the exact
|
|
882
|
+
* baseline line bytes; fails closed on malformed Slice entries. Only Slices of
|
|
883
|
+
* bound pending Tasks must already exist in the baseline; unbound new Tasks
|
|
884
|
+
* contribute fresh Slice lines to the approved final Parent body.
|
|
885
|
+
*/
|
|
886
|
+
function baselineHistoricalSlices(parentBaselineBody: string, amendedSliceIds: Set<string>, boundSliceIds: Set<string>): string[] | string {
|
|
887
|
+
const lines: string[] = [];
|
|
888
|
+
for (const match of parentBaselineBody.matchAll(/^.*?<!-- immune-brain:slice-id=([A-Za-z0-9._:-]+) -->.*$/gm)) {
|
|
889
|
+
const sliceId = match[1];
|
|
890
|
+
const line = match[0];
|
|
891
|
+
// A baseline line must carry exactly one Slice marker: a line mixing a
|
|
892
|
+
// historical marker with a pending marker, or duplicating a marker, is
|
|
893
|
+
// ambiguous remote state — greedy whole-line matching would otherwise
|
|
894
|
+
// silently drop one side's marker when the Parent is rewritten.
|
|
895
|
+
const markersOnLine = [...line.matchAll(/<!-- immune-brain:slice-id=([A-Za-z0-9._:-]+) -->/g)];
|
|
896
|
+
if (markersOnLine.length !== 1)
|
|
897
|
+
return `historical Slice ${sliceId} shares a baseline line with another Slice marker; each Slice marker must sit on its own line`;
|
|
898
|
+
if (amendedSliceIds.has(sliceId)) {
|
|
899
|
+
// The pending batch replaces this Slice line: the line belongs to the
|
|
900
|
+
// amendment's own pending or historical Children (membership and slice
|
|
901
|
+
// uniqueness are validated elsewhere), so it must not be carried over.
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (countLiteral(line, marker("slice-id", sliceId)) !== 1)
|
|
905
|
+
return `historical Slice ${sliceId} has missing or duplicate Slice markers in the approved Parent baseline`;
|
|
906
|
+
if (!ownershipMarkerValue(line, "slice-id"))
|
|
907
|
+
return `historical Slice ${sliceId} has a malformed Slice marker in the approved Parent baseline`;
|
|
908
|
+
lines.push(line);
|
|
909
|
+
}
|
|
910
|
+
for (const sliceId of boundSliceIds) {
|
|
911
|
+
if (countLiteral(parentBaselineBody, marker("slice-id", sliceId)) !== 1)
|
|
912
|
+
return `approved Parent baseline does not carry exactly one Slice marker for bound pending Slice ${sliceId}`;
|
|
913
|
+
}
|
|
914
|
+
return lines;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Edit an open Initiative Parent's title/body to the approved amendment content. */
|
|
918
|
+
async function amendInitiativeParent(
|
|
919
|
+
root: string,
|
|
920
|
+
gh: GhTransport,
|
|
921
|
+
operation: Extract<TrackerOperation, { op: "create-initiative" }>,
|
|
922
|
+
source: RepositorySnapshot,
|
|
923
|
+
binding: InitiativeAmendmentBinding,
|
|
924
|
+
context: AmendmentExecutionContext,
|
|
925
|
+
): Promise<GithubTrackerResult> {
|
|
926
|
+
const lookup = (issues: GithubIssue[]) => initiativeLookup(issues, source.repository.id, operation.initiative_id);
|
|
927
|
+
const body = context.parent.body;
|
|
928
|
+
const oversized = bodyLimitFailure(operation.op, body);
|
|
929
|
+
if (oversized) return oversized;
|
|
930
|
+
const title = context.parent.title;
|
|
931
|
+
// Re-read the Parent immediately before writing: earlier topology and
|
|
932
|
+
// historical-dependency reads may have raced a concurrent user edit.
|
|
933
|
+
const reread = await snapshot(root, gh, operation.op);
|
|
934
|
+
if ("contract" in reread) return reread;
|
|
935
|
+
const found = lookup(reread.issues);
|
|
936
|
+
if (found.kind !== "found")
|
|
937
|
+
return result(operation.op, "permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
938
|
+
if (found.issue.state !== "open")
|
|
939
|
+
return result(operation.op, "ambiguous_remote_state", "an amendment requires the Initiative Parent to remain open", found.issue);
|
|
940
|
+
if (binding.issue_number !== found.issue.number)
|
|
941
|
+
return result(operation.op, "ambiguous_remote_state", `amendment Parent is bound to Issue #${binding.issue_number} but observed Issue #${found.issue.number}`, found.issue);
|
|
942
|
+
if (found.issue.body === body && found.issue.title === title)
|
|
943
|
+
return result(operation.op, "already_current", "Initiative Issue already carries the requested amended content", found.issue);
|
|
944
|
+
if (found.issue.body !== binding.body || found.issue.title !== binding.title)
|
|
945
|
+
return result(operation.op, "ambiguous_remote_state", "Initiative Parent changed since the approved amendment baseline", found.issue);
|
|
946
|
+
const edited = await gh.run([
|
|
947
|
+
"issue", "edit", String(found.issue.number), "--repo", source.repository.name_with_owner,
|
|
948
|
+
"--title", title,
|
|
949
|
+
"--body-file", "-",
|
|
950
|
+
], { cwd: root, stdin: body });
|
|
951
|
+
if (edited.exit_code !== 0 || edited.output_exceeded) return ghFailure(operation.op, edited, "Initiative amendment edit failed");
|
|
952
|
+
const refreshed = await snapshot(root, gh, operation.op);
|
|
953
|
+
if ("contract" in refreshed) return refreshed;
|
|
954
|
+
const confirmed = lookup(refreshed.issues);
|
|
955
|
+
if (confirmed.kind !== "found") return result(operation.op, "ambiguous_remote_state", "Initiative Parent became ambiguous after amendment", found.issue);
|
|
956
|
+
return confirmed.issue.body === body && confirmed.issue.title === title
|
|
957
|
+
? result(operation.op, "updated", "Initiative Issue updated with approved amendment content", confirmed.issue)
|
|
958
|
+
: result(operation.op, "retryable_failure", "Initiative amendment did not converge to the requested title and body", confirmed.issue);
|
|
959
|
+
}
|
|
960
|
+
|
|
682
961
|
async function upsertTask(
|
|
683
962
|
root: string,
|
|
684
963
|
gh: GhTransport,
|
|
685
964
|
operation: Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
686
965
|
source: RepositorySnapshot,
|
|
966
|
+
pendingBinding: InitiativeAmendmentBinding | undefined | null = null,
|
|
967
|
+
amendmentContext: AmendmentExecutionContext | undefined = undefined,
|
|
687
968
|
): Promise<GithubTrackerResult> {
|
|
688
969
|
const parent = initiativeLookup(source.issues, source.repository.id, operation.initiative_id);
|
|
689
970
|
if (parent.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", parent.message);
|
|
@@ -699,6 +980,8 @@ async function upsertTask(
|
|
|
699
980
|
const lookup = (issues: GithubIssue[]) => taskLookup(issues, source.repository.id, operation.task_id);
|
|
700
981
|
const found = lookup(source.issues);
|
|
701
982
|
if (found.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", found.message);
|
|
983
|
+
if (found.kind === "missing" && pendingBinding !== null && pendingBinding !== undefined)
|
|
984
|
+
return result(operation.op, "ambiguous_remote_state", `Task ${operation.task_id} is bound to Issue #${pendingBinding.issue_number} but that Issue no longer holds its Task marker; amend the binding or restore the marker instead of recreating`, parent.issue);
|
|
702
985
|
const blockerIds = operation.projection?.blocked_by ?? [];
|
|
703
986
|
const blockers: GithubIssue[] = [];
|
|
704
987
|
for (const blockerId of blockerIds) {
|
|
@@ -718,6 +1001,40 @@ async function upsertTask(
|
|
|
718
1001
|
let child: GithubIssue;
|
|
719
1002
|
let createdChild = false;
|
|
720
1003
|
if (found.kind === "missing") {
|
|
1004
|
+
// Pre-create re-read (amendment path): a concurrent writer may have already
|
|
1005
|
+
// created this unbound Task between the initial snapshot and our create — the
|
|
1006
|
+
// same resumable-creation contract applies before we issue any write, so we
|
|
1007
|
+
// fail closed on divergent content instead of issuing a duplicate create.
|
|
1008
|
+
if (amendmentContext !== undefined) {
|
|
1009
|
+
const reRead = await snapshot(root, gh, operation.op);
|
|
1010
|
+
if ("contract" in reRead) return reRead;
|
|
1011
|
+
// The re-read snapshot must also still hold the amendment's Parent exactly
|
|
1012
|
+
// as approved: a Parent closed or edited after the initial snapshot fails
|
|
1013
|
+
// closed here, before any Child create (avoiding an avoidable remote write
|
|
1014
|
+
// that the post-create attachment guard would otherwise reject).
|
|
1015
|
+
const reReadParent = initiativeLookup(reRead.issues, reRead.repository.id, operation.initiative_id);
|
|
1016
|
+
if (reReadParent.kind !== "found")
|
|
1017
|
+
return result(operation.op, "ambiguous_remote_state", reReadParent.kind === "ambiguous" ? reReadParent.message : "amendment Parent is not observable before creating a new Child", parent.issue);
|
|
1018
|
+
if (reReadParent.issue.number !== amendmentContext.parentIssueNumber)
|
|
1019
|
+
return result(operation.op, "ambiguous_remote_state", `amendment Parent is bound to Issue #${amendmentContext.parentIssueNumber} but observed Issue #${reReadParent.issue.number} before creating a new Child`, reReadParent.issue);
|
|
1020
|
+
if (reReadParent.issue.state !== "open")
|
|
1021
|
+
return result(operation.op, "ambiguous_remote_state", "amendment Parent is no longer open before creating a new Child", reReadParent.issue);
|
|
1022
|
+
if (amendmentContext.parent.title !== reReadParent.issue.title || amendmentContext.parent.body !== reReadParent.issue.body)
|
|
1023
|
+
return result(operation.op, "ambiguous_remote_state", "amendment Parent content changed before creating a new Child", reReadParent.issue);
|
|
1024
|
+
if (sliceCount(reReadParent.issue.body, operation.slice_id) !== 1)
|
|
1025
|
+
return result(operation.op, "ambiguous_remote_state", `Parent Issue #${reReadParent.issue.number} lost its exact Slice marker ${operation.slice_id} before creating a new Child`, reReadParent.issue);
|
|
1026
|
+
const raced = lookup(reRead.issues);
|
|
1027
|
+
if (raced.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", raced.message);
|
|
1028
|
+
if (raced.kind === "found") {
|
|
1029
|
+
const approved = amendmentContext.pendingContent.get(operation.task_id);
|
|
1030
|
+
const resumable = approved !== undefined
|
|
1031
|
+
&& raced.issue.state === "open"
|
|
1032
|
+
&& carriesApprovedContent(raced.issue.title, raced.issue.body, approved);
|
|
1033
|
+
if (!resumable)
|
|
1034
|
+
return result(operation.op, "ambiguous_remote_state", `new pending Task ${operation.task_id} is unbound but Issue #${raced.issue.number} already exists with divergent content; bind it to amend`, raced.issue);
|
|
1035
|
+
return updatePendingChild(root, gh, reRead, operation, raced.issue, undefined, blockers, approved, amendmentContext);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
721
1038
|
const mutation = await gh.run([
|
|
722
1039
|
"issue", "create", "--repo", source.repository.name_with_owner,
|
|
723
1040
|
"--title", title,
|
|
@@ -734,14 +1051,61 @@ async function upsertTask(
|
|
|
734
1051
|
}
|
|
735
1052
|
if (created.issue.body !== body || created.issue.title !== title)
|
|
736
1053
|
return result(operation.op, "retryable_failure", "Task Issue did not converge to the requested title and body", created.issue);
|
|
1054
|
+
if (amendmentContext !== undefined) {
|
|
1055
|
+
// Amendment unbound new Child: creation converged, but the Child must
|
|
1056
|
+
// still be open — a concurrent close between create and read fails closed
|
|
1057
|
+
// instead of attaching and wiring dependencies onto closed work.
|
|
1058
|
+
if (created.issue.state !== "open")
|
|
1059
|
+
return result(operation.op, "ambiguous_remote_state", `new pending Task ${operation.task_id} (Issue #${created.issue.number}) is not open after creation`, created.issue);
|
|
1060
|
+
// Route through the amendment convergence path so attachment (R4
|
|
1061
|
+
// re-attach) and every dependency write carry the same pre-write
|
|
1062
|
+
// revalidation as bound pending Children.
|
|
1063
|
+
return updatePendingChild(root, gh, source, operation, created.issue, undefined, blockers, amendmentContext.pendingContent.get(operation.task_id), amendmentContext);
|
|
1064
|
+
}
|
|
737
1065
|
child = created.issue;
|
|
738
1066
|
createdChild = true;
|
|
739
1067
|
} else {
|
|
740
|
-
if (found.issue.body !== body || found.issue.title !== title)
|
|
741
|
-
return result(operation.op, "permanent_failure", "Task Issue already exists with a different title or Agent Brief; edit the GitHub source or retry the original projection before changing native relations", found.issue);
|
|
742
1068
|
const owned = ownedTaskLookup(source.issues, source.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
|
|
743
1069
|
if (owned.kind !== "found") return result(operation.op, "ambiguous_remote_state", owned.kind === "ambiguous" ? owned.message : "Task Issue ownership changed during publication", found.issue);
|
|
744
1070
|
child = owned.issue;
|
|
1071
|
+
if (pendingBinding !== null) {
|
|
1072
|
+
if (child.state !== "open")
|
|
1073
|
+
return result(operation.op, "ambiguous_remote_state", `Task ${operation.task_id} is closed and cannot be amended as pending work`, found.issue);
|
|
1074
|
+
// The bound pending Child must still be the Issue its binding pins (review-5):
|
|
1075
|
+
// a replacement Issue that took over the markers fails closed instead of
|
|
1076
|
+
// being edited or recreated.
|
|
1077
|
+
if (pendingBinding !== undefined && pendingBinding.issue_number !== child.number)
|
|
1078
|
+
return result(operation.op, "ambiguous_remote_state", `Task ${operation.task_id} is bound to Issue #${pendingBinding.issue_number} but observed Issue #${child.number}`, found.issue);
|
|
1079
|
+
const approvedFinal = amendmentContext?.pendingContent.get(operation.task_id);
|
|
1080
|
+
// A Child left open with a validated terminal suffix (failed terminal close)
|
|
1081
|
+
// counts as approved-final when its suffix-free bytes match the approved bytes.
|
|
1082
|
+
const isFinal = approvedFinal !== undefined && carriesApprovedContent(found.issue.title, found.issue.body, approvedFinal);
|
|
1083
|
+
if (!isFinal) {
|
|
1084
|
+
// Suffix-aware baseline equality: a Child whose remote bytes carry a validated
|
|
1085
|
+
// terminal suffix over the binding baseline (failed terminal close) still
|
|
1086
|
+
// matches its binding, because the suffix is terminal evidence, not drift.
|
|
1087
|
+
const baselineMatches = pendingBinding !== undefined
|
|
1088
|
+
&& pendingBinding.title === found.issue.title
|
|
1089
|
+
&& carriesApprovedContent(found.issue.title, found.issue.body, pendingBinding);
|
|
1090
|
+
if (!baselineMatches)
|
|
1091
|
+
return result(operation.op, "ambiguous_remote_state", `Task ${operation.task_id} changed since the approved amendment baseline`, found.issue);
|
|
1092
|
+
}
|
|
1093
|
+
return updatePendingChild(root, gh, source, operation, child, pendingBinding?.issue_number, blockers, approvedFinal, amendmentContext);
|
|
1094
|
+
}
|
|
1095
|
+
if (amendmentContext !== undefined) {
|
|
1096
|
+
// Amendment unbound Child observed before any write: it is only valid
|
|
1097
|
+
// as the exact approved-final creation of this same batch (resumable
|
|
1098
|
+
// creation); anything else fails closed.
|
|
1099
|
+
const approvedFinal = amendmentContext.pendingContent.get(operation.task_id);
|
|
1100
|
+
const resumable = approvedFinal !== undefined
|
|
1101
|
+
&& child.state === "open"
|
|
1102
|
+
&& carriesApprovedContent(found.issue.title, found.issue.body, approvedFinal);
|
|
1103
|
+
if (!resumable)
|
|
1104
|
+
return result(operation.op, "ambiguous_remote_state", `new pending Task ${operation.task_id} is unbound but Issue #${child.number} already exists with divergent content; bind it to amend`, found.issue);
|
|
1105
|
+
return updatePendingChild(root, gh, source, operation, child, undefined, blockers, approvedFinal, amendmentContext);
|
|
1106
|
+
}
|
|
1107
|
+
if (found.issue.body !== body || found.issue.title !== title)
|
|
1108
|
+
return result(operation.op, "permanent_failure", "Task Issue already exists with a different title or Agent Brief; edit the GitHub source or retry the original projection before changing native relations", found.issue);
|
|
745
1109
|
}
|
|
746
1110
|
const attachment = await confirmAttachment(root, gh, operation.op, source.repository, parent.issue.number, child.number);
|
|
747
1111
|
if (!("attached" in attachment)) return attachment;
|
|
@@ -981,6 +1345,20 @@ function validateOperation(operation: TrackerOperation): TrackerOperation {
|
|
|
981
1345
|
};
|
|
982
1346
|
}
|
|
983
1347
|
|
|
1348
|
+
/** Run one amendment pending Task: create unbound new Children, amend bound drift. */
|
|
1349
|
+
async function runAmendmentTaskOperation(
|
|
1350
|
+
root: string,
|
|
1351
|
+
gh: GhTransport,
|
|
1352
|
+
operation: Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
1353
|
+
pendingBinding: InitiativeAmendmentBinding | undefined | null,
|
|
1354
|
+
context: AmendmentExecutionContext | undefined,
|
|
1355
|
+
): Promise<GithubTrackerResult> {
|
|
1356
|
+
const absoluteRoot = resolve(root);
|
|
1357
|
+
const source = await snapshot(absoluteRoot, gh, operation.op);
|
|
1358
|
+
if ("contract" in source) return source;
|
|
1359
|
+
return upsertTask(absoluteRoot, gh, operation, source, pendingBinding, context);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
984
1362
|
export async function runGithubTrackerOperation(
|
|
985
1363
|
root: string,
|
|
986
1364
|
input: TrackerOperation,
|
|
@@ -1030,12 +1408,12 @@ interface PreparedPublicationTask {
|
|
|
1030
1408
|
intent_content_hash: string;
|
|
1031
1409
|
}
|
|
1032
1410
|
|
|
1033
|
-
function publicationPlan(operations: Array<Extract<TrackerOperation, { op: "upsert-task" }
|
|
1411
|
+
function publicationPlan(operations: Array<Extract<TrackerOperation, { op: "upsert-task" }>>, satisfiedPrerequisites: Set<string> = new Set()): {
|
|
1034
1412
|
order: Array<Extract<TrackerOperation, { op: "upsert-task" }>>;
|
|
1035
1413
|
parallel_groups: string[][];
|
|
1036
1414
|
} {
|
|
1037
1415
|
const remaining = new Set(operations.map((operation) => operation.task_id));
|
|
1038
|
-
const done = new Set<string>();
|
|
1416
|
+
const done = new Set<string>(satisfiedPrerequisites);
|
|
1039
1417
|
const order: Array<Extract<TrackerOperation, { op: "upsert-task" }>> = [];
|
|
1040
1418
|
const parallelGroups: string[][] = [];
|
|
1041
1419
|
while (remaining.size) {
|
|
@@ -1057,9 +1435,10 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1057
1435
|
order: Array<Extract<TrackerOperation, { op: "upsert-task" }>>;
|
|
1058
1436
|
parallel_groups: string[][];
|
|
1059
1437
|
intent_bindings: Map<string, Pick<PreparedPublicationTask, "intent_path" | "intent_content_hash">>;
|
|
1438
|
+
foreign_dependers: Map<string, string[]>;
|
|
1060
1439
|
} {
|
|
1061
1440
|
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("publication must be an object");
|
|
1062
|
-
if (!Array.isArray(input.tasks) || input.tasks.length < 2)
|
|
1441
|
+
if (!Array.isArray(input.tasks) || input.tasks.length < (input.amendment ? 1 : 2))
|
|
1063
1442
|
throw new Error("a complete Initiative publication requires at least two Tasks");
|
|
1064
1443
|
if (!input.projection || typeof input.projection !== "object" || Array.isArray(input.projection))
|
|
1065
1444
|
throw new Error("publication projection must be an object");
|
|
@@ -1070,8 +1449,13 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1070
1449
|
const publications = input.tasks.map((task, index) => {
|
|
1071
1450
|
if (!task || typeof task !== "object" || Array.isArray(task)) throw new Error(`tasks[${index}] must be an object`);
|
|
1072
1451
|
if (typeof task.intent !== "string") throw new Error(`tasks[${index}].intent must be a string`);
|
|
1073
|
-
return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.projection);
|
|
1452
|
+
return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.acceptance, task.projection);
|
|
1074
1453
|
});
|
|
1454
|
+
const historicalIds = new Set<string>();
|
|
1455
|
+
if (input.amendment) {
|
|
1456
|
+
if (!Array.isArray(input.amendment.historical)) throw new Error("amendment.historical must be an array");
|
|
1457
|
+
for (const child of input.amendment.historical) historicalIds.add(identifier(child.task_id, "amendment.historical task_id"));
|
|
1458
|
+
}
|
|
1075
1459
|
const operations = publications.map((publication) => publication.operation);
|
|
1076
1460
|
const taskIds = new Set<string>();
|
|
1077
1461
|
const sliceIds = new Set<string>();
|
|
@@ -1081,12 +1465,20 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1081
1465
|
taskIds.add(operation.task_id);
|
|
1082
1466
|
sliceIds.add(operation.slice_id);
|
|
1083
1467
|
}
|
|
1468
|
+
const foreignDependers = new Map<string, string[]>();
|
|
1084
1469
|
for (const operation of operations) {
|
|
1085
1470
|
for (const blocker of operation.projection?.blocked_by ?? []) {
|
|
1086
|
-
if (!taskIds.has(blocker)
|
|
1471
|
+
if (!taskIds.has(blocker) && !historicalIds.has(blocker)) {
|
|
1472
|
+
if (input.amendment === undefined)
|
|
1473
|
+
throw new Error(`Task ${operation.task_id} depends on ${blocker}, which is outside the complete Initiative batch`);
|
|
1474
|
+
foreignDependers.set(blocker, [...(foreignDependers.get(blocker) ?? []), operation.task_id]);
|
|
1475
|
+
}
|
|
1087
1476
|
}
|
|
1088
1477
|
}
|
|
1089
|
-
const
|
|
1478
|
+
const foreignIds = new Set(foreignDependers.keys());
|
|
1479
|
+
const plan = input.amendment === undefined
|
|
1480
|
+
? publicationPlan(operations)
|
|
1481
|
+
: publicationPlan(operations, new Set([...historicalIds, ...foreignIds]));
|
|
1090
1482
|
const initiative = validateOperation({
|
|
1091
1483
|
op: "create-initiative",
|
|
1092
1484
|
initiative_id: input.initiative_id,
|
|
@@ -1103,7 +1495,7 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1103
1495
|
intent_path: publication.intent_path,
|
|
1104
1496
|
intent_content_hash: publication.intent_content_hash,
|
|
1105
1497
|
}]));
|
|
1106
|
-
return { initiative, ...plan, intent_bindings: intentBindings };
|
|
1498
|
+
return { initiative, ...plan, intent_bindings: intentBindings, foreign_dependers: foreignDependers };
|
|
1107
1499
|
}
|
|
1108
1500
|
|
|
1109
1501
|
function publicationIntentDrift(
|
|
@@ -1129,14 +1521,524 @@ function publicationIssueDrift(expected: {
|
|
|
1129
1521
|
issue_number?: number;
|
|
1130
1522
|
issue_url?: string;
|
|
1131
1523
|
node_id?: string;
|
|
1132
|
-
}, actual: GithubIssue, title: string, body: string, label: string): string | null {
|
|
1524
|
+
}, actual: GithubIssue, title: string, body: string, label: string, options?: { allowTerminalSuffix?: boolean }): string | null {
|
|
1133
1525
|
if (expected.issue_number !== actual.number || expected.issue_url !== actual.url || expected.node_id !== String(actual.id))
|
|
1134
1526
|
return `${label} identity changed during Initiative publication`;
|
|
1135
1527
|
if (actual.state !== "open") return `${label} is no longer open`;
|
|
1136
|
-
if (actual.title !== title || actual.body !== body)
|
|
1528
|
+
if (actual.title !== title || actual.body !== body) {
|
|
1529
|
+
// Only a pending amendment Child left open with a validated terminal suffix
|
|
1530
|
+
// (failed terminal close) matches its suffix-free expected content. The
|
|
1531
|
+
// Parent and the strict non-amendment path have no terminal-suffix
|
|
1532
|
+
// lifecycle: byte-exact comparison, any extra suffix is real drift.
|
|
1533
|
+
if (options?.allowTerminalSuffix && carriesApprovedContent(actual.title, actual.body, { title, body })) return null;
|
|
1534
|
+
return `${label} content changed during Initiative publication`;
|
|
1535
|
+
}
|
|
1536
|
+
return null;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/** One bound Issue's expected remote identity and content. */
|
|
1540
|
+
function validateAmendmentBinding(binding: unknown, label: string): InitiativeAmendmentBinding {
|
|
1541
|
+
if (!binding || typeof binding !== "object" || Array.isArray(binding)) throw new Error(`${label} must be an object`);
|
|
1542
|
+
const raw = binding as Record<string, unknown>;
|
|
1543
|
+
if (!Number.isSafeInteger(raw.issue_number) || (raw.issue_number as number) < 1)
|
|
1544
|
+
throw new Error(`${label}.issue_number must be a positive integer`);
|
|
1545
|
+
if (raw.state !== "open" && raw.state !== "closed") throw new Error(`${label}.state must be "open" or "closed"`);
|
|
1546
|
+
if (typeof raw.title !== "string" || !raw.title.trim()) throw new Error(`${label}.title must be a non-empty string`);
|
|
1547
|
+
if (typeof raw.body !== "string") throw new Error(`${label}.body must be a string`);
|
|
1548
|
+
if (Buffer.byteLength(raw.body, "utf8") > GITHUB_ISSUE_BODY_LIMIT) throw new Error(`${label}.body exceeds 65,536 UTF-8 bytes`);
|
|
1549
|
+
return { issue_number: raw.issue_number as number, title: raw.title, body: raw.body, state: raw.state };
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function validateAmendment(input: InitiativePublicationInput): {
|
|
1553
|
+
parent: InitiativeAmendmentBinding;
|
|
1554
|
+
tasks: Map<string, InitiativeAmendmentBinding | undefined>;
|
|
1555
|
+
historical: Map<string, InitiativeAmendmentBinding>;
|
|
1556
|
+
} {
|
|
1557
|
+
const amendment = input.amendment!;
|
|
1558
|
+
const parent = validateAmendmentBinding(amendment.parent, "amendment.parent");
|
|
1559
|
+
if (parent.state !== "open") throw new Error("amendment.parent must be an open Issue");
|
|
1560
|
+
const tasks = new Map<string, InitiativeAmendmentBinding | undefined>();
|
|
1561
|
+
const historical = new Map<string, InitiativeAmendmentBinding>();
|
|
1562
|
+
if (!Array.isArray(amendment.tasks)) throw new Error("amendment.tasks must be an array");
|
|
1563
|
+
for (const [index, task] of amendment.tasks.entries()) {
|
|
1564
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) throw new Error(`amendment.tasks[${index}] must be an object`);
|
|
1565
|
+
const taskId = identifier((task as { task_id: unknown }).task_id, `amendment.tasks[${index}].task_id`);
|
|
1566
|
+
if (tasks.has(taskId) || historical.has(taskId)) throw new Error(`duplicate amendment Task id: ${taskId}`);
|
|
1567
|
+
const binding = (task as { binding: unknown }).binding as InitiativeAmendmentBinding | undefined;
|
|
1568
|
+
if (binding !== undefined && binding.state !== "open")
|
|
1569
|
+
throw new Error(`amendment.tasks[${index}].binding.state must be "open" for pending Children`);
|
|
1570
|
+
tasks.set(taskId, binding === undefined ? undefined : validateAmendmentBinding(binding, `amendment.tasks[${index}].binding`));
|
|
1571
|
+
}
|
|
1572
|
+
if (!Array.isArray(amendment.historical)) throw new Error("amendment.historical must be an array");
|
|
1573
|
+
for (const [index, child] of amendment.historical.entries()) {
|
|
1574
|
+
if (!child || typeof child !== "object" || Array.isArray(child)) throw new Error(`amendment.historical[${index}] must be an object`);
|
|
1575
|
+
const raw = child as { task_id: unknown; binding: unknown };
|
|
1576
|
+
const taskId = identifier(raw.task_id, `amendment.historical[${index}].task_id`);
|
|
1577
|
+
if (tasks.has(taskId) || historical.has(taskId)) throw new Error(`duplicate amendment Task id: ${taskId}`);
|
|
1578
|
+
historical.set(taskId, validateAmendmentBinding(raw.binding, `amendment.historical[${index}].binding`));
|
|
1579
|
+
}
|
|
1580
|
+
return { parent, tasks, historical };
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/** Compare a bound Issue's observed remote identity/content against the approved baseline. */
|
|
1584
|
+
function amendmentBindingDrift(binding: InitiativeAmendmentBinding, actual: GithubIssue, label: string): string | null {
|
|
1585
|
+
if (binding.issue_number !== actual.number) return `${label} is bound to Issue #${binding.issue_number} but observed Issue #${actual.number}`;
|
|
1586
|
+
if (binding.state !== actual.state) return `${label} is bound as ${binding.state} but observed as ${actual.state}`;
|
|
1587
|
+
if (binding.title !== actual.title || binding.body !== actual.body) return `${label} remote content does not match the approved baseline`;
|
|
1137
1588
|
return null;
|
|
1138
1589
|
}
|
|
1139
1590
|
|
|
1591
|
+
/** Split observed Sub-issues into bound pending vs bound historical, verifying complete membership. */
|
|
1592
|
+
function classifyObservedChildren(
|
|
1593
|
+
source: RepositorySnapshot,
|
|
1594
|
+
initiativeId: string,
|
|
1595
|
+
pendingBindings: Map<string, InitiativeAmendmentBinding | undefined>,
|
|
1596
|
+
historicalBindings: Map<string, InitiativeAmendmentBinding>,
|
|
1597
|
+
): Map<string, GithubIssue> | string {
|
|
1598
|
+
const observed = new Map<string, GithubIssue>();
|
|
1599
|
+
for (const issue of source.issues) {
|
|
1600
|
+
if (issue.body.includes(marker("initiative-id", initiativeId)) && issue.body.includes(KIND_TASK_MARKER)) {
|
|
1601
|
+
const taskId = ownershipMarkerValue(issue.body, "task-id");
|
|
1602
|
+
if (!taskId) return `Issue #${issue.number} has missing or duplicate task-id ownership markers`;
|
|
1603
|
+
if (observed.has(taskId)) return `duplicate Task Issue identity: ${taskId}`;
|
|
1604
|
+
observed.set(taskId, issue);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
const pendingIds = new Set(pendingBindings.keys());
|
|
1608
|
+
const historicalIds = new Set(historicalBindings.keys());
|
|
1609
|
+
for (const [taskId, issue] of observed) {
|
|
1610
|
+
if (issue.state === "closed" && pendingIds.has(taskId))
|
|
1611
|
+
return `pending Task ${taskId} (Issue #${issue.number}) is closed; the amendment cannot treat closed work as pending`;
|
|
1612
|
+
if (issue.state === "closed" && !historicalIds.has(taskId))
|
|
1613
|
+
return `closed Child ${taskId} (Issue #${issue.number}) must be declared as historical`;
|
|
1614
|
+
if (pendingIds.has(taskId)) continue;
|
|
1615
|
+
if (historicalIds.has(taskId)) {
|
|
1616
|
+
const binding = historicalBindings.get(taskId)!;
|
|
1617
|
+
const drift = amendmentBindingDrift(binding, issue, `historical Task ${taskId}`);
|
|
1618
|
+
if (drift) return drift;
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
return `observed Child ${taskId} (Issue #${issue.number}) is neither approved pending nor declared historical; the amendment must declare the complete membership`;
|
|
1622
|
+
}
|
|
1623
|
+
return observed;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
async function validateAmendmentTopology(
|
|
1627
|
+
root: string,
|
|
1628
|
+
gh: GhTransport,
|
|
1629
|
+
source: RepositorySnapshot,
|
|
1630
|
+
initiativeId: string,
|
|
1631
|
+
parentIssue: GithubIssue,
|
|
1632
|
+
pendingBindings: Map<string, InitiativeAmendmentBinding | undefined>,
|
|
1633
|
+
historicalBindings: Map<string, InitiativeAmendmentBinding>,
|
|
1634
|
+
foreignDependers: Map<string, string[]>,
|
|
1635
|
+
approvedFinal: {
|
|
1636
|
+
pendingContent: Map<string, { title: string; body: string }>;
|
|
1637
|
+
parent: { title: string; body: string };
|
|
1638
|
+
historicalSlices: string[];
|
|
1639
|
+
},
|
|
1640
|
+
order: Array<Extract<TrackerOperation, { op: "upsert-task" }>>,
|
|
1641
|
+
): Promise<Map<string, GithubIssue> | GithubTrackerResult> {
|
|
1642
|
+
const classified = classifyObservedChildren(source, initiativeId, pendingBindings, historicalBindings);
|
|
1643
|
+
if (typeof classified === "string") return result("create-initiative", "ambiguous_remote_state", classified, parentIssue);
|
|
1644
|
+
const bound = classified;
|
|
1645
|
+
const pendingIds = new Set(pendingBindings.keys());
|
|
1646
|
+
const historicalIds = new Set(historicalBindings.keys());
|
|
1647
|
+
const attached = await readSubIssueNumbers(root, gh, "create-initiative", source.repository, parentIssue.number);
|
|
1648
|
+
if (!Array.isArray(attached)) return attached;
|
|
1649
|
+
const attachedNumbers = new Set(attached);
|
|
1650
|
+
for (const [taskId, binding] of historicalBindings) {
|
|
1651
|
+
const issue = bound.get(taskId);
|
|
1652
|
+
if (!issue) return result("create-initiative", "ambiguous_remote_state", `historical Task ${taskId} is not observable`, parentIssue);
|
|
1653
|
+
if (binding.issue_number !== issue.number)
|
|
1654
|
+
return result("create-initiative", "ambiguous_remote_state", `historical Task ${taskId} is bound to Issue #${binding.issue_number} but observed Issue #${issue.number}`, issue);
|
|
1655
|
+
// Historical Children carry the same repository/protocol/marker-uniqueness
|
|
1656
|
+
// ownership guarantees as pending Children: a full taskLookup must resolve
|
|
1657
|
+
// the exact bound Issue, or the amendment fails closed before any write.
|
|
1658
|
+
const owned = taskLookup(source.issues, source.repository.id, taskId);
|
|
1659
|
+
if (owned.kind !== "found" || owned.issue.number !== issue.number)
|
|
1660
|
+
return result("create-initiative", "ambiguous_remote_state", owned.kind === "found" ? `historical Task ${taskId} resolves to Issue #${owned.issue.number}, not the bound Issue #${issue.number}` : `historical Task ${taskId} ownership failed: ${owned.kind === "ambiguous" ? owned.message : "not observable"}`, issue);
|
|
1661
|
+
if (!attachedNumbers.has(issue.number))
|
|
1662
|
+
return result("create-initiative", "ambiguous_remote_state", `historical Task ${taskId} (Issue #${issue.number}) is not attached to the Parent`, issue);
|
|
1663
|
+
const ownership = await confirmTerminalOwnership(root, gh, "create-initiative", source, issue);
|
|
1664
|
+
if (!("owned" in ownership)) return ownership;
|
|
1665
|
+
}
|
|
1666
|
+
for (const [taskId, binding] of pendingBindings) {
|
|
1667
|
+
const issue = bound.get(taskId);
|
|
1668
|
+
// Terminal evidence is validated before any mutation, including exact-baseline
|
|
1669
|
+
// matches: a lone marker, truncated suffix, or multiple markers in a bound
|
|
1670
|
+
// or resumable Child is malformed evidence (Kernel-only boundary), never
|
|
1671
|
+
// synthesizable content.
|
|
1672
|
+
if (issue) {
|
|
1673
|
+
const suffixEvent = issueTerminalEventId(issue.body);
|
|
1674
|
+
if (suffixEvent === "multiple")
|
|
1675
|
+
return result("create-initiative", "ambiguous_remote_state", `pending Task ${taskId} has multiple terminal markers`, issue);
|
|
1676
|
+
if (suffixEvent === "malformed")
|
|
1677
|
+
return result("create-initiative", "ambiguous_remote_state", `pending Task ${taskId} carries a malformed terminal marker (marker without its exact canonical suffix)`, issue);
|
|
1678
|
+
}
|
|
1679
|
+
if (binding === undefined) {
|
|
1680
|
+
// Unbound new Child: either it must not exist yet (fresh creation), or it
|
|
1681
|
+
// may already be the exact approved-final creation from a prior partial
|
|
1682
|
+
// write of this same batch (resumable creation) — anything else fails closed.
|
|
1683
|
+
// Resolve the task_id repo-wide regardless of whether the issue was found
|
|
1684
|
+
// under this Initiative: an unbound Task whose id is already owned by
|
|
1685
|
+
// another Initiative (or ambiguous repo-wide) must be rejected here,
|
|
1686
|
+
// before any Parent or preceding Child rewrite — not later in upsertTask.
|
|
1687
|
+
const ownedNew = taskLookup(source.issues, source.repository.id, taskId);
|
|
1688
|
+
if (ownedNew.kind !== "missing") {
|
|
1689
|
+
if (ownedNew.kind === "found") {
|
|
1690
|
+
if (issue && ownedNew.issue.number === issue.number) {
|
|
1691
|
+
const approved = approvedFinal.pendingContent.get(taskId)!;
|
|
1692
|
+
// An open Child left by a failed terminal close carries a terminal suffix;
|
|
1693
|
+
// the suffix is validated terminal evidence, not baseline drift.
|
|
1694
|
+
const resumable = issue.state === "open"
|
|
1695
|
+
&& approved !== undefined
|
|
1696
|
+
&& carriesApprovedContent(issue.title, issue.body, approved);
|
|
1697
|
+
if (!resumable)
|
|
1698
|
+
return result("create-initiative", "ambiguous_remote_state", `new pending Task ${taskId} is unbound but Issue #${issue.number} already exists with divergent content; bind it to amend`, issue);
|
|
1699
|
+
} else {
|
|
1700
|
+
return result("create-initiative", "ambiguous_remote_state", `new pending Task ${taskId} is unbound but task_id is already owned by Issue #${ownedNew.issue.number}; bind it to amend`, ownedNew.issue);
|
|
1701
|
+
}
|
|
1702
|
+
} else {
|
|
1703
|
+
return result("create-initiative", "ambiguous_remote_state", `new pending Task ${taskId} ownership failed: ${ownedNew.kind === "ambiguous" ? ownedNew.message : "not observable"}`, parentIssue);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
if (!issue) return result("create-initiative", "ambiguous_remote_state", `bound pending Task ${taskId} is not observable`, parentIssue);
|
|
1709
|
+
if (binding.issue_number !== issue.number)
|
|
1710
|
+
return result("create-initiative", "ambiguous_remote_state", `pending Task ${taskId} is bound to Issue #${binding.issue_number} but observed Issue #${issue.number}`, issue);
|
|
1711
|
+
if (issue.state !== "open")
|
|
1712
|
+
return result("create-initiative", "ambiguous_remote_state", `pending Task ${taskId} (Issue #${issue.number}) is closed and cannot be amended`, issue);
|
|
1713
|
+
// Repository-wide ownership resolution must hold for pending Children too,
|
|
1714
|
+
// before any Parent rewrite: a missing repo/protocol marker or a duplicate
|
|
1715
|
+
// task-id in another Initiative fails closed here, not later in upsertTask.
|
|
1716
|
+
const ownedPending = taskLookup(source.issues, source.repository.id, taskId);
|
|
1717
|
+
if (ownedPending.kind !== "found" || ownedPending.issue.number !== issue.number)
|
|
1718
|
+
return result("create-initiative", "ambiguous_remote_state", ownedPending.kind === "found" ? `pending Task ${taskId} resolves to Issue #${ownedPending.issue.number}, not the observed Issue #${issue.number}` : `pending Task ${taskId} ownership failed: ${ownedPending.kind === "ambiguous" ? ownedPending.message : "not observable"}`, issue);
|
|
1719
|
+
// The observed Child's Slice identity must match the operation's requested
|
|
1720
|
+
// slice before any write: a bound Child observed under another Task's slice
|
|
1721
|
+
// (e.g. a historical slice) is immutable ownership, never a rewrite target.
|
|
1722
|
+
const requestedSlice = order.find((operation) => operation.task_id === taskId)?.slice_id;
|
|
1723
|
+
const observedSlice = ownershipMarkerValue(issue.body, "slice-id");
|
|
1724
|
+
if (requestedSlice !== undefined && observedSlice !== null && observedSlice !== requestedSlice)
|
|
1725
|
+
return result("create-initiative", "ambiguous_remote_state", `pending Task ${taskId} (Issue #${issue.number}) carries slice-id=${observedSlice}, which does not match its requested Slice ${requestedSlice}`, issue);
|
|
1726
|
+
const approved = approvedFinal.pendingContent.get(taskId)!;
|
|
1727
|
+
const matchesBaseline = binding.title === issue.title
|
|
1728
|
+
&& carriesApprovedContent(issue.title, issue.body, binding);
|
|
1729
|
+
const matchesApprovedFinal = approved && carriesApprovedContent(issue.title, issue.body, approved);
|
|
1730
|
+
// Attachment is required only for the baseline observation; an unattached
|
|
1731
|
+
// Child that already carries exact approved-final content (a prior partial
|
|
1732
|
+
// write) re-attaches during the upsert instead of failing closed.
|
|
1733
|
+
const carriesApprovedFinal = matchesApprovedFinal && issue.state === "open";
|
|
1734
|
+
if (!attachedNumbers.has(issue.number) && !carriesApprovedFinal)
|
|
1735
|
+
return result("create-initiative", "ambiguous_remote_state", `pending Task ${taskId} (Issue #${issue.number}) is not attached to the Parent`, issue);
|
|
1736
|
+
if (!matchesBaseline && !matchesApprovedFinal)
|
|
1737
|
+
return result("create-initiative", "ambiguous_remote_state", `pending Task ${taskId} remote content does not match the approved baseline or already-applied approved content`, issue);
|
|
1738
|
+
if (!carriesApprovedFinal) {
|
|
1739
|
+
// Ownership demands native Sub-issue attachment; an unattached Child that
|
|
1740
|
+
// already carries exact approved-final content (a prior partial write) skips
|
|
1741
|
+
// the attachment check here and is re-attached during the upsert instead.
|
|
1742
|
+
const ownership = await confirmTerminalOwnership(root, gh, "create-initiative", source, issue);
|
|
1743
|
+
if (!("owned" in ownership)) return ownership;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
for (const number of attachedNumbers) {
|
|
1747
|
+
const issue = source.issues.find((candidate) => candidate.number === number);
|
|
1748
|
+
const taskId = issue ? ownershipMarkerValue(issue.body, "task-id") : null;
|
|
1749
|
+
if (!taskId || (!pendingIds.has(taskId) && !historicalIds.has(taskId)))
|
|
1750
|
+
return result("create-initiative", "ambiguous_remote_state", `Parent Sub-issue #${number} is not part of the declared amendment membership`, parentIssue);
|
|
1751
|
+
}
|
|
1752
|
+
for (const [blockerId, dependers] of foreignDependers) {
|
|
1753
|
+
const issue = [...bound.values()].find((candidate) => ownershipMarkerValue(candidate.body, "task-id") === blockerId)
|
|
1754
|
+
?? source.issues.find((candidate) => ownershipMarkerValue(candidate.body, "task-id") === blockerId);
|
|
1755
|
+
if (!issue)
|
|
1756
|
+
return result("create-initiative", "ambiguous_remote_state", `Task ${dependers.join(", ")} depends on ${blockerId}, which is not part of the declared amendment membership`, parentIssue);
|
|
1757
|
+
if (!attachedNumbers.has(issue.number))
|
|
1758
|
+
return result("create-initiative", "ambiguous_remote_state", `blocking Task ${blockerId} (Issue #${issue.number}) is not attached to the Parent`, issue);
|
|
1759
|
+
}
|
|
1760
|
+
return bound;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Extract a single validated terminal suffix from an Issue body, or null.
|
|
1765
|
+
* A marker only counts as terminal evidence when the body ends with the exact
|
|
1766
|
+
* canonical suffix for that event id (marker + evidence line + bounded id):
|
|
1767
|
+
* a lone marker or truncated suffix is malformed evidence, not a suffix.
|
|
1768
|
+
*/
|
|
1769
|
+
function issueTerminalEventId(body: string): string | null | "multiple" | "malformed" {
|
|
1770
|
+
const suffixMatch = [...body.matchAll(/<!-- immune-brain:terminal-event=([A-Za-z0-9._:-]+) -->/g)];
|
|
1771
|
+
if (suffixMatch.length === 0) return null;
|
|
1772
|
+
if (suffixMatch.length > 1) return "multiple";
|
|
1773
|
+
const eventId = suffixMatch[0][1];
|
|
1774
|
+
if (eventId.length > MAX_TERMINAL_EVENT_ID || !body.endsWith(terminalSuffix(eventId)))
|
|
1775
|
+
return "malformed";
|
|
1776
|
+
return eventId;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Strip one validated terminal suffix from an Issue body, returning the
|
|
1781
|
+
* suffix-free bytes. Returns null when the body carries no terminal suffix;
|
|
1782
|
+
* callers treat a "multiple" result as fail-closed before calling.
|
|
1783
|
+
*/
|
|
1784
|
+
/** Extract a validated terminal suffix from raw body bytes, or null. */
|
|
1785
|
+
function stripTerminalSuffixFromBytes(body: string | undefined): string | null | "multiple" | "malformed" {
|
|
1786
|
+
if (body === undefined) return null;
|
|
1787
|
+
const eventId = issueTerminalEventId(body);
|
|
1788
|
+
if (eventId === null || eventId === "multiple" || eventId === "malformed") return eventId;
|
|
1789
|
+
const suffix = terminalSuffix(eventId);
|
|
1790
|
+
if (!body.endsWith(suffix)) return null;
|
|
1791
|
+
return body.slice(0, body.length - suffix.length);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* Suffix-aware content equality: an Issue (or raw baseline body) whose bytes
|
|
1796
|
+
* match the approved bytes exactly, or whose suffix-stripped bytes match the
|
|
1797
|
+
* approved (suffix-free) bytes, counts as carrying the approved content. A
|
|
1798
|
+
* validated terminal suffix is terminal evidence, not content drift. Multiple
|
|
1799
|
+
* terminal markers always fail closed.
|
|
1800
|
+
*/
|
|
1801
|
+
function carriesApprovedContent(title: string, body: string, approved: { title: string; body: string }): boolean {
|
|
1802
|
+
if (title !== approved.title) return false;
|
|
1803
|
+
if (body === approved.body) return true;
|
|
1804
|
+
const stripped = stripTerminalSuffixFromBytes(body);
|
|
1805
|
+
return typeof stripped === "string" && stripped.trimEnd() === approved.body.trimEnd();
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
async function updatePendingChild(
|
|
1809
|
+
root: string,
|
|
1810
|
+
gh: GhTransport,
|
|
1811
|
+
source: RepositorySnapshot,
|
|
1812
|
+
op: Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
1813
|
+
child: GithubIssue,
|
|
1814
|
+
boundNumber: number | undefined,
|
|
1815
|
+
desiredBlockers: GithubIssue[],
|
|
1816
|
+
approvedFinal: { title: string; body: string } | undefined,
|
|
1817
|
+
amendmentContext: AmendmentExecutionContext | undefined = undefined,
|
|
1818
|
+
): Promise<GithubTrackerResult> { // eslint-disable-line @typescript-eslint/no-unused-vars -- boundNumber retained for call-site symmetry
|
|
1819
|
+
const parent = initiativeLookup(source.issues, source.repository.id, op.initiative_id);
|
|
1820
|
+
const body = childBody(source.repository, op, parent.kind === "found" ? parent.issue : child);
|
|
1821
|
+
const oversized = bodyLimitFailure(op.op, body, MAX_TERMINAL_SUFFIX_BYTES);
|
|
1822
|
+
if (oversized) return oversized;
|
|
1823
|
+
const title = issueTitle(`${op.initiative_id}/${op.slice_id}`, op.projection?.result ?? op.goal);
|
|
1824
|
+
// Parent content expectation for pre-write revalidation: on the amendment
|
|
1825
|
+
// path the expectation is always the fixed approved final Parent bytes —
|
|
1826
|
+
// never re-adopt freshly observed content as the baseline, so an edit that
|
|
1827
|
+
// lands between the Parent write and this Child write fails closed. On the
|
|
1828
|
+
// non-amendment path (no amendmentContext) no Parent content expectation is
|
|
1829
|
+
// enforced here.
|
|
1830
|
+
const parentApprovedContent = amendmentContext?.parent;
|
|
1831
|
+
const parentBoundNumber = amendmentContext?.parentIssueNumber;
|
|
1832
|
+
const baseBody = child.body;
|
|
1833
|
+
const suffixEvent = issueTerminalEventId(baseBody);
|
|
1834
|
+
if (suffixEvent === "multiple") return result(op.op, "ambiguous_remote_state", "pending Task has multiple terminal markers", child);
|
|
1835
|
+
if (suffixEvent === "malformed") return result(op.op, "ambiguous_remote_state", `pending Task ${op.task_id} carries a malformed terminal marker (marker without its exact canonical suffix)`, child);
|
|
1836
|
+
// An open Child left by a failed terminal close retains its validated terminal
|
|
1837
|
+
// suffix; the approved-final bytes keep that suffix so the original-input batch
|
|
1838
|
+
// retry converges instead of failing closed on its own partial write.
|
|
1839
|
+
let finalBody = suffixEvent !== null ? `${body.trimEnd()}${terminalSuffix(suffixEvent)}` : body;
|
|
1840
|
+
// The approved-final bytes are suffix-free by construction; a Child carrying a
|
|
1841
|
+
// validated terminal suffix matches when its suffix-free bytes are exact.
|
|
1842
|
+
const approvedMatches = approvedFinal !== undefined && carriesApprovedContent(title, body, approvedFinal);
|
|
1843
|
+
if (!approvedMatches)
|
|
1844
|
+
return result(op.op, "ambiguous_remote_state", `pending Task ${op.task_id} carries a terminal suffix that diverges from the approved amendment content`, child);
|
|
1845
|
+
// R4 re-attach: a Child that already carries the exact approved-final content
|
|
1846
|
+
// but lost its native Sub-issue attachment (a detached intermediate state from
|
|
1847
|
+
// a prior partial write) is re-attached instead of failing closed, keeping the
|
|
1848
|
+
// original batch retryable. The Child must be bound to this amendment's
|
|
1849
|
+
// Parent (its marker Initiative) and carry no other parent edge — a foreign
|
|
1850
|
+
// attachment is ambiguous remote state, never re-attached.
|
|
1851
|
+
const approvedNow = approvedFinal !== undefined && carriesApprovedContent(child.title, child.body, approvedFinal);
|
|
1852
|
+
if (approvedNow) {
|
|
1853
|
+
const currentParent = initiativeLookup(source.issues, source.repository.id, op.initiative_id);
|
|
1854
|
+
if (currentParent.kind !== "found")
|
|
1855
|
+
return result(op.op, "ambiguous_remote_state", "pending Task Parent is not observable before attachment convergence", child);
|
|
1856
|
+
// Re-read and revalidate the Child immediately before the attachment
|
|
1857
|
+
// mutation: the earlier snapshot may have raced a concurrent edit or
|
|
1858
|
+
// close. exact identity, open state and approved-final bytes must hold,
|
|
1859
|
+
// or the re-attach fails closed with zero relation writes. The attachment
|
|
1860
|
+
// check is skipped here: the re-attach itself converges it and verifies
|
|
1861
|
+
// the relation after the write.
|
|
1862
|
+
const revalidated = await revalidatePendingChildBeforeWrite(root, gh, child.number, op.task_id, approvedFinal, undefined, true, parentApprovedContent, parentBoundNumber);
|
|
1863
|
+
if ("contract" in revalidated) return revalidated;
|
|
1864
|
+
const targetParentNumber = parentBoundNumber ?? currentParent.issue.number;
|
|
1865
|
+
const attachment = await confirmAttachment(root, gh, op.op, source.repository, targetParentNumber, child.number);
|
|
1866
|
+
if (!("attached" in attachment)) return attachment;
|
|
1867
|
+
if (!attachment.attached) {
|
|
1868
|
+
const attached = await attachSubIssue(root, gh, op.op, source.repository, targetParentNumber, child);
|
|
1869
|
+
if (!("attached" in attached)) return attached;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
// The authoritative remote observation for the write decision is the
|
|
1873
|
+
// revalidated pre-write read, not the earlier snapshot: a terminal suffix
|
|
1874
|
+
// that landed after the snapshot must be preserved exactly, never
|
|
1875
|
+
// overwritten by bytes computed from stale data.
|
|
1876
|
+
let observed = child;
|
|
1877
|
+
if (child.title !== title || child.body !== finalBody) {
|
|
1878
|
+
// Re-read the Child immediately before writing: earlier blocker ownership
|
|
1879
|
+
// reads may have raced a concurrent user edit. The bound issue_number must
|
|
1880
|
+
// still hold and the remote content must still be baseline-or-approved-final.
|
|
1881
|
+
// revalidatePendingChildBeforeWrite additionally rechecks native Parent
|
|
1882
|
+
// attachment and exact Parent/Slice ownership so a detached or moved Child
|
|
1883
|
+
// never receives a content rewrite.
|
|
1884
|
+
const revalidated = await revalidatePendingChildBeforeWrite(
|
|
1885
|
+
root, gh, child.number, op.task_id, approvedFinal,
|
|
1886
|
+
{ title: child.title, body: child.body },
|
|
1887
|
+
false, parentApprovedContent, parentBoundNumber,
|
|
1888
|
+
);
|
|
1889
|
+
if ("contract" in revalidated) return revalidated;
|
|
1890
|
+
observed = revalidated;
|
|
1891
|
+
// A newly observed terminal suffix on the remote (approved-final content
|
|
1892
|
+
// plus evidence appended after the snapshot) must survive this write: the
|
|
1893
|
+
// written body keeps the observed suffix instead of the snapshot-derived one.
|
|
1894
|
+
const observedEvent = issueTerminalEventId(observed.body);
|
|
1895
|
+
if (observedEvent === "malformed") return result(op.op, "ambiguous_remote_state", `pending Task ${op.task_id} carries a malformed terminal marker (marker without its exact canonical suffix)`, child);
|
|
1896
|
+
const writeBody = typeof observedEvent === "string"
|
|
1897
|
+
? `${body.trimEnd()}${terminalSuffix(observedEvent)}`
|
|
1898
|
+
: finalBody;
|
|
1899
|
+
if (observed.title !== title || observed.body !== writeBody) {
|
|
1900
|
+
const edited = await gh.run([
|
|
1901
|
+
"issue", "edit", String(child.number), "--repo", source.repository.name_with_owner,
|
|
1902
|
+
"--title", title,
|
|
1903
|
+
"--body-file", "-",
|
|
1904
|
+
], { cwd: root, stdin: writeBody });
|
|
1905
|
+
if (edited.exit_code !== 0 || edited.output_exceeded) return ghFailure(op.op, edited, `pending Task Issue #${child.number} update failed`);
|
|
1906
|
+
finalBody = writeBody;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
const dependencies = await convergePendingDependencies(root, gh, source, child.number, op.task_id, desiredBlockers, approvedFinal, parentApprovedContent, parentBoundNumber);
|
|
1910
|
+
if (!("complete" in dependencies)) return dependencies;
|
|
1911
|
+
const refreshed = await snapshot(root, gh, op.op);
|
|
1912
|
+
if ("contract" in refreshed) return refreshed;
|
|
1913
|
+
const reread = ownedTaskLookup(refreshed.issues, refreshed.repository.id, op.task_id, op.initiative_id, op.slice_id);
|
|
1914
|
+
if (reread.kind !== "found" || reread.issue.number !== child.number)
|
|
1915
|
+
return result(op.op, "ambiguous_remote_state", `pending Task ${op.task_id} changed identity during amendment`, child);
|
|
1916
|
+
if (reread.issue.title !== title || reread.issue.body !== finalBody)
|
|
1917
|
+
return result(op.op, "retryable_failure", `pending Task ${op.task_id} update did not converge`, reread.issue);
|
|
1918
|
+
const currentDependencies = await confirmBlockedBy(root, gh, op.op, refreshed.repository, reread.issue.number, desiredBlockers);
|
|
1919
|
+
if (!("complete" in currentDependencies)) return currentDependencies;
|
|
1920
|
+
if (!currentDependencies.complete)
|
|
1921
|
+
return result(op.op, "retryable_failure", `pending Task ${op.task_id} dependencies did not converge`, reread.issue);
|
|
1922
|
+
const contentCurrent = child.title === title && child.body === finalBody;
|
|
1923
|
+
return contentCurrent
|
|
1924
|
+
? result(op.op, "already_current", `pending Task ${op.task_id} already carries the approved amendment content`, reread.issue)
|
|
1925
|
+
: result(op.op, "updated", `pending Task ${op.task_id} Agent Brief updated with approved amendment content`, reread.issue);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/** Converge the exact approved dependency set on a pending Child (amendment only). */
|
|
1929
|
+
async function convergePendingDependencies(
|
|
1930
|
+
root: string,
|
|
1931
|
+
gh: GhTransport,
|
|
1932
|
+
source: RepositorySnapshot,
|
|
1933
|
+
childNumber: number,
|
|
1934
|
+
childTaskId: string,
|
|
1935
|
+
requestedBlockers: GithubIssue[],
|
|
1936
|
+
approvedFinal: { title: string; body: string } | undefined,
|
|
1937
|
+
parentApprovedContent: { title: string; body: string } | undefined = undefined,
|
|
1938
|
+
parentBoundNumber: number | undefined = undefined,
|
|
1939
|
+
): Promise<GithubTrackerResult | { complete: true }> {
|
|
1940
|
+
const expected = requestedBlockers.map((blocker) => blocker.id);
|
|
1941
|
+
const existing = await readBlockedByIds(root, gh, "upsert-task", source.repository, childNumber);
|
|
1942
|
+
if (!Array.isArray(existing)) return existing;
|
|
1943
|
+
const removed = existing.filter((id) => !expected.includes(id));
|
|
1944
|
+
for (const id of removed) {
|
|
1945
|
+
const revalidated = await revalidatePendingChildBeforeWrite(root, gh, childNumber, childTaskId, approvedFinal, undefined, false, parentApprovedContent, parentBoundNumber);
|
|
1946
|
+
if ("contract" in revalidated) return revalidated;
|
|
1947
|
+
const mutation = await gh.run([
|
|
1948
|
+
"api", "--method", "DELETE",
|
|
1949
|
+
`repos/${source.repository.name_with_owner}/issues/${childNumber}/dependencies/blocked_by/${id}`,
|
|
1950
|
+
], { cwd: root });
|
|
1951
|
+
if (mutation.exit_code !== 0 || mutation.output_exceeded)
|
|
1952
|
+
return ghFailure("upsert-task", mutation, `native blocked_by removal failed for Issue #${childNumber}`);
|
|
1953
|
+
}
|
|
1954
|
+
const additions = requestedBlockers.filter((blocker) => !existing.includes(blocker.id));
|
|
1955
|
+
for (const blocker of additions) {
|
|
1956
|
+
const revalidated = await revalidatePendingChildBeforeWrite(root, gh, childNumber, childTaskId, approvedFinal, undefined, false, parentApprovedContent, parentBoundNumber);
|
|
1957
|
+
if ("contract" in revalidated) return revalidated;
|
|
1958
|
+
const mutation = await gh.run([
|
|
1959
|
+
"api", "-F", `issue_id=${blocker.id}`,
|
|
1960
|
+
`repos/${source.repository.name_with_owner}/issues/${childNumber}/dependencies/blocked_by`,
|
|
1961
|
+
], { cwd: root });
|
|
1962
|
+
if (mutation.exit_code !== 0 || mutation.output_exceeded)
|
|
1963
|
+
return ghFailure("upsert-task", mutation, `native blocked_by attachment failed for Issue #${blocker.number}`);
|
|
1964
|
+
}
|
|
1965
|
+
const confirm = await confirmBlockedBy(root, gh, "upsert-task", source.repository, childNumber, requestedBlockers);
|
|
1966
|
+
if (!("complete" in confirm)) return confirm;
|
|
1967
|
+
return confirm.complete ? { complete: true } : { complete: true };
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* Re-snapshot and re-validate a pending Child's bound identity, open state,
|
|
1972
|
+
* ownership, and baseline-or-approved-final content immediately before each
|
|
1973
|
+
* dependency write. Any drift stops the remaining mutations and fails closed
|
|
1974
|
+
* as ambiguous remote state. On success returns the validated Child
|
|
1975
|
+
* observation so callers can write the bytes actually present on the remote
|
|
1976
|
+
* (preserving a terminal suffix that landed after their earlier snapshot).
|
|
1977
|
+
* The return is discriminated by `contract`: a GithubTrackerResult failure
|
|
1978
|
+
* short-circuits the caller, a GithubIssue is the validated observation.
|
|
1979
|
+
*/
|
|
1980
|
+
async function revalidatePendingChildBeforeWrite(
|
|
1981
|
+
root: string,
|
|
1982
|
+
gh: GhTransport,
|
|
1983
|
+
childNumber: number,
|
|
1984
|
+
childTaskId: string,
|
|
1985
|
+
approvedFinal: { title: string; body: string } | undefined,
|
|
1986
|
+
/** Additional byte-exact content this Child is allowed to carry (e.g. its pre-update baseline). */
|
|
1987
|
+
allowedBaseline: { title: string; body: string } | undefined = undefined,
|
|
1988
|
+
/** When true the native Sub-issue attachment check is skipped (re-attach phase: the attachment write itself is about to run). */
|
|
1989
|
+
skipAttachmentCheck = false,
|
|
1990
|
+
/** Expected exact Parent bytes (approved final after the Parent write, baseline before it); undefined skips the Parent content check. */
|
|
1991
|
+
parentApproved: { title: string; body: string } | undefined = undefined,
|
|
1992
|
+
/** Expected exact bound Parent issue number; undefined skips the Parent issue_number check. */
|
|
1993
|
+
parentExpectedNumber: number | undefined = undefined,
|
|
1994
|
+
): Promise<GithubTrackerResult | GithubIssue> {
|
|
1995
|
+
const refreshed = await snapshot(root, gh, "upsert-task");
|
|
1996
|
+
if ("contract" in refreshed) return refreshed;
|
|
1997
|
+
// Repository-wide identity resolution: a duplicate Task Issue introduced after
|
|
1998
|
+
// the caller's snapshot must fail closed here, not at a later lookup — the
|
|
1999
|
+
// immediate pre-write observation is the authoritative one.
|
|
2000
|
+
const resolved = taskLookup(refreshed.issues, refreshed.repository.id, childTaskId);
|
|
2001
|
+
if (resolved.kind !== "found")
|
|
2002
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} is not uniquely owned before a write: ${resolved.kind}`);
|
|
2003
|
+
const child = resolved.issue;
|
|
2004
|
+
if (child.number !== childNumber)
|
|
2005
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} resolves to Issue #${child.number}, not the bound Issue #${childNumber}`, child);
|
|
2006
|
+
if (child.state !== "open")
|
|
2007
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) is no longer open before a dependency write`, child);
|
|
2008
|
+
const contentFinal = approvedFinal !== undefined && carriesApprovedContent(child.title, child.body, approvedFinal);
|
|
2009
|
+
const contentBaseline = allowedBaseline !== undefined && child.title === allowedBaseline.title && child.body === allowedBaseline.body;
|
|
2010
|
+
if (!contentFinal && !contentBaseline)
|
|
2011
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) content is no longer the approved final bytes before a dependency write`, child);
|
|
2012
|
+
const initiativeId = [...child.body.matchAll(/<!-- immune-brain:initiative-id=([A-Za-z0-9._:-]+) -->/g)].map((match) => match[1])[0];
|
|
2013
|
+
if (!initiativeId)
|
|
2014
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) lost its Initiative marker before a dependency write`, child);
|
|
2015
|
+
const parent = initiativeLookup(refreshed.issues, refreshed.repository.id, initiativeId);
|
|
2016
|
+
if (parent.kind !== "found" || parent.issue.state !== "open")
|
|
2017
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) lost its open Parent before a dependency write`, child);
|
|
2018
|
+
if (parentExpectedNumber !== undefined && parent.issue.number !== parentExpectedNumber)
|
|
2019
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) Parent resolves to Issue #${parent.issue.number}, not the bound Parent Issue #${parentExpectedNumber} before a dependency write`, child);
|
|
2020
|
+
// The Parent must still carry exactly the expected amendment bytes before any
|
|
2021
|
+
// Child write: after the Parent write it is the approved final content; before
|
|
2022
|
+
// the Parent write (non-amendment or pre-write paths) the earlier caller
|
|
2023
|
+
// snapshot bytes apply. A concurrent edit that keeps ownership markers intact
|
|
2024
|
+
// must fail closed here, not after the dependency mutations in final verification.
|
|
2025
|
+
if (parentApproved !== undefined
|
|
2026
|
+
&& (parent.issue.title !== parentApproved.title || parent.issue.body !== parentApproved.body))
|
|
2027
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) Parent changed since the approved amendment content before a dependency write`, child);
|
|
2028
|
+
// Native Sub-issue attachment must still hold immediately before each
|
|
2029
|
+
// dependency write: a detached Child no longer belongs to the amendment.
|
|
2030
|
+
if (!skipAttachmentCheck) {
|
|
2031
|
+
const attachedNow = await readSubIssueNumbers(root, gh, "upsert-task", refreshed.repository, parent.issue.number);
|
|
2032
|
+
if (!Array.isArray(attachedNow)) return attachedNow;
|
|
2033
|
+
if (!attachedNow.includes(childNumber))
|
|
2034
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) is no longer attached to the Parent before a dependency write`, child);
|
|
2035
|
+
}
|
|
2036
|
+
const sliceId = ownershipMarkerValue(child.body, "slice-id");
|
|
2037
|
+
if (sliceId && sliceCount(parent.issue.body, sliceId) !== 1)
|
|
2038
|
+
return result("upsert-task", "ambiguous_remote_state", `pending Task ${childTaskId} (Issue #${childNumber}) lost its exact Slice in the Parent before a dependency write`, child);
|
|
2039
|
+
return child;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1140
2042
|
export async function runGithubInitiativePublication(
|
|
1141
2043
|
root: string,
|
|
1142
2044
|
input: InitiativePublicationInput,
|
|
@@ -1144,8 +2046,38 @@ export async function runGithubInitiativePublication(
|
|
|
1144
2046
|
): Promise<GithubInitiativePublicationResult> {
|
|
1145
2047
|
const absoluteRoot = resolve(root);
|
|
1146
2048
|
let prepared: ReturnType<typeof preflightPublication>;
|
|
2049
|
+
let amendment: ReturnType<typeof validateAmendment> | undefined;
|
|
2050
|
+
let amendmentContext: AmendmentExecutionContext | undefined;
|
|
1147
2051
|
try {
|
|
1148
2052
|
prepared = preflightPublication(absoluteRoot, input);
|
|
2053
|
+
if (input.amendment !== undefined) {
|
|
2054
|
+
if (prepared.order.length < 1)
|
|
2055
|
+
throw new Error("an amendment requires at least one pending Task");
|
|
2056
|
+
const validated = validateAmendment(input);
|
|
2057
|
+
const declared = new Set([...validated.tasks.keys(), ...validated.historical.keys()]);
|
|
2058
|
+
for (const operation of prepared.order) {
|
|
2059
|
+
if (!declared.has(operation.task_id))
|
|
2060
|
+
throw new Error(`amendment.tasks must declare pending Task ${operation.task_id}`);
|
|
2061
|
+
}
|
|
2062
|
+
// The pending batch must be exactly the declared amendment membership: an
|
|
2063
|
+
// input.tasks projection that omits a declared pending Task would pass
|
|
2064
|
+
// topology preflight and mutate the Parent before failing at final
|
|
2065
|
+
// membership, leaving the Parent without that Child's Slice line.
|
|
2066
|
+
const projectedIds = new Set(prepared.order.map((operation) => operation.task_id));
|
|
2067
|
+
for (const taskId of validated.tasks.keys()) {
|
|
2068
|
+
if (!projectedIds.has(taskId))
|
|
2069
|
+
throw new Error(`amendment.tasks declares pending Task ${taskId} but input.tasks omits its projection; the pending batch must cover every declared pending Task`);
|
|
2070
|
+
}
|
|
2071
|
+
for (const [taskId] of validated.historical) {
|
|
2072
|
+
if (projectedIds.has(taskId))
|
|
2073
|
+
throw new Error(`Task ${taskId} is declared historical but also projected in input.tasks`);
|
|
2074
|
+
}
|
|
2075
|
+
for (const [taskId, binding] of validated.historical) {
|
|
2076
|
+
if (binding.state === "open")
|
|
2077
|
+
throw new Error(`historical Task ${taskId} must be a closed Issue; open work belongs in amendment.tasks`);
|
|
2078
|
+
}
|
|
2079
|
+
amendment = validated;
|
|
2080
|
+
}
|
|
1149
2081
|
} catch (error) {
|
|
1150
2082
|
return publicationResult("permanent_failure", error instanceof Error ? error.message : String(error));
|
|
1151
2083
|
}
|
|
@@ -1155,6 +2087,8 @@ export async function runGithubInitiativePublication(
|
|
|
1155
2087
|
if ("contract" in initial) return publicationResult(initial.status, initial.message, initial);
|
|
1156
2088
|
const initialParent = initiativeLookup(initial.issues, initial.repository.id, prepared.initiative.initiative_id);
|
|
1157
2089
|
if (initialParent.kind === "ambiguous") return publicationResult("ambiguous_remote_state", initialParent.message);
|
|
2090
|
+
if (amendment && initialParent.kind === "missing")
|
|
2091
|
+
return publicationResult("permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
1158
2092
|
const parentForPreflight = initialParent.kind === "found" ? initialParent.issue : {
|
|
1159
2093
|
id: Number.MAX_SAFE_INTEGER,
|
|
1160
2094
|
number: Number.MAX_SAFE_INTEGER,
|
|
@@ -1171,16 +2105,85 @@ export async function runGithubInitiativePublication(
|
|
|
1171
2105
|
if (childFailure) return publicationResult(childFailure.status, childFailure.message, childFailure);
|
|
1172
2106
|
}
|
|
1173
2107
|
|
|
2108
|
+
if (amendment) {
|
|
2109
|
+
if (initialParent.kind !== "found")
|
|
2110
|
+
return publicationResult("permanent_failure", "an amendment requires the Initiative Parent to already exist");
|
|
2111
|
+
// Caller-controlled binding constraints (missing or duplicate historical
|
|
2112
|
+
// Slice markers) must surface as structured fail-closed publication results,
|
|
2113
|
+
// never as thrown exceptions escaping the guarded validation boundary.
|
|
2114
|
+
let approvedFinalParent: ReturnType<typeof approvedAmendmentContent>;
|
|
2115
|
+
try {
|
|
2116
|
+
approvedFinalParent = approvedAmendmentContent(absoluteRoot, initial.repository, initialParent.issue, prepared, amendment);
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
return publicationResult("permanent_failure", error instanceof Error ? error.message : String(error));
|
|
2119
|
+
}
|
|
2120
|
+
// Slice identity collisions are deterministic input contract violations (the
|
|
2121
|
+
// batch itself declares two Children with one Slice id), not remote drift.
|
|
2122
|
+
if (typeof approvedFinalParent === "string" && approvedFinalParent.includes("collides with a historical Task Slice"))
|
|
2123
|
+
return publicationResult("permanent_failure", approvedFinalParent);
|
|
2124
|
+
if (typeof approvedFinalParent === "string") return publicationResult("ambiguous_remote_state", approvedFinalParent);
|
|
2125
|
+
const { parent, pendingContent, historicalSlices } = approvedFinalParent;
|
|
2126
|
+
// Deterministic prerequisite completion is validated before any write: a
|
|
2127
|
+
// historical prerequisite already closed without state_reason "completed"
|
|
2128
|
+
// (e.g. not_planned) makes the batch permanently unfulfillable, so it must
|
|
2129
|
+
// fail closed in preflight with zero mutations instead of after the Parent
|
|
2130
|
+
// and Child writes (the final check still guards concurrent races).
|
|
2131
|
+
for (const operation of prepared.order) {
|
|
2132
|
+
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2133
|
+
if (!amendment.historical.has(blockerId)) continue;
|
|
2134
|
+
const blocker = taskLookup(initial.issues, initial.repository.id, blockerId);
|
|
2135
|
+
if (blocker.kind === "found" && (blocker.issue.state !== "closed" || blocker.issue.state_reason !== "completed"))
|
|
2136
|
+
return publicationResult("ambiguous_remote_state", `Task ${operation.task_id} depends on stopped historical prerequisite ${blockerId}`);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
const parentBaseline = amendment.parent.body !== initialParent.issue.body || amendment.parent.title !== initialParent.issue.title;
|
|
2140
|
+
const parentApproved = initialParent.issue.title === parent.title && initialParent.issue.body === parent.body;
|
|
2141
|
+
if (parentBaseline && !parentApproved) return publicationResult("ambiguous_remote_state", `amendment Parent changed since the approved amendment baseline`);
|
|
2142
|
+
if (amendment.parent.issue_number !== initialParent.issue.number)
|
|
2143
|
+
return publicationResult("ambiguous_remote_state", `amendment Parent is bound to Issue #${amendment.parent.issue_number} but observed Issue #${initialParent.issue.number}`);
|
|
2144
|
+
if (initialParent.issue.state !== "open")
|
|
2145
|
+
return publicationResult("ambiguous_remote_state", "an amendment requires the Initiative Parent to remain open");
|
|
2146
|
+
const topology = await validateAmendmentTopology(
|
|
2147
|
+
absoluteRoot, gh, initial, prepared.initiative.initiative_id, initialParent.issue,
|
|
2148
|
+
amendment.tasks, amendment.historical,
|
|
2149
|
+
prepared.foreign_dependers,
|
|
2150
|
+
{ pendingContent, parent, historicalSlices },
|
|
2151
|
+
prepared.order,
|
|
2152
|
+
);
|
|
2153
|
+
if (!("get" in topology)) return publicationResult(topology.status, topology.message, topology);
|
|
2154
|
+
const historicalRelations = new Map<string, { blocked_by: number[]; state_reason: string | null }>();
|
|
2155
|
+
for (const [taskId, binding] of amendment.historical) {
|
|
2156
|
+
const issue = topology.get(taskId)!;
|
|
2157
|
+
const observed = await readBlockedByIds(absoluteRoot, gh, "upsert-task", initial.repository, issue.number);
|
|
2158
|
+
if (!Array.isArray(observed)) return publicationResult(observed.status, observed.message, observed);
|
|
2159
|
+
historicalRelations.set(taskId, { blocked_by: observed, state_reason: issue.state_reason });
|
|
2160
|
+
}
|
|
2161
|
+
amendmentContext = {
|
|
2162
|
+
pendingContent,
|
|
2163
|
+
parent,
|
|
2164
|
+
parentIssueNumber: amendment.parent.issue_number,
|
|
2165
|
+
historicalSlices,
|
|
2166
|
+
historicalRelations,
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
|
|
1174
2170
|
const beforeParentWrite = publicationIntentDrift(absoluteRoot, prepared.intent_bindings);
|
|
1175
2171
|
if (beforeParentWrite) return publicationResult("ambiguous_remote_state", beforeParentWrite);
|
|
1176
|
-
|
|
2172
|
+
let parentResult: GithubTrackerResult;
|
|
2173
|
+
if (amendment && amendmentContext) {
|
|
2174
|
+
parentResult = await amendInitiativeParent(absoluteRoot, gh, prepared.initiative, initial, amendment.parent, amendmentContext);
|
|
2175
|
+
} else {
|
|
2176
|
+
parentResult = await runGithubTrackerOperation(absoluteRoot, prepared.initiative, gh);
|
|
2177
|
+
}
|
|
1177
2178
|
if (!isSuccessfulTrackerStatus(parentResult.status))
|
|
1178
2179
|
return publicationResult(parentResult.status, parentResult.message, parentResult);
|
|
1179
2180
|
const taskResults: GithubInitiativePublicationResult["tasks"] = [];
|
|
1180
2181
|
for (const operation of prepared.order) {
|
|
1181
2182
|
const intentDrift = publicationIntentDrift(absoluteRoot, prepared.intent_bindings, [operation.task_id]);
|
|
1182
2183
|
if (intentDrift) return publicationResult("ambiguous_remote_state", intentDrift, parentResult, taskResults);
|
|
1183
|
-
const taskResult =
|
|
2184
|
+
const taskResult = amendment
|
|
2185
|
+
? await runAmendmentTaskOperation(absoluteRoot, gh, operation, amendment.tasks.get(operation.task_id), amendmentContext)
|
|
2186
|
+
: await runGithubTrackerOperation(absoluteRoot, operation, gh);
|
|
1184
2187
|
taskResults.push({
|
|
1185
2188
|
task_id: operation.task_id,
|
|
1186
2189
|
slice_id: operation.slice_id,
|
|
@@ -1197,16 +2200,37 @@ export async function runGithubInitiativePublication(
|
|
|
1197
2200
|
if (finalIntentDrift) return publicationResult("ambiguous_remote_state", finalIntentDrift, parentResult, taskResults);
|
|
1198
2201
|
const finalSource = await snapshot(absoluteRoot, gh, "upsert-task");
|
|
1199
2202
|
if ("contract" in finalSource) return publicationResult(finalSource.status, finalSource.message, parentResult, taskResults);
|
|
2203
|
+
if (amendment) {
|
|
2204
|
+
// Repeat complete observed-membership classification on the final snapshot:
|
|
2205
|
+
// an undeclared closed Task carrying this Initiative's markers that becomes
|
|
2206
|
+
// observable only after preflight (detached, so the Sub-issue parity check
|
|
2207
|
+
// cannot see it) must still fail the publication, not report success.
|
|
2208
|
+
const finalClassified = classifyObservedChildren(finalSource, prepared.initiative.initiative_id, amendment.tasks, amendment.historical);
|
|
2209
|
+
if (typeof finalClassified === "string") return publicationResult("ambiguous_remote_state", finalClassified, parentResult, taskResults);
|
|
2210
|
+
}
|
|
1200
2211
|
const parent = initiativeLookup(finalSource.issues, finalSource.repository.id, prepared.initiative.initiative_id);
|
|
1201
2212
|
if (parent.kind !== "found") return publicationResult("ambiguous_remote_state", parent.kind === "ambiguous" ? parent.message : "published Initiative Parent disappeared", parentResult, taskResults);
|
|
2213
|
+
const amendedFinalSliceIds = new Set(prepared.initiative.slices.map((slice) => slice.id));
|
|
1202
2214
|
const parentDrift = publicationIssueDrift(
|
|
1203
2215
|
parentResult,
|
|
1204
2216
|
parent.issue,
|
|
1205
|
-
issueTitle(prepared.initiative.initiative_id, prepared.initiative.projection?.result ?? prepared.initiative.goal),
|
|
1206
|
-
|
|
2217
|
+
amendmentContext ? amendmentContext.parent.title : issueTitle(prepared.initiative.initiative_id, prepared.initiative.projection?.result ?? prepared.initiative.goal),
|
|
2218
|
+
amendmentContext
|
|
2219
|
+
? amendmentContext.parent.body
|
|
2220
|
+
: createInitiativeBody(finalSource.repository, prepared.initiative),
|
|
1207
2221
|
"Initiative Parent",
|
|
1208
2222
|
);
|
|
1209
2223
|
if (parentDrift) return publicationResult("ambiguous_remote_state", parentDrift, parentResult, taskResults);
|
|
2224
|
+
if (amendmentContext) {
|
|
2225
|
+
// The rewritten Parent must carry each declared historical Slice marker
|
|
2226
|
+
// exactly once: generation drops or duplicates would break terminal
|
|
2227
|
+
// ownership without failing any other final check.
|
|
2228
|
+
for (const line of amendmentContext.historicalSlices) {
|
|
2229
|
+
const sliceId = ownershipMarkerValue(line, "slice-id");
|
|
2230
|
+
if (!sliceId || countLiteral(parent.issue.body, marker("slice-id", sliceId)) !== 1)
|
|
2231
|
+
return publicationResult("ambiguous_remote_state", `regenerated Parent does not carry historical Slice ${sliceId ?? "?"} exactly once`, parentResult, taskResults);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
1210
2234
|
const resultByTask = new Map(taskResults.map((task) => [task.task_id, task]));
|
|
1211
2235
|
const expectedNumbers: number[] = [];
|
|
1212
2236
|
for (const operation of prepared.order) {
|
|
@@ -1220,6 +2244,7 @@ export async function runGithubInitiativePublication(
|
|
|
1220
2244
|
issueTitle(`${operation.initiative_id}/${operation.slice_id}`, operation.projection?.result ?? operation.goal),
|
|
1221
2245
|
childBody(finalSource.repository, operation, parent.issue),
|
|
1222
2246
|
`Task ${operation.task_id}`,
|
|
2247
|
+
{ allowTerminalSuffix: amendmentContext !== undefined },
|
|
1223
2248
|
);
|
|
1224
2249
|
if (childDrift) return publicationResult("ambiguous_remote_state", childDrift, parentResult, taskResults);
|
|
1225
2250
|
expectedNumbers.push(child.issue.number);
|
|
@@ -1237,8 +2262,54 @@ export async function runGithubInitiativePublication(
|
|
|
1237
2262
|
}
|
|
1238
2263
|
const attached = await readSubIssueNumbers(absoluteRoot, gh, "upsert-task", finalSource.repository, parent.issue.number);
|
|
1239
2264
|
if (!Array.isArray(attached)) return publicationResult(attached.status, attached.message, parentResult, taskResults);
|
|
2265
|
+
if (amendment) {
|
|
2266
|
+
for (const [taskId, binding] of amendment.historical) {
|
|
2267
|
+
// Full ownership lookup: repository, protocol, marker uniqueness, and
|
|
2268
|
+
// repo-wide Task identity must resolve the exact bound Issue.
|
|
2269
|
+
const owned = taskLookup(finalSource.issues, finalSource.repository.id, taskId);
|
|
2270
|
+
if (owned.kind !== "found" || owned.issue.number !== binding.issue_number)
|
|
2271
|
+
return publicationResult("ambiguous_remote_state", `historical Task ${taskId} ownership failed after publication`, parentResult, taskResults);
|
|
2272
|
+
const historical = owned.issue;
|
|
2273
|
+
const drift = amendmentBindingDrift(binding, historical, `historical Task ${taskId}`);
|
|
2274
|
+
if (drift) return publicationResult("ambiguous_remote_state", drift, parentResult, taskResults);
|
|
2275
|
+
if (!attached.includes(historical.number))
|
|
2276
|
+
return publicationResult("ambiguous_remote_state", `historical Task ${taskId} lost its native Sub-issue attachment`, parentResult, taskResults);
|
|
2277
|
+
// R1 final check: historical Slice identities must not collide with any
|
|
2278
|
+
// pending Slice published by this batch — two Children must never share
|
|
2279
|
+
// one Slice identity, including historical Children whose Parent lines are
|
|
2280
|
+
// carried over rather than re-rendered.
|
|
2281
|
+
const historicalSliceId = ownershipMarkerValue(historical.body, "slice-id");
|
|
2282
|
+
if (historicalSliceId && amendedFinalSliceIds.has(historicalSliceId))
|
|
2283
|
+
return publicationResult("ambiguous_remote_state", `historical Task ${taskId} Slice ${historicalSliceId} collides with a pending Task Slice of the same id`, parentResult, taskResults);
|
|
2284
|
+
const snapshotRelations = amendmentContext?.historicalRelations.get(taskId);
|
|
2285
|
+
if (snapshotRelations) {
|
|
2286
|
+
if (historical.state_reason !== snapshotRelations.state_reason)
|
|
2287
|
+
return publicationResult("ambiguous_remote_state", `historical Task ${taskId} terminal state_reason changed during amendment`, parentResult, taskResults);
|
|
2288
|
+
const finalBlockedBy = await readBlockedByIds(absoluteRoot, gh, "upsert-task", finalSource.repository, historical.number);
|
|
2289
|
+
if (!Array.isArray(finalBlockedBy)) return publicationResult(finalBlockedBy.status, finalBlockedBy.message, parentResult, taskResults);
|
|
2290
|
+
if (finalBlockedBy.length !== snapshotRelations.blocked_by.length || finalBlockedBy.some((id, index) => id !== snapshotRelations.blocked_by[index]))
|
|
2291
|
+
return publicationResult("ambiguous_remote_state", `historical Task ${taskId} native blocked_by relations changed during amendment`, parentResult, taskResults);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
const attachedNumbers = new Set(attached);
|
|
2295
|
+
for (const number of attachedNumbers) {
|
|
2296
|
+
const issue = finalSource.issues.find((candidate) => candidate.number === number);
|
|
2297
|
+
const taskId = issue ? ownershipMarkerValue(issue.body, "task-id") : null;
|
|
2298
|
+
if (!taskId || (!amendment.tasks.has(taskId) && !amendment.historical.has(taskId)))
|
|
2299
|
+
return publicationResult("ambiguous_remote_state", `Parent Sub-issue #${number} is not part of the declared amendment membership`, parentResult, taskResults);
|
|
2300
|
+
}
|
|
2301
|
+
for (const operation of prepared.order) {
|
|
2302
|
+
for (const blockerId of operation.projection?.blocked_by ?? []) {
|
|
2303
|
+
if (amendment.historical.has(blockerId)) {
|
|
2304
|
+
const blocker = taskLookup(finalSource.issues, finalSource.repository.id, blockerId);
|
|
2305
|
+
if (blocker.kind !== "found" || blocker.issue.state_reason !== "completed")
|
|
2306
|
+
return publicationResult("ambiguous_remote_state", `Task ${operation.task_id} depends on stopped historical prerequisite ${blockerId}`, parentResult, taskResults);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
1240
2311
|
const sortedAttached = [...attached].sort((left, right) => left - right);
|
|
1241
|
-
const sortedExpected = [...expectedNumbers].sort((left, right) => left - right);
|
|
2312
|
+
const sortedExpected = [...expectedNumbers, ...(amendment ? [...amendment.historical.values()].map((binding) => binding.issue_number) : [])].sort((left, right) => left - right);
|
|
1242
2313
|
if (sortedAttached.length !== sortedExpected.length || sortedAttached.some((number, index) => number !== sortedExpected[index]))
|
|
1243
2314
|
return publicationResult("ambiguous_remote_state", "Initiative Parent Sub-issues do not match the complete publication batch", parentResult, taskResults);
|
|
1244
2315
|
const statuses = [parentResult.status, ...taskResults.map((task) => task.status)];
|
|
@@ -1264,7 +2335,14 @@ function isSuccessfulTrackerStatus(status: TrackerStatus): boolean {
|
|
|
1264
2335
|
return status === "created" || status === "updated" || status === "already_current";
|
|
1265
2336
|
}
|
|
1266
2337
|
|
|
1267
|
-
function taskPublication(
|
|
2338
|
+
function taskPublication(
|
|
2339
|
+
root: string,
|
|
2340
|
+
initiativeId: string,
|
|
2341
|
+
sliceId: string,
|
|
2342
|
+
intentPath: string,
|
|
2343
|
+
acceptance: unknown,
|
|
2344
|
+
projection?: TaskProjection,
|
|
2345
|
+
): PreparedPublicationTask {
|
|
1268
2346
|
const absoluteRoot = resolve(root);
|
|
1269
2347
|
const absolutePath = resolve(absoluteRoot, intentPath);
|
|
1270
2348
|
const rel = relative(absoluteRoot, absolutePath);
|
|
@@ -1275,6 +2353,20 @@ function taskPublication(root: string, initiativeId: string, sliceId: string, in
|
|
|
1275
2353
|
const read = readTaskIntent(absoluteRoot, taskId);
|
|
1276
2354
|
if (read.intent_ref.path !== rel) throw new Error("TaskIntent path must match its canonical sidecar path");
|
|
1277
2355
|
const intent = read.intent;
|
|
2356
|
+
if (!Array.isArray(acceptance)) throw new Error(`Task ${taskId} requires public acceptance summaries`);
|
|
2357
|
+
const expectedIds = new Set(intent.acceptance.map((item) => item.id));
|
|
2358
|
+
const publicById = new Map<string, { id: string; summary: string }>();
|
|
2359
|
+
acceptance.forEach((item, index) => {
|
|
2360
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
2361
|
+
throw new Error(`Task ${taskId} acceptance[${index}] must be an object`);
|
|
2362
|
+
const raw = item as Record<string, unknown>;
|
|
2363
|
+
const id = identifier(raw.id, `Task ${taskId} acceptance[${index}].id`);
|
|
2364
|
+
if (!expectedIds.has(id)) throw new Error(`Task ${taskId} has unknown public acceptance id: ${id}`);
|
|
2365
|
+
if (publicById.has(id)) throw new Error(`Task ${taskId} has duplicate public acceptance id: ${id}`);
|
|
2366
|
+
publicById.set(id, { id, summary: projectionText(raw.summary, `Task ${taskId} acceptance[${index}].summary`, 500) });
|
|
2367
|
+
});
|
|
2368
|
+
const missingIds = [...expectedIds].filter((id) => !publicById.has(id));
|
|
2369
|
+
if (missingIds.length) throw new Error(`Task ${taskId} is missing public acceptance ids: ${missingIds.join(", ")}`);
|
|
1278
2370
|
return {
|
|
1279
2371
|
operation: validateOperation({
|
|
1280
2372
|
op: "upsert-task",
|
|
@@ -1283,7 +2375,7 @@ function taskPublication(root: string, initiativeId: string, sliceId: string, in
|
|
|
1283
2375
|
slice_id: sliceId,
|
|
1284
2376
|
goal: intent.goal,
|
|
1285
2377
|
risk: intent.risk,
|
|
1286
|
-
acceptance: intent.acceptance.map((item) => (
|
|
2378
|
+
acceptance: intent.acceptance.map((item) => publicById.get(item.id)!),
|
|
1287
2379
|
projection,
|
|
1288
2380
|
}) as Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
1289
2381
|
intent_path: read.intent_ref.path,
|