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.
Files changed (101) hide show
  1. package/README.md +239 -0
  2. package/README.zh-CN.md +239 -0
  3. package/package.json +84 -0
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +666 -0
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1622 -0
  6. package/plugins/immune-brain/.pi-extension/package.json +11 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +711 -0
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance.ts +154 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +349 -0
  10. package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +90 -0
  11. package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +214 -0
  12. package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +50 -0
  13. package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +262 -0
  14. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +28 -0
  15. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +237 -0
  16. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +414 -0
  17. package/plugins/immune-brain/.pi-extension/tsconfig.json +20 -0
  18. package/plugins/immune-brain/bin/imm-kernel +4 -0
  19. package/plugins/immune-brain/bin/imm-plan +4 -0
  20. package/plugins/immune-brain/bin/imm-pr-diag +230 -0
  21. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +4 -0
  22. package/plugins/immune-brain/bin/imm-retired +4 -0
  23. package/plugins/immune-brain/bin/imm-tracker +4 -0
  24. package/plugins/immune-brain/dist/BASELINE.md +138 -0
  25. package/plugins/immune-brain/dist/docs/reference/HANDOFF-template.md +122 -0
  26. package/plugins/immune-brain/dist/docs/reference/design-contract-audit-rubric.md +149 -0
  27. package/plugins/immune-brain/dist/docs/reference/design-contract-review-checklist.md +55 -0
  28. package/plugins/immune-brain/dist/docs/reference/i18n-review-checklist.md +110 -0
  29. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +52 -0
  30. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +94 -0
  31. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +44 -0
  32. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +105 -0
  33. package/plugins/immune-brain/dist/docs/reference/ux-heuristic-checklist.md +131 -0
  34. package/plugins/immune-brain/dist/imm-brainstorm.md +140 -0
  35. package/plugins/immune-brain/dist/imm-doc-prune.md +137 -0
  36. package/plugins/immune-brain/dist/imm-loop.md +158 -0
  37. package/plugins/immune-brain/dist/imm-planner.md +387 -0
  38. package/plugins/immune-brain/dist/imm-pr-fix.md +71 -0
  39. package/plugins/immune-brain/dist/registry.yaml +49 -0
  40. package/plugins/immune-brain/dist/role-prompts/advisory-reviewer.md +16 -0
  41. package/plugins/immune-brain/dist/role-prompts/arch-explorer.md +14 -0
  42. package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
  43. package/plugins/immune-brain/dist/role-prompts/compounder.md +20 -0
  44. package/plugins/immune-brain/dist/role-prompts/executor.md +13 -0
  45. package/plugins/immune-brain/dist/role-prompts/pr-fix.md +81 -0
  46. package/plugins/immune-brain/dist/role-prompts/qa.md +23 -0
  47. package/plugins/immune-brain/dist/role-prompts/test-fixer.md +3 -0
  48. package/plugins/immune-brain/dist/role-prompts/ui-review.md +14 -0
  49. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +716 -0
  50. package/plugins/immune-brain/runtime/canonical_json.ts +19 -0
  51. package/plugins/immune-brain/runtime/commands/kernel.ts +1160 -0
  52. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1009 -0
  53. package/plugins/immune-brain/runtime/kernel/application.ts +300 -0
  54. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +284 -0
  55. package/plugins/immune-brain/runtime/kernel/authority_port.ts +208 -0
  56. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +451 -0
  57. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +197 -0
  58. package/plugins/immune-brain/runtime/kernel/canary_application.ts +507 -0
  59. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +73 -0
  60. package/plugins/immune-brain/runtime/kernel/completion.ts +160 -0
  61. package/plugins/immune-brain/runtime/kernel/enrollment.ts +194 -0
  62. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +123 -0
  63. package/plugins/immune-brain/runtime/kernel/index.ts +29 -0
  64. package/plugins/immune-brain/runtime/kernel/intent.ts +563 -0
  65. package/plugins/immune-brain/runtime/kernel/intent_token_registry.ts +80 -0
  66. package/plugins/immune-brain/runtime/kernel/legacy.ts +299 -0
  67. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +153 -0
  68. package/plugins/immune-brain/runtime/kernel/observation.ts +395 -0
  69. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +169 -0
  70. package/plugins/immune-brain/runtime/kernel/readiness.ts +282 -0
  71. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +132 -0
  72. package/plugins/immune-brain/runtime/kernel/reducer.ts +624 -0
  73. package/plugins/immune-brain/runtime/kernel/storage.ts +1780 -0
  74. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +791 -0
  75. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +492 -0
  76. package/plugins/immune-brain/runtime/kernel/types.ts +295 -0
  77. package/plugins/immune-brain/runtime/kernel/validation.ts +963 -0
  78. package/plugins/immune-brain/runtime/loop_contract.ts +362 -0
  79. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +462 -0
  80. package/plugins/immune-brain/runtime/plan_core.ts +1053 -0
  81. package/plugins/immune-brain/runtime/prompts/advisory-reviewer.md +16 -0
  82. package/plugins/immune-brain/runtime/prompts/arch-explorer.md +14 -0
  83. package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
  84. package/plugins/immune-brain/runtime/prompts/compounder.md +20 -0
  85. package/plugins/immune-brain/runtime/prompts/executor.md +13 -0
  86. package/plugins/immune-brain/runtime/prompts/pr-fix.md +81 -0
  87. package/plugins/immune-brain/runtime/prompts/qa.md +23 -0
  88. package/plugins/immune-brain/runtime/prompts/test-fixer.md +3 -0
  89. package/plugins/immune-brain/runtime/prompts/ui-review.md +14 -0
  90. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +160 -0
  91. package/plugins/immune-brain/runtime/v4_runtime.ts +295 -0
  92. package/plugins/immune-brain/runtime/verification_descriptor.ts +162 -0
  93. package/plugins/immune-brain/runtime/workspace_scope.ts +623 -0
  94. package/plugins/immune-brain/skills/.ignore +1 -0
  95. package/plugins/immune-brain/skills/BASELINE.md +138 -0
  96. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +66 -0
  97. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +11 -0
  98. package/plugins/immune-brain/skills/imm-loop/SKILL.md +52 -0
  99. package/plugins/immune-brain/skills/imm-planner/SKILL.md +221 -0
  100. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +10 -0
  101. package/plugins/immune-brain/skills/registry.yaml +49 -0
@@ -0,0 +1,154 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text, type Component } from "@earendil-works/pi-tui";
3
+ import { renderStructuredCall, renderStructuredResult } from "./pi-canary-interaction";
4
+
5
+ export type AssuranceRole = "qa" | "review";
6
+
7
+ export interface AssuranceCorrelation {
8
+ record_revision: string;
9
+ intent_content_hash: string;
10
+ diff_hash: string;
11
+ }
12
+
13
+ export interface AssuranceFindingPresentation {
14
+ id: string;
15
+ kind: "blocking" | "advisory";
16
+ acceptance_id: string | null;
17
+ summary: string;
18
+ }
19
+
20
+ export interface AssuranceResultPresentation {
21
+ passed_acceptance_ids: string[];
22
+ missing_acceptance_ids: string[];
23
+ findings: AssuranceFindingPresentation[];
24
+ }
25
+
26
+
27
+ export interface CanaryToolArgs {
28
+ task_id: string;
29
+ action: { op: string };
30
+ }
31
+
32
+ export function renderCanaryCall(args: CanaryToolArgs, theme: Theme): Component {
33
+ return renderStructuredCall(
34
+ "imm_kernel_canary",
35
+ args.action?.op ?? "unknown",
36
+ args.task_id,
37
+ theme,
38
+ );
39
+ }
40
+
41
+ export function renderCanaryResult(
42
+ result: { content?: Array<{ type?: string; text?: string }>; details?: Record<string, unknown> },
43
+ theme: Theme,
44
+ ): Component {
45
+ return renderStructuredResult(result, theme);
46
+ }
47
+
48
+ export function renderAssuranceResultMessage(
49
+ message: { content?: unknown; details?: unknown },
50
+ options: { expanded?: boolean; outputPad?: number },
51
+ theme: Theme,
52
+ ): Component {
53
+ const payload = isRecord(message.details) ? message.details : undefined;
54
+ if (
55
+ !payload
56
+ || typeof payload.task_id !== "string"
57
+ || (payload.role !== "qa" && payload.role !== "review")
58
+ || typeof payload.terminal !== "string"
59
+ ) {
60
+ const fallback = typeof message.content === "string" ? message.content : "Assurance result unavailable";
61
+ return new Text(theme.fg("dim", fallback), options.outputPad ?? 0, 0);
62
+ }
63
+
64
+ const presentation = isAssuranceResultPresentation(payload.presentation)
65
+ ? payload.presentation
66
+ : undefined;
67
+ const findings = presentation?.findings ?? [];
68
+ const blockers = findings.filter((finding) => finding.kind === "blocking");
69
+ const warnings = findings.filter((finding) => finding.kind === "advisory");
70
+ const passed = presentation?.passed_acceptance_ids ?? [];
71
+ const missing = presentation?.missing_acceptance_ids ?? [];
72
+ const terminal = payload.terminal.replaceAll("_", " ");
73
+ const role = payload.role === "qa" ? "QA" : "Review";
74
+ const status = `${role} ${terminal}`;
75
+ const lines = [`${theme.fg("muted", "Status:")} ${theme.fg("accent", capitalize(status))}`];
76
+
77
+ if (presentation) {
78
+ lines.push(
79
+ `${theme.fg("muted", "Result:")} ${[
80
+ theme.fg(blockers.length > 0 ? "error" : "muted", `Blockers: ${blockers.length}`),
81
+ theme.fg(warnings.length > 0 ? "warning" : "muted", `Warnings: ${warnings.length}`),
82
+ theme.fg("success", `Passed: ${passed.length}`),
83
+ theme.fg(missing.length > 0 ? "warning" : "muted", `Missing: ${missing.length}`),
84
+ ].join(" | ")}`,
85
+ );
86
+ const next = renderNextAction(payload.next_action);
87
+ if (next) lines.push(`${theme.fg("muted", "Next:")} ${theme.fg("dim", next)}`);
88
+ for (const finding of blockers) lines.push(renderFinding(finding, theme));
89
+ if (options.expanded) {
90
+ for (const finding of warnings) lines.push(renderFinding(finding, theme));
91
+ if (passed.length > 0) lines.push(theme.fg("dim", `Passed acceptance: ${passed.join(", ")}`));
92
+ if (missing.length > 0) lines.push(theme.fg("warning", `Missing acceptance: ${missing.join(", ")}`));
93
+ if (typeof payload.summary === "string" && payload.summary) lines.push(theme.fg("dim", payload.summary));
94
+ }
95
+ } else if (typeof payload.summary === "string" && payload.summary) {
96
+ lines.push(`${theme.fg("muted", "Result:")} ${theme.fg("dim", payload.summary)}`);
97
+ }
98
+
99
+ return new Text(lines.join("\n"), options.outputPad ?? 0, 0);
100
+ }
101
+
102
+ function isRecord(value: unknown): value is Record<string, unknown> {
103
+ return typeof value === "object" && value !== null && !Array.isArray(value);
104
+ }
105
+
106
+ function isAssuranceResultPresentation(value: unknown): value is AssuranceResultPresentation {
107
+ if (!isRecord(value)) return false;
108
+ if (!isStringArray(value.passed_acceptance_ids) || !isStringArray(value.missing_acceptance_ids)) return false;
109
+ return Array.isArray(value.findings) && value.findings.every((finding) =>
110
+ isRecord(finding)
111
+ && typeof finding.id === "string"
112
+ && (finding.kind === "blocking" || finding.kind === "advisory")
113
+ && (finding.acceptance_id === null || typeof finding.acceptance_id === "string")
114
+ && typeof finding.summary === "string");
115
+ }
116
+
117
+ function isStringArray(value: unknown): value is string[] {
118
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
119
+ }
120
+
121
+ function capitalize(value: string): string {
122
+ return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`;
123
+ }
124
+
125
+ function renderNextAction(value: unknown): string | undefined {
126
+ switch (value) {
127
+ case "request_authorization":
128
+ return "Agent opens native confirmation";
129
+ case "repair_findings":
130
+ return "Agent repairs blocking findings";
131
+ case "inspect_assurance_failure":
132
+ return "Agent inspects the assurance failure";
133
+ case "none":
134
+ return "No action required";
135
+ default:
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ function renderFinding(finding: AssuranceFindingPresentation, theme: Theme): string {
141
+ const label = finding.kind === "blocking"
142
+ ? theme.fg("error", "[blocking]")
143
+ : theme.fg("warning", "[advisory]");
144
+ const acceptance = finding.acceptance_id ? ` (${finding.acceptance_id})` : "";
145
+ return `${label} ${finding.id}${acceptance}: ${finding.summary}`;
146
+ }
147
+
148
+ // Foreground Tool results render directly in the chat transcript. There is no
149
+ // session-owned status or message continuation channel.
150
+ export class AssurancePresenter {
151
+ publish(): void {}
152
+ clear(): void {}
153
+ reset(): void {}
154
+ }
@@ -0,0 +1,349 @@
1
+ import { DynamicBorder, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, SelectList, Text, type Component, type SelectItem } from "@earendil-works/pi-tui";
3
+
4
+ export const USER_ATTENTION_EVENT = "immune-brain:user-attention.v1" as const;
5
+ export const TASK_RAIL_KEY = "immune-brain.task-rail" as const;
6
+
7
+ export type UserAttentionReason =
8
+ | "enrollment"
9
+ | "descriptor_waiver"
10
+ | "breaking_intent_revision"
11
+ | "review_authorization"
12
+ | "authority_repair";
13
+
14
+ export interface UserAttentionEventV1 {
15
+ active: boolean;
16
+ attention_id: string;
17
+ task_id: string;
18
+ reason: UserAttentionReason;
19
+ label?: string;
20
+ }
21
+
22
+ export type TaskRailState =
23
+ | "Planning"
24
+ | "Approval required"
25
+ | "Working"
26
+ | "Verifying"
27
+ | "Reviewing"
28
+ | "Blocked"
29
+ | "Completed"
30
+ | "Stopped";
31
+
32
+ export interface TaskRailView {
33
+ task_id: string;
34
+ state: TaskRailState;
35
+ result: string;
36
+ next: string;
37
+ }
38
+
39
+ export interface AuthorityDialogAction<T extends string> {
40
+ value: T;
41
+ label: string;
42
+ description: string;
43
+ }
44
+
45
+ export interface AuthorityDialogOptions<T extends string> {
46
+ title: string;
47
+ summary: string;
48
+ details: string;
49
+ actions: readonly AuthorityDialogAction<T>[];
50
+ signal?: AbortSignal;
51
+ }
52
+
53
+ type EventPublisher = Pick<ExtensionAPI, "events">;
54
+ type UiContext = Pick<ExtensionContext, "ui">;
55
+
56
+ const terminalRailUis = new WeakSet<object>();
57
+ const deliveredNotifications = new WeakMap<object, Set<string>>();
58
+
59
+ function beginUserAttention(
60
+ pi: EventPublisher,
61
+ event: Omit<UserAttentionEventV1, "active">,
62
+ ): () => void {
63
+ let active = true;
64
+ emitAttention(pi, { ...event, active: true });
65
+ return () => {
66
+ if (!active) return;
67
+ active = false;
68
+ emitAttention(pi, {
69
+ active: false,
70
+ attention_id: event.attention_id,
71
+ task_id: event.task_id,
72
+ reason: event.reason,
73
+ });
74
+ };
75
+ }
76
+
77
+ async function withUserAttention<T>(
78
+ pi: EventPublisher,
79
+ event: Omit<UserAttentionEventV1, "active">,
80
+ waitForUser: () => Promise<T>,
81
+ ): Promise<T> {
82
+ const endAttention = beginUserAttention(pi, event);
83
+ try {
84
+ return await waitForUser();
85
+ } finally {
86
+ endAttention();
87
+ }
88
+ }
89
+
90
+ export async function requestAuthorityDialog<T extends string, R = T | undefined>(
91
+ pi: EventPublisher,
92
+ ctx: UiContext,
93
+ event: Omit<UserAttentionEventV1, "active">,
94
+ options: AuthorityDialogOptions<T>,
95
+ completeDecision?: (selection: T | undefined) => Promise<R>,
96
+ ): Promise<R> {
97
+ return withUserAttention(pi, event, async () => {
98
+ let selected: T | undefined;
99
+ let finish: ((result: T | undefined) => void) | undefined;
100
+ let settled = false;
101
+ const complete = (result: T | undefined) => {
102
+ if (settled) return;
103
+ settled = true;
104
+ finish?.(result);
105
+ };
106
+ const abort = () => complete(undefined);
107
+ if (!options.signal?.aborted) {
108
+ options.signal?.addEventListener("abort", abort, { once: true });
109
+ try {
110
+ selected = await ctx.ui.custom<T | undefined>((tui, theme, _keybindings, done) => {
111
+ finish = done;
112
+ if (settled || options.signal?.aborted) done(undefined);
113
+ let expanded = false;
114
+ const detailText = new Text(theme.fg("muted", "Details collapsed; press d to expand."), 1, 0);
115
+ const selectList = new SelectList(
116
+ options.actions.map((action): SelectItem => ({ ...action })),
117
+ options.actions.length,
118
+ {
119
+ selectedPrefix: (text) => theme.fg("accent", text),
120
+ selectedText: (text) => theme.fg("accent", text),
121
+ description: (text) => theme.fg("muted", text),
122
+ scrollInfo: (text) => theme.fg("dim", text),
123
+ noMatch: (text) => theme.fg("warning", text),
124
+ },
125
+ );
126
+ selectList.onSelect = (item) => complete(item.value as T);
127
+ selectList.onCancel = () => complete(undefined);
128
+ const container = new Container();
129
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
130
+ container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
131
+ container.addChild(new Text(options.summary, 1, 0));
132
+ container.addChild(detailText);
133
+ container.addChild(selectList);
134
+ container.addChild(new Text(theme.fg("dim", "d: toggle details | enter: choose | esc: cancel"), 1, 0));
135
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
136
+ return {
137
+ render: (width) => container.render(width),
138
+ invalidate: () => container.invalidate(),
139
+ handleInput: (data) => {
140
+ if (data === "d" || data === "D") {
141
+ expanded = !expanded;
142
+ detailText.setText(expanded ? options.details : theme.fg("muted", "Details collapsed; press d to expand."));
143
+ tui.requestRender();
144
+ return;
145
+ }
146
+ selectList.handleInput(data);
147
+ tui.requestRender();
148
+ },
149
+ };
150
+ }, {
151
+ overlay: true,
152
+ overlayOptions: { anchor: "center", width: "80%", maxHeight: "80%" },
153
+ });
154
+ } finally {
155
+ options.signal?.removeEventListener("abort", abort);
156
+ }
157
+ }
158
+ return completeDecision ? await completeDecision(selected) : selected as R;
159
+ });
160
+ }
161
+
162
+ export function presentTaskRail(ctx: UiContext, view: TaskRailView): void {
163
+ try {
164
+ ctx.ui.setWidget(TASK_RAIL_KEY, renderTaskRail(view), { placement: "aboveEditor" });
165
+ if (view.state === "Completed" || view.state === "Stopped") terminalRailUis.add(ctx.ui);
166
+ else terminalRailUis.delete(ctx.ui);
167
+ } catch {
168
+ notifyOnce(ctx, `task-rail:${view.task_id}`, "Task Rail is unavailable; Tool results remain authoritative.", "warning");
169
+ }
170
+ }
171
+
172
+ export function presentTaskRailResult(
173
+ ctx: UiContext,
174
+ taskId: string,
175
+ details: Record<string, unknown> | undefined,
176
+ ): void {
177
+ if (!details) return;
178
+ const taskState = record(details.task_state);
179
+ const lifecycle = string(taskState?.lifecycle) ?? string(details.lifecycle) ?? string(details.stage);
180
+ const operation = string(details.operation);
181
+ const rawState = string(details.state);
182
+ const result = string(details.result) ?? string(details.reason) ?? operation ?? rawState ?? "Task state updated";
183
+ const next = string(details.next_action) ?? "Continue through the projected authority";
184
+ presentTaskRail(ctx, {
185
+ task_id: taskId,
186
+ state: railState({ lifecycle, obligation: string(taskState?.next_obligation), operation, state: rawState }),
187
+ result,
188
+ next,
189
+ });
190
+ }
191
+
192
+ export function clearTerminalTaskRailOnInput(ctx: UiContext): void {
193
+ if (!terminalRailUis.has(ctx.ui)) return;
194
+ clearTaskRail(ctx);
195
+ }
196
+
197
+ export function clearTaskRail(ctx: UiContext): void {
198
+ try {
199
+ ctx.ui.setWidget(TASK_RAIL_KEY, undefined);
200
+ } catch {
201
+ // Tool rows remain the fallback observation surface.
202
+ }
203
+ terminalRailUis.delete(ctx.ui);
204
+ }
205
+
206
+ export function resetInteractionPresentation(ctx?: UiContext): void {
207
+ if (!ctx) return;
208
+ clearTaskRail(ctx);
209
+ deliveredNotifications.delete(ctx.ui);
210
+ }
211
+
212
+ export function notifyOnce(
213
+ ctx: UiContext,
214
+ key: string,
215
+ message: string,
216
+ level: "warning" | "error",
217
+ ): void {
218
+ let delivered = deliveredNotifications.get(ctx.ui);
219
+ if (!delivered) {
220
+ delivered = new Set<string>();
221
+ deliveredNotifications.set(ctx.ui, delivered);
222
+ }
223
+ if (delivered.has(key)) return;
224
+ delivered.add(key);
225
+ try {
226
+ ctx.ui.notify(message, level);
227
+ } catch {
228
+ // Notifications never participate in workflow authority.
229
+ }
230
+ }
231
+
232
+ export function renderStructuredCall(
233
+ tool: string,
234
+ action: string,
235
+ subject: string | undefined,
236
+ theme: Theme,
237
+ ): Component {
238
+ return new Text(
239
+ [
240
+ theme.fg("toolTitle", theme.bold(tool)),
241
+ theme.fg("muted", action),
242
+ ...(subject ? [theme.fg("accent", subject)] : []),
243
+ ].join(" "),
244
+ 0,
245
+ 0,
246
+ );
247
+ }
248
+
249
+ export function renderStructuredResult(
250
+ result: { content?: Array<{ type?: string; text?: string }>; details?: Record<string, unknown> },
251
+ theme: Theme,
252
+ ): Component {
253
+ const details = result.details;
254
+ if (!details) return new Text(theme.fg("dim", "Result details unavailable"), 0, 0);
255
+ const taskState = record(details.task_state);
256
+ const lifecycle = string(taskState?.lifecycle) ?? string(details.lifecycle) ?? string(details.stage);
257
+ const state = string(details.state) ?? "unknown";
258
+ const summary = string(details.result) ?? string(details.reason) ?? string(details.operation) ?? state;
259
+ const next = string(details.next_action) ?? "No action reported";
260
+ const terminal = lifecycle === "done" || lifecycle === "stopped";
261
+ const lines = [
262
+ `${theme.fg("muted", "State:")} ${theme.fg(state === "blocked" || state === "failed" ? "warning" : "accent", lifecycle ?? state)}`,
263
+ `${theme.fg("muted", "Result:")} ${theme.fg(state === "blocked" || state === "failed" ? "warning" : "dim", summary)}`,
264
+ `${theme.fg("muted", "Next:")} ${theme.fg("dim", next)}`,
265
+ ];
266
+ if (terminal && taskState) lines.push(...renderFinalLines(taskState, theme));
267
+ return new Text(lines.join("\n"), 0, 0);
268
+ }
269
+
270
+ export function loopResultDetails(result: unknown, operation: string): Record<string, unknown> {
271
+ const projected = record(result);
272
+ const next = string(projected?.next) ?? "none";
273
+ return {
274
+ state: "projected",
275
+ operation,
276
+ result: `Loop selected ${next} without state mutation`,
277
+ next_action: next === "none" ? "No action required" : `Follow ${next} authority`,
278
+ };
279
+ }
280
+
281
+ function emitAttention(pi: EventPublisher, event: UserAttentionEventV1): void {
282
+ try {
283
+ pi.events.emit(USER_ATTENTION_EVENT, event);
284
+ } catch {
285
+ // External attention adapters are optional and non-authoritative.
286
+ }
287
+ }
288
+
289
+ function renderTaskRail(view: TaskRailView): string[] {
290
+ return [
291
+ `Task ${boundedMiddle(view.task_id, 52)} · ${view.state}`,
292
+ `Result: ${bounded(view.result, 112)}`,
293
+ `Next: ${bounded(view.next, 112)}`,
294
+ ];
295
+ }
296
+
297
+ function railState(input: { lifecycle?: string; obligation?: string; operation?: string; state?: string }): TaskRailState {
298
+ if (input.state === "blocked" || input.state === "failed" || input.state === "settlement_unknown") return "Blocked";
299
+ if (input.lifecycle === "done") return "Completed";
300
+ if (input.lifecycle === "stopped") return "Stopped";
301
+ if (input.state === "awaiting_user" || input.operation === "request_authorization") return "Approval required";
302
+ if (input.operation === "advance_assurance") return "Verifying";
303
+ if (input.operation === "submit_review" || input.obligation === "run_review") return "Reviewing";
304
+ if (input.lifecycle === "active") return "Working";
305
+ if (input.state === "running") return "Planning";
306
+ return "Working";
307
+ }
308
+
309
+ function renderFinalLines(taskState: Record<string, unknown>, theme: Theme): string[] {
310
+ const fresh = strings(taskState.fresh_acceptance_ids).length;
311
+ const missing = strings(taskState.missing_acceptance_ids).length;
312
+ const approvals = strings(taskState.fresh_approval_kinds);
313
+ const blockers = strings(taskState.blocking_finding_ids).length
314
+ + strings(taskState.unresolved_user_decision_ids).length
315
+ + strings(taskState.replan_required_ids).length;
316
+ const diffHash = string(taskState.diff_hash);
317
+ return [
318
+ `${theme.fg("muted", "Acceptance:")} ${theme.fg(missing === 0 ? "success" : "warning", `${fresh}/${fresh + missing} fresh`)}`,
319
+ `${theme.fg("muted", "QA / Review:")} ${theme.fg("dim", approvals.length > 0 ? approvals.join(", ") : "not recorded")}`,
320
+ `${theme.fg("muted", "Residual blockers:")} ${theme.fg(blockers === 0 ? "dim" : "warning", String(blockers))}`,
321
+ `${theme.fg("muted", "Repository health:")} ${theme.fg("dim", "not assessed")}`,
322
+ `${theme.fg("muted", "Git:")} ${theme.fg("dim", diffHash ? `task diff ${diffHash.slice(0, 15)}` : "not reported")}`,
323
+ ];
324
+ }
325
+
326
+ function record(value: unknown): Record<string, unknown> | undefined {
327
+ return typeof value === "object" && value !== null && !Array.isArray(value)
328
+ ? value as Record<string, unknown>
329
+ : undefined;
330
+ }
331
+
332
+ function string(value: unknown): string | undefined {
333
+ return typeof value === "string" && value.length > 0 ? value : undefined;
334
+ }
335
+
336
+ function strings(value: unknown): string[] {
337
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
338
+ }
339
+
340
+ function bounded(value: string, max: number): string {
341
+ return value.length <= max ? value : `${value.slice(0, max - 3)}...`;
342
+ }
343
+
344
+ function boundedMiddle(value: string, max: number): string {
345
+ if (value.length <= max) return value;
346
+ const visible = max - 3;
347
+ const start = Math.ceil(visible / 2);
348
+ return `${value.slice(0, start)}...${value.slice(-(visible - start))}`;
349
+ }
@@ -0,0 +1,90 @@
1
+ // P2B2 task-scoped invocation registry. NOT part of the Kernel runtime graph.
2
+ // One linear state transition per task: `open -> committed | cancelled`.
3
+ // Concurrent assure/authorize operations for the same task are rejected;
4
+ // timeout/cancel wins `open -> cancelled` first; a successful continuation
5
+ // must win `open -> committed` immediately before mint/apply; application
6
+ // failure leaves the invocation closed so retry requires a new invocation.
7
+ // No memory/file cross-transaction atomicity is claimed.
8
+
9
+ export type InvocationState = "open" | "committed" | "cancelled";
10
+
11
+ export interface InvocationToken {
12
+ readonly task_id: string;
13
+ readonly nonce: string;
14
+ }
15
+
16
+ export interface InvocationRegistry {
17
+ /** Open a new invocation for one task; rejects if one is already open. */
18
+ open(taskId: string): InvocationToken;
19
+ /**
20
+ * Linearization point: wins `open -> committed` for exactly this token.
21
+ * Throws for a foreign token or a token whose invocation already ended.
22
+ * Only the winner may mint/apply a capability.
23
+ */
24
+ commit(token: InvocationToken): void;
25
+ /** Wins `open -> cancelled` (timeout/cancel/abort path). */
26
+ cancel(token: InvocationToken): void;
27
+ /** Inspect the current state of the caller's token. */
28
+ stateOf(token: InvocationToken): InvocationState;
29
+ /** Whether any invocation for the task is currently open. */
30
+ isOpen(taskId: string): boolean;
31
+ /** Internal task state (for tests). */
32
+ states(): Record<string, InvocationState>;
33
+ }
34
+
35
+ export function createInvocationRegistry(): InvocationRegistry {
36
+ const states = new Map<string, { token: InvocationToken; state: InvocationState }>();
37
+
38
+ function tokenOf(taskId: string, nonce: string): InvocationToken {
39
+ return Object.freeze({ task_id: taskId, nonce });
40
+ }
41
+
42
+ function entryOf(token: InvocationToken): { token: InvocationToken; state: InvocationState } {
43
+ const entry = states.get(token.task_id);
44
+ if (!entry || entry.token.nonce !== token.nonce)
45
+ throw new Error("invocation token is not recognized for this task");
46
+ return entry;
47
+ }
48
+
49
+ return {
50
+ open(taskId: string): InvocationToken {
51
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
52
+ throw new Error("task id is not a safe file identity");
53
+ const existing = states.get(taskId);
54
+ if (existing && existing.state === "open") {
55
+ throw new Error(
56
+ `task ${taskId} already has an open invocation; concurrent assure/authorize is rejected`,
57
+ );
58
+ }
59
+ // A closed (committed/cancelled) invocation is replaced by a fresh
60
+ // one: retry requires a new invocation.
61
+ const token = tokenOf(taskId, `${taskId}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`);
62
+ states.set(taskId, { token, state: "open" });
63
+ return token;
64
+ },
65
+ commit(token: InvocationToken): void {
66
+ const entry = entryOf(token);
67
+ if (entry.state !== "open")
68
+ throw new Error(
69
+ `invocation for task ${token.task_id} is already ${entry.state}; a new invocation is required`,
70
+ );
71
+ entry.state = "committed";
72
+ },
73
+ cancel(token: InvocationToken): void {
74
+ const entry = entryOf(token);
75
+ if (entry.state === "open") entry.state = "cancelled";
76
+ // Cancel on an already-closed invocation is a no-op (idempotent).
77
+ },
78
+ stateOf(token: InvocationToken): InvocationState {
79
+ return entryOf(token).state;
80
+ },
81
+ isOpen(taskId: string): boolean {
82
+ return states.get(taskId)?.state === "open";
83
+ },
84
+ states(): Record<string, InvocationState> {
85
+ const out: Record<string, InvocationState> = {};
86
+ for (const [taskId, entry] of states) out[taskId] = entry.state;
87
+ return out;
88
+ },
89
+ };
90
+ }