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,623 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readlinkSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface GitWorkspaceSnapshot {
|
|
13
|
+
kind: "git-workspace-v1";
|
|
14
|
+
repository_root: string;
|
|
15
|
+
head: string;
|
|
16
|
+
dirty_files: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function git(root: string, args: string[]): string | null {
|
|
20
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
+
});
|
|
24
|
+
return result.status === 0 ? result.stdout : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function splitNull(value: string): string[] {
|
|
28
|
+
return value.split("\0").filter((entry) => entry.length > 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function comparePaths(left: string, right: string): number {
|
|
32
|
+
if (left < right) return -1;
|
|
33
|
+
if (left > right) return 1;
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRuntimeAuthorityPath(path: string): boolean {
|
|
38
|
+
return (
|
|
39
|
+
// Kernel v2 task state, workspace coordination, and authority journals are
|
|
40
|
+
// runtime-owned and must not change the code/workspace diff identity.
|
|
41
|
+
// State-layout cutover: .imm/state/ is wholly ignored runtime state;
|
|
42
|
+
// .imm/audit/ is tracked evidence and must NEVER be excluded here.
|
|
43
|
+
// The legacy .imm/tasks/.imm/memory/ paths remain excluded until the
|
|
44
|
+
// one-release migration relocates them (Slice 2 deletes those arms).
|
|
45
|
+
path === ".imm/workspace.json" ||
|
|
46
|
+
path.startsWith(".imm/tasks/") ||
|
|
47
|
+
path.startsWith(".imm/memory/") ||
|
|
48
|
+
path.startsWith(".imm/state/") ||
|
|
49
|
+
path.startsWith(".imm/authority/") ||
|
|
50
|
+
path.startsWith(".imm/journal") ||
|
|
51
|
+
path === "HANDOFF.md"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fileFingerprint(root: string, relativePath: string): string {
|
|
56
|
+
const absolutePath = resolve(root, relativePath);
|
|
57
|
+
if (!existsSync(absolutePath)) return "missing";
|
|
58
|
+
const stat = lstatSync(absolutePath);
|
|
59
|
+
if (stat.isSymbolicLink()) return `symlink:${readlinkSync(absolutePath)}`;
|
|
60
|
+
if (!stat.isFile()) return `other:${stat.mode}:${stat.size}`;
|
|
61
|
+
return `file:${stat.mode}:${createHash("sha256")
|
|
62
|
+
.update(readFileSync(absolutePath))
|
|
63
|
+
.digest("hex")}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dirtyPaths(root: string): string[] | null {
|
|
67
|
+
const tracked = git(root, ["diff", "--name-only", "-z", "HEAD", "--"]);
|
|
68
|
+
const untracked = git(root, [
|
|
69
|
+
"ls-files",
|
|
70
|
+
"--others",
|
|
71
|
+
"--exclude-standard",
|
|
72
|
+
"-z",
|
|
73
|
+
"--",
|
|
74
|
+
]);
|
|
75
|
+
if (tracked === null || untracked === null) return null;
|
|
76
|
+
return [...new Set([...splitNull(tracked), ...splitNull(untracked)])]
|
|
77
|
+
.filter((path) => !isRuntimeAuthorityPath(path))
|
|
78
|
+
.sort(comparePaths);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function captureGitWorkspaceSnapshot(
|
|
82
|
+
projectRoot: string,
|
|
83
|
+
): GitWorkspaceSnapshot | null {
|
|
84
|
+
const root = realpathSync(resolve(projectRoot));
|
|
85
|
+
const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim();
|
|
86
|
+
const head = git(root, ["rev-parse", "HEAD"])?.trim();
|
|
87
|
+
if (
|
|
88
|
+
!repositoryRoot ||
|
|
89
|
+
!head ||
|
|
90
|
+
realpathSync(resolve(repositoryRoot)) !== root
|
|
91
|
+
)
|
|
92
|
+
return null;
|
|
93
|
+
const paths = dirtyPaths(root);
|
|
94
|
+
if (!paths) return null;
|
|
95
|
+
return {
|
|
96
|
+
kind: "git-workspace-v1",
|
|
97
|
+
repository_root: root,
|
|
98
|
+
head,
|
|
99
|
+
dirty_files: Object.fromEntries(
|
|
100
|
+
paths.map((path) => [path, fileFingerprint(root, path)]),
|
|
101
|
+
),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function workspaceDiffHash(projectRoot: string): string {
|
|
106
|
+
const snapshot = captureGitWorkspaceSnapshot(projectRoot);
|
|
107
|
+
if (!snapshot) throw new Error("Cannot derive workspace diff without a Git workspace.");
|
|
108
|
+
return `sha256:${createHash("sha256")
|
|
109
|
+
.update(JSON.stringify(snapshot))
|
|
110
|
+
.digest("hex")}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface GitTaskIndexEntry {
|
|
114
|
+
status: "added" | "modified" | "deleted";
|
|
115
|
+
mode: "100644" | "100755" | "120000" | null;
|
|
116
|
+
oid: string | null;
|
|
117
|
+
base_mode: "100644" | "100755" | "120000" | null;
|
|
118
|
+
base_oid: string | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface GitTaskSnapshot {
|
|
122
|
+
kind: "git-task-index-v1";
|
|
123
|
+
repository_root: string;
|
|
124
|
+
head: string;
|
|
125
|
+
scope: string[];
|
|
126
|
+
staged_files: Record<string, GitTaskIndexEntry>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const GIT_OBJECT_ID = /^[a-f0-9]{40,64}$/;
|
|
130
|
+
const TASK_GIT_MODES = new Set(["100644", "100755", "120000"] as const);
|
|
131
|
+
const fatalUtf8 = new TextDecoder("utf-8", { fatal: true });
|
|
132
|
+
const portablePathCollator = new Intl.Collator("und", {
|
|
133
|
+
usage: "search",
|
|
134
|
+
sensitivity: "base",
|
|
135
|
+
numeric: false,
|
|
136
|
+
ignorePunctuation: false,
|
|
137
|
+
});
|
|
138
|
+
let gitTaskSnapshotTestHook: (() => void) | undefined;
|
|
139
|
+
|
|
140
|
+
export function setGitTaskSnapshotTestHook(hook: () => void): () => void {
|
|
141
|
+
const previous = gitTaskSnapshotTestHook;
|
|
142
|
+
gitTaskSnapshotTestHook = hook;
|
|
143
|
+
return () => {
|
|
144
|
+
if (gitTaskSnapshotTestHook === hook) gitTaskSnapshotTestHook = previous;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function gitBytes(root: string, args: string[]): Buffer {
|
|
149
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
150
|
+
encoding: null,
|
|
151
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
152
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
153
|
+
});
|
|
154
|
+
if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) {
|
|
155
|
+
throw new Error(`Git task snapshot command failed: git ${args.join(" ")}`);
|
|
156
|
+
}
|
|
157
|
+
return result.stdout;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function decodeCanonicalGitPath(bytes: Buffer, label: string): string {
|
|
161
|
+
let value: string;
|
|
162
|
+
try {
|
|
163
|
+
value = fatalUtf8.decode(bytes);
|
|
164
|
+
} catch {
|
|
165
|
+
throw new Error(`${label} contains invalid UTF-8 path bytes`);
|
|
166
|
+
}
|
|
167
|
+
if (!Buffer.from(value, "utf8").equals(bytes))
|
|
168
|
+
throw new Error(`${label} path does not round-trip through UTF-8`);
|
|
169
|
+
if (value.normalize("NFC") !== value)
|
|
170
|
+
throw new Error(`${label} path is not NFC-normalized: ${value}`);
|
|
171
|
+
if (
|
|
172
|
+
!value ||
|
|
173
|
+
value.includes("\0") ||
|
|
174
|
+
value.includes("\\") ||
|
|
175
|
+
value.startsWith("/") ||
|
|
176
|
+
/^[A-Za-z]:\//.test(value) ||
|
|
177
|
+
value.split("/").some((part) => !part || part === "." || part === "..")
|
|
178
|
+
)
|
|
179
|
+
throw new Error(`${label} is not a canonical project-relative path: ${value}`);
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function decodeNullPaths(bytes: Buffer, label: string): string[] {
|
|
184
|
+
if (bytes.length === 0) return [];
|
|
185
|
+
if (bytes[bytes.length - 1] !== 0)
|
|
186
|
+
throw new Error(`${label} is not NUL-terminated`);
|
|
187
|
+
const paths: string[] = [];
|
|
188
|
+
let start = 0;
|
|
189
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
190
|
+
if (bytes[index] !== 0) continue;
|
|
191
|
+
if (index === start) throw new Error(`${label} contains an empty path`);
|
|
192
|
+
paths.push(decodeCanonicalGitPath(bytes.subarray(start, index), label));
|
|
193
|
+
start = index + 1;
|
|
194
|
+
}
|
|
195
|
+
return paths;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function assertNoCaseFoldCollisions(paths: string[], label: string): void {
|
|
199
|
+
const prefixes: string[] = [];
|
|
200
|
+
for (const path of paths) {
|
|
201
|
+
let prefix = "";
|
|
202
|
+
for (const component of path.split("/")) {
|
|
203
|
+
prefix = prefix ? `${prefix}/${component}` : component;
|
|
204
|
+
const prior = prefixes.find(
|
|
205
|
+
(candidate) => candidate !== prefix && portablePathCollator.compare(candidate, prefix) === 0,
|
|
206
|
+
);
|
|
207
|
+
if (prior !== undefined)
|
|
208
|
+
throw new Error(`${label} contains a case-fold path collision: ${prior} and ${prefix}`);
|
|
209
|
+
if (!prefixes.includes(prefix)) prefixes.push(prefix);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function assertCanonicalTaskScope(scope: unknown): string[] {
|
|
215
|
+
if (!Array.isArray(scope) || scope.length === 0)
|
|
216
|
+
throw new Error("task scope must contain at least one canonical path");
|
|
217
|
+
const paths = scope.map((entry, index) => {
|
|
218
|
+
if (typeof entry !== "string")
|
|
219
|
+
throw new Error(`task scope entry ${index} must be a string`);
|
|
220
|
+
return decodeCanonicalGitPath(Buffer.from(entry, "utf8"), `task scope entry ${index}`);
|
|
221
|
+
});
|
|
222
|
+
assertNoCaseFoldCollisions(paths, "task scope");
|
|
223
|
+
const canonical = [...new Set(paths)].sort(comparePaths).filter(
|
|
224
|
+
(path, _index, all) =>
|
|
225
|
+
!all.some(
|
|
226
|
+
(candidate) =>
|
|
227
|
+
candidate !== path &&
|
|
228
|
+
!candidate.includes("*") &&
|
|
229
|
+
!candidate.includes("?") &&
|
|
230
|
+
path.startsWith(`${candidate}/`),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
if (JSON.stringify(canonical) !== JSON.stringify(paths))
|
|
234
|
+
throw new Error("task scope must already be canonical, sorted, and non-overlapping");
|
|
235
|
+
return paths;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
type GitTreeEntry = {
|
|
239
|
+
mode: GitTaskIndexEntry["mode"];
|
|
240
|
+
oid: string;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
function parseGitTreeEntry(
|
|
244
|
+
bytes: Buffer,
|
|
245
|
+
path: string,
|
|
246
|
+
kind: "index" | "HEAD",
|
|
247
|
+
): GitTreeEntry | null {
|
|
248
|
+
if (bytes.length === 0) return null;
|
|
249
|
+
if (bytes[bytes.length - 1] !== 0)
|
|
250
|
+
throw new Error(`${kind} entry for ${path} is not NUL-terminated`);
|
|
251
|
+
const record = bytes.subarray(0, bytes.length - 1);
|
|
252
|
+
if (record.includes(0)) throw new Error(`${kind} returned multiple entries for ${path}`);
|
|
253
|
+
const tab = record.indexOf(9);
|
|
254
|
+
if (tab < 0) throw new Error(`${kind} entry for ${path} is malformed`);
|
|
255
|
+
const metadata = record.subarray(0, tab).toString("ascii").split(" ");
|
|
256
|
+
const returnedPath = decodeCanonicalGitPath(record.subarray(tab + 1), `${kind} entry`);
|
|
257
|
+
if (returnedPath !== path) throw new Error(`${kind} returned an unexpected path: ${returnedPath}`);
|
|
258
|
+
const mode = metadata[0];
|
|
259
|
+
const oid = kind === "index" ? metadata[1] : metadata[2];
|
|
260
|
+
const stage = kind === "index" ? metadata[2] : undefined;
|
|
261
|
+
if (!TASK_GIT_MODES.has(mode as "100644" | "100755" | "120000"))
|
|
262
|
+
throw new Error(`${kind} entry for ${path} has unsupported mode ${mode}`);
|
|
263
|
+
if (!oid || !GIT_OBJECT_ID.test(oid) || /^0+$/.test(oid))
|
|
264
|
+
throw new Error(`${kind} entry for ${path} has invalid object identity`);
|
|
265
|
+
if (stage !== undefined && stage !== "0")
|
|
266
|
+
throw new Error(`index entry for ${path} is not at stage zero`);
|
|
267
|
+
return { mode: mode as GitTaskIndexEntry["mode"], oid };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function indexEntry(root: string, path: string): GitTreeEntry | null {
|
|
271
|
+
return parseGitTreeEntry(
|
|
272
|
+
gitBytes(root, ["ls-files", "--stage", "-z", "--", path]),
|
|
273
|
+
path,
|
|
274
|
+
"index",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function headEntry(root: string, head: string, path: string): GitTreeEntry | null {
|
|
279
|
+
return parseGitTreeEntry(
|
|
280
|
+
gitBytes(root, ["ls-tree", "-z", head, "--", path]),
|
|
281
|
+
path,
|
|
282
|
+
"HEAD",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function taskPathMatchesScope(path: string, scope: string[]): boolean {
|
|
287
|
+
return scope.some((scopePath) => pathMatchesScope(path, scopePath));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function taskSnapshotOnce(root: string, scope: string[]): GitTaskSnapshot {
|
|
291
|
+
const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim();
|
|
292
|
+
const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim();
|
|
293
|
+
if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head))
|
|
294
|
+
throw new Error("cannot derive task snapshot outside a committed Git workspace");
|
|
295
|
+
if (realpathSync(resolve(repositoryRoot)) !== root)
|
|
296
|
+
throw new Error("task snapshot repository root does not match the project root");
|
|
297
|
+
const sparseCheckout = git(root, ["config", "--bool", "core.sparseCheckout"])?.trim();
|
|
298
|
+
const sparseIndex = git(root, ["config", "--bool", "index.sparse"])?.trim();
|
|
299
|
+
if (sparseCheckout === "true" || sparseIndex === "true")
|
|
300
|
+
throw new Error("task snapshot does not support sparse checkout or sparse index");
|
|
301
|
+
if (gitBytes(root, ["ls-files", "--unmerged", "-z"]).length > 0)
|
|
302
|
+
throw new Error("task snapshot does not support unmerged index entries");
|
|
303
|
+
|
|
304
|
+
const stagedPaths = decodeNullPaths(
|
|
305
|
+
gitBytes(root, ["diff", "--cached", "--no-renames", "--name-only", "-z", head, "--"]),
|
|
306
|
+
"staged task paths",
|
|
307
|
+
);
|
|
308
|
+
const unstagedPaths = decodeNullPaths(
|
|
309
|
+
gitBytes(root, ["diff", "--no-renames", "--name-only", "-z", "--"]),
|
|
310
|
+
"unstaged task paths",
|
|
311
|
+
);
|
|
312
|
+
const untrackedPaths = decodeNullPaths(
|
|
313
|
+
gitBytes(root, ["ls-files", "--others", "--exclude-standard", "-z", "--"]),
|
|
314
|
+
"untracked task paths",
|
|
315
|
+
);
|
|
316
|
+
assertNoCaseFoldCollisions([...stagedPaths, ...unstagedPaths, ...untrackedPaths], "Git task paths");
|
|
317
|
+
const uncommittedInScope = [...new Set([...unstagedPaths, ...untrackedPaths])]
|
|
318
|
+
.filter((path) => taskPathMatchesScope(path, scope))
|
|
319
|
+
.sort(comparePaths);
|
|
320
|
+
if (uncommittedInScope.length > 0)
|
|
321
|
+
throw new Error(`task scope contains unstaged or untracked changes: ${uncommittedInScope.join(", ")}`);
|
|
322
|
+
|
|
323
|
+
const taskPaths = [...new Set(stagedPaths)]
|
|
324
|
+
.filter((path) => taskPathMatchesScope(path, scope))
|
|
325
|
+
.sort(comparePaths);
|
|
326
|
+
const stagedFiles: Record<string, GitTaskIndexEntry> = {};
|
|
327
|
+
for (const path of taskPaths) {
|
|
328
|
+
const current = indexEntry(root, path);
|
|
329
|
+
const base = headEntry(root, head, path);
|
|
330
|
+
if (!current && !base) throw new Error(`task path has no index or HEAD identity: ${path}`);
|
|
331
|
+
stagedFiles[path] = {
|
|
332
|
+
status: !base ? "added" : !current ? "deleted" : "modified",
|
|
333
|
+
mode: current?.mode ?? null,
|
|
334
|
+
oid: current?.oid ?? null,
|
|
335
|
+
base_mode: base?.mode ?? null,
|
|
336
|
+
base_oid: base?.oid ?? null,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
kind: "git-task-index-v1",
|
|
341
|
+
repository_root: root,
|
|
342
|
+
head,
|
|
343
|
+
scope,
|
|
344
|
+
staged_files: stagedFiles,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function captureGitTaskSnapshot(
|
|
349
|
+
projectRoot: string,
|
|
350
|
+
scopeHint: unknown,
|
|
351
|
+
): GitTaskSnapshot {
|
|
352
|
+
const requestedRoot = resolve(projectRoot);
|
|
353
|
+
const requestedStat = lstatSync(requestedRoot);
|
|
354
|
+
if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory())
|
|
355
|
+
throw new Error("task snapshot root must be a real directory");
|
|
356
|
+
const root = realpathSync(requestedRoot);
|
|
357
|
+
const scope = assertCanonicalTaskScope(scopeHint);
|
|
358
|
+
const before = taskSnapshotOnce(root, scope);
|
|
359
|
+
gitTaskSnapshotTestHook?.();
|
|
360
|
+
const after = taskSnapshotOnce(root, scope);
|
|
361
|
+
if (JSON.stringify(after) !== JSON.stringify(before))
|
|
362
|
+
throw new Error("Git task snapshot changed while being captured");
|
|
363
|
+
return before;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function taskDiffHash(projectRoot: string, scopeHint: unknown): string {
|
|
367
|
+
const snapshot = captureGitTaskSnapshot(projectRoot, scopeHint);
|
|
368
|
+
return `sha256:${createHash("sha256")
|
|
369
|
+
.update(JSON.stringify(snapshot))
|
|
370
|
+
.digest("hex")}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isGitWorkspaceSnapshot(value: unknown): value is GitWorkspaceSnapshot {
|
|
374
|
+
return (
|
|
375
|
+
typeof value === "object" &&
|
|
376
|
+
value !== null &&
|
|
377
|
+
!Array.isArray(value) &&
|
|
378
|
+
(value as Record<string, unknown>).kind === "git-workspace-v1" &&
|
|
379
|
+
typeof (value as Record<string, unknown>).repository_root === "string" &&
|
|
380
|
+
typeof (value as Record<string, unknown>).head === "string" &&
|
|
381
|
+
typeof (value as Record<string, unknown>).dirty_files === "object" &&
|
|
382
|
+
(value as Record<string, unknown>).dirty_files !== null
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function canonicalizeClaimedPath(value: string): string {
|
|
387
|
+
const normalized = value.trim().replace(/\\/g, "/");
|
|
388
|
+
if (
|
|
389
|
+
!normalized ||
|
|
390
|
+
normalized.includes("\0") ||
|
|
391
|
+
normalized.startsWith("/") ||
|
|
392
|
+
/^[A-Za-z]:\//.test(normalized)
|
|
393
|
+
) {
|
|
394
|
+
throw new Error(`Invalid replacement changed file claim: ${value}`);
|
|
395
|
+
}
|
|
396
|
+
const parts: string[] = [];
|
|
397
|
+
for (const part of normalized.split("/")) {
|
|
398
|
+
if (!part || part === ".") continue;
|
|
399
|
+
if (part === "..") {
|
|
400
|
+
throw new Error(`Invalid replacement changed file claim: ${value}`);
|
|
401
|
+
}
|
|
402
|
+
parts.push(part);
|
|
403
|
+
}
|
|
404
|
+
if (parts.length === 0) {
|
|
405
|
+
throw new Error(`Invalid replacement changed file claim: ${value}`);
|
|
406
|
+
}
|
|
407
|
+
return parts.join("/");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export type ReplacementChangedFiles = {
|
|
411
|
+
changedFiles: string[] | null;
|
|
412
|
+
carriedFiles: string[];
|
|
413
|
+
workspaceSnapshot: GitWorkspaceSnapshot;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
export function replacementChangedFilesSinceSnapshot(
|
|
417
|
+
projectRoot: string,
|
|
418
|
+
baseline: unknown,
|
|
419
|
+
claimedFiles: string[],
|
|
420
|
+
): ReplacementChangedFiles | null {
|
|
421
|
+
const baselineSnapshot = isGitWorkspaceSnapshot(baseline) ? baseline : null;
|
|
422
|
+
const current = captureGitWorkspaceSnapshot(projectRoot);
|
|
423
|
+
if (!current) {
|
|
424
|
+
if (!baselineSnapshot) return null;
|
|
425
|
+
throw new Error(
|
|
426
|
+
"Cannot verify execution scope because the Git workspace is unavailable.",
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (!baselineSnapshot || current.head !== baselineSnapshot.head) {
|
|
430
|
+
return {
|
|
431
|
+
changedFiles: null,
|
|
432
|
+
carriedFiles: [],
|
|
433
|
+
workspaceSnapshot: current,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (current.repository_root !== baselineSnapshot.repository_root) {
|
|
437
|
+
throw new Error("Git workspace root changed after Step activation.");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const currentDirty = new Set(Object.keys(current.dirty_files));
|
|
441
|
+
const claimed = new Set(claimedFiles.map(canonicalizeClaimedPath));
|
|
442
|
+
const carriedFiles = Object.keys(baselineSnapshot.dirty_files)
|
|
443
|
+
.filter((path) => !isRuntimeAuthorityPath(path))
|
|
444
|
+
.filter((path) => claimed.has(path))
|
|
445
|
+
.filter((path) => currentDirty.has(path))
|
|
446
|
+
.sort(comparePaths);
|
|
447
|
+
const changedFiles = [
|
|
448
|
+
...new Set([
|
|
449
|
+
...Object.keys(baselineSnapshot.dirty_files),
|
|
450
|
+
...Object.keys(current.dirty_files),
|
|
451
|
+
]),
|
|
452
|
+
]
|
|
453
|
+
.filter((path) => !isRuntimeAuthorityPath(path))
|
|
454
|
+
.filter(
|
|
455
|
+
(path) =>
|
|
456
|
+
baselineSnapshot.dirty_files[path] !== current.dirty_files[path],
|
|
457
|
+
)
|
|
458
|
+
.filter((path) => currentDirty.has(path));
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
changedFiles: [...new Set([...changedFiles, ...carriedFiles])].sort(
|
|
462
|
+
comparePaths,
|
|
463
|
+
),
|
|
464
|
+
carriedFiles,
|
|
465
|
+
workspaceSnapshot: current,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function changedFilesSinceSnapshot(
|
|
470
|
+
projectRoot: string,
|
|
471
|
+
baseline: unknown,
|
|
472
|
+
): string[] | null {
|
|
473
|
+
if (!isGitWorkspaceSnapshot(baseline)) return null;
|
|
474
|
+
const current = captureGitWorkspaceSnapshot(projectRoot);
|
|
475
|
+
if (!current) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
"Cannot verify execution scope because the Git workspace is unavailable.",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
if (current.repository_root !== baseline.repository_root) {
|
|
481
|
+
throw new Error("Git workspace root changed after Step activation.");
|
|
482
|
+
}
|
|
483
|
+
if (current.head !== baseline.head) {
|
|
484
|
+
// Commits after activation invalidate git-delta derivation (the baseline
|
|
485
|
+
// dirty set cannot be compared against a different tree). Rather than
|
|
486
|
+
// blocking execution recording entirely — which strands HITL steps that
|
|
487
|
+
// legitimately commit deploy fixes mid-step — fall back to the same
|
|
488
|
+
// self-reported provenance path used when no baseline exists. The
|
|
489
|
+
// claimed files still pass the Step Scope check and independent QA.
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
return [
|
|
493
|
+
...new Set([
|
|
494
|
+
...Object.keys(baseline.dirty_files),
|
|
495
|
+
...Object.keys(current.dirty_files),
|
|
496
|
+
]),
|
|
497
|
+
]
|
|
498
|
+
.filter((path) => !isRuntimeAuthorityPath(path))
|
|
499
|
+
.filter((path) => baseline.dirty_files[path] !== current.dirty_files[path])
|
|
500
|
+
.sort(comparePaths);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function normalizeBoundaryPath(value: string): string {
|
|
504
|
+
return value
|
|
505
|
+
.trim()
|
|
506
|
+
.replace(/^\.\//, "")
|
|
507
|
+
.replace(/\\/g, "/")
|
|
508
|
+
.replace(/\/+$/, "");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function globMatches(path: string, pattern: string): boolean {
|
|
512
|
+
const memo = new Map<string, boolean>();
|
|
513
|
+
const match = (pathIndex: number, patternIndex: number): boolean => {
|
|
514
|
+
const key = `${pathIndex}:${patternIndex}`;
|
|
515
|
+
const cached = memo.get(key);
|
|
516
|
+
if (cached !== undefined) return cached;
|
|
517
|
+
let result: boolean;
|
|
518
|
+
if (patternIndex === pattern.length) result = pathIndex === path.length;
|
|
519
|
+
else if (pattern[patternIndex] === "*") {
|
|
520
|
+
const recursive = pattern[patternIndex + 1] === "*";
|
|
521
|
+
const nextPatternIndex = patternIndex + (recursive ? 2 : 1);
|
|
522
|
+
result = match(pathIndex, nextPatternIndex);
|
|
523
|
+
if (!result && pathIndex < path.length) {
|
|
524
|
+
result =
|
|
525
|
+
(recursive || path[pathIndex] !== "/") &&
|
|
526
|
+
match(pathIndex + 1, patternIndex);
|
|
527
|
+
}
|
|
528
|
+
} else if (pathIndex === path.length) result = false;
|
|
529
|
+
else if (pattern[patternIndex] === "?") {
|
|
530
|
+
result =
|
|
531
|
+
path[pathIndex] !== "/" && match(pathIndex + 1, patternIndex + 1);
|
|
532
|
+
} else {
|
|
533
|
+
result =
|
|
534
|
+
path[pathIndex] === pattern[patternIndex] &&
|
|
535
|
+
match(pathIndex + 1, patternIndex + 1);
|
|
536
|
+
}
|
|
537
|
+
memo.set(key, result);
|
|
538
|
+
return result;
|
|
539
|
+
};
|
|
540
|
+
return match(0, 0);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function pathMatchesScope(path: string, scopePath: string): boolean {
|
|
544
|
+
const normalizedPath = normalizeBoundaryPath(path);
|
|
545
|
+
const normalizedScope = normalizeBoundaryPath(scopePath);
|
|
546
|
+
if (!normalizedPath || !normalizedScope) return false;
|
|
547
|
+
if (normalizedScope.includes("*") || normalizedScope.includes("?")) {
|
|
548
|
+
return globMatches(normalizedPath, normalizedScope);
|
|
549
|
+
}
|
|
550
|
+
return (
|
|
551
|
+
normalizedPath === normalizedScope ||
|
|
552
|
+
normalizedPath.startsWith(`${normalizedScope}/`)
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const TEST_FILE_REFERENCE = /[\w./-]*\.(?:test|spec)\.[cm]?[jt]sx?\b/g;
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* `bun test` treats its arguments as filters rather than paths, so a command
|
|
560
|
+
* naming three files where one was renamed away runs the other two and still
|
|
561
|
+
* exits 0. Left unchecked the evidence records a pass whose coverage silently
|
|
562
|
+
* shrank. Verification runs after the work is done, so a cited test file is
|
|
563
|
+
* expected to exist by the time evidence is recorded.
|
|
564
|
+
*
|
|
565
|
+
* Only passing checks are held to this. A failure must stay recordable — that
|
|
566
|
+
* is how a missing test file gets reported in the first place, since a command
|
|
567
|
+
* naming nothing else exits non-zero — and it cannot manufacture false
|
|
568
|
+
* confidence because QA refuses to pass on failed evidence.
|
|
569
|
+
*/
|
|
570
|
+
export function assertCheckCommandsCiteExistingTests(
|
|
571
|
+
checks: unknown,
|
|
572
|
+
root: string,
|
|
573
|
+
): void {
|
|
574
|
+
if (!Array.isArray(checks)) return;
|
|
575
|
+
const missing = new Set<string>();
|
|
576
|
+
for (const check of checks) {
|
|
577
|
+
if (typeof check !== "object" || check === null || Array.isArray(check))
|
|
578
|
+
continue;
|
|
579
|
+
const entry = check as Record<string, unknown>;
|
|
580
|
+
if (entry.kind === "manual" || entry.status !== "passed") continue;
|
|
581
|
+
const command = entry.command;
|
|
582
|
+
if (typeof command !== "string") continue;
|
|
583
|
+
// A command that moves elsewhere first resolves its paths against that
|
|
584
|
+
// directory, not the project root, so the check cannot be trusted.
|
|
585
|
+
if (/(^|\s|&|;)cd\s/.test(command)) continue;
|
|
586
|
+
for (const [candidate] of command.matchAll(TEST_FILE_REFERENCE)) {
|
|
587
|
+
// A glob is resolved by the shell or the runner, which reports its own
|
|
588
|
+
// miss; only a literal path can be confirmed here.
|
|
589
|
+
if (!candidate.includes("/") || /[*?[\]]/.test(candidate)) continue;
|
|
590
|
+
if (!existsSync(resolve(root, candidate))) missing.add(candidate);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (missing.size > 0) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`Evidence cites test files that do not exist: ${[...missing].join(", ")}. A renamed or deleted test leaves the command passing on a smaller set than it names.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function assertChangedFilesWithinScope(
|
|
601
|
+
changedFiles: string[],
|
|
602
|
+
scope: unknown,
|
|
603
|
+
boundaryName: string,
|
|
604
|
+
): void {
|
|
605
|
+
if (!Array.isArray(scope) || scope.length === 0) return;
|
|
606
|
+
const scopePaths = scope.filter(
|
|
607
|
+
(value): value is string =>
|
|
608
|
+
typeof value === "string" && value.trim().length > 0,
|
|
609
|
+
);
|
|
610
|
+
if (scopePaths.length !== scope.length) {
|
|
611
|
+
throw new Error(`${boundaryName} contains an invalid path.`);
|
|
612
|
+
}
|
|
613
|
+
if (scopePaths.length === 0) return;
|
|
614
|
+
const outside = changedFiles.filter(
|
|
615
|
+
(path) =>
|
|
616
|
+
!scopePaths.some((scopePath) => pathMatchesScope(path, scopePath)),
|
|
617
|
+
);
|
|
618
|
+
if (outside.length > 0) {
|
|
619
|
+
throw new Error(
|
|
620
|
+
`Changed files outside the ${boundaryName}: ${outside.join(", ")}. Allowed: ${scopePaths.join(", ")}.`,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
BASELINE.md
|