immune-brain 2.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -0
- package/README.zh-CN.md +239 -0
- package/package.json +84 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +666 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1622 -0
- package/plugins/immune-brain/.pi-extension/package.json +11 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +711 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance.ts +154 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +349 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +90 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +214 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +50 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +262 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +28 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +237 -0
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +414 -0
- package/plugins/immune-brain/.pi-extension/tsconfig.json +20 -0
- package/plugins/immune-brain/bin/imm-kernel +4 -0
- package/plugins/immune-brain/bin/imm-plan +4 -0
- package/plugins/immune-brain/bin/imm-pr-diag +230 -0
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +4 -0
- package/plugins/immune-brain/bin/imm-retired +4 -0
- package/plugins/immune-brain/bin/imm-tracker +4 -0
- package/plugins/immune-brain/dist/BASELINE.md +138 -0
- package/plugins/immune-brain/dist/docs/reference/HANDOFF-template.md +122 -0
- package/plugins/immune-brain/dist/docs/reference/design-contract-audit-rubric.md +149 -0
- package/plugins/immune-brain/dist/docs/reference/design-contract-review-checklist.md +55 -0
- package/plugins/immune-brain/dist/docs/reference/i18n-review-checklist.md +110 -0
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +52 -0
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +94 -0
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +44 -0
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +105 -0
- package/plugins/immune-brain/dist/docs/reference/ux-heuristic-checklist.md +131 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +140 -0
- package/plugins/immune-brain/dist/imm-doc-prune.md +137 -0
- package/plugins/immune-brain/dist/imm-loop.md +158 -0
- package/plugins/immune-brain/dist/imm-planner.md +387 -0
- package/plugins/immune-brain/dist/imm-pr-fix.md +71 -0
- package/plugins/immune-brain/dist/registry.yaml +49 -0
- package/plugins/immune-brain/dist/role-prompts/advisory-reviewer.md +16 -0
- package/plugins/immune-brain/dist/role-prompts/arch-explorer.md +14 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
- package/plugins/immune-brain/dist/role-prompts/compounder.md +20 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +13 -0
- package/plugins/immune-brain/dist/role-prompts/pr-fix.md +81 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +23 -0
- package/plugins/immune-brain/dist/role-prompts/test-fixer.md +3 -0
- package/plugins/immune-brain/dist/role-prompts/ui-review.md +14 -0
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +716 -0
- package/plugins/immune-brain/runtime/canonical_json.ts +19 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +1160 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1009 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +300 -0
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +284 -0
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +208 -0
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +451 -0
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +197 -0
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +507 -0
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +73 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +160 -0
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +194 -0
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +123 -0
- package/plugins/immune-brain/runtime/kernel/index.ts +29 -0
- package/plugins/immune-brain/runtime/kernel/intent.ts +563 -0
- package/plugins/immune-brain/runtime/kernel/intent_token_registry.ts +80 -0
- package/plugins/immune-brain/runtime/kernel/legacy.ts +299 -0
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +153 -0
- package/plugins/immune-brain/runtime/kernel/observation.ts +395 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +169 -0
- package/plugins/immune-brain/runtime/kernel/readiness.ts +282 -0
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +132 -0
- package/plugins/immune-brain/runtime/kernel/reducer.ts +624 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1780 -0
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +791 -0
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +492 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +295 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +963 -0
- package/plugins/immune-brain/runtime/loop_contract.ts +362 -0
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +462 -0
- package/plugins/immune-brain/runtime/plan_core.ts +1053 -0
- package/plugins/immune-brain/runtime/prompts/advisory-reviewer.md +16 -0
- package/plugins/immune-brain/runtime/prompts/arch-explorer.md +14 -0
- package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
- package/plugins/immune-brain/runtime/prompts/compounder.md +20 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +13 -0
- package/plugins/immune-brain/runtime/prompts/pr-fix.md +81 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +23 -0
- package/plugins/immune-brain/runtime/prompts/test-fixer.md +3 -0
- package/plugins/immune-brain/runtime/prompts/ui-review.md +14 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +160 -0
- package/plugins/immune-brain/runtime/v4_runtime.ts +295 -0
- package/plugins/immune-brain/runtime/verification_descriptor.ts +162 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +623 -0
- package/plugins/immune-brain/skills/.ignore +1 -0
- package/plugins/immune-brain/skills/BASELINE.md +138 -0
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +66 -0
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +11 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +52 -0
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +221 -0
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +10 -0
- package/plugins/immune-brain/skills/registry.yaml +49 -0
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { readTaskIntent } from "./kernel/intent";
|
|
5
|
+
|
|
6
|
+
const CONTRACT = "immune_brain/github_issue_tracker_result/v1" as const;
|
|
7
|
+
const PROTOCOL_MARKER = "<!-- immune-brain-tracker:v1 -->";
|
|
8
|
+
const KIND_INITIATIVE_MARKER = "<!-- immune-brain:kind=initiative -->";
|
|
9
|
+
const KIND_TASK_MARKER = "<!-- immune-brain:kind=task -->";
|
|
10
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
11
|
+
const MAX_GH_OUTPUT = 1024 * 1024;
|
|
12
|
+
const MAX_DIAGNOSTIC = 512;
|
|
13
|
+
const GH_TIMEOUT_MS = 20_000;
|
|
14
|
+
const GITHUB_ISSUE_BODY_LIMIT = 65_536;
|
|
15
|
+
const MAX_TERMINAL_EVENT_ID = 500;
|
|
16
|
+
|
|
17
|
+
type TrackerStatus =
|
|
18
|
+
| "created"
|
|
19
|
+
| "updated"
|
|
20
|
+
| "already_current"
|
|
21
|
+
| "retryable_failure"
|
|
22
|
+
| "permanent_failure"
|
|
23
|
+
| "ambiguous_remote_state";
|
|
24
|
+
|
|
25
|
+
export interface GithubTrackerResult {
|
|
26
|
+
contract: typeof CONTRACT;
|
|
27
|
+
operation: TrackerOperation["op"];
|
|
28
|
+
status: TrackerStatus;
|
|
29
|
+
association_found: boolean;
|
|
30
|
+
issue_number?: number;
|
|
31
|
+
issue_url?: string;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface InitiativeSlice {
|
|
36
|
+
id: string;
|
|
37
|
+
goal: string;
|
|
38
|
+
result?: string;
|
|
39
|
+
blocked_by?: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InitiativeProjection {
|
|
43
|
+
problem?: string;
|
|
44
|
+
result?: string;
|
|
45
|
+
decisions?: string[];
|
|
46
|
+
testing_strategy?: string;
|
|
47
|
+
out_of_scope?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TaskProjection {
|
|
51
|
+
result?: string;
|
|
52
|
+
current_behavior?: string;
|
|
53
|
+
desired_behavior?: string;
|
|
54
|
+
key_interfaces?: string[];
|
|
55
|
+
verification?: string;
|
|
56
|
+
blocked_by?: string[];
|
|
57
|
+
out_of_scope?: string[];
|
|
58
|
+
agent_handoff?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type TrackerOperation =
|
|
62
|
+
| {
|
|
63
|
+
op: "create-initiative";
|
|
64
|
+
initiative_id: string;
|
|
65
|
+
goal: string;
|
|
66
|
+
slices: InitiativeSlice[];
|
|
67
|
+
projection?: InitiativeProjection;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
op: "upsert-task";
|
|
71
|
+
initiative_id: string;
|
|
72
|
+
task_id: string;
|
|
73
|
+
slice_id: string;
|
|
74
|
+
goal: string;
|
|
75
|
+
risk: "routine" | "material" | "critical";
|
|
76
|
+
acceptance: Array<{ id: string; summary: string }>;
|
|
77
|
+
projection?: TaskProjection;
|
|
78
|
+
}
|
|
79
|
+
| {
|
|
80
|
+
op: "mark-terminal";
|
|
81
|
+
task_id: string;
|
|
82
|
+
phase: "done" | "stopped";
|
|
83
|
+
terminal_event_id: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export interface GhExecution {
|
|
87
|
+
exit_code: number;
|
|
88
|
+
stdout: string;
|
|
89
|
+
stderr: string;
|
|
90
|
+
timed_out: boolean;
|
|
91
|
+
output_exceeded: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface GhTransport {
|
|
95
|
+
run(args: string[], options?: { cwd?: string; stdin?: string }): Promise<GhExecution>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface RepositoryInfo {
|
|
99
|
+
id: number;
|
|
100
|
+
name_with_owner: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface GithubIssue {
|
|
104
|
+
id: number;
|
|
105
|
+
number: number;
|
|
106
|
+
url: string;
|
|
107
|
+
title: string;
|
|
108
|
+
body: string;
|
|
109
|
+
state: "open" | "closed";
|
|
110
|
+
state_reason: string | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface RepositorySnapshot {
|
|
114
|
+
repository: RepositoryInfo;
|
|
115
|
+
issues: GithubIssue[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface FoundIssue {
|
|
119
|
+
kind: "found";
|
|
120
|
+
issue: GithubIssue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface MissingIssue {
|
|
124
|
+
kind: "missing";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface AmbiguousIssue {
|
|
128
|
+
kind: "ambiguous";
|
|
129
|
+
message: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type IssueLookup = FoundIssue | MissingIssue | AmbiguousIssue;
|
|
133
|
+
|
|
134
|
+
function countLiteral(value: string, needle: string): number {
|
|
135
|
+
if (!needle) return 0;
|
|
136
|
+
let count = 0;
|
|
137
|
+
let index = 0;
|
|
138
|
+
while ((index = value.indexOf(needle, index)) !== -1) {
|
|
139
|
+
count += 1;
|
|
140
|
+
index += needle.length;
|
|
141
|
+
}
|
|
142
|
+
return count;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function marker(name: "repo-id" | "initiative-id" | "task-id" | "slice-id", value: string | number): string {
|
|
146
|
+
return `<!-- immune-brain:${name}=${value} -->`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function terminalMarker(eventId: string): string {
|
|
150
|
+
return `<!-- immune-brain:terminal-event=${eventId} -->`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function terminalSuffix(eventId: string): string {
|
|
154
|
+
return `\n\n${terminalMarker(eventId)}\nTerminal event: \`${eventId}\`\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const MAX_TERMINAL_SUFFIX_BYTES = Buffer.byteLength(terminalSuffix("x".repeat(MAX_TERMINAL_EVENT_ID)), "utf8");
|
|
158
|
+
|
|
159
|
+
function terminalEvent(value: unknown): string {
|
|
160
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,500}$/.test(value))
|
|
161
|
+
throw new Error("terminal_event_id must be a bounded opaque Kernel event id");
|
|
162
|
+
if (/(?:gh[pousr]_|github_pat_)/i.test(value)) throw new Error("terminal_event_id must not contain a token-like value");
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function redactSecrets(value: string): string {
|
|
167
|
+
return value
|
|
168
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9_]+\b/g, "[REDACTED_GITHUB_TOKEN]")
|
|
169
|
+
.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, "[REDACTED_GITHUB_TOKEN]")
|
|
170
|
+
.replace(/\bBearer\s+\S+/gi, "Bearer [REDACTED]")
|
|
171
|
+
.replace(/\b(?:token|secret|password)\s*[=:]\s*\S+/gi, "credential=[REDACTED]");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function publicText(value: unknown, name: string, max = 2_000): string {
|
|
175
|
+
if (typeof value !== "string") throw new Error(`${name} must be a string`);
|
|
176
|
+
const text = value.trim();
|
|
177
|
+
if (!text || text.length > max || text.includes("\0"))
|
|
178
|
+
throw new Error(`${name} must contain 1-${max} safe characters`);
|
|
179
|
+
return redactSecrets(text).replaceAll("<!--", "<!--").replaceAll("-->", "-->");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function identifier(value: unknown, name: string): string {
|
|
183
|
+
if (typeof value !== "string" || !ID_PATTERN.test(value))
|
|
184
|
+
throw new Error(`${name} must match ${ID_PATTERN}`);
|
|
185
|
+
if (/^(?:gh[pousr]_|github_pat_)/i.test(value)) throw new Error(`${name} must not contain a token-like value`);
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function titleText(value: string): string {
|
|
190
|
+
return redactSecrets(value).replace(/\s+/g, " ");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function issueTitle(owner: string, result: string): string {
|
|
194
|
+
const title = `[${owner}] ${titleText(result)}`;
|
|
195
|
+
if (title.length > 256) throw new Error("GitHub Issue title must not exceed 256 characters");
|
|
196
|
+
return title;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function redactGithubDiagnostic(value: string): string {
|
|
200
|
+
return redactSecrets(value)
|
|
201
|
+
.replace(/\s+/g, " ")
|
|
202
|
+
.trim()
|
|
203
|
+
.slice(0, MAX_DIAGNOSTIC);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function result(
|
|
207
|
+
operation: TrackerOperation["op"],
|
|
208
|
+
status: TrackerStatus,
|
|
209
|
+
message: string,
|
|
210
|
+
issue?: GithubIssue,
|
|
211
|
+
): GithubTrackerResult {
|
|
212
|
+
return {
|
|
213
|
+
contract: CONTRACT,
|
|
214
|
+
operation,
|
|
215
|
+
status,
|
|
216
|
+
association_found: issue !== undefined,
|
|
217
|
+
...(issue ? { issue_number: issue.number, issue_url: issue.url } : {}),
|
|
218
|
+
message: redactGithubDiagnostic(message),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function ghFailure(
|
|
223
|
+
operation: TrackerOperation["op"],
|
|
224
|
+
execution: GhExecution,
|
|
225
|
+
message: string,
|
|
226
|
+
): GithubTrackerResult {
|
|
227
|
+
const retryable = execution.timed_out
|
|
228
|
+
|| /timeout|timed out|network|connection|temporar|rate limit|502|503|504/i.test(execution.stderr);
|
|
229
|
+
return result(
|
|
230
|
+
operation,
|
|
231
|
+
retryable ? "retryable_failure" : "permanent_failure",
|
|
232
|
+
`${message}: ${execution.output_exceeded ? "gh output limit exceeded" : execution.stderr || `gh exited ${execution.exit_code}`}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createGhTransport(binary = "gh"): GhTransport {
|
|
237
|
+
return {
|
|
238
|
+
run(args, options = {}) {
|
|
239
|
+
return new Promise((complete) => {
|
|
240
|
+
let stdout = Buffer.alloc(0);
|
|
241
|
+
let stderr = Buffer.alloc(0);
|
|
242
|
+
let timedOut = false;
|
|
243
|
+
let outputExceeded = false;
|
|
244
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
245
|
+
let settled = false;
|
|
246
|
+
const finish = (exitCode: number, spawnError = ""): void => {
|
|
247
|
+
if (settled) return;
|
|
248
|
+
settled = true;
|
|
249
|
+
if (timer) clearTimeout(timer);
|
|
250
|
+
complete({
|
|
251
|
+
exit_code: exitCode,
|
|
252
|
+
stdout: stdout.toString("utf8"),
|
|
253
|
+
stderr: `${stderr.toString("utf8")}${spawnError}`,
|
|
254
|
+
timed_out: timedOut,
|
|
255
|
+
output_exceeded: outputExceeded,
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
let child: ReturnType<typeof spawn>;
|
|
259
|
+
try {
|
|
260
|
+
child = spawn(binary, args, {
|
|
261
|
+
cwd: options.cwd,
|
|
262
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
263
|
+
env: process.env,
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
finish(1, error instanceof Error ? error.message : String(error));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const append = (current: Buffer, chunk: Buffer): Buffer => {
|
|
270
|
+
const available = Math.max(0, MAX_GH_OUTPUT - stdout.length - stderr.length);
|
|
271
|
+
if (chunk.length > available) {
|
|
272
|
+
outputExceeded = true;
|
|
273
|
+
child.kill("SIGKILL");
|
|
274
|
+
}
|
|
275
|
+
return available > 0 ? Buffer.concat([current, chunk.subarray(0, available)]) : current;
|
|
276
|
+
};
|
|
277
|
+
child.stdout.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); });
|
|
278
|
+
child.stderr.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
|
|
279
|
+
child.once("error", (error) => { finish(1, error.message); });
|
|
280
|
+
child.stdin.once("error", (error) => { finish(1, error.message); });
|
|
281
|
+
timer = setTimeout(() => {
|
|
282
|
+
timedOut = true;
|
|
283
|
+
child.kill("SIGKILL");
|
|
284
|
+
}, GH_TIMEOUT_MS);
|
|
285
|
+
child.once("close", (code) => { finish(code ?? 1); });
|
|
286
|
+
try {
|
|
287
|
+
child.stdin.end(options.stdin ?? "");
|
|
288
|
+
} catch (error) {
|
|
289
|
+
finish(1, error instanceof Error ? error.message : String(error));
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseRepository(raw: string): RepositoryInfo {
|
|
297
|
+
const value = JSON.parse(raw) as { id?: unknown; full_name?: unknown };
|
|
298
|
+
if (!Number.isSafeInteger(value.id) || typeof value.full_name !== "string" || !value.full_name.includes("/"))
|
|
299
|
+
throw new Error("gh returned malformed repository identity");
|
|
300
|
+
return { id: value.id as number, name_with_owner: value.full_name };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parseIssues(raw: string): GithubIssue[] {
|
|
304
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
305
|
+
const pages = Array.isArray(parsed) && parsed.every(Array.isArray) ? parsed.flat() : parsed;
|
|
306
|
+
if (!Array.isArray(pages)) throw new Error("gh returned malformed Issue list");
|
|
307
|
+
return pages
|
|
308
|
+
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !("pull_request" in (item as object)))
|
|
309
|
+
.map((item) => {
|
|
310
|
+
if (
|
|
311
|
+
!Number.isSafeInteger(item.id)
|
|
312
|
+
|| !Number.isSafeInteger(item.number)
|
|
313
|
+
|| typeof item.html_url !== "string"
|
|
314
|
+
|| typeof item.title !== "string"
|
|
315
|
+
|| typeof item.body !== "string"
|
|
316
|
+
|| (item.state !== "open" && item.state !== "closed")
|
|
317
|
+
) throw new Error("gh returned a malformed Issue");
|
|
318
|
+
return {
|
|
319
|
+
id: item.id as number,
|
|
320
|
+
number: item.number as number,
|
|
321
|
+
url: item.html_url,
|
|
322
|
+
title: item.title,
|
|
323
|
+
body: item.body,
|
|
324
|
+
state: item.state,
|
|
325
|
+
state_reason: typeof item.state_reason === "string" ? item.state_reason.toLowerCase() : null,
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseSubIssueNumbers(raw: string): number[] {
|
|
331
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
332
|
+
const pages = Array.isArray(parsed) && parsed.every(Array.isArray) ? parsed.flat() : parsed;
|
|
333
|
+
if (!Array.isArray(pages)) throw new Error("gh returned malformed Sub-issue list");
|
|
334
|
+
return pages.map((item, index) => {
|
|
335
|
+
const number = (item as { number?: unknown })?.number;
|
|
336
|
+
if (!Number.isSafeInteger(number)) throw new Error(`gh returned a malformed Sub-issue entry at ${index}`);
|
|
337
|
+
return number;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function snapshot(root: string, gh: GhTransport, operation: TrackerOperation["op"]): Promise<RepositorySnapshot | GithubTrackerResult> {
|
|
342
|
+
const repositoryExecution = await gh.run(["api", "repos/{owner}/{repo}"], { cwd: root });
|
|
343
|
+
if (repositoryExecution.exit_code !== 0 || repositoryExecution.output_exceeded)
|
|
344
|
+
return ghFailure(operation, repositoryExecution, "cannot resolve GitHub repository");
|
|
345
|
+
let repository: RepositoryInfo;
|
|
346
|
+
try {
|
|
347
|
+
repository = parseRepository(repositoryExecution.stdout);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return result(operation, "permanent_failure", error instanceof Error ? error.message : String(error));
|
|
350
|
+
}
|
|
351
|
+
const issuesExecution = await gh.run([
|
|
352
|
+
"api",
|
|
353
|
+
"--paginate",
|
|
354
|
+
"--slurp",
|
|
355
|
+
`repos/${repository.name_with_owner}/issues?state=all&per_page=100`,
|
|
356
|
+
], { cwd: root });
|
|
357
|
+
if (issuesExecution.exit_code !== 0 || issuesExecution.output_exceeded)
|
|
358
|
+
return ghFailure(operation, issuesExecution, "cannot query GitHub Issues");
|
|
359
|
+
try {
|
|
360
|
+
return { repository, issues: parseIssues(issuesExecution.stdout) };
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return result(operation, "permanent_failure", error instanceof Error ? error.message : String(error));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function findIssue(issues: GithubIssue[], primary: string[], required: string[]): IssueLookup {
|
|
367
|
+
const candidates = issues.filter((issue) => primary.every((needle) => issue.body.includes(needle)));
|
|
368
|
+
if (candidates.length === 0) return { kind: "missing" };
|
|
369
|
+
if (candidates.length !== 1)
|
|
370
|
+
return { kind: "ambiguous", message: `multiple Issues contain identity marker ${primary[0]}` };
|
|
371
|
+
const issue = candidates[0];
|
|
372
|
+
for (const expected of [PROTOCOL_MARKER, ...required]) {
|
|
373
|
+
if (countLiteral(issue.body, expected) !== 1)
|
|
374
|
+
return { kind: "ambiguous", message: `Issue #${issue.number} has missing or duplicate identity markers` };
|
|
375
|
+
}
|
|
376
|
+
return { kind: "found", issue };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function initiativeLookup(issues: GithubIssue[], repositoryId: number, initiativeId: string): IssueLookup {
|
|
380
|
+
const initiative = marker("initiative-id", initiativeId);
|
|
381
|
+
return findIssue(issues, [initiative, KIND_INITIATIVE_MARKER], [marker("repo-id", repositoryId), initiative]);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function ownershipMarkerValue(body: string, name: "initiative-id" | "slice-id" | "task-id"): string | null {
|
|
385
|
+
const values = [...body.matchAll(new RegExp(`<!-- immune-brain:${name}=([A-Za-z0-9][A-Za-z0-9._-]{0,127}) -->`, "g"))];
|
|
386
|
+
return values.length === 1 ? values[0][1] : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function taskLookup(issues: GithubIssue[], repositoryId: number, taskId: string): IssueLookup {
|
|
390
|
+
const task = marker("task-id", taskId);
|
|
391
|
+
const base = findIssue(issues, [task, KIND_TASK_MARKER], [marker("repo-id", repositoryId), task]);
|
|
392
|
+
if (base.kind !== "found") return base;
|
|
393
|
+
for (const name of ["task-id", "initiative-id", "slice-id"] as const) {
|
|
394
|
+
if (!ownershipMarkerValue(base.issue.body, name))
|
|
395
|
+
return { kind: "ambiguous", message: `Issue #${base.issue.number} has missing or duplicate ${name} ownership markers` };
|
|
396
|
+
}
|
|
397
|
+
return base;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function ownedTaskLookup(
|
|
401
|
+
issues: GithubIssue[],
|
|
402
|
+
repositoryId: number,
|
|
403
|
+
taskId: string,
|
|
404
|
+
initiativeId: string,
|
|
405
|
+
sliceId: string,
|
|
406
|
+
): IssueLookup {
|
|
407
|
+
const base = taskLookup(issues, repositoryId, taskId);
|
|
408
|
+
if (base.kind !== "found") return base;
|
|
409
|
+
return base.issue.body.includes(marker("initiative-id", initiativeId))
|
|
410
|
+
&& base.issue.body.includes(marker("slice-id", sliceId))
|
|
411
|
+
? base
|
|
412
|
+
: { kind: "ambiguous", message: `Issue #${base.issue.number} belongs to another Initiative or Slice; Task ownership is immutable` };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function sliceCount(parentBody: string, sliceId: string): number {
|
|
416
|
+
return countLiteral(parentBody, marker("slice-id", sliceId));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function confirmAttachment(
|
|
420
|
+
root: string,
|
|
421
|
+
gh: GhTransport,
|
|
422
|
+
operation: TrackerOperation["op"],
|
|
423
|
+
repository: RepositoryInfo,
|
|
424
|
+
parentNumber: number,
|
|
425
|
+
childNumber: number,
|
|
426
|
+
): Promise<GithubTrackerResult | { attached: boolean }> {
|
|
427
|
+
const listed = await gh.run(["api", "--paginate", "--slurp", `repos/${repository.name_with_owner}/issues/${parentNumber}/sub_issues?per_page=100`], { cwd: root });
|
|
428
|
+
if (listed.exit_code !== 0 || listed.output_exceeded)
|
|
429
|
+
return ghFailure(operation, listed, "cannot read native Sub-issue relations");
|
|
430
|
+
let numbers: number[];
|
|
431
|
+
try {
|
|
432
|
+
numbers = parseSubIssueNumbers(listed.stdout);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return result(operation, "permanent_failure", error instanceof Error ? error.message : String(error));
|
|
435
|
+
}
|
|
436
|
+
const matches = numbers.filter((candidate) => candidate === childNumber).length;
|
|
437
|
+
if (matches > 1) return result(operation, "ambiguous_remote_state", `Issue #${parentNumber} lists the Task Issue more than once`);
|
|
438
|
+
return { attached: matches === 1 };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function attachSubIssue(
|
|
442
|
+
root: string,
|
|
443
|
+
gh: GhTransport,
|
|
444
|
+
operation: TrackerOperation["op"],
|
|
445
|
+
repository: RepositoryInfo,
|
|
446
|
+
parentNumber: number,
|
|
447
|
+
child: GithubIssue,
|
|
448
|
+
): Promise<GithubTrackerResult | { attached: true }> {
|
|
449
|
+
const mutation = await gh.run([
|
|
450
|
+
"api",
|
|
451
|
+
"-F",
|
|
452
|
+
`sub_issue_id=${child.id}`,
|
|
453
|
+
`repos/${repository.name_with_owner}/issues/${parentNumber}/sub_issues`,
|
|
454
|
+
], { cwd: root });
|
|
455
|
+
if (mutation.exit_code !== 0 || mutation.output_exceeded)
|
|
456
|
+
return ghFailure(operation, mutation, "native Sub-issue attachment failed");
|
|
457
|
+
const confirmed = await confirmAttachment(root, gh, operation, repository, parentNumber, child.number);
|
|
458
|
+
if (!("attached" in confirmed)) return confirmed;
|
|
459
|
+
return confirmed.attached
|
|
460
|
+
? { attached: true }
|
|
461
|
+
: result(operation, "retryable_failure", "native Sub-issue relation did not converge", child);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function readBlockedByIds(
|
|
465
|
+
root: string,
|
|
466
|
+
gh: GhTransport,
|
|
467
|
+
operation: TrackerOperation["op"],
|
|
468
|
+
repository: RepositoryInfo,
|
|
469
|
+
childNumber: number,
|
|
470
|
+
): Promise<GithubTrackerResult | number[]> {
|
|
471
|
+
const listed = await gh.run(["api", "--paginate", "--slurp", `repos/${repository.name_with_owner}/issues/${childNumber}/dependencies/blocked_by?per_page=100`], { cwd: root });
|
|
472
|
+
if (listed.exit_code !== 0 || listed.output_exceeded) return ghFailure(operation, listed, "cannot read native blocked_by relations");
|
|
473
|
+
try {
|
|
474
|
+
const pages = JSON.parse(listed.stdout) as unknown;
|
|
475
|
+
if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) throw new Error("gh returned malformed blocked_by pages");
|
|
476
|
+
const ids = pages.flat().map((item, index) => {
|
|
477
|
+
const id = (item as { id?: unknown; issue_id?: unknown })?.issue_id ?? (item as { id?: unknown })?.id;
|
|
478
|
+
if (!Number.isSafeInteger(id)) throw new Error(`gh returned malformed blocked_by entry at ${index}`);
|
|
479
|
+
return id as number;
|
|
480
|
+
});
|
|
481
|
+
if (new Set(ids).size !== ids.length) return result(operation, "ambiguous_remote_state", `Issue #${childNumber} has duplicate native blocked_by relations`);
|
|
482
|
+
return ids;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return result(operation, "permanent_failure", error instanceof Error ? error.message : String(error));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function confirmBlockedBy(
|
|
489
|
+
root: string,
|
|
490
|
+
gh: GhTransport,
|
|
491
|
+
operation: TrackerOperation["op"],
|
|
492
|
+
repository: RepositoryInfo,
|
|
493
|
+
childNumber: number,
|
|
494
|
+
blockers: GithubIssue[],
|
|
495
|
+
): Promise<GithubTrackerResult | { complete: boolean }> {
|
|
496
|
+
const ids = await readBlockedByIds(root, gh, operation, repository, childNumber);
|
|
497
|
+
if (!Array.isArray(ids)) return ids;
|
|
498
|
+
const expected = blockers.map((blocker) => blocker.id);
|
|
499
|
+
if (ids.some((id) => !expected.includes(id)))
|
|
500
|
+
return result(operation, "ambiguous_remote_state", `Issue #${childNumber} has unrequested native blocked_by relations`);
|
|
501
|
+
return { complete: ids.length === expected.length && expected.every((id) => ids.includes(id)) };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function attachBlockedBy(
|
|
505
|
+
root: string,
|
|
506
|
+
gh: GhTransport,
|
|
507
|
+
operation: TrackerOperation["op"],
|
|
508
|
+
repository: RepositoryInfo,
|
|
509
|
+
childNumber: number,
|
|
510
|
+
blockers: GithubIssue[],
|
|
511
|
+
): Promise<GithubTrackerResult | { complete: true }> {
|
|
512
|
+
const existing = await readBlockedByIds(root, gh, operation, repository, childNumber);
|
|
513
|
+
if (!Array.isArray(existing)) return existing;
|
|
514
|
+
for (const blocker of blockers) {
|
|
515
|
+
if (existing.includes(blocker.id)) continue;
|
|
516
|
+
const mutation = await gh.run([
|
|
517
|
+
"api", "-F", `issue_id=${blocker.id}`,
|
|
518
|
+
`repos/${repository.name_with_owner}/issues/${childNumber}/dependencies/blocked_by`,
|
|
519
|
+
], { cwd: root });
|
|
520
|
+
if (mutation.exit_code !== 0 || mutation.output_exceeded) return ghFailure(operation, mutation, `native blocked_by attachment failed for Issue #${blocker.number}`);
|
|
521
|
+
existing.push(blocker.id);
|
|
522
|
+
}
|
|
523
|
+
const confirmed = await confirmBlockedBy(root, gh, operation, repository, childNumber, blockers);
|
|
524
|
+
if ("complete" in confirmed) return confirmed.complete ? { complete: true } : result(operation, "retryable_failure", "native blocked_by relations did not converge");
|
|
525
|
+
return confirmed;
|
|
526
|
+
}
|
|
527
|
+
function carrierConflict(root: string, operation: TrackerOperation["op"], initiativeId: string): GithubTrackerResult | null {
|
|
528
|
+
if (!existsSync(resolve(root, "docs", "initiatives", `${initiativeId}.md`))) return null;
|
|
529
|
+
return result(
|
|
530
|
+
operation,
|
|
531
|
+
"permanent_failure",
|
|
532
|
+
`Initiative carrier conflict: docs/initiatives/${initiativeId}.md already owns this slug locally; remove the duplicate carrier before using the GitHub projection`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function listText(values: string[] | undefined, fallback: string): string {
|
|
537
|
+
return values?.length ? values.map((value) => `- ${value}`).join("\n") : fallback;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function bodyLimitFailure(operation: TrackerOperation["op"], body: string, reserve = 0): GithubTrackerResult | null {
|
|
541
|
+
return Buffer.byteLength(body, "utf8") + reserve <= GITHUB_ISSUE_BODY_LIMIT
|
|
542
|
+
? null
|
|
543
|
+
: result(operation, "permanent_failure", "rendered GitHub Issue body exceeds 65,536 UTF-8 bytes");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function createInitiativeBody(repository: RepositoryInfo, operation: Extract<TrackerOperation, { op: "create-initiative" }>): string {
|
|
547
|
+
const projection = operation.projection ?? {};
|
|
548
|
+
return `${[
|
|
549
|
+
PROTOCOL_MARKER,
|
|
550
|
+
KIND_INITIATIVE_MARKER,
|
|
551
|
+
marker("repo-id", repository.id),
|
|
552
|
+
marker("initiative-id", operation.initiative_id),
|
|
553
|
+
].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## 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`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function createInitiative(
|
|
557
|
+
root: string,
|
|
558
|
+
gh: GhTransport,
|
|
559
|
+
operation: Extract<TrackerOperation, { op: "create-initiative" }>,
|
|
560
|
+
source: RepositorySnapshot,
|
|
561
|
+
): Promise<GithubTrackerResult> {
|
|
562
|
+
const lookup = (issues: GithubIssue[]) => initiativeLookup(issues, source.repository.id, operation.initiative_id);
|
|
563
|
+
const found = lookup(source.issues);
|
|
564
|
+
if (found.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", found.message);
|
|
565
|
+
const body = createInitiativeBody(source.repository, operation);
|
|
566
|
+
const oversized = bodyLimitFailure(operation.op, body);
|
|
567
|
+
if (oversized) return oversized;
|
|
568
|
+
const title = issueTitle(operation.initiative_id, operation.projection?.result ?? operation.goal);
|
|
569
|
+
if (found.kind === "missing") {
|
|
570
|
+
const mutation = await gh.run([
|
|
571
|
+
"issue", "create", "--repo", source.repository.name_with_owner,
|
|
572
|
+
"--title", title,
|
|
573
|
+
"--body-file", "-",
|
|
574
|
+
], { cwd: root, stdin: body });
|
|
575
|
+
const refreshed = await snapshot(root, gh, operation.op);
|
|
576
|
+
if ("contract" in refreshed) return refreshed;
|
|
577
|
+
const confirmed = lookup(refreshed.issues);
|
|
578
|
+
if (confirmed.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", confirmed.message);
|
|
579
|
+
if (confirmed.kind === "missing") {
|
|
580
|
+
return mutation.exit_code !== 0 && !mutation.timed_out && !mutation.output_exceeded
|
|
581
|
+
? ghFailure(operation.op, mutation, "Initiative Issue creation failed")
|
|
582
|
+
: result(operation.op, "retryable_failure", "Initiative creation could not be confirmed");
|
|
583
|
+
}
|
|
584
|
+
return confirmed.issue.body === body && confirmed.issue.title === title
|
|
585
|
+
? result(operation.op, "created", "Initiative Issue created as the single GitHub source", confirmed.issue)
|
|
586
|
+
: result(operation.op, "retryable_failure", "Initiative Issue did not converge to the requested initial title and body", confirmed.issue);
|
|
587
|
+
}
|
|
588
|
+
if (found.issue.body === body && found.issue.title === title)
|
|
589
|
+
return result(operation.op, "already_current", "Initiative Issue already carries the requested initial source", found.issue);
|
|
590
|
+
return result(
|
|
591
|
+
operation.op,
|
|
592
|
+
"permanent_failure",
|
|
593
|
+
"Initiative Issue already exists and the tracker never rewrites it; edit the GitHub source directly for later planning changes",
|
|
594
|
+
found.issue,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function childBody(repository: RepositoryInfo, operation: Extract<TrackerOperation, { op: "upsert-task" }>): string {
|
|
599
|
+
const projection = operation.projection ?? {};
|
|
600
|
+
const acceptance = operation.acceptance.map((item) => `- \`${item.id}\`: ${item.summary}`).join("\n");
|
|
601
|
+
return `${[
|
|
602
|
+
PROTOCOL_MARKER,
|
|
603
|
+
KIND_TASK_MARKER,
|
|
604
|
+
marker("repo-id", repository.id),
|
|
605
|
+
marker("initiative-id", operation.initiative_id),
|
|
606
|
+
marker("slice-id", operation.slice_id),
|
|
607
|
+
marker("task-id", operation.task_id),
|
|
608
|
+
].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| 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`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function upsertTask(
|
|
612
|
+
root: string,
|
|
613
|
+
gh: GhTransport,
|
|
614
|
+
operation: Extract<TrackerOperation, { op: "upsert-task" }>,
|
|
615
|
+
source: RepositorySnapshot,
|
|
616
|
+
): Promise<GithubTrackerResult> {
|
|
617
|
+
const parent = initiativeLookup(source.issues, source.repository.id, operation.initiative_id);
|
|
618
|
+
if (parent.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", parent.message);
|
|
619
|
+
if (parent.kind === "missing")
|
|
620
|
+
return result(operation.op, "permanent_failure", "the Initiative Parent Issue must exist before publishing a Task");
|
|
621
|
+
if (sliceCount(parent.issue.body, operation.slice_id) !== 1)
|
|
622
|
+
return result(
|
|
623
|
+
operation.op,
|
|
624
|
+
"ambiguous_remote_state",
|
|
625
|
+
`Parent Issue #${parent.issue.number} has missing or duplicate Slice marker ${operation.slice_id}; restore exactly one stable Slice entry in the GitHub source`,
|
|
626
|
+
parent.issue,
|
|
627
|
+
);
|
|
628
|
+
const lookup = (issues: GithubIssue[]) => taskLookup(issues, source.repository.id, operation.task_id);
|
|
629
|
+
const found = lookup(source.issues);
|
|
630
|
+
if (found.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", found.message);
|
|
631
|
+
const blockerIds = operation.projection?.blocked_by ?? [];
|
|
632
|
+
const blockers: GithubIssue[] = [];
|
|
633
|
+
for (const blockerId of blockerIds) {
|
|
634
|
+
if (blockerId === operation.task_id)
|
|
635
|
+
return result(operation.op, "ambiguous_remote_state", "a Task cannot block itself", parent.issue);
|
|
636
|
+
const blocker = taskLookup(source.issues, source.repository.id, blockerId);
|
|
637
|
+
if (blocker.kind === "missing") return result(operation.op, "permanent_failure", `blocking Task ${blockerId} has not been published`, parent.issue);
|
|
638
|
+
if (blocker.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", blocker.message, parent.issue);
|
|
639
|
+
const ownership = await confirmTerminalOwnership(root, gh, operation.op, source, blocker.issue);
|
|
640
|
+
if (!("owned" in ownership)) return ownership;
|
|
641
|
+
blockers.push(blocker.issue);
|
|
642
|
+
}
|
|
643
|
+
const body = childBody(source.repository, operation);
|
|
644
|
+
const oversized = bodyLimitFailure(operation.op, body, MAX_TERMINAL_SUFFIX_BYTES);
|
|
645
|
+
if (oversized) return oversized;
|
|
646
|
+
const title = issueTitle(`${operation.initiative_id}/${operation.slice_id}`, operation.projection?.result ?? operation.goal);
|
|
647
|
+
let child: GithubIssue;
|
|
648
|
+
let createdChild = false;
|
|
649
|
+
if (found.kind === "missing") {
|
|
650
|
+
const mutation = await gh.run([
|
|
651
|
+
"issue", "create", "--repo", source.repository.name_with_owner,
|
|
652
|
+
"--title", title,
|
|
653
|
+
"--body-file", "-",
|
|
654
|
+
], { cwd: root, stdin: body });
|
|
655
|
+
const refreshed = await snapshot(root, gh, operation.op);
|
|
656
|
+
if ("contract" in refreshed) return refreshed;
|
|
657
|
+
const created = lookup(refreshed.issues);
|
|
658
|
+
if (created.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", created.message);
|
|
659
|
+
if (created.kind === "missing") {
|
|
660
|
+
return mutation.exit_code !== 0 && !mutation.timed_out && !mutation.output_exceeded
|
|
661
|
+
? ghFailure(operation.op, mutation, "Task Issue creation failed")
|
|
662
|
+
: result(operation.op, "retryable_failure", "Task creation could not be confirmed");
|
|
663
|
+
}
|
|
664
|
+
if (created.issue.body !== body || created.issue.title !== title)
|
|
665
|
+
return result(operation.op, "retryable_failure", "Task Issue did not converge to the requested title and body", created.issue);
|
|
666
|
+
child = created.issue;
|
|
667
|
+
createdChild = true;
|
|
668
|
+
} else {
|
|
669
|
+
if (found.issue.body !== body || found.issue.title !== title)
|
|
670
|
+
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);
|
|
671
|
+
const owned = ownedTaskLookup(source.issues, source.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
|
|
672
|
+
if (owned.kind !== "found") return result(operation.op, "ambiguous_remote_state", owned.kind === "ambiguous" ? owned.message : "Task Issue ownership changed during publication", found.issue);
|
|
673
|
+
child = owned.issue;
|
|
674
|
+
}
|
|
675
|
+
const attachment = await confirmAttachment(root, gh, operation.op, source.repository, parent.issue.number, child.number);
|
|
676
|
+
if (!("attached" in attachment)) return attachment;
|
|
677
|
+
if (!attachment.attached) {
|
|
678
|
+
const attach = await attachSubIssue(root, gh, operation.op, source.repository, parent.issue.number, child);
|
|
679
|
+
if (!("attached" in attach)) return attach;
|
|
680
|
+
}
|
|
681
|
+
const dependencies = await confirmBlockedBy(root, gh, operation.op, source.repository, child.number, blockers);
|
|
682
|
+
if (!("complete" in dependencies)) return dependencies;
|
|
683
|
+
if (!dependencies.complete) {
|
|
684
|
+
const attached = await attachBlockedBy(root, gh, operation.op, source.repository, child.number, blockers);
|
|
685
|
+
if (!("complete" in attached)) return attached;
|
|
686
|
+
}
|
|
687
|
+
const finalSource = await snapshot(root, gh, operation.op);
|
|
688
|
+
if ("contract" in finalSource) return finalSource;
|
|
689
|
+
const finalChild = ownedTaskLookup(finalSource.issues, finalSource.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
|
|
690
|
+
if (finalChild.kind !== "found" || finalChild.issue.id !== child.id || finalChild.issue.title !== title || finalChild.issue.body !== body)
|
|
691
|
+
return result(operation.op, "ambiguous_remote_state", "Task Issue changed identity, title, or body during dependency publication", child);
|
|
692
|
+
const finalChildOwnership = await confirmTerminalOwnership(root, gh, operation.op, finalSource, finalChild.issue);
|
|
693
|
+
if (!("owned" in finalChildOwnership)) return finalChildOwnership;
|
|
694
|
+
child = finalChild.issue;
|
|
695
|
+
for (let index = 0; index < blockerIds.length; index += 1) {
|
|
696
|
+
const current = taskLookup(finalSource.issues, finalSource.repository.id, blockerIds[index]);
|
|
697
|
+
if (current.kind !== "found" || current.issue.id !== blockers[index].id)
|
|
698
|
+
return result(operation.op, "ambiguous_remote_state", `blocking Task ${blockerIds[index]} changed ownership during dependency publication`, child);
|
|
699
|
+
const ownership = await confirmTerminalOwnership(root, gh, operation.op, finalSource, current.issue);
|
|
700
|
+
if (!("owned" in ownership)) return ownership;
|
|
701
|
+
}
|
|
702
|
+
const finalDependencies = await confirmBlockedBy(root, gh, operation.op, finalSource.repository, child.number, blockers);
|
|
703
|
+
if (!("complete" in finalDependencies)) return finalDependencies;
|
|
704
|
+
if (!finalDependencies.complete)
|
|
705
|
+
return result(operation.op, "ambiguous_remote_state", "native blocked_by relations changed during dependency publication", child);
|
|
706
|
+
if (attachment.attached && dependencies.complete)
|
|
707
|
+
return createdChild
|
|
708
|
+
? result(operation.op, "created", "Task Issue created and attached with native blocking relations", child)
|
|
709
|
+
: result(operation.op, "already_current", "Task Issue, native Sub-issue relation, and blocking relations are current", child);
|
|
710
|
+
return createdChild
|
|
711
|
+
? result(operation.op, "created", "Task Issue created and attached as a native Sub-issue", child)
|
|
712
|
+
: result(operation.op, "updated", "existing Task Issue attached as a native Sub-issue", child);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function confirmTerminalOwnership(
|
|
716
|
+
root: string,
|
|
717
|
+
gh: GhTransport,
|
|
718
|
+
operation: TrackerOperation["op"],
|
|
719
|
+
source: RepositorySnapshot,
|
|
720
|
+
child: GithubIssue,
|
|
721
|
+
): Promise<GithubTrackerResult | { owned: true }> {
|
|
722
|
+
const initiativeId = ownershipMarkerValue(child.body, "initiative-id");
|
|
723
|
+
const sliceId = ownershipMarkerValue(child.body, "slice-id");
|
|
724
|
+
if (!initiativeId || !sliceId)
|
|
725
|
+
return result(operation, "ambiguous_remote_state", `Issue #${child.number} has invalid ownership markers`, child);
|
|
726
|
+
const parent = initiativeLookup(source.issues, source.repository.id, initiativeId);
|
|
727
|
+
if (parent.kind !== "found")
|
|
728
|
+
return result(operation, "ambiguous_remote_state", parent.kind === "ambiguous" ? parent.message : `Issue #${child.number} has no exact Initiative Parent`, child);
|
|
729
|
+
if (sliceCount(parent.issue.body, sliceId) !== 1)
|
|
730
|
+
return result(operation, "ambiguous_remote_state", `Issue #${child.number} has no exact Slice in Parent #${parent.issue.number}`, child);
|
|
731
|
+
const attachment = await confirmAttachment(root, gh, operation, source.repository, parent.issue.number, child.number);
|
|
732
|
+
if (!("attached" in attachment)) return attachment;
|
|
733
|
+
return attachment.attached
|
|
734
|
+
? { owned: true }
|
|
735
|
+
: result(operation, "ambiguous_remote_state", `Issue #${child.number} is not attached to its marker-bound Parent #${parent.issue.number}`, child);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function closeTerminalIssue(
|
|
739
|
+
root: string,
|
|
740
|
+
gh: GhTransport,
|
|
741
|
+
operation: Extract<TrackerOperation, { op: "mark-terminal" }>,
|
|
742
|
+
source: RepositorySnapshot,
|
|
743
|
+
issue: GithubIssue,
|
|
744
|
+
lookup: (issues: GithubIssue[]) => IssueLookup,
|
|
745
|
+
): Promise<GithubTrackerResult> {
|
|
746
|
+
const desiredReason = operation.phase === "done" ? "completed" : "not_planned";
|
|
747
|
+
const close = await gh.run([
|
|
748
|
+
"issue", "close", String(issue.number), "--repo", source.repository.name_with_owner,
|
|
749
|
+
"--reason", operation.phase === "done" ? "completed" : "not planned",
|
|
750
|
+
], { cwd: root });
|
|
751
|
+
const refreshed = await snapshot(root, gh, operation.op);
|
|
752
|
+
if ("contract" in refreshed) return refreshed;
|
|
753
|
+
const found = lookup(refreshed.issues);
|
|
754
|
+
if (found.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", found.message);
|
|
755
|
+
if (found.kind === "missing") return result(operation.op, "retryable_failure", "terminal Issue closure could not be confirmed");
|
|
756
|
+
if (countLiteral(found.issue.body, terminalMarker(operation.terminal_event_id)) !== 1)
|
|
757
|
+
return result(operation.op, "ambiguous_remote_state", "terminal Issue body changed during closure", found.issue);
|
|
758
|
+
if (found.issue.state === "closed" && found.issue.state_reason === desiredReason)
|
|
759
|
+
return result(operation.op, "updated", "terminal Task Issue closure confirmed", found.issue);
|
|
760
|
+
return close.exit_code !== 0
|
|
761
|
+
? ghFailure(operation.op, close, "terminal Issue closure failed")
|
|
762
|
+
: result(operation.op, "retryable_failure", "terminal Issue closure did not converge", found.issue);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function markTerminal(
|
|
766
|
+
root: string,
|
|
767
|
+
gh: GhTransport,
|
|
768
|
+
operation: Extract<TrackerOperation, { op: "mark-terminal" }>,
|
|
769
|
+
source: RepositorySnapshot,
|
|
770
|
+
): Promise<GithubTrackerResult> {
|
|
771
|
+
const lookup = (issues: GithubIssue[]) => taskLookup(issues, source.repository.id, operation.task_id);
|
|
772
|
+
const found = lookup(source.issues);
|
|
773
|
+
if (found.kind === "missing") return result(operation.op, "already_current", "Task has no opted-in tracker association");
|
|
774
|
+
if (found.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", found.message);
|
|
775
|
+
const issue = found.issue;
|
|
776
|
+
const ownership = await confirmTerminalOwnership(root, gh, operation.op, source, issue);
|
|
777
|
+
if (!("owned" in ownership)) return ownership;
|
|
778
|
+
const existingEvents = [...issue.body.matchAll(/<!-- immune-brain:terminal-event=([A-Za-z0-9._:-]+) -->/g)];
|
|
779
|
+
if (existingEvents.length > 1 || (existingEvents.length === 1 && existingEvents[0][1] !== operation.terminal_event_id))
|
|
780
|
+
return result(operation.op, "ambiguous_remote_state", "terminal Issue conflicts with authoritative settlement", issue);
|
|
781
|
+
const desiredReason = operation.phase === "done" ? "completed" : "not_planned";
|
|
782
|
+
if (existingEvents.length === 1) {
|
|
783
|
+
if (issue.state === "closed")
|
|
784
|
+
return issue.state_reason === desiredReason
|
|
785
|
+
? result(operation.op, "already_current", "terminal Task Issue is current", issue)
|
|
786
|
+
: result(operation.op, "ambiguous_remote_state", "closed Issue reason conflicts with authoritative settlement", issue);
|
|
787
|
+
return closeTerminalIssue(root, gh, operation, source, issue, lookup);
|
|
788
|
+
}
|
|
789
|
+
if (issue.state === "closed")
|
|
790
|
+
return result(operation.op, "ambiguous_remote_state", "a manually closed nonterminal Task Issue is preserved and never reopened automatically", issue);
|
|
791
|
+
const updated = `${issue.body.trimEnd()}${terminalSuffix(operation.terminal_event_id)}`;
|
|
792
|
+
const oversized = bodyLimitFailure(operation.op, updated);
|
|
793
|
+
if (oversized) return oversized;
|
|
794
|
+
const edited = await gh.run([
|
|
795
|
+
"issue", "edit", String(issue.number), "--repo", source.repository.name_with_owner,
|
|
796
|
+
"--body-file", "-",
|
|
797
|
+
], { cwd: root, stdin: updated });
|
|
798
|
+
if (edited.exit_code !== 0 || edited.output_exceeded)
|
|
799
|
+
return ghFailure(operation.op, edited, "terminal marker publication failed");
|
|
800
|
+
const refreshed = await snapshot(root, gh, operation.op);
|
|
801
|
+
if ("contract" in refreshed) return refreshed;
|
|
802
|
+
const reread = lookup(refreshed.issues);
|
|
803
|
+
if (reread.kind === "ambiguous") return result(operation.op, "ambiguous_remote_state", reread.message);
|
|
804
|
+
if (reread.kind === "missing" || countLiteral(reread.issue.body, terminalMarker(operation.terminal_event_id)) !== 1)
|
|
805
|
+
return result(operation.op, "retryable_failure", "terminal marker publication could not be confirmed");
|
|
806
|
+
const refreshedOwnership = await confirmTerminalOwnership(root, gh, operation.op, refreshed, reread.issue);
|
|
807
|
+
if (!("owned" in refreshedOwnership)) return refreshedOwnership;
|
|
808
|
+
return closeTerminalIssue(root, gh, operation, refreshed, reread.issue, lookup);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function normalizedList(value: unknown, name: string, max = 2_000): string[] | undefined {
|
|
812
|
+
if (value === undefined) return undefined;
|
|
813
|
+
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
|
814
|
+
return value.map((item, index) => publicText(item, `${name}[${index}]`, max));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function projectionText(value: unknown, name: string, max = 2_000): string {
|
|
818
|
+
const text = publicText(value, name, max);
|
|
819
|
+
if (/(?:docs\/plans\/|\.intent\.json\b|internal[\s_-]+roles?|role[\s_-]+prompts?|review[\s_-]+reservations?|model[\s_-]+reservations?|prompt[\s_-]+digests?|kernel[\s_-]+runtimes?(?:[\s_-]+states?)?|runtime[\s_-]+states?|review[\s_-]+gates?|tool[\s_-]+polic(?:y|ies)|mutable[\s_-]+scopes?|scope[\s_-]+authorit(?:y|ies)|widen[\s_-]+scopes?|QA[\s_-]+settlements?|record_approval|submit_review|advance_assurance|request_authorization)/i.test(text))
|
|
820
|
+
throw new Error(`${name} contains restricted authority context`);
|
|
821
|
+
return text;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function normalizedProjectionList(value: unknown, name: string, max = 2_000): string[] | undefined {
|
|
825
|
+
if (value === undefined) return undefined;
|
|
826
|
+
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
|
827
|
+
return value.map((item, index) => projectionText(item, `${name}[${index}]`, max));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function projectionRisk(value: unknown): "routine" | "material" | "critical" {
|
|
831
|
+
if (value === "routine" || value === "material" || value === "critical") return value;
|
|
832
|
+
throw new Error("risk must be routine, material, or critical");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function normalizeProjection(value: TaskProjection | undefined): TaskProjection | undefined {
|
|
836
|
+
if (value === undefined) return undefined;
|
|
837
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("projection must be an object");
|
|
838
|
+
const blockedBy = normalizedProjectionList(value.blocked_by, "projection.blocked_by", 128)?.map((id) => identifier(id, "projection.blocked_by task_id"));
|
|
839
|
+
if (blockedBy && new Set(blockedBy).size !== blockedBy.length) throw new Error("projection.blocked_by must not contain duplicate Task IDs");
|
|
840
|
+
return {
|
|
841
|
+
result: value.result === undefined ? undefined : projectionText(value.result, "projection.result"),
|
|
842
|
+
current_behavior: value.current_behavior === undefined ? undefined : projectionText(value.current_behavior, "projection.current_behavior"),
|
|
843
|
+
desired_behavior: value.desired_behavior === undefined ? undefined : projectionText(value.desired_behavior, "projection.desired_behavior"),
|
|
844
|
+
key_interfaces: normalizedProjectionList(value.key_interfaces, "projection.key_interfaces", 500),
|
|
845
|
+
verification: value.verification === undefined ? undefined : projectionText(value.verification, "projection.verification"),
|
|
846
|
+
blocked_by: blockedBy,
|
|
847
|
+
out_of_scope: normalizedProjectionList(value.out_of_scope, "projection.out_of_scope", 500),
|
|
848
|
+
agent_handoff: value.agent_handoff === undefined ? undefined : projectionText(value.agent_handoff, "projection.agent_handoff"),
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function normalizeInitiativeProjection(value: InitiativeProjection | undefined): InitiativeProjection | undefined {
|
|
853
|
+
if (value === undefined) return undefined;
|
|
854
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("projection must be an object");
|
|
855
|
+
return {
|
|
856
|
+
problem: value.problem === undefined ? undefined : projectionText(value.problem, "projection.problem"),
|
|
857
|
+
result: value.result === undefined ? undefined : projectionText(value.result, "projection.result"),
|
|
858
|
+
decisions: normalizedProjectionList(value.decisions, "projection.decisions", 500),
|
|
859
|
+
testing_strategy: value.testing_strategy === undefined ? undefined : projectionText(value.testing_strategy, "projection.testing_strategy"),
|
|
860
|
+
out_of_scope: normalizedProjectionList(value.out_of_scope, "projection.out_of_scope", 500),
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function validateOperation(operation: TrackerOperation): TrackerOperation {
|
|
865
|
+
if (operation.op === "create-initiative") {
|
|
866
|
+
const seen = new Set<string>();
|
|
867
|
+
const normalized: Extract<TrackerOperation, { op: "create-initiative" }> = {
|
|
868
|
+
...operation,
|
|
869
|
+
initiative_id: identifier(operation.initiative_id, "initiative_id"),
|
|
870
|
+
goal: projectionText(operation.goal, "goal"),
|
|
871
|
+
projection: normalizeInitiativeProjection(operation.projection),
|
|
872
|
+
slices: operation.slices.map((slice, index) => {
|
|
873
|
+
const id = identifier(slice.id, `slices[${index}].id`);
|
|
874
|
+
if (seen.has(id)) throw new Error(`duplicate Slice id: ${id}`);
|
|
875
|
+
seen.add(id);
|
|
876
|
+
return {
|
|
877
|
+
id,
|
|
878
|
+
goal: projectionText(slice.goal, `slices[${index}].goal`, 1_000),
|
|
879
|
+
result: slice.result === undefined ? undefined : projectionText(slice.result, `slices[${index}].result`, 1_000),
|
|
880
|
+
blocked_by: normalizedList(slice.blocked_by, `slices[${index}].blocked_by`, 128)?.map((taskId, blockerIndex) =>
|
|
881
|
+
identifier(projectionText(taskId, `slices[${index}].blocked_by[${blockerIndex}]`, 128), `slices[${index}].blocked_by task_id`)),
|
|
882
|
+
};
|
|
883
|
+
}),
|
|
884
|
+
};
|
|
885
|
+
issueTitle(normalized.initiative_id, normalized.projection?.result ?? normalized.goal);
|
|
886
|
+
return normalized;
|
|
887
|
+
}
|
|
888
|
+
if (operation.op === "upsert-task") {
|
|
889
|
+
const normalized: Extract<TrackerOperation, { op: "upsert-task" }> = {
|
|
890
|
+
...operation,
|
|
891
|
+
initiative_id: identifier(operation.initiative_id, "initiative_id"),
|
|
892
|
+
task_id: identifier(operation.task_id, "task_id"),
|
|
893
|
+
slice_id: identifier(operation.slice_id, "slice_id"),
|
|
894
|
+
risk: projectionRisk(operation.risk),
|
|
895
|
+
goal: projectionText(operation.goal, "goal"),
|
|
896
|
+
projection: normalizeProjection(operation.projection),
|
|
897
|
+
acceptance: operation.acceptance.map((item, index) => ({
|
|
898
|
+
id: identifier(item.id, `acceptance[${index}].id`),
|
|
899
|
+
summary: projectionText(item.summary, `acceptance[${index}].summary`, 500),
|
|
900
|
+
})),
|
|
901
|
+
};
|
|
902
|
+
issueTitle(`${normalized.initiative_id}/${normalized.slice_id}`, normalized.projection?.result ?? normalized.goal);
|
|
903
|
+
return normalized;
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
...operation,
|
|
907
|
+
task_id: identifier(operation.task_id, "task_id"),
|
|
908
|
+
terminal_event_id: terminalEvent(operation.terminal_event_id),
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export async function runGithubTrackerOperation(
|
|
913
|
+
root: string,
|
|
914
|
+
input: TrackerOperation,
|
|
915
|
+
gh: GhTransport = createGhTransport(),
|
|
916
|
+
): Promise<GithubTrackerResult> {
|
|
917
|
+
let operation: TrackerOperation;
|
|
918
|
+
try {
|
|
919
|
+
operation = validateOperation(input);
|
|
920
|
+
} catch (error) {
|
|
921
|
+
return result(input.op, "permanent_failure", error instanceof Error ? error.message : String(error));
|
|
922
|
+
}
|
|
923
|
+
const absoluteRoot = resolve(root);
|
|
924
|
+
if (operation.op !== "mark-terminal") {
|
|
925
|
+
const conflict = carrierConflict(absoluteRoot, operation.op, operation.initiative_id);
|
|
926
|
+
if (conflict) return conflict;
|
|
927
|
+
}
|
|
928
|
+
const source = await snapshot(absoluteRoot, gh, operation.op);
|
|
929
|
+
if ("contract" in source) return source;
|
|
930
|
+
switch (operation.op) {
|
|
931
|
+
case "create-initiative": return createInitiative(absoluteRoot, gh, operation, source);
|
|
932
|
+
case "upsert-task": return upsertTask(absoluteRoot, gh, operation, source);
|
|
933
|
+
case "mark-terminal": return markTerminal(absoluteRoot, gh, operation, source);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function valueAfter(args: string[], name: string): string | undefined {
|
|
938
|
+
const index = args.indexOf(name);
|
|
939
|
+
return index === -1 ? undefined : args[index + 1];
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function taskPublication(root: string, initiativeId: string, sliceId: string, intentPath: string, projection?: TaskProjection): Extract<TrackerOperation, { op: "upsert-task" }> {
|
|
943
|
+
const absoluteRoot = resolve(root);
|
|
944
|
+
const absolutePath = resolve(absoluteRoot, intentPath);
|
|
945
|
+
const rel = relative(absoluteRoot, absolutePath);
|
|
946
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`)) throw new Error("TaskIntent path escapes the repository");
|
|
947
|
+
const filename = basename(rel);
|
|
948
|
+
if (!filename.endsWith(".intent.json")) throw new Error("TaskIntent path must name a canonical sidecar");
|
|
949
|
+
const taskId = identifier(filename.slice(0, -".intent.json".length), "task_id");
|
|
950
|
+
const read = readTaskIntent(absoluteRoot, taskId);
|
|
951
|
+
if (read.intent_ref.path !== rel) throw new Error("TaskIntent path must match its canonical sidecar path");
|
|
952
|
+
const intent = read.intent;
|
|
953
|
+
return {
|
|
954
|
+
op: "upsert-task",
|
|
955
|
+
initiative_id: initiativeId,
|
|
956
|
+
task_id: intent.task_id,
|
|
957
|
+
slice_id: sliceId,
|
|
958
|
+
goal: intent.goal,
|
|
959
|
+
risk: intent.risk,
|
|
960
|
+
acceptance: intent.acceptance.map((item) => ({ id: item.id, summary: item.assertion })),
|
|
961
|
+
projection,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export async function runGithubTrackerCli(
|
|
966
|
+
args: string[],
|
|
967
|
+
root: string,
|
|
968
|
+
options: { gh?: GhTransport; stdin?: () => string } = {},
|
|
969
|
+
): Promise<{ stdout: string; stderr: string; returncode: number }> {
|
|
970
|
+
const op = args[0];
|
|
971
|
+
if (!args.includes("--json"))
|
|
972
|
+
return { stdout: "", stderr: "invalid_tracker_command: --json is required\n", returncode: 2 };
|
|
973
|
+
let operation: TrackerOperation;
|
|
974
|
+
try {
|
|
975
|
+
if (op === "create-initiative" && args.length === 3 && args[1] === "--stdin") {
|
|
976
|
+
const raw = JSON.parse((options.stdin ?? (() => readFileSync(0, "utf8")))()) as Record<string, unknown>;
|
|
977
|
+
operation = {
|
|
978
|
+
op: "create-initiative",
|
|
979
|
+
initiative_id: raw.initiative_id as string,
|
|
980
|
+
goal: raw.goal as string,
|
|
981
|
+
slices: raw.slices as InitiativeSlice[],
|
|
982
|
+
projection: raw.projection as InitiativeProjection | undefined,
|
|
983
|
+
};
|
|
984
|
+
} else if (op === "upsert-task" && (args.length === 8 || args.length === 10)) {
|
|
985
|
+
const initiative = valueAfter(args, "--initiative-id");
|
|
986
|
+
const slice = valueAfter(args, "--slice-id");
|
|
987
|
+
const intent = valueAfter(args, "--intent");
|
|
988
|
+
if (!initiative || !slice || !intent)
|
|
989
|
+
throw new Error("upsert-task requires --initiative-id, --slice-id, and --intent");
|
|
990
|
+
const rawProjection = valueAfter(args, "--projection-json");
|
|
991
|
+
const projection = rawProjection ? JSON.parse(rawProjection) as TaskProjection : undefined;
|
|
992
|
+
operation = taskPublication(root, initiative, slice, intent, projection);
|
|
993
|
+
} else {
|
|
994
|
+
throw new Error("use create-initiative --stdin --json or upsert-task --initiative-id <id> --slice-id <id> --intent <path> [--projection-json <json>] --json");
|
|
995
|
+
}
|
|
996
|
+
} catch (error) {
|
|
997
|
+
return {
|
|
998
|
+
stdout: "",
|
|
999
|
+
stderr: `invalid_tracker_command: ${redactGithubDiagnostic(error instanceof Error ? error.message : String(error))}\n`,
|
|
1000
|
+
returncode: 2,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
const projected = await runGithubTrackerOperation(root, operation, options.gh);
|
|
1004
|
+
return {
|
|
1005
|
+
stdout: `${JSON.stringify(projected, null, 2)}\n`,
|
|
1006
|
+
stderr: "",
|
|
1007
|
+
returncode: projected.status === "permanent_failure" || projected.status === "ambiguous_remote_state" ? 1 : 0,
|
|
1008
|
+
};
|
|
1009
|
+
}
|