halfcycle 0.3.17 → 0.3.18

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/dist/index.js CHANGED
@@ -54,11 +54,26 @@ var firedGuardSchema = z2.object({
54
54
  */
55
55
  path: z2.string().optional()
56
56
  }).strict();
57
+ var notRunCheckSchema = z2.object({
58
+ /** Which check did not run, by the same reference a fired check carries. */
59
+ patternRef: z2.string(),
60
+ /**
61
+ * Why it could not run, in plain words — what this client did not send, or
62
+ * that the check's kind is not implemented yet. Never the check's own rule.
63
+ */
64
+ reason: z2.string()
65
+ }).strict();
57
66
  var resultEnvelopeSchema = z2.object({
58
67
  guardsFired: z2.array(firedGuardSchema),
59
68
  severity: severitySchema.nullable(),
60
69
  blocking: z2.boolean(),
61
- explanation: z2.string()
70
+ explanation: z2.string(),
71
+ /**
72
+ * The checks that could not run on this edit, each named with the reason.
73
+ * Absent when every check ran — so an evaluation where nothing was skipped
74
+ * looks exactly as it always has, and an absent list is never an empty one.
75
+ */
76
+ notRun: z2.array(notRunCheckSchema).optional()
62
77
  }).strict();
63
78
  var wireErrorSchema = z2.object({
64
79
  statusCode: z2.number(),
@@ -170,6 +185,65 @@ var CREW_ROSTER = [
170
185
  var CREW_ROSTER_SIZE = CREW_ROSTER.length;
171
186
  var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
172
187
 
188
+ // ../events/dist/phase-identity.js
189
+ var PHASE_SEGMENT_PREFIX = "phase-";
190
+ function isValidPhaseIdentity(identity) {
191
+ if (typeof identity === "number")
192
+ return Number.isInteger(identity) && identity >= 0;
193
+ if (typeof identity !== "string")
194
+ return false;
195
+ if (identity === "")
196
+ return false;
197
+ if (identity.includes("/"))
198
+ return false;
199
+ if (identity.includes("\\"))
200
+ return false;
201
+ if (identity.includes("\0"))
202
+ return false;
203
+ if (identity.startsWith("."))
204
+ return false;
205
+ return true;
206
+ }
207
+ var InvalidPhaseIdentityError = class extends Error {
208
+ /** The value that was refused, exactly as supplied. */
209
+ identity;
210
+ constructor(identity, allowedLocation) {
211
+ const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
212
+ const where = allowedLocation === void 0 ? "" : `
213
+ Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
214
+ super(`that phase identity cannot name ${subject}.
215
+ identity: ${describeIdentity(identity)}` + where);
216
+ this.name = "InvalidPhaseIdentityError";
217
+ this.identity = identity;
218
+ }
219
+ };
220
+ function assertValidPhaseIdentity(identity, allowedLocation) {
221
+ if (!isValidPhaseIdentity(identity)) {
222
+ throw new InvalidPhaseIdentityError(identity, allowedLocation);
223
+ }
224
+ }
225
+ function renderPhaseSegment(identity) {
226
+ if (!isValidPhaseIdentity(identity)) {
227
+ throw new InvalidPhaseIdentityError(identity);
228
+ }
229
+ return `${PHASE_SEGMENT_PREFIX}${identity}`;
230
+ }
231
+ function describeIdentity(identity) {
232
+ if (typeof identity === "string")
233
+ return identity;
234
+ if (identity === null)
235
+ return "null";
236
+ if (identity === void 0)
237
+ return "none supplied";
238
+ if (typeof identity === "number" || typeof identity === "boolean")
239
+ return String(identity);
240
+ if (Array.isArray(identity))
241
+ return "(a list)";
242
+ if (typeof identity === "object")
243
+ return "(an object)";
244
+ return `(a ${typeof identity})`;
245
+ }
246
+
173
247
  // dist/engagement-credential.js
174
248
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
175
249
  import { platform as platform2 } from "node:os";
@@ -233,33 +307,50 @@ function parseEnvText(raw) {
233
307
  return out;
234
308
  }
235
309
  function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
236
- const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
310
+ const desired = new Map(keys.map((k) => {
311
+ const value = values[k];
312
+ return [k, value === void 0 ? "" : value];
313
+ }));
237
314
  const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
315
+ const written = keys.filter((k) => desired.get(k) !== null);
238
316
  if (existing === null) {
317
+ if (written.length === 0)
318
+ return "";
239
319
  return `${ENV_HEADER}
240
- ` + keys.map(line).join("\n") + "\n";
320
+ ` + written.map(line).join("\n") + "\n";
241
321
  }
242
322
  const seen = /* @__PURE__ */ new Set();
243
- const out = existing.split("\n").map((existingLine) => {
323
+ const out = [];
324
+ for (const existingLine of existing.split("\n")) {
244
325
  const eq = existingLine.indexOf("=");
245
- if (eq === -1)
246
- return existingLine;
326
+ if (eq === -1) {
327
+ out.push(existingLine);
328
+ continue;
329
+ }
247
330
  const lhs = existingLine.slice(0, eq);
248
331
  const exported = /^\s*export\s+/.exec(lhs);
249
332
  const prefix = exported === null ? "" : exported[0];
250
333
  const key = lhs.slice(prefix.length).trim();
251
- if (desired.has(key)) {
252
- seen.add(key);
253
- return `${prefix}${line(key)}`;
334
+ if (!desired.has(key)) {
335
+ out.push(existingLine);
336
+ continue;
254
337
  }
255
- return existingLine;
256
- });
257
- const missing = keys.filter((k) => !seen.has(k));
338
+ seen.add(key);
339
+ if (desired.get(key) === null)
340
+ continue;
341
+ out.push(`${prefix}${line(key)}`);
342
+ }
343
+ const missing = written.filter((k) => !seen.has(k));
258
344
  if (missing.length > 0) {
345
+ const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
346
+ `;
347
+ const block = header + missing.map(line).join("\n");
259
348
  const trailingBlank = out.length > 0 && out[out.length - 1] === "";
260
- const prefix = trailingBlank ? "" : existing.endsWith("\n") ? "" : "\n";
261
- out.push(`${prefix}${ENV_HEADER}
262
- ` + missing.map(line).join("\n"));
349
+ if (trailingBlank)
350
+ out.splice(out.length - 1, 0, block);
351
+ else
352
+ out.push(`
353
+ ${block}`);
263
354
  }
264
355
  let result = out.join("\n");
265
356
  if (existing.endsWith("\n") && !result.endsWith("\n"))
@@ -273,17 +364,19 @@ function readEngagementEnv(engagementId, home) {
273
364
  return null;
274
365
  }
275
366
  }
276
- function writeEngagementEnv(engagementId, values, home) {
367
+ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KEYS) {
277
368
  const path = engagementEnvPath(engagementId, home);
278
369
  const dir = engagementStateDir(engagementId, home);
279
- mkdirSync(dir, { recursive: true, mode: 448 });
280
370
  let existing;
281
371
  try {
282
372
  existing = readFileSync(path, "utf-8");
283
373
  } catch {
284
374
  existing = null;
285
375
  }
286
- const reconciled = reconcileEnvText(existing, values);
376
+ const reconciled = reconcileEnvText(existing, values, keys);
377
+ if (existing === null && reconciled === "")
378
+ return "skipped";
379
+ mkdirSync(dir, { recursive: true, mode: 448 });
287
380
  const outcome = existing === reconciled ? "skipped" : "written";
288
381
  if (outcome === "written") {
289
382
  writeFileSync(path, reconciled, { mode: 384 });
@@ -1877,8 +1970,203 @@ function projectSeedGuard(fired, includeExplanation) {
1877
1970
  // dist/build-record/sources.js
1878
1971
  import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
1879
1972
  import { createHash } from "node:crypto";
1880
- import { join as join8 } from "node:path";
1973
+ import { join as join10 } from "node:path";
1974
+
1975
+ // dist/build-record/close-record.js
1881
1976
  import { execFileSync as execFileSync2 } from "node:child_process";
1977
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
1978
+ import { dirname as dirname4, join as join8 } from "node:path";
1979
+ var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
1980
+ var BUILD_RECORD_ZONE_B_DIR = join8(ZONE_B_DIR, "build-record");
1981
+ var InvalidCloseRecordError = class extends Error {
1982
+ constructor(path, detail) {
1983
+ super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
1984
+ this.name = "InvalidCloseRecordError";
1985
+ }
1986
+ };
1987
+ function closeRecordPath(repoRoot, phase) {
1988
+ assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
1989
+ return join8(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
1990
+ }
1991
+ function resolveCloseAtHead(repoRoot) {
1992
+ try {
1993
+ const closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
1994
+ encoding: "utf-8",
1995
+ stdio: ["ignore", "pipe", "ignore"]
1996
+ }).trim();
1997
+ const closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
1998
+ encoding: "utf-8",
1999
+ stdio: ["ignore", "pipe", "ignore"]
2000
+ }).trim();
2001
+ if (closeCommit === "" || closedDate === "")
2002
+ return null;
2003
+ return { closeCommit, closedDate };
2004
+ } catch {
2005
+ return null;
2006
+ }
2007
+ }
2008
+ function writeCloseRecord(repoRoot, phase) {
2009
+ let path;
2010
+ try {
2011
+ path = closeRecordPath(repoRoot, phase);
2012
+ } catch (err) {
2013
+ return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
2014
+ }
2015
+ const at = resolveCloseAtHead(repoRoot);
2016
+ if (at === null) {
2017
+ return {
2018
+ recorded: false,
2019
+ path,
2020
+ reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
2021
+ };
2022
+ }
2023
+ const record2 = {
2024
+ format: CLOSE_RECORD_FORMAT,
2025
+ phase,
2026
+ closeCommit: at.closeCommit,
2027
+ closedDate: at.closedDate
2028
+ };
2029
+ try {
2030
+ mkdirSync5(dirname4(path), { recursive: true });
2031
+ writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
2032
+ } catch (err) {
2033
+ return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
2034
+ }
2035
+ return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
2036
+ }
2037
+ function parseCloseRecord(path, parsed) {
2038
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2039
+ throw new InvalidCloseRecordError(path, "it does not hold a record");
2040
+ }
2041
+ const record2 = parsed;
2042
+ if (record2.format !== CLOSE_RECORD_FORMAT) {
2043
+ throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
2044
+ }
2045
+ const commit = record2.closeCommit;
2046
+ const date = record2.closedDate;
2047
+ const missing = [];
2048
+ if (typeof commit !== "string" || commit.trim() === "")
2049
+ missing.push("closeCommit");
2050
+ if (typeof date !== "string" || date.trim() === "")
2051
+ missing.push("closedDate");
2052
+ if (missing.length > 0) {
2053
+ throw new InvalidCloseRecordError(path, `${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} not recorded in it. A close records the commit and its date together or records neither, so a file carrying one of them was edited by hand.`);
2054
+ }
2055
+ if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
2056
+ throw new InvalidCloseRecordError(path, "it does not say which phase closed");
2057
+ }
2058
+ return {
2059
+ format: CLOSE_RECORD_FORMAT,
2060
+ phase: record2.phase,
2061
+ closeCommit: commit,
2062
+ closedDate: date
2063
+ };
2064
+ }
2065
+
2066
+ // dist/build-record/write.js
2067
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
2068
+ import { basename, join as join9, resolve, relative as relative2, isAbsolute } from "node:path";
2069
+
2070
+ // dist/build-record/template.js
2071
+ function severityLabel(seed) {
2072
+ return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
2073
+ }
2074
+ var NOT_RECORDED = "not recorded";
2075
+ function renderCloseLine(record2) {
2076
+ const status = `**Status:** ${record2.status.toUpperCase()}`;
2077
+ const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
2078
+ const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
2079
+ return `${status} \xB7 ${closed} \xB7 ${commit}`;
2080
+ }
2081
+ function renderCloseProvenance(record2) {
2082
+ return record2.closeCommit === null && record2.closedDate === null ? "*The close date and the commit were not recorded when this phase closed, so this record does not name them. Nothing has been substituted for them.*" : "*The close date and the commit are the client's own assertion about their own repository, written down when the phase was closed. Halfcycle did not observe them and does not check them.*";
2083
+ }
2084
+ function renderDoneWhenRows(record2) {
2085
+ const rows = [];
2086
+ for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
2087
+ const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
2088
+ rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
2089
+ }
2090
+ return rows.join("\n");
2091
+ }
2092
+ function renderBuildRecordMarkdown(record2) {
2093
+ const { phase } = record2;
2094
+ const companion = `${renderPhaseSegment(phase.id)}.json`;
2095
+ const guardsList = record2.seedGuards.map(severityLabel).join(", ");
2096
+ const lines = [
2097
+ // Fixed prose (vault-canonical template) + projected title slots.
2098
+ `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
2099
+ "",
2100
+ `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
2101
+ "",
2102
+ "> This Build Record was assembled automatically at phase close by the method bundle (`packages/bundle`). Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
2103
+ "",
2104
+ `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
2105
+ `**Phase:** ${phase.id} \u2014 ${phase.name}`,
2106
+ renderCloseLine(record2),
2107
+ `**Companion:** [\`${companion}\`](${companion})`,
2108
+ "",
2109
+ renderCloseProvenance(record2),
2110
+ "",
2111
+ "## What this phase proved",
2112
+ "",
2113
+ `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
2114
+ "",
2115
+ "## Delivered",
2116
+ "",
2117
+ `- **Packages:** ${record2.delivered.packages.join(", ")}`,
2118
+ `- **Services:** ${record2.delivered.services.join(", ")}`,
2119
+ `- **Tools:** ${record2.delivered.tools.join(", ")}`,
2120
+ `- **Dogfood:** ${record2.delivered.dogfood}`,
2121
+ "",
2122
+ guardsList ? `Guards evaluated (INV-007-safe results): ${guardsList}. *(Guard \`channel\`/\`version\` are guard-asset metadata, excluded from the v1 record \u2014 INV-007/INV-001.)*` : "No guards fired during this phase.",
2123
+ "",
2124
+ `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
2125
+ "",
2126
+ `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
2127
+ "",
2128
+ "| Done-when | Result | Evidence |",
2129
+ "|---|---|---|",
2130
+ renderDoneWhenRows(record2),
2131
+ ""
2132
+ ];
2133
+ if (record2.acceptance.inv002ProdBypassProbe) {
2134
+ lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
2135
+ }
2136
+ lines.push("## Gates that earned their keep", "", `**${record2.gatesEarnedKeep.defectsCaughtPreHuman}** defects were caught by the gates before the human walk; the walk itself found **${record2.gatesEarnedKeep.bugsReachingHumanWalk}**. Named: ${record2.gatesEarnedKeep.named.join("; ")}.`, "", "## Instruments", "", `- **Marginal-cost self-accounting:** ${record2.instruments.marginalCostSelfAccounting}`, `- **COE add-rate:** seam-new ${record2.instruments.coeAddRate.seamNew}, seam-repeat ${record2.instruments.coeAddRate.seamRepeat}, model-limitation ${record2.instruments.coeAddRate.modelLimitation}. ${record2.instruments.coeAddRate.notes}`, "", "## Invariants exercised", "", record2.invariantsExercised.join(", "), "", "## Deviations & decisions recorded", "", ...record2.deviations.map((d) => `- ${d}`), "", "## Tasks", "", `- **Planned:** ${record2.tasks.planned} \xB7 **Landed:** ${record2.tasks.landed} \xB7 **Cancelled:** ${record2.tasks.cancelled.length > 0 ? record2.tasks.cancelled.join(", ") : "none"}`, "");
2137
+ return lines.join("\n");
2138
+ }
2139
+
2140
+ // dist/build-record/write.js
2141
+ var BUILD_RECORD_DIR = join9("docs", "build-records");
2142
+ function serialiseRecordJson(record2) {
2143
+ return JSON.stringify(record2, null, 2) + "\n";
2144
+ }
2145
+ function assertInsideOutDir(outDir, candidate, identity) {
2146
+ const rel = relative2(outDir, resolve(candidate));
2147
+ const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute(rel) && rel === basename(rel);
2148
+ if (!isPlainFilename) {
2149
+ throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
2150
+ }
2151
+ }
2152
+ function writeBuildRecord(repoRoot, record2) {
2153
+ const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
2154
+ const identity = record2.phase.id;
2155
+ if (!isValidPhaseIdentity(identity)) {
2156
+ throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
2157
+ }
2158
+ const segment = renderPhaseSegment(identity);
2159
+ const jsonPath = join9(outDir, `${segment}.json`);
2160
+ const mdPath = join9(outDir, `${segment}.md`);
2161
+ assertInsideOutDir(outDir, jsonPath, identity);
2162
+ assertInsideOutDir(outDir, mdPath, identity);
2163
+ mkdirSync6(outDir, { recursive: true });
2164
+ writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
2165
+ writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
2166
+ return { jsonPath, mdPath };
2167
+ }
2168
+
2169
+ // dist/build-record/sources.js
1882
2170
  var MissingSourceError = class extends Error {
1883
2171
  constructor(sourceName, detail) {
1884
2172
  super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
@@ -1934,7 +2222,7 @@ function syntheticRunId(record2) {
1934
2222
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
1935
2223
  }
1936
2224
  function readGuardEvalLog(logDir, phaseId) {
1937
- const logPath = join8(logDir, `phase-${phaseId}.jsonl`);
2225
+ const logPath = join10(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
1938
2226
  if (!existsSync6(logPath)) {
1939
2227
  throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
1940
2228
  }
@@ -1950,7 +2238,7 @@ function readGuardEvalLog(logDir, phaseId) {
1950
2238
  return runs;
1951
2239
  }
1952
2240
  function readOrchestrationState(phasesDir, phaseId) {
1953
- const statePath = join8(phasesDir, `phase-${phaseId}-orchestration-state.json`);
2241
+ const statePath = join10(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
1954
2242
  if (!existsSync6(statePath)) {
1955
2243
  throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
1956
2244
  }
@@ -1967,22 +2255,21 @@ function readOrchestrationState(phasesDir, phaseId) {
1967
2255
  }
1968
2256
  return { tasks };
1969
2257
  }
1970
- function readGitClose(repoRoot) {
1971
- let closeCommit;
1972
- let closedDate;
2258
+ function readPhaseClose(repoRoot, phaseId) {
2259
+ const path = closeRecordPath(repoRoot, phaseId);
2260
+ if (!existsSync6(path))
2261
+ return { closeCommit: null, closedDate: null };
2262
+ let parsed;
1973
2263
  try {
1974
- closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
1975
- encoding: "utf-8"
1976
- }).trim();
1977
- closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], { encoding: "utf-8" }).trim();
2264
+ parsed = JSON.parse(readFileSync7(path, "utf-8"));
1978
2265
  } catch (err) {
1979
- throw new MissingSourceError("git HEAD", `cannot resolve close commit/date in ${repoRoot}: ${err instanceof Error ? err.message : String(err)}`);
2266
+ throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
2267
+ }
2268
+ const record2 = parseCloseRecord(path, parsed);
2269
+ if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
2270
+ throw new InvalidCloseRecordError(path, `it records the close of ${String(record2.phase)}, not of ${String(phaseId)}`);
1980
2271
  }
1981
- if (!closeCommit)
1982
- throw new MissingSourceError("git HEAD", `empty commit sha in ${repoRoot}`);
1983
- if (!closedDate)
1984
- throw new MissingSourceError("git HEAD", `empty commit date in ${repoRoot}`);
1985
- return { closeCommit, closedDate };
2272
+ return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
1986
2273
  }
1987
2274
  function validateNarrated(narrated) {
1988
2275
  const req = (v, name) => {
@@ -1993,6 +2280,7 @@ function validateNarrated(narrated) {
1993
2280
  req(narrated.engagement, "engagement");
1994
2281
  req(narrated.engagementType, "engagementType");
1995
2282
  req(narrated.phase?.name, "phase.name");
2283
+ assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
1996
2284
  req(narrated.status, "status");
1997
2285
  req(narrated.thesis, "thesis");
1998
2286
  req(narrated.delivered, "delivered");
@@ -2008,7 +2296,7 @@ function validateNarrated(narrated) {
2008
2296
  function loadSources(opts) {
2009
2297
  const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
2010
2298
  const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
2011
- const git2 = readGitClose(opts.repoRoot);
2299
+ const close = readPhaseClose(opts.repoRoot, opts.phaseId);
2012
2300
  const narrated = validateNarrated(opts.narrated);
2013
2301
  if (!Array.isArray(opts.touchedInvariants)) {
2014
2302
  throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
@@ -2016,7 +2304,7 @@ function loadSources(opts) {
2016
2304
  return {
2017
2305
  orchestration,
2018
2306
  narrated,
2019
- git: git2,
2307
+ close,
2020
2308
  guardEvalRuns,
2021
2309
  touchedInvariants: opts.touchedInvariants
2022
2310
  };
@@ -2046,15 +2334,15 @@ function deriveInvariantsExercised(touched) {
2046
2334
  return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
2047
2335
  }
2048
2336
  function assembleBuildRecord(sources) {
2049
- const { narrated, git: git2, orchestration } = sources;
2337
+ const { narrated, close, orchestration } = sources;
2050
2338
  return {
2051
2339
  format: BUILD_RECORD_FORMAT,
2052
2340
  engagement: narrated.engagement,
2053
2341
  engagementType: narrated.engagementType,
2054
2342
  phase: { id: narrated.phase.id, name: narrated.phase.name },
2055
2343
  status: narrated.status,
2056
- closedDate: git2.closedDate,
2057
- closeCommit: git2.closeCommit,
2344
+ closedDate: close.closedDate,
2345
+ closeCommit: close.closeCommit,
2058
2346
  thesis: narrated.thesis,
2059
2347
  thesisHeld: deriveThesisHeld(narrated.acceptance),
2060
2348
  delivered: narrated.delivered,
@@ -2068,85 +2356,6 @@ function assembleBuildRecord(sources) {
2068
2356
  };
2069
2357
  }
2070
2358
 
2071
- // dist/build-record/template.js
2072
- function severityLabel(seed) {
2073
- return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
2074
- }
2075
- function renderDoneWhenRows(record2) {
2076
- const rows = [];
2077
- for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
2078
- const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
2079
- rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
2080
- }
2081
- return rows.join("\n");
2082
- }
2083
- function renderBuildRecordMarkdown(record2) {
2084
- const { phase } = record2;
2085
- const guardsList = record2.seedGuards.map(severityLabel).join(", ");
2086
- const lines = [
2087
- // Fixed prose (vault-canonical template) + projected title slots.
2088
- `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
2089
- "",
2090
- `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
2091
- "",
2092
- "> This Build Record was assembled automatically at phase close by the method bundle (`packages/bundle`). Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
2093
- "",
2094
- `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
2095
- `**Phase:** ${phase.id} \u2014 ${phase.name}`,
2096
- `**Status:** ${record2.status.toUpperCase()} \xB7 **Closed:** ${record2.closedDate} \xB7 **Close commit:** \`${record2.closeCommit}\``,
2097
- `**Companion:** [\`phase-${phase.id}.json\`](phase-${phase.id}.json)`,
2098
- "",
2099
- "## What this phase proved",
2100
- "",
2101
- `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
2102
- "",
2103
- "## Delivered",
2104
- "",
2105
- `- **Packages:** ${record2.delivered.packages.join(", ")}`,
2106
- `- **Services:** ${record2.delivered.services.join(", ")}`,
2107
- `- **Tools:** ${record2.delivered.tools.join(", ")}`,
2108
- `- **Dogfood:** ${record2.delivered.dogfood}`,
2109
- "",
2110
- guardsList ? `Guards evaluated (INV-007-safe results): ${guardsList}. *(Guard \`channel\`/\`version\` are guard-asset metadata, excluded from the v1 record \u2014 INV-007/INV-001.)*` : "No guards fired during this phase.",
2111
- "",
2112
- `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
2113
- "",
2114
- `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
2115
- "",
2116
- "| Done-when | Result | Evidence |",
2117
- "|---|---|---|",
2118
- renderDoneWhenRows(record2),
2119
- ""
2120
- ];
2121
- if (record2.acceptance.inv002ProdBypassProbe) {
2122
- lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
2123
- }
2124
- lines.push("## Gates that earned their keep", "", `**${record2.gatesEarnedKeep.defectsCaughtPreHuman}** defects were caught by the gates before the human walk; the walk itself found **${record2.gatesEarnedKeep.bugsReachingHumanWalk}**. Named: ${record2.gatesEarnedKeep.named.join("; ")}.`, "", "## Instruments", "", `- **Marginal-cost self-accounting:** ${record2.instruments.marginalCostSelfAccounting}`, `- **COE add-rate:** seam-new ${record2.instruments.coeAddRate.seamNew}, seam-repeat ${record2.instruments.coeAddRate.seamRepeat}, model-limitation ${record2.instruments.coeAddRate.modelLimitation}. ${record2.instruments.coeAddRate.notes}`, "", "## Invariants exercised", "", record2.invariantsExercised.join(", "), "", "## Deviations & decisions recorded", "", ...record2.deviations.map((d) => `- ${d}`), "", "## Tasks", "", `- **Planned:** ${record2.tasks.planned} \xB7 **Landed:** ${record2.tasks.landed} \xB7 **Cancelled:** ${record2.tasks.cancelled.length > 0 ? record2.tasks.cancelled.join(", ") : "none"}`, "");
2125
- return lines.join("\n");
2126
- }
2127
-
2128
- // dist/build-record/write.js
2129
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
2130
- import { join as join9, resolve, relative as relative2, isAbsolute } from "node:path";
2131
- var BUILD_RECORD_DIR = join9("docs", "build-records");
2132
- function serialiseRecordJson(record2) {
2133
- return JSON.stringify(record2, null, 2) + "\n";
2134
- }
2135
- function writeBuildRecord(repoRoot, record2) {
2136
- const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
2137
- const expected = resolve(repoRoot, BUILD_RECORD_DIR);
2138
- const rel = relative2(expected, outDir);
2139
- if (rel !== "" || isAbsolute(rel)) {
2140
- throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
2141
- }
2142
- mkdirSync5(outDir, { recursive: true });
2143
- const jsonPath = join9(outDir, `phase-${record2.phase.id}.json`);
2144
- const mdPath = join9(outDir, `phase-${record2.phase.id}.md`);
2145
- writeFileSync5(jsonPath, serialiseRecordJson(record2), "utf-8");
2146
- writeFileSync5(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
2147
- return { jsonPath, mdPath };
2148
- }
2149
-
2150
2359
  // dist/build-record/index.js
2151
2360
  function assemblePhaseBuildRecord(opts) {
2152
2361
  const sources = loadSources({
@@ -2163,9 +2372,13 @@ function assemblePhaseBuildRecord(opts) {
2163
2372
  export {
2164
2373
  BUILD_RECORD_DIR,
2165
2374
  BUILD_RECORD_FORMAT,
2375
+ BUILD_RECORD_ZONE_B_DIR,
2376
+ CLOSE_RECORD_FORMAT,
2166
2377
  CreateEngagementRefused,
2167
2378
  FLAT_FILE_TRACKER_HOME,
2168
2379
  HALFCYCLE_STATE_FORMAT,
2380
+ InvalidCloseRecordError,
2381
+ InvalidPhaseIdentityError,
2169
2382
  MissingSourceError,
2170
2383
  SetupManifest,
2171
2384
  ZONE_B_DIR,
@@ -2173,8 +2386,10 @@ export {
2173
2386
  applySetupWrites,
2174
2387
  assembleBuildRecord,
2175
2388
  assemblePhaseBuildRecord,
2389
+ assertValidPhaseIdentity,
2176
2390
  buildEngagementRecord,
2177
2391
  checkDrift,
2392
+ closeRecordPath,
2178
2393
  contextIndexRow,
2179
2394
  createEngagement,
2180
2395
  deriveInvariantsExercised,
@@ -2185,29 +2400,33 @@ export {
2185
2400
  engagementRecordRow,
2186
2401
  flatFileTrackerDefault,
2187
2402
  install,
2403
+ isValidPhaseIdentity,
2188
2404
  layerDocumentRow,
2189
2405
  loadSources,
2190
2406
  mergeSettings,
2191
2407
  mergeWithRecords,
2192
2408
  mintBoardEnterCode,
2193
2409
  mintOrReadIdentity,
2410
+ parseCloseRecord,
2194
2411
  projectSeedGuard,
2195
2412
  projectSeedGuards,
2196
2413
  questionsToPut,
2197
2414
  readBundlePin,
2198
- readGitClose,
2199
2415
  readGuardEvalLog,
2200
2416
  readOrchestrationState,
2417
+ readPhaseClose,
2201
2418
  readRemote,
2202
2419
  readRootCommit,
2203
2420
  renderAnchor,
2204
2421
  renderBuildRecordMarkdown,
2205
2422
  renderDiscovery,
2423
+ renderPhaseSegment,
2206
2424
  runBootstrapScan,
2207
2425
  scanLayers,
2208
2426
  serialiseRecordJson,
2209
2427
  trackerHomeRow,
2210
2428
  validateNarrated,
2211
- writeBuildRecord
2429
+ writeBuildRecord,
2430
+ writeCloseRecord
2212
2431
  };
2213
2432
  //# sourceMappingURL=index.js.map