halfcycle 0.3.17 → 0.3.19

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,67 @@ 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 (identity === "")
233
+ return "(empty)";
234
+ if (typeof identity === "string")
235
+ return identity;
236
+ if (identity === null)
237
+ return "null";
238
+ if (identity === void 0)
239
+ return "none supplied";
240
+ if (typeof identity === "number" || typeof identity === "boolean")
241
+ return String(identity);
242
+ if (Array.isArray(identity))
243
+ return "(a list)";
244
+ if (typeof identity === "object")
245
+ return "(an object)";
246
+ return `(a ${typeof identity})`;
247
+ }
248
+
173
249
  // dist/engagement-credential.js
174
250
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
175
251
  import { platform as platform2 } from "node:os";
@@ -192,6 +268,7 @@ function engagementStateDir(engagementId, home) {
192
268
  function engagementEnvPath(engagementId, home) {
193
269
  return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
194
270
  }
271
+ var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
195
272
  var GUARD_ENV_KEYS = [
196
273
  "GUARD_SERVICE_URL",
197
274
  "GUARD_SERVICE_TOKEN",
@@ -233,33 +310,50 @@ function parseEnvText(raw) {
233
310
  return out;
234
311
  }
235
312
  function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
236
- const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
313
+ const desired = new Map(keys.map((k) => {
314
+ const value = values[k];
315
+ return [k, value === void 0 ? "" : value];
316
+ }));
237
317
  const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
318
+ const written = keys.filter((k) => desired.get(k) !== null);
238
319
  if (existing === null) {
320
+ if (written.length === 0)
321
+ return "";
239
322
  return `${ENV_HEADER}
240
- ` + keys.map(line).join("\n") + "\n";
323
+ ` + written.map(line).join("\n") + "\n";
241
324
  }
242
325
  const seen = /* @__PURE__ */ new Set();
243
- const out = existing.split("\n").map((existingLine) => {
326
+ const out = [];
327
+ for (const existingLine of existing.split("\n")) {
244
328
  const eq = existingLine.indexOf("=");
245
- if (eq === -1)
246
- return existingLine;
329
+ if (eq === -1) {
330
+ out.push(existingLine);
331
+ continue;
332
+ }
247
333
  const lhs = existingLine.slice(0, eq);
248
334
  const exported = /^\s*export\s+/.exec(lhs);
249
335
  const prefix = exported === null ? "" : exported[0];
250
336
  const key = lhs.slice(prefix.length).trim();
251
- if (desired.has(key)) {
252
- seen.add(key);
253
- return `${prefix}${line(key)}`;
337
+ if (!desired.has(key)) {
338
+ out.push(existingLine);
339
+ continue;
254
340
  }
255
- return existingLine;
256
- });
257
- const missing = keys.filter((k) => !seen.has(k));
341
+ seen.add(key);
342
+ if (desired.get(key) === null)
343
+ continue;
344
+ out.push(`${prefix}${line(key)}`);
345
+ }
346
+ const missing = written.filter((k) => !seen.has(k));
258
347
  if (missing.length > 0) {
348
+ const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
349
+ `;
350
+ const block = header + missing.map(line).join("\n");
259
351
  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"));
352
+ if (trailingBlank)
353
+ out.splice(out.length - 1, 0, block);
354
+ else
355
+ out.push(`
356
+ ${block}`);
263
357
  }
264
358
  let result = out.join("\n");
265
359
  if (existing.endsWith("\n") && !result.endsWith("\n"))
@@ -273,17 +367,19 @@ function readEngagementEnv(engagementId, home) {
273
367
  return null;
274
368
  }
275
369
  }
276
- function writeEngagementEnv(engagementId, values, home) {
370
+ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KEYS) {
277
371
  const path = engagementEnvPath(engagementId, home);
278
372
  const dir = engagementStateDir(engagementId, home);
279
- mkdirSync(dir, { recursive: true, mode: 448 });
280
373
  let existing;
281
374
  try {
282
375
  existing = readFileSync(path, "utf-8");
283
376
  } catch {
284
377
  existing = null;
285
378
  }
286
- const reconciled = reconcileEnvText(existing, values);
379
+ const reconciled = reconcileEnvText(existing, values, keys);
380
+ if (existing === null && reconciled === "")
381
+ return "skipped";
382
+ mkdirSync(dir, { recursive: true, mode: 448 });
287
383
  const outcome = existing === reconciled ? "skipped" : "written";
288
384
  if (outcome === "written") {
289
385
  writeFileSync(path, reconciled, { mode: 384 });
@@ -313,14 +409,15 @@ function engagementResolutionShell() {
313
409
  # desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
314
410
  # file, because sed is line-oriented and the pin's line breaks are not a contract.
315
411
  #
316
- # Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT and HALFCYCLE_ENV_PROBLEM in
317
- # the CALLER's shell and returns a status. Call it as \`if halfcycle_env_file \u2026\`,
318
- # never as \`$(halfcycle_env_file \u2026)\` \u2014 a command substitution is a subshell and
319
- # would discard everything it set.
412
+ # Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT, HALFCYCLE_ENV_PROBLEM and
413
+ # HALFCYCLE_ENV_NOT_THIS_ACCOUNT in the CALLER's shell and returns a status. Call
414
+ # it as \`if halfcycle_env_file \u2026\`, never as \`$(halfcycle_env_file \u2026)\` \u2014 a
415
+ # command substitution is a subshell and would discard everything it set.
320
416
  halfcycle_env_file() {
321
417
  HALFCYCLE_ENV_FILE=""
322
418
  HALFCYCLE_ENV_ENGAGEMENT=""
323
419
  HALFCYCLE_ENV_PROBLEM=""
420
+ HALFCYCLE_ENV_NOT_THIS_ACCOUNT=""
324
421
  hc_pin="$1/.halfcycle/bundle.json"
325
422
  if [ ! -f "$hc_pin" ]; then
326
423
  HALFCYCLE_ENV_PROBLEM="no-pin"
@@ -336,6 +433,9 @@ halfcycle_env_file() {
336
433
  HALFCYCLE_ENV_PROBLEM="no-home"
337
434
  return 1
338
435
  fi
436
+ if [ -f "$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
437
+ HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
438
+ fi
339
439
  hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
340
440
  if [ ! -f "$hc_env" ]; then
341
441
  HALFCYCLE_ENV_PROBLEM="no-credential"
@@ -715,6 +815,13 @@ if halfcycle_env_file "$PROJECT_DIR"; then
715
815
  set +a
716
816
  fi
717
817
 
818
+ # Pass along WHY this machine has no credential, as a plain variable \u2014 the
819
+ # marker check above already answered it, and this is the one bit of that
820
+ # answer the runner binary needs to say the right thing on every edit.
821
+ if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
822
+ export HALFCYCLE_NOT_THIS_ACCOUNT=1
823
+ fi
824
+
718
825
  exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
719
826
  `;
720
827
  }
@@ -730,13 +837,20 @@ MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
730
837
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
731
838
 
732
839
  # ---------------------------------------------------------------------------
733
- # STANDING GUARD-COVERAGE STATEMENT (T-27).
840
+ # STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
734
841
  #
735
842
  # A repository with no credential on this machine is UNGUARDED: the PostToolUse
736
843
  # hook will find nothing to evaluate with, and it says so once per edit, after
737
844
  # the edit. This says it once, up front, before anything is written \u2014 which is
738
845
  # the one thing a session-start hook can do that nothing else can.
739
846
  #
847
+ # TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
848
+ # solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
849
+ # fixed by re-running the installer; "this is not your project" is permanent,
850
+ # and the same remedy would send the reader in a circle forever. The two are
851
+ # told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
852
+ # below from a machine-level marker beside the credential store.
853
+ #
740
854
  # Everything below runs in a SUBSHELL: it sources a credential store, and a
741
855
  # session-start hook must not leak an engagement's variables into whatever the
742
856
  # editor runs next. Any failure inside it is swallowed; this script's exit
@@ -750,18 +864,33 @@ ${engagementResolutionShell()}
750
864
  . "$HALFCYCLE_ENV_FILE"
751
865
  set +a
752
866
  fi
753
- hc_missing=""
754
- for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
755
- eval "hc_value=\\\${$hc_key:-}"
756
- if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
757
- done
758
- if [ -n "$hc_missing" ]; then
759
- echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
760
- echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
761
- echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
762
- echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
763
- echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
764
- echo "[halfcycle] and needs nothing configured first."
867
+ if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
868
+ # OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
869
+ # case below wearing different words: there is nothing to fix, and the loop
870
+ # that follows is skipped entirely rather than run and then overridden \u2014 a
871
+ # repository whose credential is absent for THIS reason must never be told
872
+ # to re-run the installer, which can never obtain one for it.
873
+ echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
874
+ echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
875
+ echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
876
+ echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
877
+ echo "[halfcycle] Ask the project's owner for view access."
878
+ echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
879
+ echo "[halfcycle] \\"npx halfcycle\\" again."
880
+ else
881
+ hc_missing=""
882
+ for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
883
+ eval "hc_value=\\\${$hc_key:-}"
884
+ if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
885
+ done
886
+ if [ -n "$hc_missing" ]; then
887
+ echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
888
+ echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
889
+ echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
890
+ echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
891
+ echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
892
+ echo "[halfcycle] and needs nothing configured first."
893
+ fi
765
894
  fi
766
895
  ) 2>/dev/null || true
767
896
  exit 0
@@ -921,8 +1050,20 @@ if [ -z "$TOKEN" ]; then
921
1050
  no-home)
922
1051
  echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
923
1052
  *)
924
- echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
925
- echo "halfcycle: run \\"npx halfcycle\\" in this repository \u2014 it will sign you in through your browser if needed, and write the credential to $HOME/.halfcycle, outside this repository." >&2 ;;
1053
+ # OUTCOME B (T-02b, solo-project-ownership) is a \`no-credential\` PROBLEM
1054
+ # with a REASON attached, checked here rather than as a fifth case value
1055
+ # \u2014 see \`engagementResolutionShell\`'s own docblock for why. Re-running
1056
+ # this installer can never obtain a credential for this reason, so it is
1057
+ # the one sentence in this file that must not say to.
1058
+ if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
1059
+ echo "halfcycle: this project belongs to another Halfcycle account, so this machine has no" >&2
1060
+ echo "halfcycle: credential for it and the Halfcycle tools are not available here. Nothing is" >&2
1061
+ echo "halfcycle: broken \u2014 ask the project's owner for view access, or sign in as the account" >&2
1062
+ echo "halfcycle: that owns it and run \\"npx halfcycle\\" again." >&2
1063
+ else
1064
+ echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
1065
+ echo "halfcycle: run \\"npx halfcycle\\" in this repository \u2014 it will sign you in through your browser if needed, and write the credential to $HOME/.halfcycle, outside this repository." >&2
1066
+ fi ;;
926
1067
  esac
927
1068
  exit 1
928
1069
  fi
@@ -1266,11 +1407,13 @@ var CreateEngagementRefused = class extends Error {
1266
1407
  status;
1267
1408
  serverMessage;
1268
1409
  errorCode;
1269
- constructor(status, serverMessage, errorCode, message) {
1410
+ reason;
1411
+ constructor(status, serverMessage, errorCode, reason, message) {
1270
1412
  super(message);
1271
1413
  this.status = status;
1272
1414
  this.serverMessage = serverMessage;
1273
1415
  this.errorCode = errorCode;
1416
+ this.reason = reason;
1274
1417
  this.name = "CreateEngagementRefused";
1275
1418
  }
1276
1419
  /**
@@ -1315,7 +1458,8 @@ function planeRefusal(detail) {
1315
1458
  return void 0;
1316
1459
  return {
1317
1460
  message: candidate.message,
1318
- error: typeof candidate.error === "string" ? candidate.error : void 0
1461
+ error: typeof candidate.error === "string" ? candidate.error : void 0,
1462
+ reason: typeof candidate.reason === "string" ? candidate.reason : void 0
1319
1463
  };
1320
1464
  }
1321
1465
  var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
@@ -1349,7 +1493,7 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
1349
1493
  const detail = await res.text().catch(() => "");
1350
1494
  const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
1351
1495
  if (refusal) {
1352
- throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
1496
+ throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, refusal.reason, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
1353
1497
  }
1354
1498
  const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
1355
1499
  throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
@@ -1877,8 +2021,203 @@ function projectSeedGuard(fired, includeExplanation) {
1877
2021
  // dist/build-record/sources.js
1878
2022
  import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
1879
2023
  import { createHash } from "node:crypto";
1880
- import { join as join8 } from "node:path";
2024
+ import { join as join10 } from "node:path";
2025
+
2026
+ // dist/build-record/close-record.js
1881
2027
  import { execFileSync as execFileSync2 } from "node:child_process";
2028
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
2029
+ import { dirname as dirname4, join as join8 } from "node:path";
2030
+ var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
2031
+ var BUILD_RECORD_ZONE_B_DIR = join8(ZONE_B_DIR, "build-record");
2032
+ var InvalidCloseRecordError = class extends Error {
2033
+ constructor(path, detail) {
2034
+ super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
2035
+ this.name = "InvalidCloseRecordError";
2036
+ }
2037
+ };
2038
+ function closeRecordPath(repoRoot, phase) {
2039
+ assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
2040
+ return join8(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
2041
+ }
2042
+ function resolveCloseAtHead(repoRoot) {
2043
+ try {
2044
+ const closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
2045
+ encoding: "utf-8",
2046
+ stdio: ["ignore", "pipe", "ignore"]
2047
+ }).trim();
2048
+ const closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
2049
+ encoding: "utf-8",
2050
+ stdio: ["ignore", "pipe", "ignore"]
2051
+ }).trim();
2052
+ if (closeCommit === "" || closedDate === "")
2053
+ return null;
2054
+ return { closeCommit, closedDate };
2055
+ } catch {
2056
+ return null;
2057
+ }
2058
+ }
2059
+ function writeCloseRecord(repoRoot, phase) {
2060
+ let path;
2061
+ try {
2062
+ path = closeRecordPath(repoRoot, phase);
2063
+ } catch (err) {
2064
+ return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
2065
+ }
2066
+ const at = resolveCloseAtHead(repoRoot);
2067
+ if (at === null) {
2068
+ return {
2069
+ recorded: false,
2070
+ path,
2071
+ reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
2072
+ };
2073
+ }
2074
+ const record2 = {
2075
+ format: CLOSE_RECORD_FORMAT,
2076
+ phase,
2077
+ closeCommit: at.closeCommit,
2078
+ closedDate: at.closedDate
2079
+ };
2080
+ try {
2081
+ mkdirSync5(dirname4(path), { recursive: true });
2082
+ writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
2083
+ } catch (err) {
2084
+ return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
2085
+ }
2086
+ return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
2087
+ }
2088
+ function parseCloseRecord(path, parsed) {
2089
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2090
+ throw new InvalidCloseRecordError(path, "it does not hold a record");
2091
+ }
2092
+ const record2 = parsed;
2093
+ if (record2.format !== CLOSE_RECORD_FORMAT) {
2094
+ throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
2095
+ }
2096
+ const commit = record2.closeCommit;
2097
+ const date = record2.closedDate;
2098
+ const missing = [];
2099
+ if (typeof commit !== "string" || commit.trim() === "")
2100
+ missing.push("closeCommit");
2101
+ if (typeof date !== "string" || date.trim() === "")
2102
+ missing.push("closedDate");
2103
+ if (missing.length > 0) {
2104
+ 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.`);
2105
+ }
2106
+ if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
2107
+ throw new InvalidCloseRecordError(path, "it does not say which phase closed");
2108
+ }
2109
+ return {
2110
+ format: CLOSE_RECORD_FORMAT,
2111
+ phase: record2.phase,
2112
+ closeCommit: commit,
2113
+ closedDate: date
2114
+ };
2115
+ }
2116
+
2117
+ // dist/build-record/write.js
2118
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
2119
+ import { basename, join as join9, resolve, relative as relative2, isAbsolute } from "node:path";
2120
+
2121
+ // dist/build-record/template.js
2122
+ function severityLabel(seed) {
2123
+ return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
2124
+ }
2125
+ var NOT_RECORDED = "not recorded";
2126
+ function renderCloseLine(record2) {
2127
+ const status = `**Status:** ${record2.status.toUpperCase()}`;
2128
+ const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
2129
+ const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
2130
+ return `${status} \xB7 ${closed} \xB7 ${commit}`;
2131
+ }
2132
+ function renderCloseProvenance(record2) {
2133
+ 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.*";
2134
+ }
2135
+ function renderDoneWhenRows(record2) {
2136
+ const rows = [];
2137
+ for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
2138
+ const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
2139
+ rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
2140
+ }
2141
+ return rows.join("\n");
2142
+ }
2143
+ function renderBuildRecordMarkdown(record2) {
2144
+ const { phase } = record2;
2145
+ const companion = `${renderPhaseSegment(phase.id)}.json`;
2146
+ const guardsList = record2.seedGuards.map(severityLabel).join(", ");
2147
+ const lines = [
2148
+ // Fixed prose (vault-canonical template) + projected title slots.
2149
+ `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
2150
+ "",
2151
+ `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
2152
+ "",
2153
+ "> 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.",
2154
+ "",
2155
+ `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
2156
+ `**Phase:** ${phase.id} \u2014 ${phase.name}`,
2157
+ renderCloseLine(record2),
2158
+ `**Companion:** [\`${companion}\`](${companion})`,
2159
+ "",
2160
+ renderCloseProvenance(record2),
2161
+ "",
2162
+ "## What this phase proved",
2163
+ "",
2164
+ `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
2165
+ "",
2166
+ "## Delivered",
2167
+ "",
2168
+ `- **Packages:** ${record2.delivered.packages.join(", ")}`,
2169
+ `- **Services:** ${record2.delivered.services.join(", ")}`,
2170
+ `- **Tools:** ${record2.delivered.tools.join(", ")}`,
2171
+ `- **Dogfood:** ${record2.delivered.dogfood}`,
2172
+ "",
2173
+ 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.",
2174
+ "",
2175
+ `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
2176
+ "",
2177
+ `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
2178
+ "",
2179
+ "| Done-when | Result | Evidence |",
2180
+ "|---|---|---|",
2181
+ renderDoneWhenRows(record2),
2182
+ ""
2183
+ ];
2184
+ if (record2.acceptance.inv002ProdBypassProbe) {
2185
+ lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
2186
+ }
2187
+ 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"}`, "");
2188
+ return lines.join("\n");
2189
+ }
2190
+
2191
+ // dist/build-record/write.js
2192
+ var BUILD_RECORD_DIR = join9("docs", "build-records");
2193
+ function serialiseRecordJson(record2) {
2194
+ return JSON.stringify(record2, null, 2) + "\n";
2195
+ }
2196
+ function assertInsideOutDir(outDir, candidate, identity) {
2197
+ const rel = relative2(outDir, resolve(candidate));
2198
+ const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute(rel) && rel === basename(rel);
2199
+ if (!isPlainFilename) {
2200
+ throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
2201
+ }
2202
+ }
2203
+ function writeBuildRecord(repoRoot, record2) {
2204
+ const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
2205
+ const identity = record2.phase.id;
2206
+ if (!isValidPhaseIdentity(identity)) {
2207
+ throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
2208
+ }
2209
+ const segment = renderPhaseSegment(identity);
2210
+ const jsonPath = join9(outDir, `${segment}.json`);
2211
+ const mdPath = join9(outDir, `${segment}.md`);
2212
+ assertInsideOutDir(outDir, jsonPath, identity);
2213
+ assertInsideOutDir(outDir, mdPath, identity);
2214
+ mkdirSync6(outDir, { recursive: true });
2215
+ writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
2216
+ writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
2217
+ return { jsonPath, mdPath };
2218
+ }
2219
+
2220
+ // dist/build-record/sources.js
1882
2221
  var MissingSourceError = class extends Error {
1883
2222
  constructor(sourceName, detail) {
1884
2223
  super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
@@ -1934,7 +2273,7 @@ function syntheticRunId(record2) {
1934
2273
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
1935
2274
  }
1936
2275
  function readGuardEvalLog(logDir, phaseId) {
1937
- const logPath = join8(logDir, `phase-${phaseId}.jsonl`);
2276
+ const logPath = join10(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
1938
2277
  if (!existsSync6(logPath)) {
1939
2278
  throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
1940
2279
  }
@@ -1950,7 +2289,7 @@ function readGuardEvalLog(logDir, phaseId) {
1950
2289
  return runs;
1951
2290
  }
1952
2291
  function readOrchestrationState(phasesDir, phaseId) {
1953
- const statePath = join8(phasesDir, `phase-${phaseId}-orchestration-state.json`);
2292
+ const statePath = join10(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
1954
2293
  if (!existsSync6(statePath)) {
1955
2294
  throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
1956
2295
  }
@@ -1967,22 +2306,21 @@ function readOrchestrationState(phasesDir, phaseId) {
1967
2306
  }
1968
2307
  return { tasks };
1969
2308
  }
1970
- function readGitClose(repoRoot) {
1971
- let closeCommit;
1972
- let closedDate;
2309
+ function readPhaseClose(repoRoot, phaseId) {
2310
+ const path = closeRecordPath(repoRoot, phaseId);
2311
+ if (!existsSync6(path))
2312
+ return { closeCommit: null, closedDate: null };
2313
+ let parsed;
1973
2314
  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();
2315
+ parsed = JSON.parse(readFileSync7(path, "utf-8"));
1978
2316
  } catch (err) {
1979
- throw new MissingSourceError("git HEAD", `cannot resolve close commit/date in ${repoRoot}: ${err instanceof Error ? err.message : String(err)}`);
2317
+ throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
2318
+ }
2319
+ const record2 = parseCloseRecord(path, parsed);
2320
+ if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
2321
+ throw new InvalidCloseRecordError(path, `it records the close of ${String(record2.phase)}, not of ${String(phaseId)}`);
1980
2322
  }
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 };
2323
+ return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
1986
2324
  }
1987
2325
  function validateNarrated(narrated) {
1988
2326
  const req = (v, name) => {
@@ -1993,6 +2331,7 @@ function validateNarrated(narrated) {
1993
2331
  req(narrated.engagement, "engagement");
1994
2332
  req(narrated.engagementType, "engagementType");
1995
2333
  req(narrated.phase?.name, "phase.name");
2334
+ assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
1996
2335
  req(narrated.status, "status");
1997
2336
  req(narrated.thesis, "thesis");
1998
2337
  req(narrated.delivered, "delivered");
@@ -2008,7 +2347,7 @@ function validateNarrated(narrated) {
2008
2347
  function loadSources(opts) {
2009
2348
  const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
2010
2349
  const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
2011
- const git2 = readGitClose(opts.repoRoot);
2350
+ const close = readPhaseClose(opts.repoRoot, opts.phaseId);
2012
2351
  const narrated = validateNarrated(opts.narrated);
2013
2352
  if (!Array.isArray(opts.touchedInvariants)) {
2014
2353
  throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
@@ -2016,7 +2355,7 @@ function loadSources(opts) {
2016
2355
  return {
2017
2356
  orchestration,
2018
2357
  narrated,
2019
- git: git2,
2358
+ close,
2020
2359
  guardEvalRuns,
2021
2360
  touchedInvariants: opts.touchedInvariants
2022
2361
  };
@@ -2046,15 +2385,15 @@ function deriveInvariantsExercised(touched) {
2046
2385
  return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
2047
2386
  }
2048
2387
  function assembleBuildRecord(sources) {
2049
- const { narrated, git: git2, orchestration } = sources;
2388
+ const { narrated, close, orchestration } = sources;
2050
2389
  return {
2051
2390
  format: BUILD_RECORD_FORMAT,
2052
2391
  engagement: narrated.engagement,
2053
2392
  engagementType: narrated.engagementType,
2054
2393
  phase: { id: narrated.phase.id, name: narrated.phase.name },
2055
2394
  status: narrated.status,
2056
- closedDate: git2.closedDate,
2057
- closeCommit: git2.closeCommit,
2395
+ closedDate: close.closedDate,
2396
+ closeCommit: close.closeCommit,
2058
2397
  thesis: narrated.thesis,
2059
2398
  thesisHeld: deriveThesisHeld(narrated.acceptance),
2060
2399
  delivered: narrated.delivered,
@@ -2068,85 +2407,6 @@ function assembleBuildRecord(sources) {
2068
2407
  };
2069
2408
  }
2070
2409
 
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
2410
  // dist/build-record/index.js
2151
2411
  function assemblePhaseBuildRecord(opts) {
2152
2412
  const sources = loadSources({
@@ -2163,9 +2423,13 @@ function assemblePhaseBuildRecord(opts) {
2163
2423
  export {
2164
2424
  BUILD_RECORD_DIR,
2165
2425
  BUILD_RECORD_FORMAT,
2426
+ BUILD_RECORD_ZONE_B_DIR,
2427
+ CLOSE_RECORD_FORMAT,
2166
2428
  CreateEngagementRefused,
2167
2429
  FLAT_FILE_TRACKER_HOME,
2168
2430
  HALFCYCLE_STATE_FORMAT,
2431
+ InvalidCloseRecordError,
2432
+ InvalidPhaseIdentityError,
2169
2433
  MissingSourceError,
2170
2434
  SetupManifest,
2171
2435
  ZONE_B_DIR,
@@ -2173,8 +2437,10 @@ export {
2173
2437
  applySetupWrites,
2174
2438
  assembleBuildRecord,
2175
2439
  assemblePhaseBuildRecord,
2440
+ assertValidPhaseIdentity,
2176
2441
  buildEngagementRecord,
2177
2442
  checkDrift,
2443
+ closeRecordPath,
2178
2444
  contextIndexRow,
2179
2445
  createEngagement,
2180
2446
  deriveInvariantsExercised,
@@ -2185,29 +2451,33 @@ export {
2185
2451
  engagementRecordRow,
2186
2452
  flatFileTrackerDefault,
2187
2453
  install,
2454
+ isValidPhaseIdentity,
2188
2455
  layerDocumentRow,
2189
2456
  loadSources,
2190
2457
  mergeSettings,
2191
2458
  mergeWithRecords,
2192
2459
  mintBoardEnterCode,
2193
2460
  mintOrReadIdentity,
2461
+ parseCloseRecord,
2194
2462
  projectSeedGuard,
2195
2463
  projectSeedGuards,
2196
2464
  questionsToPut,
2197
2465
  readBundlePin,
2198
- readGitClose,
2199
2466
  readGuardEvalLog,
2200
2467
  readOrchestrationState,
2468
+ readPhaseClose,
2201
2469
  readRemote,
2202
2470
  readRootCommit,
2203
2471
  renderAnchor,
2204
2472
  renderBuildRecordMarkdown,
2205
2473
  renderDiscovery,
2474
+ renderPhaseSegment,
2206
2475
  runBootstrapScan,
2207
2476
  scanLayers,
2208
2477
  serialiseRecordJson,
2209
2478
  trackerHomeRow,
2210
2479
  validateNarrated,
2211
- writeBuildRecord
2480
+ writeBuildRecord,
2481
+ writeCloseRecord
2212
2482
  };
2213
2483
  //# sourceMappingURL=index.js.map