@tryinget/pi-agent-registry 0.3.0

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.
@@ -0,0 +1,323 @@
1
+ // ---
2
+ // summary: captures bounded immutable Git objects plus separate worktree-currentness observations for fleet lint.
3
+ // read_when:
4
+ // - changing immutable fleet revisions, committed file capture, dirty-state handling, or Git race detection.
5
+ // ---
6
+
7
+ import { execFile } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
+ import { realpath } from "node:fs/promises";
10
+ import { isAbsolute, join, relative, resolve } from "node:path";
11
+ import { promisify } from "node:util";
12
+
13
+ const execFileAsync = promisify(execFile);
14
+ const GIT_TIMEOUT_MS = 15_000;
15
+ const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024;
16
+ const FULL_GIT_OID = /^[0-9a-f]{40,64}$/u;
17
+
18
+ export interface CapturedGitFile {
19
+ path: string;
20
+ mode: string;
21
+ blobOid: string;
22
+ bytes: Buffer;
23
+ sha256: string;
24
+ }
25
+
26
+ export interface FleetGitSnapshot {
27
+ root: string;
28
+ commit: string;
29
+ treeOid: string;
30
+ status: "clean_observed" | "dirty";
31
+ statusSha256: string;
32
+ latestActivityAt?: string;
33
+ readFile(path: string, maxBytes?: number): Promise<CapturedGitFile | undefined>;
34
+ finish(): Promise<{ stable: boolean; finalCommit: string; finalStatusSha256: string }>;
35
+ }
36
+
37
+ export class FleetGitSnapshotError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "FleetGitSnapshotError";
41
+ }
42
+ }
43
+
44
+ async function runGit(
45
+ root: string,
46
+ args: string[],
47
+ encoding?: BufferEncoding,
48
+ ): Promise<string | Buffer> {
49
+ try {
50
+ const result = await execFileAsync(
51
+ "git",
52
+ [
53
+ "--no-pager",
54
+ "-c",
55
+ "core.fsmonitor=false",
56
+ "-c",
57
+ "core.hooksPath=/dev/null",
58
+ "-c",
59
+ "core.untrackedCache=false",
60
+ "-c",
61
+ "diff.external=",
62
+ "-C",
63
+ root,
64
+ ...args,
65
+ ],
66
+ {
67
+ encoding: encoding ?? "buffer",
68
+ timeout: GIT_TIMEOUT_MS,
69
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
70
+ windowsHide: true,
71
+ env: {
72
+ PATH: process.env.PATH,
73
+ LC_ALL: "C",
74
+ GIT_CONFIG_NOSYSTEM: "1",
75
+ GIT_CONFIG_GLOBAL: "/dev/null",
76
+ GIT_OPTIONAL_LOCKS: "0",
77
+ GIT_NO_REPLACE_OBJECTS: "1",
78
+ GIT_PAGER: "cat",
79
+ GIT_TERMINAL_PROMPT: "0",
80
+ },
81
+ },
82
+ );
83
+ return result.stdout;
84
+ } catch {
85
+ const operation =
86
+ args[0] === "rev-parse"
87
+ ? "revision query"
88
+ : args[0] === "status"
89
+ ? "worktree status query"
90
+ : args[0] === "log"
91
+ ? "activity query"
92
+ : args[0] === "ls-tree"
93
+ ? "committed tree query"
94
+ : args[0] === "cat-file"
95
+ ? "committed object query"
96
+ : "immutable repository query";
97
+ throw new FleetGitSnapshotError(`Git ${operation} failed`);
98
+ }
99
+ }
100
+
101
+ function oneLine(value: string | Buffer, label: string): string {
102
+ const text = Buffer.isBuffer(value) ? value.toString("utf8") : value;
103
+ if (!text.endsWith("\n") || text.endsWith("\n\n")) {
104
+ throw new FleetGitSnapshotError(`${label} returned malformed line output`);
105
+ }
106
+ const line = text.slice(0, -1);
107
+ if (!line || /[\0\r\n]/u.test(line)) {
108
+ throw new FleetGitSnapshotError(`${label} returned an invalid value`);
109
+ }
110
+ return line;
111
+ }
112
+
113
+ function sha256(value: string | Buffer): string {
114
+ return createHash("sha256").update(value).digest("hex");
115
+ }
116
+
117
+ function safeRelativePath(root: string, path: string): string {
118
+ if (!path || isAbsolute(path) || /[\0\r\n]/u.test(path)) {
119
+ throw new FleetGitSnapshotError("committed path is not one safe repository-relative path");
120
+ }
121
+ const normalized = relative(resolve(root), resolve(root, path)).split("\\").join("/");
122
+ if (!normalized || normalized.startsWith("../") || normalized === "..") {
123
+ throw new FleetGitSnapshotError("committed path escapes its repository snapshot");
124
+ }
125
+ return normalized;
126
+ }
127
+
128
+ async function captureHead(root: string): Promise<string> {
129
+ const head = oneLine(
130
+ await runGit(root, ["rev-parse", "--verify", "HEAD^{commit}"], "utf8"),
131
+ "HEAD",
132
+ );
133
+ if (!FULL_GIT_OID.test(head)) throw new FleetGitSnapshotError("HEAD is not a full Git object id");
134
+ return head;
135
+ }
136
+
137
+ async function captureStatus(root: string): Promise<Buffer> {
138
+ const output = await runGit(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
139
+ return Buffer.isBuffer(output) ? output : Buffer.from(output, "utf8");
140
+ }
141
+
142
+ export async function verifyFleetGitRevision(
143
+ path: string,
144
+ revision: string,
145
+ options: { requiredFiles?: string[] } = {},
146
+ ): Promise<{ repoRoot: string; commit: string; treeOid: string; sourceRelative: string }> {
147
+ if (!FULL_GIT_OID.test(revision)) {
148
+ throw new FleetGitSnapshotError("template revision is not one full Git object id");
149
+ }
150
+ const canonical = await realpath(path).catch(() => undefined);
151
+ if (!canonical) throw new FleetGitSnapshotError("template source cannot be resolved");
152
+ const topRaw = oneLine(
153
+ await runGit(canonical, ["rev-parse", "--show-toplevel"], "utf8"),
154
+ "template Git top-level",
155
+ );
156
+ const repoRoot = await realpath(topRaw).catch(() => undefined);
157
+ if (!repoRoot) throw new FleetGitSnapshotError("template Git root cannot be resolved");
158
+ const sourceRelative = relative(repoRoot, canonical).split("\\").join("/");
159
+ if (!sourceRelative || sourceRelative.startsWith("../") || isAbsolute(sourceRelative)) {
160
+ throw new FleetGitSnapshotError("template source must be a subdirectory of its Git repository");
161
+ }
162
+ const commit = oneLine(
163
+ await runGit(repoRoot, ["rev-parse", "--verify", `${revision}^{commit}`], "utf8"),
164
+ "template revision",
165
+ );
166
+ if (commit !== revision)
167
+ throw new FleetGitSnapshotError("template revision did not resolve exactly");
168
+ const treeOid = oneLine(
169
+ await runGit(repoRoot, ["rev-parse", `${commit}^{tree}`], "utf8"),
170
+ "template tree",
171
+ );
172
+ if (!FULL_GIT_OID.test(treeOid)) throw new FleetGitSnapshotError("template tree is invalid");
173
+ for (const required of options.requiredFiles ?? []) {
174
+ const pathAtRevision = join(sourceRelative, safeRelativePath(canonical, required))
175
+ .split("\\")
176
+ .join("/");
177
+ const raw = await runGit(repoRoot, ["ls-tree", "-z", commit, "--", pathAtRevision]);
178
+ const listing = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, "utf8");
179
+ const text = listing.subarray(0, -1).toString("utf8");
180
+ const tab = text.indexOf("\t");
181
+ const metadata = tab >= 0 ? text.slice(0, tab).split(" ") : [];
182
+ if (
183
+ listing.length === 0 ||
184
+ listing[listing.length - 1] !== 0 ||
185
+ text.slice(tab + 1) !== pathAtRevision ||
186
+ (metadata[0] !== "100644" && metadata[0] !== "100755") ||
187
+ metadata[1] !== "blob" ||
188
+ !FULL_GIT_OID.test(metadata[2] ?? "")
189
+ ) {
190
+ throw new FleetGitSnapshotError(`template revision lacks required file: ${required}`);
191
+ }
192
+ }
193
+ return { repoRoot, commit, treeOid, sourceRelative };
194
+ }
195
+
196
+ /** Resolve one exact Git repository root from a working directory (read-only). */
197
+ export async function resolveGitRepoRoot(cwd: string): Promise<string> {
198
+ const { execFile } = await import("node:child_process");
199
+ const { promisify } = await import("node:util");
200
+ const stdout = await promisify(execFile)(
201
+ "git",
202
+ ["--no-pager", "-C", cwd, "rev-parse", "--show-toplevel"],
203
+ { timeout: 15_000, windowsHide: true, encoding: "utf8" },
204
+ );
205
+ const root = stdout.stdout.trim();
206
+ if (!root) throw new FleetGitSnapshotError("empty repository root");
207
+ return root;
208
+ }
209
+
210
+ export async function captureFleetGitSnapshot(repoRoot: string): Promise<FleetGitSnapshot> {
211
+ const root = await realpath(repoRoot).catch(() => undefined);
212
+ if (!root) throw new FleetGitSnapshotError("repository root cannot be resolved");
213
+ const topRaw = oneLine(
214
+ await runGit(root, ["rev-parse", "--show-toplevel"], "utf8"),
215
+ "Git top-level",
216
+ );
217
+ const top = await realpath(topRaw).catch(() => undefined);
218
+ if (top !== root) {
219
+ throw new FleetGitSnapshotError("candidate is not one exact Git repository root");
220
+ }
221
+
222
+ const commit = await captureHead(root);
223
+ const treeOid = oneLine(
224
+ await runGit(root, ["rev-parse", `${commit}^{tree}`], "utf8"),
225
+ "Git tree",
226
+ );
227
+ if (!FULL_GIT_OID.test(treeOid))
228
+ throw new FleetGitSnapshotError("tree is not a full Git object id");
229
+ const statusBytes = await captureStatus(root);
230
+ const statusSha256 = sha256(statusBytes);
231
+ const activityRaw = await runGit(
232
+ root,
233
+ ["log", "-1", "--format=%cI", commit, "--", "diary", "docs/learnings"],
234
+ "utf8",
235
+ );
236
+ const latestActivityAt = String(activityRaw).trim() || undefined;
237
+ const fileCache = new Map<string, CapturedGitFile | undefined>();
238
+
239
+ return {
240
+ root,
241
+ commit,
242
+ treeOid,
243
+ status: statusBytes.length === 0 ? "clean_observed" : "dirty",
244
+ statusSha256,
245
+ ...(latestActivityAt ? { latestActivityAt } : {}),
246
+ async readFile(path: string, maxBytes = 1024 * 1024) {
247
+ const relativePath = safeRelativePath(root, path);
248
+ if (fileCache.has(relativePath)) {
249
+ const cached = fileCache.get(relativePath);
250
+ if (cached && cached.bytes.length > maxBytes) {
251
+ throw new FleetGitSnapshotError(
252
+ `committed blob exceeds ${maxBytes} bytes: ${relativePath}`,
253
+ );
254
+ }
255
+ return cached;
256
+ }
257
+ const listingRaw = await runGit(root, ["ls-tree", "-z", commit, "--", relativePath]);
258
+ const listing = Buffer.isBuffer(listingRaw) ? listingRaw : Buffer.from(listingRaw, "utf8");
259
+ if (listing.length === 0) {
260
+ fileCache.set(relativePath, undefined);
261
+ return undefined;
262
+ }
263
+ const records = listing
264
+ .subarray(0, listing.length - 1)
265
+ .toString("utf8")
266
+ .split("\0");
267
+ if (!listing.subarray(-1).equals(Buffer.from([0])) || records.length !== 1) {
268
+ throw new FleetGitSnapshotError(`committed path is ambiguous: ${relativePath}`);
269
+ }
270
+ const separator = records[0]?.indexOf("\t") ?? -1;
271
+ const metadata = separator >= 0 ? records[0]?.slice(0, separator).split(" ") : [];
272
+ const listedPath = separator >= 0 ? records[0]?.slice(separator + 1) : undefined;
273
+ const [mode, type, blobOid] = metadata;
274
+ if (
275
+ listedPath !== relativePath ||
276
+ type !== "blob" ||
277
+ !mode ||
278
+ !blobOid ||
279
+ !FULL_GIT_OID.test(blobOid)
280
+ ) {
281
+ throw new FleetGitSnapshotError(`committed path is not one exact blob: ${relativePath}`);
282
+ }
283
+ if (mode !== "100644" && mode !== "100755") {
284
+ fileCache.set(relativePath, undefined);
285
+ return undefined;
286
+ }
287
+ const size = Number.parseInt(
288
+ oneLine(
289
+ await runGit(root, ["cat-file", "-s", blobOid], "utf8"),
290
+ `blob size ${relativePath}`,
291
+ ),
292
+ 10,
293
+ );
294
+ if (!Number.isSafeInteger(size) || size < 0 || size > maxBytes) {
295
+ throw new FleetGitSnapshotError(
296
+ `committed blob exceeds ${maxBytes} bytes: ${relativePath}`,
297
+ );
298
+ }
299
+ const raw = await runGit(root, ["cat-file", "blob", blobOid]);
300
+ const bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw, "utf8");
301
+ if (bytes.length !== size) {
302
+ throw new FleetGitSnapshotError(
303
+ `committed blob size changed during capture: ${relativePath}`,
304
+ );
305
+ }
306
+ const captured = { path: relativePath, mode, blobOid, bytes, sha256: sha256(bytes) };
307
+ fileCache.set(relativePath, captured);
308
+ return captured;
309
+ },
310
+ async finish() {
311
+ const [finalCommit, finalStatus] = await Promise.all([
312
+ captureHead(root),
313
+ captureStatus(root),
314
+ ]);
315
+ const finalStatusSha256 = sha256(finalStatus);
316
+ return {
317
+ stable: finalCommit === commit && finalStatusSha256 === statusSha256,
318
+ finalCommit,
319
+ finalStatusSha256,
320
+ };
321
+ },
322
+ };
323
+ }
@@ -0,0 +1,356 @@
1
+ // ---
2
+ // summary: bounded template provenance and exact-role collision helpers for fleet lint.
3
+ // read_when:
4
+ // - changing Copier lineage diagnostics, template ownership recognition, or role normalization.
5
+ // ---
6
+
7
+ import { basename, isAbsolute, resolve } from "node:path";
8
+ import { type FleetGitSnapshot, verifyFleetGitRevision } from "./fleet-git-snapshot.ts";
9
+ import type { FleetLintDiagnostic, FleetLintRepositoryResult } from "./fleet-lint-types.ts";
10
+
11
+ const FULL_REVISION = /^[0-9a-f]{40,64}$/u;
12
+ const ANSWERS_PATH = ".copier-answers.yml";
13
+ const OWNERSHIP_PATH = "contracts/template-ownership.yml";
14
+
15
+ function decodeOwnerUtf8(bytes: Buffer, label: string): string {
16
+ try {
17
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
18
+ } catch {
19
+ throw new Error(`${label} is not strict UTF-8`);
20
+ }
21
+ }
22
+
23
+ function isPythonWhitespace(code: number): boolean {
24
+ return (
25
+ (code >= 0x0009 && code <= 0x000d) ||
26
+ (code >= 0x001c && code <= 0x001f) ||
27
+ code === 0x0020 ||
28
+ code === 0x0085 ||
29
+ code === 0x00a0 ||
30
+ code === 0x1680 ||
31
+ (code >= 0x2000 && code <= 0x200a) ||
32
+ code === 0x2028 ||
33
+ code === 0x2029 ||
34
+ code === 0x202f ||
35
+ code === 0x205f ||
36
+ code === 0x3000
37
+ );
38
+ }
39
+
40
+ function pythonStrip(value: string): string {
41
+ let start = 0;
42
+ let end = value.length;
43
+ while (start < end && isPythonWhitespace(value.charCodeAt(start))) start += 1;
44
+ while (end > start && isPythonWhitespace(value.charCodeAt(end - 1))) end -= 1;
45
+ return value.slice(start, end);
46
+ }
47
+
48
+ function pythonSplitLines(value: string): string[] {
49
+ const lines: string[] = [];
50
+ let current = "";
51
+ for (let index = 0; index < value.length; index += 1) {
52
+ const code = value.charCodeAt(index);
53
+ const separator =
54
+ code === 10 ||
55
+ code === 11 ||
56
+ code === 12 ||
57
+ code === 13 ||
58
+ (code >= 28 && code <= 30) ||
59
+ code === 133 ||
60
+ code === 8232 ||
61
+ code === 8233;
62
+ if (!separator) {
63
+ current += value[index];
64
+ continue;
65
+ }
66
+ lines.push(current);
67
+ current = "";
68
+ if (code === 13 && value.charCodeAt(index + 1) === 10) index += 1;
69
+ }
70
+ if (current) lines.push(current);
71
+ return lines;
72
+ }
73
+
74
+ function parseCopierScalar(raw: string, label: string): string {
75
+ if (!raw || "!&*|>{[".includes(raw[0] ?? "")) {
76
+ throw new Error(`${label} uses unsupported YAML syntax`);
77
+ }
78
+ let value: unknown;
79
+ if (raw.startsWith('"')) {
80
+ try {
81
+ value = JSON.parse(raw);
82
+ } catch {
83
+ throw new Error(`${label} has invalid double-quoted syntax`);
84
+ }
85
+ } else if (raw.startsWith("'")) {
86
+ if (raw.length < 2 || !raw.endsWith("'")) {
87
+ throw new Error(`${label} has invalid single-quoted syntax`);
88
+ }
89
+ value = raw.slice(1, -1).replace(/''/gu, "'");
90
+ } else {
91
+ if (raw.includes(" #")) throw new Error(`${label} cannot contain an inline comment`);
92
+ value = raw;
93
+ }
94
+ if (typeof value !== "string" || value.length === 0) {
95
+ throw new Error(`${label} must be one non-empty scalar`);
96
+ }
97
+ return value;
98
+ }
99
+
100
+ function copierScalar(text: string, key: string, required = false): string | undefined {
101
+ const prefix = `${key}:`;
102
+ const values = pythonSplitLines(text)
103
+ .filter((line) => line.startsWith(prefix))
104
+ .map((line) => pythonStrip(line.slice(prefix.length)));
105
+ if (values.length === 0) {
106
+ if (required) throw new Error(`${key} must occur exactly once`);
107
+ return undefined;
108
+ }
109
+ if (values.length !== 1) throw new Error(`${key} must occur exactly once`);
110
+ return parseCopierScalar(values[0] ?? "", key);
111
+ }
112
+
113
+ export function parseFleetCopierSource(text: string): string {
114
+ const source = copierScalar(text, "_src_path", true);
115
+ if (source === undefined) throw new Error("_src_path must occur exactly once");
116
+ return source;
117
+ }
118
+
119
+ function ownershipPatternsOverlap(left: string, right: string): boolean {
120
+ const leftPrefix = left.endsWith("/**") ? left.slice(0, -3).replace(/\/+$/u, "") : undefined;
121
+ const rightPrefix = right.endsWith("/**") ? right.slice(0, -3).replace(/\/+$/u, "") : undefined;
122
+ if (leftPrefix === undefined && rightPrefix === undefined) return left === right;
123
+ if (leftPrefix !== undefined && rightPrefix !== undefined) {
124
+ return (
125
+ leftPrefix === rightPrefix ||
126
+ leftPrefix.startsWith(`${rightPrefix}/`) ||
127
+ rightPrefix.startsWith(`${leftPrefix}/`)
128
+ );
129
+ }
130
+ if (leftPrefix !== undefined) return right === leftPrefix || right.startsWith(`${leftPrefix}/`);
131
+ return left === rightPrefix || left.startsWith(`${rightPrefix}/`);
132
+ }
133
+
134
+ export function parseFleetTemplateOwnership(text: string): {
135
+ templateOwned: string[];
136
+ agentOwned: string[];
137
+ } {
138
+ let section: "template_owned" | "agent_owned" | undefined;
139
+ let schema = "";
140
+ const sections = { template_owned: [] as string[], agent_owned: [] as string[] };
141
+ for (const [index, raw] of pythonSplitLines(text).entries()) {
142
+ const line = pythonStrip(raw);
143
+ if (!line || line.startsWith("#")) continue;
144
+ if (raw === line && line.startsWith("schema:")) {
145
+ schema = pythonStrip(line.slice("schema:".length));
146
+ section = undefined;
147
+ continue;
148
+ }
149
+ if (raw === line && (line === "template_owned:" || line === "agent_owned:")) {
150
+ section = line.slice(0, -1) as typeof section;
151
+ continue;
152
+ }
153
+ if (section && raw.startsWith(" - ")) {
154
+ const pattern = pythonStrip(raw.slice(4));
155
+ if (!pattern || pattern.startsWith("/") || pattern.split("/").includes("..")) {
156
+ throw new Error(`invalid ownership pattern at line ${index + 1}`);
157
+ }
158
+ sections[section].push(pattern);
159
+ continue;
160
+ }
161
+ throw new Error(`unsupported ownership syntax at line ${index + 1}`);
162
+ }
163
+ if (schema !== "ai-society.template-ownership/1") {
164
+ throw new Error("unsupported template ownership schema");
165
+ }
166
+ if (sections.template_owned.length === 0 || sections.agent_owned.length === 0) {
167
+ throw new Error("ownership map requires non-empty template_owned and agent_owned lists");
168
+ }
169
+ for (const [name, patterns] of Object.entries(sections)) {
170
+ if (new Set(patterns).size !== patterns.length) {
171
+ throw new Error(`duplicate pattern in ${name}`);
172
+ }
173
+ }
174
+ for (const templatePattern of sections.template_owned) {
175
+ for (const agentPattern of sections.agent_owned) {
176
+ if (ownershipPatternsOverlap(templatePattern, agentPattern)) {
177
+ throw new Error(`ambiguous ownership patterns: ${templatePattern} and ${agentPattern}`);
178
+ }
179
+ }
180
+ }
181
+ return { templateOwned: sections.template_owned, agentOwned: sections.agent_owned };
182
+ }
183
+
184
+ export function validateFleetTemplateOwnershipPolicy(parsed: {
185
+ templateOwned: string[];
186
+ agentOwned: string[];
187
+ }): void {
188
+ const requiredAgentOwned = [".copier-answers.yml", "agent.json", "docs/person/**"];
189
+ if (requiredAgentOwned.some((entry) => !parsed.agentOwned.includes(entry))) {
190
+ throw new Error("required agent-owned manifest/persona paths are missing");
191
+ }
192
+ }
193
+
194
+ export function normalizeFleetRole(value: string): string {
195
+ return value.normalize("NFKC").trim().toLowerCase().replace(/\s+/gu, " ");
196
+ }
197
+
198
+ export async function inspectTemplateProvenance(params: {
199
+ snapshot: FleetGitSnapshot;
200
+ repoName: string;
201
+ }): Promise<{
202
+ template: FleetLintRepositoryResult["template"];
203
+ diagnostics: FleetLintDiagnostic[];
204
+ }> {
205
+ const diagnostics: FleetLintDiagnostic[] = [];
206
+ const [answersResult, ownershipResult] = await Promise.allSettled([
207
+ params.snapshot.readFile(ANSWERS_PATH, 64 * 1024),
208
+ params.snapshot.readFile(OWNERSHIP_PATH, 64 * 1024),
209
+ ]);
210
+ const answers = answersResult.status === "fulfilled" ? answersResult.value : undefined;
211
+ const ownership = ownershipResult.status === "fulfilled" ? ownershipResult.value : undefined;
212
+ const answersCaptureFailed = answersResult.status === "rejected";
213
+ const ownershipCaptureFailed = ownershipResult.status === "rejected";
214
+ const template: FleetLintRepositoryResult["template"] = {
215
+ mode: ownership ? "managed_v2" : answers && !ownershipCaptureFailed ? "legacy" : "unknown",
216
+ provenanceStatus: answersCaptureFailed || ownershipCaptureFailed ? "invalid" : "unbound",
217
+ ...(answers ? { answersSha256: answers.sha256 } : {}),
218
+ ...(ownership ? { ownershipSha256: ownership.sha256 } : {}),
219
+ };
220
+
221
+ if (ownershipCaptureFailed) {
222
+ diagnostics.push({
223
+ code: "template.ownership_capture_failed",
224
+ severity: "error",
225
+ repo: params.repoName,
226
+ path: OWNERSHIP_PATH,
227
+ message: "committed template ownership bytes could not be captured within the lint bound",
228
+ });
229
+ }
230
+ if (answersCaptureFailed) {
231
+ diagnostics.push({
232
+ code: "template.answers_capture_failed",
233
+ severity: "error",
234
+ repo: params.repoName,
235
+ path: ANSWERS_PATH,
236
+ message: "committed Copier provenance bytes could not be captured within the lint bound",
237
+ });
238
+ }
239
+
240
+ if (ownership) {
241
+ try {
242
+ const parsed = parseFleetTemplateOwnership(
243
+ decodeOwnerUtf8(ownership.bytes, "template ownership"),
244
+ );
245
+ validateFleetTemplateOwnershipPolicy(parsed);
246
+ } catch {
247
+ template.provenanceStatus = "invalid";
248
+ diagnostics.push({
249
+ code: "template.ownership_invalid",
250
+ severity: "error",
251
+ repo: params.repoName,
252
+ path: OWNERSHIP_PATH,
253
+ message: "template ownership does not satisfy the ratified parser and fleet path policy",
254
+ });
255
+ }
256
+ }
257
+
258
+ if (!answers) {
259
+ if (!answersCaptureFailed) {
260
+ diagnostics.push({
261
+ code: "template.provenance_missing",
262
+ severity: "warning",
263
+ repo: params.repoName,
264
+ path: ANSWERS_PATH,
265
+ message: "repository has no committed Copier provenance answers",
266
+ hint: "provenance remains unverifiable; do not re-render or infer template currentness",
267
+ });
268
+ }
269
+ return { template, diagnostics };
270
+ }
271
+
272
+ let answersText: string;
273
+ try {
274
+ answersText = decodeOwnerUtf8(answers.bytes, "Copier answers");
275
+ } catch {
276
+ template.provenanceStatus = "invalid";
277
+ diagnostics.push({
278
+ code: "template.answers_invalid",
279
+ severity: "error",
280
+ repo: params.repoName,
281
+ path: ANSWERS_PATH,
282
+ message: "committed Copier provenance is not strict UTF-8",
283
+ });
284
+ return { template, diagnostics };
285
+ }
286
+
287
+ let sourcePath: string | undefined;
288
+ let revision: string | undefined;
289
+ try {
290
+ sourcePath = parseFleetCopierSource(answersText);
291
+ revision =
292
+ copierScalar(answersText, "template_source_sha") ??
293
+ copierScalar(answersText, "l0_source_sha") ??
294
+ copierScalar(answersText, "_commit");
295
+ } catch {
296
+ template.provenanceStatus = "invalid";
297
+ diagnostics.push({
298
+ code: "template.answers_invalid",
299
+ severity: "error",
300
+ repo: params.repoName,
301
+ path: ANSWERS_PATH,
302
+ message: "committed Copier provenance uses unsupported or ambiguous scalar syntax",
303
+ });
304
+ return { template, diagnostics };
305
+ }
306
+ const resolvedSourcePath = sourcePath
307
+ ? isAbsolute(sourcePath)
308
+ ? sourcePath
309
+ : resolve(params.snapshot.root, sourcePath)
310
+ : undefined;
311
+ if (revision && FULL_REVISION.test(revision)) template.sourceRevision = revision;
312
+ if (
313
+ revision &&
314
+ FULL_REVISION.test(revision) &&
315
+ resolvedSourcePath &&
316
+ basename(resolvedSourcePath) === "tpl-agent-repo"
317
+ ) {
318
+ try {
319
+ const verified = await verifyFleetGitRevision(resolvedSourcePath, revision, {
320
+ requiredFiles: [
321
+ "agent.json.j2",
322
+ "copier.yml",
323
+ "contracts/template-ownership.yml",
324
+ "scripts/compile-system-prompt.py",
325
+ ],
326
+ });
327
+ template.sourcePath = `${basename(verified.repoRoot) || "root"}/${verified.sourceRelative}`;
328
+ template.sourceTreeOid = verified.treeOid;
329
+ if (template.provenanceStatus !== "invalid") {
330
+ template.provenanceStatus = "verified_local_source";
331
+ }
332
+ } catch {
333
+ diagnostics.push({
334
+ code: "template.revision_unverifiable",
335
+ severity: template.mode === "managed_v2" ? "error" : "warning",
336
+ repo: params.repoName,
337
+ path: ANSWERS_PATH,
338
+ message: "declared local template source revision could not be verified",
339
+ hint: "a declared revision is not matched until the exact source Git object is locally verifiable",
340
+ });
341
+ }
342
+ } else {
343
+ diagnostics.push({
344
+ code: revision ? "template.revision_invalid" : "template.revision_unbound",
345
+ severity: template.mode === "managed_v2" ? "error" : "warning",
346
+ repo: params.repoName,
347
+ path: ANSWERS_PATH,
348
+ message:
349
+ revision && !FULL_REVISION.test(revision)
350
+ ? "template provenance revision is not one full immutable Git object id"
351
+ : "template provenance lacks a verifiable source path and immutable revision",
352
+ hint: "record exact source revision only through the owning template propagation contract",
353
+ });
354
+ }
355
+ return { template, diagnostics };
356
+ }