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/bin.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // dist/bin.js
4
4
  import { execFileSync as execFileSync4 } from "node:child_process";
5
5
  import { readFileSync as readFileSync9 } from "node:fs";
6
- import { join as join10 } from "node:path";
6
+ import { join as join11 } from "node:path";
7
7
 
8
8
  // dist/install.js
9
9
  import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
@@ -61,11 +61,26 @@ var firedGuardSchema = z2.object({
61
61
  */
62
62
  path: z2.string().optional()
63
63
  }).strict();
64
+ var notRunCheckSchema = z2.object({
65
+ /** Which check did not run, by the same reference a fired check carries. */
66
+ patternRef: z2.string(),
67
+ /**
68
+ * Why it could not run, in plain words — what this client did not send, or
69
+ * that the check's kind is not implemented yet. Never the check's own rule.
70
+ */
71
+ reason: z2.string()
72
+ }).strict();
64
73
  var resultEnvelopeSchema = z2.object({
65
74
  guardsFired: z2.array(firedGuardSchema),
66
75
  severity: severitySchema.nullable(),
67
76
  blocking: z2.boolean(),
68
- explanation: z2.string()
77
+ explanation: z2.string(),
78
+ /**
79
+ * The checks that could not run on this edit, each named with the reason.
80
+ * Absent when every check ran — so an evaluation where nothing was skipped
81
+ * looks exactly as it always has, and an absent list is never an empty one.
82
+ */
83
+ notRun: z2.array(notRunCheckSchema).optional()
69
84
  }).strict();
70
85
  var wireErrorSchema = z2.object({
71
86
  statusCode: z2.number(),
@@ -436,6 +451,65 @@ function safeParseAccountIdentity(payload) {
436
451
  return accountIdentitySchema.safeParse(payload);
437
452
  }
438
453
 
454
+ // ../events/dist/phase-identity.js
455
+ var PHASE_SEGMENT_PREFIX = "phase-";
456
+ function isValidPhaseIdentity(identity) {
457
+ if (typeof identity === "number")
458
+ return Number.isInteger(identity) && identity >= 0;
459
+ if (typeof identity !== "string")
460
+ return false;
461
+ if (identity === "")
462
+ return false;
463
+ if (identity.includes("/"))
464
+ return false;
465
+ if (identity.includes("\\"))
466
+ return false;
467
+ if (identity.includes("\0"))
468
+ return false;
469
+ if (identity.startsWith("."))
470
+ return false;
471
+ return true;
472
+ }
473
+ var InvalidPhaseIdentityError = class extends Error {
474
+ /** The value that was refused, exactly as supplied. */
475
+ identity;
476
+ constructor(identity, allowedLocation) {
477
+ const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
478
+ const where = allowedLocation === void 0 ? "" : `
479
+ Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
480
+ super(`that phase identity cannot name ${subject}.
481
+ identity: ${describeIdentity(identity)}` + where);
482
+ this.name = "InvalidPhaseIdentityError";
483
+ this.identity = identity;
484
+ }
485
+ };
486
+ function assertValidPhaseIdentity(identity, allowedLocation) {
487
+ if (!isValidPhaseIdentity(identity)) {
488
+ throw new InvalidPhaseIdentityError(identity, allowedLocation);
489
+ }
490
+ }
491
+ function renderPhaseSegment(identity) {
492
+ if (!isValidPhaseIdentity(identity)) {
493
+ throw new InvalidPhaseIdentityError(identity);
494
+ }
495
+ return `${PHASE_SEGMENT_PREFIX}${identity}`;
496
+ }
497
+ function describeIdentity(identity) {
498
+ if (typeof identity === "string")
499
+ return identity;
500
+ if (identity === null)
501
+ return "null";
502
+ if (identity === void 0)
503
+ return "none supplied";
504
+ if (typeof identity === "number" || typeof identity === "boolean")
505
+ return String(identity);
506
+ if (Array.isArray(identity))
507
+ return "(a list)";
508
+ if (typeof identity === "object")
509
+ return "(an object)";
510
+ return `(a ${typeof identity})`;
511
+ }
512
+
439
513
  // dist/engagement-credential.js
440
514
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
441
515
  import { platform as platform2 } from "node:os";
@@ -535,6 +609,7 @@ var ENGAGEMENT_ENV_KEYS = [
535
609
  ...GUARD_ENV_KEYS,
536
610
  "CONTROL_TELEMETRY_URL"
537
611
  ];
612
+ var PHASE_STAMP_ENV_KEY = "HALFCYCLE_PHASE";
538
613
  var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
539
614
  function shq(value) {
540
615
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -562,33 +637,50 @@ function parseEnvText(raw) {
562
637
  return out;
563
638
  }
564
639
  function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
565
- const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
640
+ const desired = new Map(keys.map((k) => {
641
+ const value = values[k];
642
+ return [k, value === void 0 ? "" : value];
643
+ }));
566
644
  const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
645
+ const written = keys.filter((k) => desired.get(k) !== null);
567
646
  if (existing === null) {
647
+ if (written.length === 0)
648
+ return "";
568
649
  return `${ENV_HEADER}
569
- ` + keys.map(line).join("\n") + "\n";
650
+ ` + written.map(line).join("\n") + "\n";
570
651
  }
571
652
  const seen = /* @__PURE__ */ new Set();
572
- const out = existing.split("\n").map((existingLine) => {
653
+ const out = [];
654
+ for (const existingLine of existing.split("\n")) {
573
655
  const eq = existingLine.indexOf("=");
574
- if (eq === -1)
575
- return existingLine;
656
+ if (eq === -1) {
657
+ out.push(existingLine);
658
+ continue;
659
+ }
576
660
  const lhs = existingLine.slice(0, eq);
577
661
  const exported = /^\s*export\s+/.exec(lhs);
578
662
  const prefix = exported === null ? "" : exported[0];
579
663
  const key = lhs.slice(prefix.length).trim();
580
- if (desired.has(key)) {
581
- seen.add(key);
582
- return `${prefix}${line(key)}`;
664
+ if (!desired.has(key)) {
665
+ out.push(existingLine);
666
+ continue;
583
667
  }
584
- return existingLine;
585
- });
586
- const missing = keys.filter((k) => !seen.has(k));
668
+ seen.add(key);
669
+ if (desired.get(key) === null)
670
+ continue;
671
+ out.push(`${prefix}${line(key)}`);
672
+ }
673
+ const missing = written.filter((k) => !seen.has(k));
587
674
  if (missing.length > 0) {
675
+ const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
676
+ `;
677
+ const block = header + missing.map(line).join("\n");
588
678
  const trailingBlank = out.length > 0 && out[out.length - 1] === "";
589
- const prefix = trailingBlank ? "" : existing.endsWith("\n") ? "" : "\n";
590
- out.push(`${prefix}${ENV_HEADER}
591
- ` + missing.map(line).join("\n"));
679
+ if (trailingBlank)
680
+ out.splice(out.length - 1, 0, block);
681
+ else
682
+ out.push(`
683
+ ${block}`);
592
684
  }
593
685
  let result = out.join("\n");
594
686
  if (existing.endsWith("\n") && !result.endsWith("\n"))
@@ -602,17 +694,19 @@ function readEngagementEnv(engagementId, home) {
602
694
  return null;
603
695
  }
604
696
  }
605
- function writeEngagementEnv(engagementId, values, home) {
697
+ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KEYS) {
606
698
  const path = engagementEnvPath(engagementId, home);
607
699
  const dir = engagementStateDir(engagementId, home);
608
- mkdirSync2(dir, { recursive: true, mode: 448 });
609
700
  let existing;
610
701
  try {
611
702
  existing = readFileSync2(path, "utf-8");
612
703
  } catch {
613
704
  existing = null;
614
705
  }
615
- const reconciled = reconcileEnvText(existing, values);
706
+ const reconciled = reconcileEnvText(existing, values, keys);
707
+ if (existing === null && reconciled === "")
708
+ return "skipped";
709
+ mkdirSync2(dir, { recursive: true, mode: 448 });
616
710
  const outcome = existing === reconciled ? "skipped" : "written";
617
711
  if (outcome === "written") {
618
712
  writeFileSync2(path, reconciled, { mode: 384 });
@@ -2816,8 +2910,203 @@ function projectSeedGuard(fired, includeExplanation) {
2816
2910
  // dist/build-record/sources.js
2817
2911
  import { readFileSync as readFileSync6, existsSync as existsSync4 } from "node:fs";
2818
2912
  import { createHash } from "node:crypto";
2819
- import { join as join6 } from "node:path";
2913
+ import { join as join8 } from "node:path";
2914
+
2915
+ // dist/build-record/close-record.js
2820
2916
  import { execFileSync as execFileSync2 } from "node:child_process";
2917
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
2918
+ import { dirname as dirname3, join as join6 } from "node:path";
2919
+ var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
2920
+ var BUILD_RECORD_ZONE_B_DIR = join6(ZONE_B_DIR, "build-record");
2921
+ var InvalidCloseRecordError = class extends Error {
2922
+ constructor(path, detail) {
2923
+ super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
2924
+ this.name = "InvalidCloseRecordError";
2925
+ }
2926
+ };
2927
+ function closeRecordPath(repoRoot, phase) {
2928
+ assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
2929
+ return join6(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
2930
+ }
2931
+ function resolveCloseAtHead(repoRoot) {
2932
+ try {
2933
+ const closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
2934
+ encoding: "utf-8",
2935
+ stdio: ["ignore", "pipe", "ignore"]
2936
+ }).trim();
2937
+ const closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
2938
+ encoding: "utf-8",
2939
+ stdio: ["ignore", "pipe", "ignore"]
2940
+ }).trim();
2941
+ if (closeCommit === "" || closedDate === "")
2942
+ return null;
2943
+ return { closeCommit, closedDate };
2944
+ } catch {
2945
+ return null;
2946
+ }
2947
+ }
2948
+ function writeCloseRecord(repoRoot, phase) {
2949
+ let path;
2950
+ try {
2951
+ path = closeRecordPath(repoRoot, phase);
2952
+ } catch (err) {
2953
+ return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
2954
+ }
2955
+ const at = resolveCloseAtHead(repoRoot);
2956
+ if (at === null) {
2957
+ return {
2958
+ recorded: false,
2959
+ path,
2960
+ reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
2961
+ };
2962
+ }
2963
+ const record2 = {
2964
+ format: CLOSE_RECORD_FORMAT,
2965
+ phase,
2966
+ closeCommit: at.closeCommit,
2967
+ closedDate: at.closedDate
2968
+ };
2969
+ try {
2970
+ mkdirSync5(dirname3(path), { recursive: true });
2971
+ writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
2972
+ } catch (err) {
2973
+ return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
2974
+ }
2975
+ return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
2976
+ }
2977
+ function parseCloseRecord(path, parsed) {
2978
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2979
+ throw new InvalidCloseRecordError(path, "it does not hold a record");
2980
+ }
2981
+ const record2 = parsed;
2982
+ if (record2.format !== CLOSE_RECORD_FORMAT) {
2983
+ throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
2984
+ }
2985
+ const commit = record2.closeCommit;
2986
+ const date = record2.closedDate;
2987
+ const missing = [];
2988
+ if (typeof commit !== "string" || commit.trim() === "")
2989
+ missing.push("closeCommit");
2990
+ if (typeof date !== "string" || date.trim() === "")
2991
+ missing.push("closedDate");
2992
+ if (missing.length > 0) {
2993
+ 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.`);
2994
+ }
2995
+ if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
2996
+ throw new InvalidCloseRecordError(path, "it does not say which phase closed");
2997
+ }
2998
+ return {
2999
+ format: CLOSE_RECORD_FORMAT,
3000
+ phase: record2.phase,
3001
+ closeCommit: commit,
3002
+ closedDate: date
3003
+ };
3004
+ }
3005
+
3006
+ // dist/build-record/write.js
3007
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
3008
+ import { basename as basename2, join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
3009
+
3010
+ // dist/build-record/template.js
3011
+ function severityLabel(seed) {
3012
+ return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
3013
+ }
3014
+ var NOT_RECORDED = "not recorded";
3015
+ function renderCloseLine(record2) {
3016
+ const status = `**Status:** ${record2.status.toUpperCase()}`;
3017
+ const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
3018
+ const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
3019
+ return `${status} \xB7 ${closed} \xB7 ${commit}`;
3020
+ }
3021
+ function renderCloseProvenance(record2) {
3022
+ 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.*";
3023
+ }
3024
+ function renderDoneWhenRows(record2) {
3025
+ const rows = [];
3026
+ for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
3027
+ const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
3028
+ rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
3029
+ }
3030
+ return rows.join("\n");
3031
+ }
3032
+ function renderBuildRecordMarkdown(record2) {
3033
+ const { phase } = record2;
3034
+ const companion = `${renderPhaseSegment(phase.id)}.json`;
3035
+ const guardsList = record2.seedGuards.map(severityLabel).join(", ");
3036
+ const lines = [
3037
+ // Fixed prose (vault-canonical template) + projected title slots.
3038
+ `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
3039
+ "",
3040
+ `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
3041
+ "",
3042
+ "> 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.",
3043
+ "",
3044
+ `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
3045
+ `**Phase:** ${phase.id} \u2014 ${phase.name}`,
3046
+ renderCloseLine(record2),
3047
+ `**Companion:** [\`${companion}\`](${companion})`,
3048
+ "",
3049
+ renderCloseProvenance(record2),
3050
+ "",
3051
+ "## What this phase proved",
3052
+ "",
3053
+ `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
3054
+ "",
3055
+ "## Delivered",
3056
+ "",
3057
+ `- **Packages:** ${record2.delivered.packages.join(", ")}`,
3058
+ `- **Services:** ${record2.delivered.services.join(", ")}`,
3059
+ `- **Tools:** ${record2.delivered.tools.join(", ")}`,
3060
+ `- **Dogfood:** ${record2.delivered.dogfood}`,
3061
+ "",
3062
+ 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.",
3063
+ "",
3064
+ `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
3065
+ "",
3066
+ `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
3067
+ "",
3068
+ "| Done-when | Result | Evidence |",
3069
+ "|---|---|---|",
3070
+ renderDoneWhenRows(record2),
3071
+ ""
3072
+ ];
3073
+ if (record2.acceptance.inv002ProdBypassProbe) {
3074
+ lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
3075
+ }
3076
+ 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"}`, "");
3077
+ return lines.join("\n");
3078
+ }
3079
+
3080
+ // dist/build-record/write.js
3081
+ var BUILD_RECORD_DIR = join7("docs", "build-records");
3082
+ function serialiseRecordJson(record2) {
3083
+ return JSON.stringify(record2, null, 2) + "\n";
3084
+ }
3085
+ function assertInsideOutDir(outDir, candidate, identity) {
3086
+ const rel = relative2(outDir, resolve2(candidate));
3087
+ const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute(rel) && rel === basename2(rel);
3088
+ if (!isPlainFilename) {
3089
+ throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
3090
+ }
3091
+ }
3092
+ function writeBuildRecord(repoRoot, record2) {
3093
+ const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
3094
+ const identity = record2.phase.id;
3095
+ if (!isValidPhaseIdentity(identity)) {
3096
+ throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
3097
+ }
3098
+ const segment = renderPhaseSegment(identity);
3099
+ const jsonPath = join7(outDir, `${segment}.json`);
3100
+ const mdPath = join7(outDir, `${segment}.md`);
3101
+ assertInsideOutDir(outDir, jsonPath, identity);
3102
+ assertInsideOutDir(outDir, mdPath, identity);
3103
+ mkdirSync6(outDir, { recursive: true });
3104
+ writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
3105
+ writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
3106
+ return { jsonPath, mdPath };
3107
+ }
3108
+
3109
+ // dist/build-record/sources.js
2821
3110
  var MissingSourceError = class extends Error {
2822
3111
  constructor(sourceName, detail) {
2823
3112
  super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
@@ -2873,7 +3162,7 @@ function syntheticRunId(record2) {
2873
3162
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
2874
3163
  }
2875
3164
  function readGuardEvalLog(logDir, phaseId) {
2876
- const logPath = join6(logDir, `phase-${phaseId}.jsonl`);
3165
+ const logPath = join8(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
2877
3166
  if (!existsSync4(logPath)) {
2878
3167
  throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
2879
3168
  }
@@ -2889,7 +3178,7 @@ function readGuardEvalLog(logDir, phaseId) {
2889
3178
  return runs;
2890
3179
  }
2891
3180
  function readOrchestrationState(phasesDir, phaseId) {
2892
- const statePath = join6(phasesDir, `phase-${phaseId}-orchestration-state.json`);
3181
+ const statePath = join8(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
2893
3182
  if (!existsSync4(statePath)) {
2894
3183
  throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
2895
3184
  }
@@ -2906,22 +3195,21 @@ function readOrchestrationState(phasesDir, phaseId) {
2906
3195
  }
2907
3196
  return { tasks };
2908
3197
  }
2909
- function readGitClose(repoRoot) {
2910
- let closeCommit;
2911
- let closedDate;
3198
+ function readPhaseClose(repoRoot, phaseId) {
3199
+ const path = closeRecordPath(repoRoot, phaseId);
3200
+ if (!existsSync4(path))
3201
+ return { closeCommit: null, closedDate: null };
3202
+ let parsed;
2912
3203
  try {
2913
- closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
2914
- encoding: "utf-8"
2915
- }).trim();
2916
- closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], { encoding: "utf-8" }).trim();
3204
+ parsed = JSON.parse(readFileSync6(path, "utf-8"));
2917
3205
  } catch (err) {
2918
- throw new MissingSourceError("git HEAD", `cannot resolve close commit/date in ${repoRoot}: ${err instanceof Error ? err.message : String(err)}`);
3206
+ throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
3207
+ }
3208
+ const record2 = parseCloseRecord(path, parsed);
3209
+ if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
3210
+ throw new InvalidCloseRecordError(path, `it records the close of ${String(record2.phase)}, not of ${String(phaseId)}`);
2919
3211
  }
2920
- if (!closeCommit)
2921
- throw new MissingSourceError("git HEAD", `empty commit sha in ${repoRoot}`);
2922
- if (!closedDate)
2923
- throw new MissingSourceError("git HEAD", `empty commit date in ${repoRoot}`);
2924
- return { closeCommit, closedDate };
3212
+ return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
2925
3213
  }
2926
3214
  function validateNarrated(narrated) {
2927
3215
  const req = (v, name) => {
@@ -2932,6 +3220,7 @@ function validateNarrated(narrated) {
2932
3220
  req(narrated.engagement, "engagement");
2933
3221
  req(narrated.engagementType, "engagementType");
2934
3222
  req(narrated.phase?.name, "phase.name");
3223
+ assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
2935
3224
  req(narrated.status, "status");
2936
3225
  req(narrated.thesis, "thesis");
2937
3226
  req(narrated.delivered, "delivered");
@@ -2947,7 +3236,7 @@ function validateNarrated(narrated) {
2947
3236
  function loadSources(opts) {
2948
3237
  const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
2949
3238
  const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
2950
- const git2 = readGitClose(opts.repoRoot);
3239
+ const close = readPhaseClose(opts.repoRoot, opts.phaseId);
2951
3240
  const narrated = validateNarrated(opts.narrated);
2952
3241
  if (!Array.isArray(opts.touchedInvariants)) {
2953
3242
  throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
@@ -2955,7 +3244,7 @@ function loadSources(opts) {
2955
3244
  return {
2956
3245
  orchestration,
2957
3246
  narrated,
2958
- git: git2,
3247
+ close,
2959
3248
  guardEvalRuns,
2960
3249
  touchedInvariants: opts.touchedInvariants
2961
3250
  };
@@ -2985,15 +3274,15 @@ function deriveInvariantsExercised(touched) {
2985
3274
  return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
2986
3275
  }
2987
3276
  function assembleBuildRecord(sources) {
2988
- const { narrated, git: git2, orchestration } = sources;
3277
+ const { narrated, close, orchestration } = sources;
2989
3278
  return {
2990
3279
  format: BUILD_RECORD_FORMAT,
2991
3280
  engagement: narrated.engagement,
2992
3281
  engagementType: narrated.engagementType,
2993
3282
  phase: { id: narrated.phase.id, name: narrated.phase.name },
2994
3283
  status: narrated.status,
2995
- closedDate: git2.closedDate,
2996
- closeCommit: git2.closeCommit,
3284
+ closedDate: close.closedDate,
3285
+ closeCommit: close.closeCommit,
2997
3286
  thesis: narrated.thesis,
2998
3287
  thesisHeld: deriveThesisHeld(narrated.acceptance),
2999
3288
  delivered: narrated.delivered,
@@ -3007,85 +3296,6 @@ function assembleBuildRecord(sources) {
3007
3296
  };
3008
3297
  }
3009
3298
 
3010
- // dist/build-record/template.js
3011
- function severityLabel(seed) {
3012
- return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
3013
- }
3014
- function renderDoneWhenRows(record2) {
3015
- const rows = [];
3016
- for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
3017
- const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
3018
- rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
3019
- }
3020
- return rows.join("\n");
3021
- }
3022
- function renderBuildRecordMarkdown(record2) {
3023
- const { phase } = record2;
3024
- const guardsList = record2.seedGuards.map(severityLabel).join(", ");
3025
- const lines = [
3026
- // Fixed prose (vault-canonical template) + projected title slots.
3027
- `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
3028
- "",
3029
- `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
3030
- "",
3031
- "> 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.",
3032
- "",
3033
- `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
3034
- `**Phase:** ${phase.id} \u2014 ${phase.name}`,
3035
- `**Status:** ${record2.status.toUpperCase()} \xB7 **Closed:** ${record2.closedDate} \xB7 **Close commit:** \`${record2.closeCommit}\``,
3036
- `**Companion:** [\`phase-${phase.id}.json\`](phase-${phase.id}.json)`,
3037
- "",
3038
- "## What this phase proved",
3039
- "",
3040
- `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
3041
- "",
3042
- "## Delivered",
3043
- "",
3044
- `- **Packages:** ${record2.delivered.packages.join(", ")}`,
3045
- `- **Services:** ${record2.delivered.services.join(", ")}`,
3046
- `- **Tools:** ${record2.delivered.tools.join(", ")}`,
3047
- `- **Dogfood:** ${record2.delivered.dogfood}`,
3048
- "",
3049
- 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.",
3050
- "",
3051
- `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
3052
- "",
3053
- `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
3054
- "",
3055
- "| Done-when | Result | Evidence |",
3056
- "|---|---|---|",
3057
- renderDoneWhenRows(record2),
3058
- ""
3059
- ];
3060
- if (record2.acceptance.inv002ProdBypassProbe) {
3061
- lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
3062
- }
3063
- 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"}`, "");
3064
- return lines.join("\n");
3065
- }
3066
-
3067
- // dist/build-record/write.js
3068
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
3069
- import { join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
3070
- var BUILD_RECORD_DIR = join7("docs", "build-records");
3071
- function serialiseRecordJson(record2) {
3072
- return JSON.stringify(record2, null, 2) + "\n";
3073
- }
3074
- function writeBuildRecord(repoRoot, record2) {
3075
- const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
3076
- const expected = resolve2(repoRoot, BUILD_RECORD_DIR);
3077
- const rel = relative2(expected, outDir);
3078
- if (rel !== "" || isAbsolute(rel)) {
3079
- throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
3080
- }
3081
- mkdirSync5(outDir, { recursive: true });
3082
- const jsonPath = join7(outDir, `phase-${record2.phase.id}.json`);
3083
- const mdPath = join7(outDir, `phase-${record2.phase.id}.md`);
3084
- writeFileSync5(jsonPath, serialiseRecordJson(record2), "utf-8");
3085
- writeFileSync5(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
3086
- return { jsonPath, mdPath };
3087
- }
3088
-
3089
3299
  // dist/build-record/index.js
3090
3300
  function assemblePhaseBuildRecord(opts) {
3091
3301
  const sources = loadSources({
@@ -3102,7 +3312,7 @@ function assemblePhaseBuildRecord(opts) {
3102
3312
 
3103
3313
  // dist/open-phase.js
3104
3314
  import { readFileSync as readFileSync7 } from "node:fs";
3105
- import { join as join8 } from "node:path";
3315
+ import { join as join9 } from "node:path";
3106
3316
  var OpenPhaseRefused = class extends Error {
3107
3317
  status;
3108
3318
  finding;
@@ -3121,7 +3331,7 @@ var OPEN_PHASE_ENV_KEYS = [
3121
3331
  function readRepoCredential(repoRoot, env = process.env, home) {
3122
3332
  const pinned = readPinnedEngagementId(repoRoot);
3123
3333
  const fromStore = pinned === null ? null : readEngagementEnv(pinned, home);
3124
- const looked = pinned === null ? `${join8(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
3334
+ const looked = pinned === null ? `${join9(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
3125
3335
  const values = {};
3126
3336
  for (const key of OPEN_PHASE_ENV_KEYS) {
3127
3337
  const value = (env[key] ?? fromStore?.[key] ?? "").trim();
@@ -3138,18 +3348,33 @@ function readRepoCredential(repoRoot, env = process.env, home) {
3138
3348
  }
3139
3349
  function readPinnedEngagementId(repoRoot) {
3140
3350
  try {
3141
- const pin = JSON.parse(readFileSync7(join8(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
3351
+ const pin = JSON.parse(readFileSync7(join9(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
3142
3352
  const id = pin[PIN_ENGAGEMENT_ID_FIELD];
3143
3353
  return typeof id === "string" && id !== "" ? id : null;
3144
3354
  } catch {
3145
3355
  return null;
3146
3356
  }
3147
3357
  }
3148
- async function openPhase(credential, phase, entry) {
3358
+ function writePhaseStamp(engagementId, phase, home) {
3359
+ const path = engagementEnvPath(engagementId, home);
3360
+ if (phase !== null && !isValidPhaseIdentity(phase)) {
3361
+ return { path, reason: new InvalidPhaseIdentityError(phase).message };
3362
+ }
3363
+ try {
3364
+ writeEngagementEnv(engagementId, { [PHASE_STAMP_ENV_KEY]: phase }, home, [PHASE_STAMP_ENV_KEY]);
3365
+ return null;
3366
+ } catch (err) {
3367
+ return { path, reason: err instanceof Error ? err.message : String(err) };
3368
+ }
3369
+ }
3370
+ async function openPhase(credential, phase, entry, home) {
3149
3371
  const parsed = phaseEntryDecisionSchema.safeParse(entry);
3150
3372
  if (!parsed.success) {
3151
3373
  throw new Error(`[halfcycle] The entry decision is not a decision this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
3152
3374
  }
3375
+ if (phase !== null && !isValidPhaseIdentity(phase)) {
3376
+ throw new InvalidPhaseIdentityError(phase);
3377
+ }
3153
3378
  const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phase`;
3154
3379
  let res;
3155
3380
  try {
@@ -3174,9 +3399,11 @@ async function openPhase(credential, phase, entry) {
3174
3399
  if (!body || !(typeof body.phase === "string" || body.phase === null) || !(typeof body.previousPhase === "string" || body.previousPhase === null) || typeof body.decision !== "object" || body.decision === null || typeof body.decision.decisionId !== "string") {
3175
3400
  throw new Error(`[halfcycle] The response from ${url} did not carry { phase, previousPhase, decision }. The phase may or may not have moved \u2014 read the engagement's decision history before retrying.`);
3176
3401
  }
3402
+ const stampFailure = writePhaseStamp(credential.engagementId, body.phase, home);
3177
3403
  return {
3178
3404
  phase: body.phase,
3179
3405
  previousPhase: body.previousPhase,
3406
+ stampFailure,
3180
3407
  decision: body.decision
3181
3408
  };
3182
3409
  }
@@ -3194,7 +3421,7 @@ var ClosePhaseRefused = class extends Error {
3194
3421
  this.name = "ClosePhaseRefused";
3195
3422
  }
3196
3423
  };
3197
- async function closePhase(credential, phase, outcome, close) {
3424
+ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
3198
3425
  const parsed = acceptanceOutcomeSchema.safeParse(outcome);
3199
3426
  if (!parsed.success) {
3200
3427
  throw new Error(`[halfcycle] That is not an acceptance outcome this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
@@ -3228,7 +3455,13 @@ async function closePhase(credential, phase, outcome, close) {
3228
3455
  if (!body || typeof body.engagementId !== "string" || typeof body.status !== "string") {
3229
3456
  throw new Error(`[halfcycle] The response from ${url} did not carry { engagementId, status }. The phase may or may not have closed \u2014 read the engagement's status before retrying.`);
3230
3457
  }
3231
- return { engagementId: body.engagementId, status: body.status };
3458
+ const closeRecord = repoRoot === void 0 ? {
3459
+ recorded: false,
3460
+ path: null,
3461
+ reason: "no repository was named for this close, so there was nowhere to read a commit from"
3462
+ } : writeCloseRecord(repoRoot, phase);
3463
+ const stampFailure = writePhaseStamp(credential.engagementId, null, home);
3464
+ return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
3232
3465
  }
3233
3466
 
3234
3467
  // dist/cli-contract.js
@@ -3349,7 +3582,7 @@ var PLACEHOLDERS = {
3349
3582
  // dist/banner-facts.js
3350
3583
  import { execFileSync as execFileSync3 } from "node:child_process";
3351
3584
  import { readFileSync as readFileSync8 } from "node:fs";
3352
- import { basename as basename2, join as join9, resolve as resolve3 } from "node:path";
3585
+ import { basename as basename3, join as join10, resolve as resolve3 } from "node:path";
3353
3586
  var BRAND_URL = "halfcycle.ai";
3354
3587
  var TAGLINE = [
3355
3588
  "Fewer cycles.",
@@ -3362,7 +3595,7 @@ var TAGLINE_NARROW = [
3362
3595
  ];
3363
3596
  function bundleVersion() {
3364
3597
  try {
3365
- const pkg = JSON.parse(readFileSync8(join9(BUNDLE_ROOT, "package.json"), "utf-8"));
3598
+ const pkg = JSON.parse(readFileSync8(join10(BUNDLE_ROOT, "package.json"), "utf-8"));
3366
3599
  return typeof pkg.version === "string" ? pkg.version : void 0;
3367
3600
  } catch {
3368
3601
  return void 0;
@@ -3451,7 +3684,7 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
3451
3684
  }
3452
3685
  function safeBasename(dir) {
3453
3686
  try {
3454
- const name = basename2(resolve3(dir)).trim();
3687
+ const name = basename3(resolve3(dir)).trim();
3455
3688
  return name === "" ? "project" : name;
3456
3689
  } catch {
3457
3690
  return "project";
@@ -3512,7 +3745,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
3512
3745
  install into target (default: the current directory)
3513
3746
  --yes / -y: use this machine's saved sign-in without being asked
3514
3747
  halfcycle check-drift <target-repo>
3515
- halfcycle build-record <phase-id> [--repo <root>]
3748
+ halfcycle build-record <phase> [--repo <root>]
3516
3749
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
3517
3750
  halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
3518
3751
 
@@ -3521,7 +3754,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
3521
3754
  `;
3522
3755
  function isHalfcycleMonorepo(dir) {
3523
3756
  try {
3524
- const pkg = JSON.parse(readFileSync9(join10(dir, "package.json"), "utf-8"));
3757
+ const pkg = JSON.parse(readFileSync9(join11(dir, "package.json"), "utf-8"));
3525
3758
  return pkg.name === "halfcycle-monorepo";
3526
3759
  } catch {
3527
3760
  return false;
@@ -3755,30 +3988,30 @@ ${USAGE}`);
3755
3988
  if (cmd === "build-record") {
3756
3989
  const phaseArg = rest[0];
3757
3990
  const repoRoot = rest.includes("--repo") ? rest[rest.indexOf("--repo") + 1] : process.cwd();
3758
- if (!phaseArg || !/^\d+$/.test(phaseArg)) {
3759
- process.stderr.write("halfcycle-bundle: usage: halfcycle-bundle build-record <phase-id> [--repo <root>]\n");
3991
+ if (!phaseArg) {
3992
+ process.stderr.write("halfcycle: usage: halfcycle build-record <phase> [--repo <root>]\n");
3760
3993
  process.exit(1);
3761
3994
  return;
3762
3995
  }
3763
- const phaseId = Number(phaseArg);
3996
+ const phaseId = phaseArg;
3764
3997
  try {
3765
- const inputPath = join10(repoRoot, ".workbench", "build-record", `phase-${phaseId}.input.json`);
3998
+ const inputPath = join11(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
3766
3999
  const input = JSON.parse(readFileSync9(inputPath, "utf-8"));
3767
4000
  const result = assemblePhaseBuildRecord({
3768
4001
  repoRoot,
3769
- phasesDir: join10(repoRoot, "docs", "phases"),
3770
- guardEvalLogDir: input.guardEvalLogDir ?? join10(repoRoot, ".workbench", "guard-eval-log"),
4002
+ phasesDir: join11(repoRoot, "docs", "phases"),
4003
+ guardEvalLogDir: input.guardEvalLogDir ?? join11(repoRoot, ".workbench", "guard-eval-log"),
3771
4004
  phaseId,
3772
4005
  narrated: input.narrated,
3773
4006
  touchedInvariants: input.touchedInvariants
3774
4007
  });
3775
- process.stdout.write(`[halfcycle-bundle] Build Record written:
4008
+ process.stdout.write(`[halfcycle] Build Record written:
3776
4009
  ${result.jsonPath}
3777
4010
  ${result.mdPath}
3778
4011
  `);
3779
4012
  process.exit(0);
3780
4013
  } catch (err) {
3781
- process.stderr.write(`halfcycle-bundle build-record failed: ${err instanceof Error ? err.message : String(err)}
4014
+ process.stderr.write(`halfcycle build-record failed: ${err instanceof Error ? err.message : String(err)}
3782
4015
  `);
3783
4016
  process.exit(1);
3784
4017
  }
@@ -3820,8 +4053,22 @@ ${USAGE}`);
3820
4053
  process.stdout.write(`[halfcycle] This decision CROSSED: ${opened.decision.blockingFinding}
3821
4054
  `);
3822
4055
  }
4056
+ if (opened.stampFailure) {
4057
+ process.stderr.write(`halfcycle open-phase: the phase moved, but this machine's copy of it could not be written: ${opened.stampFailure.reason}
4058
+ halfcycle open-phase: until ${opened.stampFailure.path} can be written, guard evaluations here are filed under whatever phase that file still names. Fix the file and re-run this command \u2014 re-running it is safe.
4059
+ `);
4060
+ process.exit(1);
4061
+ return;
4062
+ }
3823
4063
  process.exit(0);
3824
4064
  } catch (err) {
4065
+ if (err instanceof InvalidPhaseIdentityError) {
4066
+ process.stderr.write(`halfcycle open-phase: ${err.message}
4067
+ halfcycle open-phase: a phase name has to work as a single folder name on this machine \u2014 that is what this machine files its guard evaluations under. Nothing was opened.
4068
+ `);
4069
+ process.exit(2);
4070
+ return;
4071
+ }
3825
4072
  if (err instanceof OpenPhaseRefused) {
3826
4073
  process.stderr.write(`halfcycle open-phase: refused (${err.status}). ${err.message}
3827
4074
  `);
@@ -3901,15 +4148,30 @@ ${USAGE}`);
3901
4148
  verdict,
3902
4149
  findings: findingsDetail.length,
3903
4150
  findingsDetail
3904
- }, closeDecision);
4151
+ }, closeDecision, void 0, repoRoot);
3905
4152
  process.stdout.write(`[halfcycle] Phase "${phaseArg}" closed as ${verdict}${findingsDetail.length > 0 ? ` with ${findingsDetail.length} finding(s)` : ""} by ${closeDecision.actor}. The engagement is now ${closed.status}.
3906
4153
  `);
3907
4154
  if (closeDecision.decision === "override") {
3908
4155
  process.stdout.write(`[halfcycle] This close was an OVERRIDE: ${closeDecision.reason}
4156
+ `);
4157
+ }
4158
+ if (closed.closeRecord.recorded) {
4159
+ process.stdout.write(`[halfcycle] Closed at commit ${closed.closeRecord.closeCommit} (${closed.closeRecord.closedDate}). The Build Record for this phase will name it.
4160
+ `);
4161
+ } else {
4162
+ process.stderr.write(`halfcycle close-phase: the phase closed, but the commit it closed at was not recorded on this machine: ${closed.closeRecord.reason}
4163
+ halfcycle close-phase: this phase's Build Record will say the close commit and the close date were not recorded.
3909
4164
  `);
3910
4165
  }
3911
4166
  process.stdout.write(`[halfcycle] Open the next phase with: halfcycle open-phase <phase> --actor "you" --evidence "\u2026"
3912
4167
  `);
4168
+ if (closed.stampFailure) {
4169
+ process.stderr.write(`halfcycle close-phase: the phase closed, but this machine's record of it could not be cleared: ${closed.stampFailure.reason}
4170
+ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard evaluations here are still filed under the phase you just closed.
4171
+ `);
4172
+ process.exit(1);
4173
+ return;
4174
+ }
3913
4175
  process.exit(0);
3914
4176
  } catch (err) {
3915
4177
  if (err instanceof ClosePhaseRefused) {