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,563 @@
1
+ // P2C1 TaskIntent v1 identity module.
2
+ // Owns the strict TaskIntent v1 parser, canonical serializer/hash, secure
3
+ // descriptor reader, opaque identity-token producer, and revision classifier.
4
+ // This module must not import storage/reducer/observation/readiness/runtime.
5
+
6
+ import { createHash } from "node:crypto";
7
+ import {
8
+ closeSync,
9
+ constants as fsConstants,
10
+ fstatSync,
11
+ lstatSync,
12
+ openSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ statSync,
16
+ } from "node:fs";
17
+ import { execFileSync } from "node:child_process";
18
+ import { join, resolve, sep } from "node:path";
19
+ import { stableStringify } from "../canonical_json";
20
+ import {
21
+ TASK_INTENT_CONTRACT_V1,
22
+ TASK_RISKS,
23
+ type TaskIntentRefV1,
24
+ type TaskIntentV1,
25
+ type TaskRisk,
26
+ } from "./types";
27
+ import {
28
+ mintToken,
29
+ type TaskIntentIdentityToken,
30
+ } from "./intent_token_registry";
31
+
32
+ export const INTENT_MAX_BYTES = 64 * 1024;
33
+ export const INTENT_SIDECAR_RELATIVE_PREFIX = "docs/plans/";
34
+
35
+ // Deterministic risk-tier floor: an intent whose scope_hint touches kernel or
36
+ // authority runtime paths is forced to at least `material` regardless of the
37
+ // tier the author declared, so self-graded `routine` can never buy weaker
38
+ // gating over the paths where gating matters most. The floor is derived solely
39
+ // from scope_hint, never from prose fields. The .pi-extension directory is the
40
+ // human gate itself (enrollment confirmation, capability minting, and review
41
+ // authorization), so a self-graded routine over it must not weaken gating over
42
+ // the code that implements gating.
43
+ export const RISK_FLOOR_SCOPE_PREFIXES = [
44
+ "plugins/immune-brain/runtime/kernel",
45
+ "plugins/immune-brain/runtime/authority_commit_receipts.ts",
46
+ "plugins/immune-brain/.pi-extension",
47
+ ] as const;
48
+
49
+ function segmentMatches(e: string, s: string): boolean {
50
+ if (e === "*") return true;
51
+ if (!e.includes("*") && !e.includes("?")) return e === s;
52
+ const rx = new RegExp(
53
+ `^${e.split("*").map((p) => p.split("?").map((q) => q.replace(/[.\\+^${}()|[\]\\/]/g, "\\$&")).join(".")).join(".*")}$`,
54
+ );
55
+ return rx.test(s);
56
+ }
57
+
58
+ // Decide whether a scope entry can match the kernel/authority prefix itself,
59
+ // any of its ancestor prefixes, or any descendant path beneath it. Glob `**`
60
+ // spans zero or more segments, `*` matches one segment, `?` one character.
61
+ // A match on any of those relations means the entry touches the floored path,
62
+ // so a routine-declared intent cannot hide behind wildcard-leading patterns.
63
+ function scopeEntryTouchesPrefixSegments(es: string[], ps: string[]): boolean {
64
+ const memo = new Map<string, boolean>();
65
+ const m = (pi: number, ei: number): boolean => {
66
+ const key = `${pi}:${ei}`;
67
+ const cached = memo.get(key);
68
+ if (cached !== undefined) return cached;
69
+ let result: boolean;
70
+ if (ei === es.length) {
71
+ result = true; // es matched a prefix of ps, ps itself, or a descendant
72
+ } else {
73
+ const e = es[ei];
74
+ if (e === "**") {
75
+ if (pi >= ps.length) result = true; // beyond ps, descendant always exists
76
+ else result = m(pi, ei + 1) || m(pi + 1, ei);
77
+ } else if (pi < ps.length && segmentMatches(e, ps[pi])) {
78
+ result = m(pi + 1, ei + 1);
79
+ } else if (pi >= ps.length) {
80
+ result = m(pi + 1, ei + 1); // remaining literal segments match descendant segments
81
+ } else {
82
+ result = false;
83
+ }
84
+ }
85
+ memo.set(key, result);
86
+ return result;
87
+ };
88
+ return m(0, 0);
89
+ }
90
+
91
+ function scopeEntryTouchesRiskFloorPaths(entry: string): boolean {
92
+ if (!entry) return false;
93
+ const es = entry.split("/");
94
+ return RISK_FLOOR_SCOPE_PREFIXES.some((prefix) =>
95
+ scopeEntryTouchesPrefixSegments(es, prefix.split("/")),
96
+ );
97
+ }
98
+
99
+ function riskFloorForScope(scopeHint: string[]): TaskRisk | null {
100
+ return scopeHint.some(scopeEntryTouchesRiskFloorPaths)
101
+ ? "material"
102
+ : null;
103
+ }
104
+
105
+ const SHA256_HEX = /^sha256:[a-f0-9]{64}$/;
106
+ const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
107
+ const portablePathCollator = new Intl.Collator("und", {
108
+ usage: "search",
109
+ sensitivity: "base",
110
+ numeric: false,
111
+ ignorePunctuation: false,
112
+ });
113
+
114
+ function sha256Hex(bytes: string | Buffer): string {
115
+ return createHash("sha256").update(bytes).digest("hex");
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Strict TaskIntent v1 parser (independent of the P1 intent parser).
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function objectAt(
123
+ value: unknown,
124
+ path: string,
125
+ violations: string[],
126
+ ): Record<string, unknown> {
127
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
128
+ violations.push(`${path} must be an object`);
129
+ return {};
130
+ }
131
+ return value as Record<string, unknown>;
132
+ }
133
+
134
+ function rejectUnknown(
135
+ record: Record<string, unknown>,
136
+ allowed: readonly string[],
137
+ path: string,
138
+ violations: string[],
139
+ ): void {
140
+ for (const key of Object.keys(record)) {
141
+ if (!allowed.includes(key))
142
+ violations.push(`unknown field: ${path}.${key}`);
143
+ }
144
+ }
145
+
146
+ function nonEmptyString(
147
+ value: unknown,
148
+ path: string,
149
+ violations: string[],
150
+ max: number,
151
+ ): string {
152
+ if (typeof value !== "string" || !value.trim() || value.length > max) {
153
+ violations.push(`${path} must be a non-empty string no longer than ${max}`);
154
+ return "";
155
+ }
156
+ return value;
157
+ }
158
+
159
+ function positiveInteger(value: unknown, path: string, violations: string[]): number {
160
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
161
+ violations.push(`${path} must be a positive integer`);
162
+ return 0;
163
+ }
164
+ return value;
165
+ }
166
+
167
+ function parseAcceptanceItemV1(
168
+ value: unknown,
169
+ index: number,
170
+ violations: string[],
171
+ ): { id: string; assertion: string; verification: string } {
172
+ const item = objectAt(value, `intent.acceptance[${index}]`, violations);
173
+ rejectUnknown(
174
+ item,
175
+ ["id", "assertion", "verification"],
176
+ `intent.acceptance[${index}]`,
177
+ violations,
178
+ );
179
+ return {
180
+ id: nonEmptyString(item.id, `intent.acceptance[${index}].id`, violations, 64),
181
+ assertion: nonEmptyString(
182
+ item.assertion,
183
+ `intent.acceptance[${index}].assertion`,
184
+ violations,
185
+ 2000,
186
+ ),
187
+ verification: nonEmptyString(
188
+ item.verification,
189
+ `intent.acceptance[${index}].verification`,
190
+ violations,
191
+ 2000,
192
+ ),
193
+ };
194
+ }
195
+
196
+ function parseScopeHintV1(value: unknown, violations: string[]): string[] {
197
+ if (!Array.isArray(value) || value.length === 0) {
198
+ violations.push("intent.scope_hint must contain at least one path");
199
+ return [];
200
+ }
201
+ const entries: string[] = [];
202
+ for (let index = 0; index < value.length; index += 1) {
203
+ const path = `intent.scope_hint[${index}]`;
204
+ const raw = nonEmptyString(value[index], path, violations, 200);
205
+ if (!raw) continue;
206
+ if (raw !== raw.trim()) violations.push(`${path} must not contain surrounding whitespace`);
207
+ let entry = raw.trim();
208
+ while (entry.endsWith("/")) entry = entry.slice(0, -1);
209
+ if (
210
+ !entry ||
211
+ entry === "." ||
212
+ entry === "*" ||
213
+ entry === "**" ||
214
+ entry === "**/*" ||
215
+ entry.includes("\0") ||
216
+ entry.includes("\\") ||
217
+ entry.startsWith("/") ||
218
+ /^[A-Za-z]:\//.test(entry) ||
219
+ entry.split("/").some((part) => !part || part === "." || part === "..")
220
+ ) {
221
+ violations.push(`${path} must be a canonical project-relative path or pattern`);
222
+ continue;
223
+ }
224
+ if (Buffer.from(entry, "utf8").toString("utf8") !== entry) {
225
+ violations.push(`${path} must contain valid round-trippable UTF-8`);
226
+ continue;
227
+ }
228
+ if (entry.normalize("NFC") !== entry) {
229
+ violations.push(`${path} must use NFC Unicode normalization`);
230
+ continue;
231
+ }
232
+ entries.push(entry);
233
+ }
234
+
235
+ const componentIdentities: string[] = [];
236
+ for (const entry of entries) {
237
+ let prefix = "";
238
+ for (const component of entry.split("/")) {
239
+ prefix = prefix ? `${prefix}/${component}` : component;
240
+ const prior = componentIdentities.find(
241
+ (candidate) => candidate !== prefix && portablePathCollator.compare(candidate, prefix) === 0,
242
+ );
243
+ if (prior !== undefined) {
244
+ violations.push(`intent.scope_hint contains a case-fold path collision: ${prior} and ${prefix}`);
245
+ } else if (!componentIdentities.includes(prefix)) {
246
+ componentIdentities.push(prefix);
247
+ }
248
+ }
249
+ }
250
+
251
+ const unique = [...new Set(entries)].sort((left, right) =>
252
+ left < right ? -1 : left > right ? 1 : 0,
253
+ );
254
+ return unique.filter(
255
+ (entry) =>
256
+ !unique.some(
257
+ (candidate) =>
258
+ candidate !== entry &&
259
+ !candidate.includes("*") &&
260
+ !candidate.includes("?") &&
261
+ entry.startsWith(`${candidate}/`),
262
+ ),
263
+ );
264
+ }
265
+
266
+ export function parseTaskIntentV1(raw: unknown): TaskIntentV1 {
267
+ const violations: string[] = [];
268
+ const value = objectAt(raw, "intent", violations);
269
+ rejectUnknown(
270
+ value,
271
+ ["contract", "task_id", "goal", "acceptance", "scope_hint", "risk", "revision", "owner"],
272
+ "intent",
273
+ violations,
274
+ );
275
+ if (value.contract !== TASK_INTENT_CONTRACT_V1)
276
+ violations.push(`contract must equal ${TASK_INTENT_CONTRACT_V1}`);
277
+ const acceptanceRaw = value.acceptance;
278
+ if (!Array.isArray(acceptanceRaw) || acceptanceRaw.length === 0) {
279
+ violations.push("intent.acceptance must contain at least one item");
280
+ } else {
281
+ const acceptance = acceptanceRaw.map((item, index) =>
282
+ parseAcceptanceItemV1(item, index, violations),
283
+ );
284
+ const ids = new Set<string>();
285
+ for (const item of acceptance) {
286
+ if (ids.has(item.id))
287
+ violations.push(`duplicate acceptance id: ${item.id}`);
288
+ ids.add(item.id);
289
+ }
290
+ }
291
+ const scopeHint = parseScopeHintV1(value.scope_hint, violations);
292
+
293
+ const risk = value.risk;
294
+ if (typeof risk !== "string" || !TASK_RISKS.includes(risk as TaskRisk))
295
+ violations.push(`intent.risk must be one of ${TASK_RISKS.join(", ")}`);
296
+ if (value.owner !== "user") violations.push("intent.owner must equal user");
297
+ const taskId = nonEmptyString(value.task_id, "intent.task_id", violations, 128);
298
+ const goal = nonEmptyString(value.goal, "intent.goal", violations, 2000);
299
+ const revision = positiveInteger(value.revision, "intent.revision", violations);
300
+
301
+ if (violations.length > 0) throw new Error(violations.join("; "));
302
+
303
+ const acceptance = (acceptanceRaw as unknown[]).map((item, index) =>
304
+ parseAcceptanceItemV1(item, index, []),
305
+ );
306
+ const declaredRisk = risk as TaskRisk;
307
+ const flooredRisk = riskFloorForScope(scopeHint);
308
+ return {
309
+ contract: TASK_INTENT_CONTRACT_V1,
310
+ task_id: taskId,
311
+ goal,
312
+ acceptance,
313
+ scope_hint: scopeHint,
314
+ risk:
315
+ flooredRisk !== null && RISK_RANK[declaredRisk] < RISK_RANK[flooredRisk]
316
+ ? flooredRisk
317
+ : declaredRisk,
318
+ revision,
319
+ owner: "user",
320
+ };
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Canonical identity.
325
+ // ---------------------------------------------------------------------------
326
+
327
+ export function canonicalIntentHash(intent: unknown): string {
328
+ return `sha256:${sha256Hex(stableStringify(intent))}`;
329
+ }
330
+
331
+ const RISK_RANK: Record<TaskRisk, number> = { routine: 0, material: 1, critical: 2 };
332
+
333
+ function contentHashWithoutRevision(intent: TaskIntentV1): string {
334
+ const { revision: _revision, ...rest } = intent;
335
+ return canonicalIntentHash(rest);
336
+ }
337
+
338
+ export function classifyIntentRevision(
339
+ previous: TaskIntentV1,
340
+ next: TaskIntentV1,
341
+ ): "unchanged" | "compatible" | "breaking" {
342
+ if (contentHashWithoutRevision(previous) === contentHashWithoutRevision(next))
343
+ return "unchanged";
344
+
345
+ const breaking =
346
+ previous.contract !== next.contract ||
347
+ previous.task_id !== next.task_id ||
348
+ previous.owner !== next.owner ||
349
+ previous.goal !== next.goal ||
350
+ JSON.stringify(previous.scope_hint) !== JSON.stringify(next.scope_hint) ||
351
+ RISK_RANK[next.risk] < RISK_RANK[previous.risk] ||
352
+ previous.acceptance.some((prior) => {
353
+ const current = next.acceptance.find((item) => item.id === prior.id);
354
+ return !current || current.assertion !== prior.assertion;
355
+ });
356
+ if (breaking) return "breaking";
357
+
358
+ return next.revision > previous.revision ? "compatible" : "breaking";
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Opaque file identity token.
363
+ // ---------------------------------------------------------------------------
364
+
365
+ // Opaque file identity token (brand and store live in intent_token_registry;
366
+ // this module re-exports the type for API compatibility).
367
+ export type { TaskIntentIdentityToken } from "./intent_token_registry";
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Secure descriptor reader.
371
+ // ---------------------------------------------------------------------------
372
+
373
+ export interface ReadTaskIntentResult {
374
+ intent: TaskIntentV1;
375
+ content_hash: string;
376
+ intent_ref: TaskIntentRefV1;
377
+ token: TaskIntentIdentityToken;
378
+ }
379
+
380
+ export function setIntentReaderTestHook(
381
+ hook: { onBeforeDescriptorRead?: () => void } | null,
382
+ ): void {
383
+ intentReaderTestHook = hook;
384
+ }
385
+
386
+ /** Test-only fault seam; production always reads through the secure path. */
387
+ let intentReaderTestHook: { onBeforeDescriptorRead?: () => void } | null = null;
388
+
389
+ function validateTaskId(taskId: string): void {
390
+ if (!TASK_ID_PATTERN.test(taskId))
391
+ throw new Error(
392
+ "task id must match [A-Za-z0-9][A-Za-z0-9._-]{0,127}",
393
+ );
394
+ }
395
+
396
+ function statIdentity(stat: ReturnType<typeof statSync>): {
397
+ dev: number;
398
+ ino: number;
399
+ size: number;
400
+ mtimeMs: number;
401
+ } {
402
+ if (!stat) throw new Error("stat identity is unavailable");
403
+ return {
404
+ dev: Number(stat.dev),
405
+ ino: Number(stat.ino),
406
+ size: Number(stat.size),
407
+ mtimeMs: Number(stat.mtimeMs),
408
+ };
409
+ }
410
+
411
+ function assertSameIdentity(
412
+ before: { dev: number; ino: number; size: number; mtimeMs: number },
413
+ after: ReturnType<typeof statSync>,
414
+ what: string,
415
+ ): void {
416
+ const current = statIdentity(after);
417
+ if (
418
+ current.dev !== before.dev ||
419
+ current.ino !== before.ino ||
420
+ current.size !== before.size ||
421
+ current.mtimeMs !== before.mtimeMs
422
+ )
423
+ throw new Error(`${what} changed while being read`);
424
+ }
425
+
426
+ function resolveCanonicalRoot(root: string): string {
427
+ const resolved = resolve(root);
428
+ const rootStat = lstatSync(resolved);
429
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
430
+ throw new Error("project root must be a real directory, not a symlink");
431
+ // Parent components may be symlinks (e.g. /var -> /private/var on macOS);
432
+ // we bind containment and Git cwd to the canonical root so root identity
433
+ // is unambiguous, and reject drift afterwards.
434
+ return realpathSync(resolved);
435
+ }
436
+
437
+ function collectPathIdentities(
438
+ canonicalRoot: string,
439
+ relativePath: string,
440
+ ): { dev: number; ino: number }[] {
441
+ const identities: { dev: number; ino: number }[] = [];
442
+ let current = canonicalRoot;
443
+ for (const part of relativePath.split("/")) {
444
+ current = join(current, part);
445
+ const stat = lstatSync(current);
446
+ if (stat.isSymbolicLink())
447
+ throw new Error("intent sidecar path contains a symlink");
448
+ identities.push({ dev: stat.dev, ino: stat.ino });
449
+ }
450
+ return identities;
451
+ }
452
+
453
+ function assertIdentitiesUnchanged(
454
+ expected: { dev: number; ino: number }[],
455
+ canonicalRoot: string,
456
+ relativePath: string,
457
+ ): void {
458
+ let current = canonicalRoot;
459
+ const parts = relativePath.split("/");
460
+ for (let index = 0; index < parts.length; index += 1) {
461
+ current = join(current, parts[index]);
462
+ const stat = lstatSync(current);
463
+ if (stat.dev !== expected[index].dev || stat.ino !== expected[index].ino)
464
+ throw new Error(`path component changed while being read: ${parts.slice(0, index + 1).join("/")}`);
465
+ }
466
+ }
467
+
468
+ export function readTaskIntent(
469
+ root: string,
470
+ taskId: string,
471
+ requestedPath?: string,
472
+ ): ReadTaskIntentResult {
473
+ validateTaskId(taskId);
474
+
475
+ const canonicalRoot = resolveCanonicalRoot(root);
476
+ const activePath = `${INTENT_SIDECAR_RELATIVE_PREFIX}${taskId}.intent.json`;
477
+ const archivedPath = `${INTENT_SIDECAR_RELATIVE_PREFIX}archive/${taskId}.intent.json`;
478
+ const sidecarPath = requestedPath ?? activePath;
479
+ if (sidecarPath !== activePath && sidecarPath !== archivedPath)
480
+ throw new Error("intent sidecar path is not the active or archived task path");
481
+ const target = join(canonicalRoot, sidecarPath);
482
+ if (!target.startsWith(canonicalRoot + sep))
483
+ throw new Error("intent sidecar escapes project root");
484
+
485
+ const pathIdentities = collectPathIdentities(canonicalRoot, sidecarPath);
486
+ const fileIdentity = pathIdentities[pathIdentities.length - 1];
487
+
488
+ // Git tracking is an ownership convention, not user authentication.
489
+ try {
490
+ execFileSync(
491
+ "git",
492
+ ["ls-files", "--error-unmatch", "--", sidecarPath],
493
+ { cwd: canonicalRoot, stdio: ["ignore", "pipe", "pipe"] },
494
+ );
495
+ } catch {
496
+ throw new Error("TaskIntent sidecar is not Git-tracked");
497
+ }
498
+
499
+ const before = lstatSync(target);
500
+ if (!before.isFile() || before.size > INTENT_MAX_BYTES)
501
+ throw new Error("TaskIntent sidecar must be a regular file no larger than 64 KiB");
502
+
503
+ const fd = openSync(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
504
+ let bytes: Buffer;
505
+ try {
506
+ const fdStat = fstatSync(fd);
507
+ assertSameIdentity(statIdentity(before), fdStat, "intent sidecar descriptor");
508
+ intentReaderTestHook?.onBeforeDescriptorRead?.();
509
+ bytes = readFileSync(fd);
510
+ } finally {
511
+ closeSync(fd);
512
+ }
513
+ if (bytes.byteLength > INTENT_MAX_BYTES)
514
+ throw new Error("TaskIntent sidecar exceeds 64 KiB");
515
+
516
+ // Post-read identity re-verification without a second path read as the
517
+ // source of bytes.
518
+ const after = lstatSync(target);
519
+ assertSameIdentity(statIdentity(before), after, "intent sidecar");
520
+ assertIdentitiesUnchanged(pathIdentities, canonicalRoot, sidecarPath);
521
+ const canonicalAgain = realpathSync(root);
522
+ if (canonicalAgain !== canonicalRoot)
523
+ throw new Error("canonical project root drifted while being read");
524
+ if (lstatSync(canonicalAgain).isSymbolicLink())
525
+ throw new Error("canonical project root became a symlink while being read");
526
+
527
+ const sourceBytesSha256 = sha256Hex(bytes);
528
+ let intent: TaskIntentV1;
529
+ try {
530
+ intent = parseTaskIntentV1(JSON.parse(bytes.toString("utf8")));
531
+ } catch (error) {
532
+ throw new Error(
533
+ `TaskIntent sidecar is invalid: ${error instanceof Error ? error.message : String(error)}`,
534
+ );
535
+ }
536
+ if (intent.task_id !== taskId)
537
+ throw new Error("intent.task_id does not match the sidecar filename task id");
538
+
539
+ const contentHash = canonicalIntentHash(intent);
540
+ const token = mintToken({
541
+ canonical_root: canonicalRoot,
542
+ sidecar_path: sidecarPath,
543
+ path_dev: fileIdentity.dev,
544
+ path_ino: fileIdentity.ino,
545
+ fd_dev: before.dev,
546
+ fd_ino: before.ino,
547
+ fd_size: before.size,
548
+ fd_mtime_ms: before.mtimeMs,
549
+ source_bytes_sha256: sourceBytesSha256,
550
+ intent_content_hash: contentHash,
551
+ });
552
+
553
+ return {
554
+ intent,
555
+ content_hash: contentHash,
556
+ intent_ref: {
557
+ path: sidecarPath,
558
+ revision: intent.revision,
559
+ content_hash: contentHash,
560
+ },
561
+ token,
562
+ };
563
+ }
@@ -0,0 +1,80 @@
1
+ // R2C2 internal intent-token registry. NOT exported from kernel/index.ts.
2
+ // Owns the opaque brand, identity store, and single-use inspect/consume
3
+ // operations shared between the R2C1 reader (mint) and the R2C2 application
4
+ // port (inspect/consume).
5
+
6
+ const TOKEN_BRAND = Symbol("assurance-kernel-task-intent-identity-token");
7
+
8
+ export interface TaskIntentIdentityToken {
9
+ readonly [TOKEN_BRAND]: true;
10
+ }
11
+
12
+ export interface TokenIdentity {
13
+ canonical_root: string;
14
+ sidecar_path: string;
15
+ path_dev: number;
16
+ path_ino: number;
17
+ fd_dev: number;
18
+ fd_ino: number;
19
+ fd_size: number;
20
+ fd_mtime_ms: number;
21
+ source_bytes_sha256: string;
22
+ intent_content_hash: string;
23
+ }
24
+
25
+ const tokenIdentities = new WeakMap<object, TokenIdentity>();
26
+ const consumedTokens = new WeakSet<object>();
27
+
28
+ export function isIntentIdentityToken(value: unknown): value is TaskIntentIdentityToken {
29
+ return (
30
+ !!value &&
31
+ typeof value === "object" &&
32
+ (value as TaskIntentIdentityToken)[TOKEN_BRAND] === true
33
+ );
34
+ }
35
+
36
+ export function mintToken(identity: TokenIdentity): TaskIntentIdentityToken {
37
+ const token = Object.freeze(
38
+ Object.defineProperty({}, TOKEN_BRAND, {
39
+ value: true,
40
+ enumerable: false,
41
+ writable: false,
42
+ configurable: false,
43
+ }),
44
+ ) as TaskIntentIdentityToken;
45
+ tokenIdentities.set(token, identity);
46
+ return token;
47
+ }
48
+
49
+ function identityOf(token: TaskIntentIdentityToken): TokenIdentity {
50
+ const identity = tokenIdentities.get(token);
51
+ if (!identity) throw new Error("intent identity token is not recognized");
52
+ return identity;
53
+ }
54
+
55
+ /** Inspect a prior/current token pair without consuming. Returns identities. */
56
+ export function inspectIntentTokenPair(
57
+ prior: TaskIntentIdentityToken,
58
+ current: TaskIntentIdentityToken,
59
+ ): { prior: TokenIdentity; current: TokenIdentity } {
60
+ if (!isIntentIdentityToken(prior) || !isIntentIdentityToken(current))
61
+ throw new Error("intent identity tokens are required");
62
+ if (consumedTokens.has(prior) || consumedTokens.has(current))
63
+ throw new Error("intent identity token is already consumed");
64
+ const priorIdentity = identityOf(prior);
65
+ const currentIdentity = identityOf(current);
66
+ if (priorIdentity.canonical_root !== currentIdentity.canonical_root)
67
+ throw new Error("intent token canonical root mismatch");
68
+ return { prior: priorIdentity, current: currentIdentity };
69
+ }
70
+
71
+ /** Consume one token irreversibly. Returns its identity. */
72
+ export function consumeIntentToken(token: TaskIntentIdentityToken): TokenIdentity {
73
+ if (!isIntentIdentityToken(token))
74
+ throw new Error("intent identity token is required");
75
+ if (consumedTokens.has(token))
76
+ throw new Error("intent identity token is already consumed");
77
+ const identity = identityOf(token);
78
+ consumedTokens.add(token);
79
+ return identity;
80
+ }