immune-brain 3.6.5 → 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 +31 -2
- 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 +20 -10
- 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 +23 -11
- package/plugins/immune-brain/dist/imm-planner.md +65 -28
- 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/verification.ts +13 -2
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1018 -17
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +7 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +3 -1
- 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
|
@@ -60,6 +60,35 @@ export interface InitiativePublicationInput {
|
|
|
60
60
|
acceptance: Array<{ id: string; summary: string }>;
|
|
61
61
|
projection?: TaskProjection;
|
|
62
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;
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
export interface GithubInitiativePublicationResult {
|
|
@@ -211,6 +240,12 @@ function terminalEvent(value: unknown): string {
|
|
|
211
240
|
if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,500}$/.test(value))
|
|
212
241
|
throw new Error("terminal_event_id must be a bounded opaque Kernel event id");
|
|
213
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}`);
|
|
214
249
|
return value;
|
|
215
250
|
}
|
|
216
251
|
|
|
@@ -680,14 +715,18 @@ function bodyLimitFailure(operation: TrackerOperation["op"], body: string, reser
|
|
|
680
715
|
: result(operation, "permanent_failure", "rendered GitHub Issue body exceeds 65,536 UTF-8 bytes");
|
|
681
716
|
}
|
|
682
717
|
|
|
683
|
-
function createInitiativeBody(
|
|
718
|
+
function createInitiativeBody(
|
|
719
|
+
repository: RepositoryInfo,
|
|
720
|
+
operation: Extract<TrackerOperation, { op: "create-initiative" }>,
|
|
721
|
+
historicalSlices: string[] = [],
|
|
722
|
+
): string {
|
|
684
723
|
const projection = operation.projection ?? {};
|
|
685
724
|
return `${[
|
|
686
725
|
PROTOCOL_MARKER,
|
|
687
726
|
KIND_INITIATIVE_MARKER,
|
|
688
727
|
marker("repo-id", repository.id),
|
|
689
728
|
marker("initiative-id", operation.initiative_id),
|
|
690
|
-
].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`;
|
|
691
730
|
}
|
|
692
731
|
|
|
693
732
|
async function createInitiative(
|
|
@@ -749,11 +788,183 @@ function childBody(
|
|
|
749
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`;
|
|
750
789
|
}
|
|
751
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
|
+
|
|
752
961
|
async function upsertTask(
|
|
753
962
|
root: string,
|
|
754
963
|
gh: GhTransport,
|
|
755
964
|
operation: Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
756
965
|
source: RepositorySnapshot,
|
|
966
|
+
pendingBinding: InitiativeAmendmentBinding | undefined | null = null,
|
|
967
|
+
amendmentContext: AmendmentExecutionContext | undefined = undefined,
|
|
757
968
|
): Promise<GithubTrackerResult> {
|
|
758
969
|
const parent = initiativeLookup(source.issues, source.repository.id, operation.initiative_id);
|
|
759
970
|
if (parent.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", parent.message);
|
|
@@ -769,6 +980,8 @@ async function upsertTask(
|
|
|
769
980
|
const lookup = (issues: GithubIssue[]) => taskLookup(issues, source.repository.id, operation.task_id);
|
|
770
981
|
const found = lookup(source.issues);
|
|
771
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);
|
|
772
985
|
const blockerIds = operation.projection?.blocked_by ?? [];
|
|
773
986
|
const blockers: GithubIssue[] = [];
|
|
774
987
|
for (const blockerId of blockerIds) {
|
|
@@ -788,6 +1001,40 @@ async function upsertTask(
|
|
|
788
1001
|
let child: GithubIssue;
|
|
789
1002
|
let createdChild = false;
|
|
790
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
|
+
}
|
|
791
1038
|
const mutation = await gh.run([
|
|
792
1039
|
"issue", "create", "--repo", source.repository.name_with_owner,
|
|
793
1040
|
"--title", title,
|
|
@@ -804,14 +1051,61 @@ async function upsertTask(
|
|
|
804
1051
|
}
|
|
805
1052
|
if (created.issue.body !== body || created.issue.title !== title)
|
|
806
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
|
+
}
|
|
807
1065
|
child = created.issue;
|
|
808
1066
|
createdChild = true;
|
|
809
1067
|
} else {
|
|
810
|
-
if (found.issue.body !== body || found.issue.title !== title)
|
|
811
|
-
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);
|
|
812
1068
|
const owned = ownedTaskLookup(source.issues, source.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
|
|
813
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);
|
|
814
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);
|
|
815
1109
|
}
|
|
816
1110
|
const attachment = await confirmAttachment(root, gh, operation.op, source.repository, parent.issue.number, child.number);
|
|
817
1111
|
if (!("attached" in attachment)) return attachment;
|
|
@@ -1051,6 +1345,20 @@ function validateOperation(operation: TrackerOperation): TrackerOperation {
|
|
|
1051
1345
|
};
|
|
1052
1346
|
}
|
|
1053
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
|
+
|
|
1054
1362
|
export async function runGithubTrackerOperation(
|
|
1055
1363
|
root: string,
|
|
1056
1364
|
input: TrackerOperation,
|
|
@@ -1100,12 +1408,12 @@ interface PreparedPublicationTask {
|
|
|
1100
1408
|
intent_content_hash: string;
|
|
1101
1409
|
}
|
|
1102
1410
|
|
|
1103
|
-
function publicationPlan(operations: Array<Extract<TrackerOperation, { op: "upsert-task" }
|
|
1411
|
+
function publicationPlan(operations: Array<Extract<TrackerOperation, { op: "upsert-task" }>>, satisfiedPrerequisites: Set<string> = new Set()): {
|
|
1104
1412
|
order: Array<Extract<TrackerOperation, { op: "upsert-task" }>>;
|
|
1105
1413
|
parallel_groups: string[][];
|
|
1106
1414
|
} {
|
|
1107
1415
|
const remaining = new Set(operations.map((operation) => operation.task_id));
|
|
1108
|
-
const done = new Set<string>();
|
|
1416
|
+
const done = new Set<string>(satisfiedPrerequisites);
|
|
1109
1417
|
const order: Array<Extract<TrackerOperation, { op: "upsert-task" }>> = [];
|
|
1110
1418
|
const parallelGroups: string[][] = [];
|
|
1111
1419
|
while (remaining.size) {
|
|
@@ -1127,9 +1435,10 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1127
1435
|
order: Array<Extract<TrackerOperation, { op: "upsert-task" }>>;
|
|
1128
1436
|
parallel_groups: string[][];
|
|
1129
1437
|
intent_bindings: Map<string, Pick<PreparedPublicationTask, "intent_path" | "intent_content_hash">>;
|
|
1438
|
+
foreign_dependers: Map<string, string[]>;
|
|
1130
1439
|
} {
|
|
1131
1440
|
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("publication must be an object");
|
|
1132
|
-
if (!Array.isArray(input.tasks) || input.tasks.length < 2)
|
|
1441
|
+
if (!Array.isArray(input.tasks) || input.tasks.length < (input.amendment ? 1 : 2))
|
|
1133
1442
|
throw new Error("a complete Initiative publication requires at least two Tasks");
|
|
1134
1443
|
if (!input.projection || typeof input.projection !== "object" || Array.isArray(input.projection))
|
|
1135
1444
|
throw new Error("publication projection must be an object");
|
|
@@ -1142,6 +1451,11 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1142
1451
|
if (typeof task.intent !== "string") throw new Error(`tasks[${index}].intent must be a string`);
|
|
1143
1452
|
return taskPublication(root, input.initiative_id, task.slice_id, task.intent, task.acceptance, task.projection);
|
|
1144
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
|
+
}
|
|
1145
1459
|
const operations = publications.map((publication) => publication.operation);
|
|
1146
1460
|
const taskIds = new Set<string>();
|
|
1147
1461
|
const sliceIds = new Set<string>();
|
|
@@ -1151,12 +1465,20 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1151
1465
|
taskIds.add(operation.task_id);
|
|
1152
1466
|
sliceIds.add(operation.slice_id);
|
|
1153
1467
|
}
|
|
1468
|
+
const foreignDependers = new Map<string, string[]>();
|
|
1154
1469
|
for (const operation of operations) {
|
|
1155
1470
|
for (const blocker of operation.projection?.blocked_by ?? []) {
|
|
1156
|
-
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
|
+
}
|
|
1157
1476
|
}
|
|
1158
1477
|
}
|
|
1159
|
-
const
|
|
1478
|
+
const foreignIds = new Set(foreignDependers.keys());
|
|
1479
|
+
const plan = input.amendment === undefined
|
|
1480
|
+
? publicationPlan(operations)
|
|
1481
|
+
: publicationPlan(operations, new Set([...historicalIds, ...foreignIds]));
|
|
1160
1482
|
const initiative = validateOperation({
|
|
1161
1483
|
op: "create-initiative",
|
|
1162
1484
|
initiative_id: input.initiative_id,
|
|
@@ -1173,7 +1495,7 @@ function preflightPublication(root: string, input: InitiativePublicationInput):
|
|
|
1173
1495
|
intent_path: publication.intent_path,
|
|
1174
1496
|
intent_content_hash: publication.intent_content_hash,
|
|
1175
1497
|
}]));
|
|
1176
|
-
return { initiative, ...plan, intent_bindings: intentBindings };
|
|
1498
|
+
return { initiative, ...plan, intent_bindings: intentBindings, foreign_dependers: foreignDependers };
|
|
1177
1499
|
}
|
|
1178
1500
|
|
|
1179
1501
|
function publicationIntentDrift(
|
|
@@ -1199,14 +1521,524 @@ function publicationIssueDrift(expected: {
|
|
|
1199
1521
|
issue_number?: number;
|
|
1200
1522
|
issue_url?: string;
|
|
1201
1523
|
node_id?: string;
|
|
1202
|
-
}, actual: GithubIssue, title: string, body: string, label: string): string | null {
|
|
1524
|
+
}, actual: GithubIssue, title: string, body: string, label: string, options?: { allowTerminalSuffix?: boolean }): string | null {
|
|
1203
1525
|
if (expected.issue_number !== actual.number || expected.issue_url !== actual.url || expected.node_id !== String(actual.id))
|
|
1204
1526
|
return `${label} identity changed during Initiative publication`;
|
|
1205
1527
|
if (actual.state !== "open") return `${label} is no longer open`;
|
|
1206
|
-
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
|
+
}
|
|
1207
1536
|
return null;
|
|
1208
1537
|
}
|
|
1209
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`;
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
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
|
+
|
|
1210
2042
|
export async function runGithubInitiativePublication(
|
|
1211
2043
|
root: string,
|
|
1212
2044
|
input: InitiativePublicationInput,
|
|
@@ -1214,8 +2046,38 @@ export async function runGithubInitiativePublication(
|
|
|
1214
2046
|
): Promise<GithubInitiativePublicationResult> {
|
|
1215
2047
|
const absoluteRoot = resolve(root);
|
|
1216
2048
|
let prepared: ReturnType<typeof preflightPublication>;
|
|
2049
|
+
let amendment: ReturnType<typeof validateAmendment> | undefined;
|
|
2050
|
+
let amendmentContext: AmendmentExecutionContext | undefined;
|
|
1217
2051
|
try {
|
|
1218
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
|
+
}
|
|
1219
2081
|
} catch (error) {
|
|
1220
2082
|
return publicationResult("permanent_failure", error instanceof Error ? error.message : String(error));
|
|
1221
2083
|
}
|
|
@@ -1225,6 +2087,8 @@ export async function runGithubInitiativePublication(
|
|
|
1225
2087
|
if ("contract" in initial) return publicationResult(initial.status, initial.message, initial);
|
|
1226
2088
|
const initialParent = initiativeLookup(initial.issues, initial.repository.id, prepared.initiative.initiative_id);
|
|
1227
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");
|
|
1228
2092
|
const parentForPreflight = initialParent.kind === "found" ? initialParent.issue : {
|
|
1229
2093
|
id: Number.MAX_SAFE_INTEGER,
|
|
1230
2094
|
number: Number.MAX_SAFE_INTEGER,
|
|
@@ -1241,16 +2105,85 @@ export async function runGithubInitiativePublication(
|
|
|
1241
2105
|
if (childFailure) return publicationResult(childFailure.status, childFailure.message, childFailure);
|
|
1242
2106
|
}
|
|
1243
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
|
+
|
|
1244
2170
|
const beforeParentWrite = publicationIntentDrift(absoluteRoot, prepared.intent_bindings);
|
|
1245
2171
|
if (beforeParentWrite) return publicationResult("ambiguous_remote_state", beforeParentWrite);
|
|
1246
|
-
|
|
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
|
+
}
|
|
1247
2178
|
if (!isSuccessfulTrackerStatus(parentResult.status))
|
|
1248
2179
|
return publicationResult(parentResult.status, parentResult.message, parentResult);
|
|
1249
2180
|
const taskResults: GithubInitiativePublicationResult["tasks"] = [];
|
|
1250
2181
|
for (const operation of prepared.order) {
|
|
1251
2182
|
const intentDrift = publicationIntentDrift(absoluteRoot, prepared.intent_bindings, [operation.task_id]);
|
|
1252
2183
|
if (intentDrift) return publicationResult("ambiguous_remote_state", intentDrift, parentResult, taskResults);
|
|
1253
|
-
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);
|
|
1254
2187
|
taskResults.push({
|
|
1255
2188
|
task_id: operation.task_id,
|
|
1256
2189
|
slice_id: operation.slice_id,
|
|
@@ -1267,16 +2200,37 @@ export async function runGithubInitiativePublication(
|
|
|
1267
2200
|
if (finalIntentDrift) return publicationResult("ambiguous_remote_state", finalIntentDrift, parentResult, taskResults);
|
|
1268
2201
|
const finalSource = await snapshot(absoluteRoot, gh, "upsert-task");
|
|
1269
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
|
+
}
|
|
1270
2211
|
const parent = initiativeLookup(finalSource.issues, finalSource.repository.id, prepared.initiative.initiative_id);
|
|
1271
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));
|
|
1272
2214
|
const parentDrift = publicationIssueDrift(
|
|
1273
2215
|
parentResult,
|
|
1274
2216
|
parent.issue,
|
|
1275
|
-
issueTitle(prepared.initiative.initiative_id, prepared.initiative.projection?.result ?? prepared.initiative.goal),
|
|
1276
|
-
|
|
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),
|
|
1277
2221
|
"Initiative Parent",
|
|
1278
2222
|
);
|
|
1279
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
|
+
}
|
|
1280
2234
|
const resultByTask = new Map(taskResults.map((task) => [task.task_id, task]));
|
|
1281
2235
|
const expectedNumbers: number[] = [];
|
|
1282
2236
|
for (const operation of prepared.order) {
|
|
@@ -1290,6 +2244,7 @@ export async function runGithubInitiativePublication(
|
|
|
1290
2244
|
issueTitle(`${operation.initiative_id}/${operation.slice_id}`, operation.projection?.result ?? operation.goal),
|
|
1291
2245
|
childBody(finalSource.repository, operation, parent.issue),
|
|
1292
2246
|
`Task ${operation.task_id}`,
|
|
2247
|
+
{ allowTerminalSuffix: amendmentContext !== undefined },
|
|
1293
2248
|
);
|
|
1294
2249
|
if (childDrift) return publicationResult("ambiguous_remote_state", childDrift, parentResult, taskResults);
|
|
1295
2250
|
expectedNumbers.push(child.issue.number);
|
|
@@ -1307,8 +2262,54 @@ export async function runGithubInitiativePublication(
|
|
|
1307
2262
|
}
|
|
1308
2263
|
const attached = await readSubIssueNumbers(absoluteRoot, gh, "upsert-task", finalSource.repository, parent.issue.number);
|
|
1309
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
|
+
}
|
|
1310
2311
|
const sortedAttached = [...attached].sort((left, right) => left - right);
|
|
1311
|
-
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);
|
|
1312
2313
|
if (sortedAttached.length !== sortedExpected.length || sortedAttached.some((number, index) => number !== sortedExpected[index]))
|
|
1313
2314
|
return publicationResult("ambiguous_remote_state", "Initiative Parent Sub-issues do not match the complete publication batch", parentResult, taskResults);
|
|
1314
2315
|
const statuses = [parentResult.status, ...taskResults.map((task) => task.status)];
|