halfcycle 0.3.31 → 0.3.33

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
@@ -1,13 +1,10 @@
1
1
  // dist/install.js
2
2
  import { execFileSync as execFileSync2 } from "node:child_process";
3
3
  import { createHash as createHash2 } from "node:crypto";
4
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
4
+ import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
5
5
  import { dirname as dirname2, join as join5, posix, relative } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
- // ../events/dist/result.js
9
- import { z as z2 } from "zod";
10
-
11
8
  // ../core/dist/guard-record.js
12
9
  import { z } from "zod";
13
10
  var severitySchema = z.enum(["info", "warn", "block"]);
@@ -113,7 +110,101 @@ ${block}`);
113
110
  return result;
114
111
  }
115
112
 
113
+ // ../core/dist/hook-wiring.js
114
+ var SETTINGS_REL = ".claude/settings.json";
115
+ var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
116
+ var SESSION_START_MARKER_REL = ".claude/hooks/session-start-marker.sh";
117
+ var GUARD_RUNNER_REL = ".claude/hooks/guard-runner.sh";
118
+ var USER_PROMPT_REMINDER_REL = ".claude/hooks/user-prompt-reminder.sh";
119
+ var SESSION_END_MARKER_REL = ".claude/hooks/session-end-marker.sh";
120
+ var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
121
+ var HALFCYCLE_HOOK_WIRING = [
122
+ {
123
+ event: "SessionStart",
124
+ matcher: null,
125
+ timeout: 5,
126
+ command: `bash ./${SESSION_START_MARKER_REL}`,
127
+ script: SESSION_START_MARKER_REL
128
+ },
129
+ {
130
+ event: "SessionStart",
131
+ matcher: null,
132
+ timeout: 10,
133
+ command: `bash ./${GUARD_RUNNER_REL} refresh-credential`,
134
+ script: GUARD_RUNNER_REL
135
+ },
136
+ {
137
+ event: "UserPromptSubmit",
138
+ matcher: null,
139
+ timeout: 5,
140
+ command: `bash ./${USER_PROMPT_REMINDER_REL}`,
141
+ script: USER_PROMPT_REMINDER_REL
142
+ },
143
+ {
144
+ event: "PostToolUse",
145
+ matcher: WRITE_CAPABLE_TOOLS.join("|"),
146
+ timeout: 60,
147
+ command: `bash ./${GUARD_RUNNER_REL} hook post-tool-use`,
148
+ script: GUARD_RUNNER_REL
149
+ },
150
+ {
151
+ event: "Stop",
152
+ matcher: null,
153
+ timeout: 60,
154
+ command: `bash ./${GUARD_RUNNER_REL} hook stop`,
155
+ script: GUARD_RUNNER_REL
156
+ },
157
+ {
158
+ event: "SubagentStop",
159
+ matcher: null,
160
+ timeout: 60,
161
+ command: `bash ./${GUARD_RUNNER_REL} hook subagent-stop`,
162
+ script: GUARD_RUNNER_REL
163
+ },
164
+ {
165
+ event: "SessionEnd",
166
+ matcher: null,
167
+ timeout: 5,
168
+ command: `bash ./${SESSION_END_MARKER_REL}`,
169
+ script: SESSION_END_MARKER_REL
170
+ }
171
+ ];
172
+ var RETIRED_HOOK_COMMANDS = [];
173
+ var HALFCYCLE_HOOK_COMMANDS = /* @__PURE__ */ new Set([
174
+ ...HALFCYCLE_HOOK_WIRING.map((entry) => entry.command),
175
+ ...RETIRED_HOOK_COMMANDS
176
+ ]);
177
+ var HALFCYCLE_HOOK_WIRING_PATHS = [
178
+ .../* @__PURE__ */ new Set([SETTINGS_REL, ...HALFCYCLE_HOOK_WIRING.map((entry) => entry.script), VENDORED_BIN_REL])
179
+ ];
180
+
181
+ // ../core/dist/secret-shape.js
182
+ var SECRET_SHAPE_PATTERNS = [
183
+ // Assignment to a credential-named identifier: `apiKey = "..."`, `secret: '...'`,
184
+ // `password=...`, `token = ...`, `access_key: ...` — the value need not be quoted.
185
+ /\b(?:api[_-]?key|secret|password|passwd|token|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|bearer)\b\s*[:=]\s*\S+/i,
186
+ // Connection strings that embed credentials: `scheme://user:pass@host`.
187
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]+@/i,
188
+ // Bearer / authorization header values.
189
+ /\bauthorization\b\s*[:=]\s*['"]?\s*bearer\s+\S+/i,
190
+ // PEM / private-key block headers.
191
+ /-----BEGIN\s+(?:RSA\s+|EC\s+|OPENSSH\s+|DSA\s+)?PRIVATE\s+KEY-----/,
192
+ // JWT shape: three base64url segments separated by dots.
193
+ /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/,
194
+ // A long high-entropy hex/base64 run adjacent to a credential-ish word on the
195
+ // same line — a raw key literal. Kept last (broadest, most likely false-drop).
196
+ /\b(?:key|token|secret|credential)\b[\s\S]{0,40}[A-Za-z0-9+/_-]{32,}/i
197
+ ];
198
+ function looksLikeSecret(text) {
199
+ for (const pattern of SECRET_SHAPE_PATTERNS) {
200
+ if (pattern.test(text))
201
+ return true;
202
+ }
203
+ return false;
204
+ }
205
+
116
206
  // ../events/dist/result.js
207
+ import { z as z2 } from "zod";
117
208
  var firedGuardSchema = z2.object({
118
209
  guardId: z2.string(),
119
210
  patternRef: z2.string(),
@@ -171,74 +262,28 @@ var wireErrorSchema = z2.object({
171
262
  message: z2.string()
172
263
  }).strict();
173
264
 
174
- // ../events/dist/telemetry.js
175
- import { z as z3 } from "zod";
176
- var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
177
- var guardEvalOutcomeSchema = z3.enum([
178
- "evaluated",
179
- "infra-error",
180
- "contract-error",
181
- "unconfigured"
182
- ]);
183
- var guardEvalRunSchema = z3.object({
184
- // `runId`/`outcome` are the E2 run-observability additions, declared
185
- // field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
186
- // migration. Every new record populated by the migrated writer (T-07)
187
- // carries both; a legacy line predating the amendment carries neither and
188
- // must still parse (the mixed-era guard-eval log the Build-Record reader
189
- // assembles from, and the runner emit not yet migrated). So the presence
190
- // refinement below is skipped for a legacy line (no `outcome`), and the
191
- // per-writer guarantee lives with the writer + reader-normalisation (T-07),
192
- // where the schema may then be tightened to required. runId is uuid-checked
193
- // WHEN present; outcome is enum-checked when present.
194
- runId: z3.string().uuid().optional(),
195
- engagementId: z3.string().optional(),
196
- runType: z3.enum(["hook", "ci"]),
197
- occurredAt: utcIso8601,
198
- outcome: guardEvalOutcomeSchema.optional(),
199
- failureReason: z3.string().optional(),
200
- phase: z3.string().optional(),
201
- scope: z3.record(z3.string(), z3.unknown()),
202
- cost: z3.record(z3.string(), z3.unknown()).optional(),
203
- result: resultEnvelopeSchema.optional()
204
- }).strict().superRefine((run, ctx) => {
205
- if (run.outcome === void 0)
206
- return;
207
- if (run.outcome === "evaluated" && run.result === void 0) {
208
- ctx.addIssue({
209
- code: z3.ZodIssueCode.custom,
210
- path: ["result"],
211
- message: "result is required when outcome is 'evaluated'"
212
- });
213
- }
214
- if (run.outcome !== "evaluated" && run.result !== void 0) {
215
- ctx.addIssue({
216
- code: z3.ZodIssueCode.custom,
217
- path: ["result"],
218
- message: "result is only present when outcome is 'evaluated'"
219
- });
220
- }
221
- if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
222
- ctx.addIssue({
223
- code: z3.ZodIssueCode.custom,
224
- path: ["engagementId"],
225
- message: "engagementId is required unless outcome is 'unconfigured'"
226
- });
227
- }
228
- });
265
+ // ../events/dist/lone-surrogates.js
266
+ var LONE_SURROGATE = new RegExp("\\p{Cs}", "u");
267
+ function hasLoneSurrogate(text) {
268
+ return LONE_SURROGATE.test(text);
269
+ }
270
+ var LONE_SURROGATE_MESSAGE = "must be valid text: it holds half of a character pair with no other half";
229
271
 
230
- // ../events/dist/state.js
272
+ // ../events/dist/build-record.js
231
273
  import { z as z4 } from "zod";
232
- var utcIso86012 = z4.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
274
+
275
+ // ../events/dist/state.js
276
+ import { z as z3 } from "zod";
277
+ var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
233
278
  var STATE_RECORD_FORMAT = "halfcycle-state-record/v1";
234
- var stepIdSchema = z4.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
235
- var methodVersionSchema = z4.string().regex(/^\d+\.\d+\.\d+$/);
279
+ var stepIdSchema = z3.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
280
+ var methodVersionSchema = z3.string().regex(/^\d+\.\d+\.\d+$/);
236
281
  var anchorPathRegex = /^(?!\/)(?!.*#)(?!method\/)(?!(.*\/)?docs\/method\/).+$/;
237
- var recordKindSchema = z4.enum(["artefact", "step-run", "gate", "intervention"]);
238
- var methodLayerSchema = z4.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
239
- var evidenceKindSchema = z4.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
240
- var declarableEvidenceKindSchema = z4.enum(["anchor", "diff", "repro", "question-answer"]);
241
- var dispositionSchema = z4.enum([
282
+ var recordKindSchema = z3.enum(["artefact", "step-run", "gate", "intervention"]);
283
+ var methodLayerSchema = z3.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
284
+ var evidenceKindSchema = z3.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
285
+ var declarableEvidenceKindSchema = z3.enum(["anchor", "diff", "repro", "question-answer"]);
286
+ var dispositionSchema = z3.enum([
242
287
  "prevented",
243
288
  "corrected-in-spec",
244
289
  "corrected-in-scope",
@@ -248,20 +293,20 @@ var dispositionSchema = z4.enum([
248
293
  "surfaced",
249
294
  "filed-forward"
250
295
  ]);
251
- var mechanismSchema = z4.enum(["guard", "verdict", "served-step", "method-step"]);
252
- var artefactStateSchema = z4.enum(["created", "updated"]);
296
+ var mechanismSchema = z3.enum(["guard", "verdict", "served-step", "method-step"]);
297
+ var artefactStateSchema = z3.enum(["created", "updated"]);
253
298
  var QUALIFIED_OUTCOMES = ["declined", "parked"];
254
- var qualifiedOutcomeSchema = z4.enum(QUALIFIED_OUTCOMES);
299
+ var qualifiedOutcomeSchema = z3.enum(QUALIFIED_OUTCOMES);
255
300
  function isQualifiedOutcome(outcome) {
256
301
  return QUALIFIED_OUTCOMES.includes(outcome);
257
302
  }
258
- var runOutcomeSchema = z4.enum([
303
+ var runOutcomeSchema = z3.enum([
259
304
  "completed",
260
305
  "attempted-failed",
261
306
  ...QUALIFIED_OUTCOMES
262
307
  ]);
263
- var gateKindSchema = z4.enum(["approval", "mechanical"]);
264
- var gateVerdictSchema = z4.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
308
+ var gateKindSchema = z3.enum(["approval", "mechanical"]);
309
+ var gateVerdictSchema = z3.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
265
310
  function outcomeReasonIssue(outcome, reason) {
266
311
  if (isQualifiedOutcome(outcome) && reason === void 0) {
267
312
  return { message: `outcomeReason is required when the outcome is '${outcome}'` };
@@ -273,142 +318,383 @@ function outcomeReasonIssue(outcome, reason) {
273
318
  }
274
319
  return null;
275
320
  }
276
- var anchorEvidenceSchema = z4.object({
277
- kind: z4.literal("anchor"),
278
- path: z4.string().regex(anchorPathRegex),
321
+ var anchorEvidenceSchema = z3.object({
322
+ kind: z3.literal("anchor"),
323
+ path: z3.string().regex(anchorPathRegex),
279
324
  // Optional 1-based line number within the file at `path`. Present when the
280
325
  // writer knows the exact line; omitted — never `0`, never `null` — when it
281
326
  // does not, because a line number is not always obtainable. Absence is a
282
327
  // valid, permanent state here, not a gap meant to be filled in later.
283
- line: z4.number().optional()
328
+ line: z3.number().optional()
284
329
  }).strict();
285
- var diffEvidenceSchema = z4.object({
286
- kind: z4.literal("diff"),
287
- ref: z4.string()
330
+ var diffEvidenceSchema = z3.object({
331
+ kind: z3.literal("diff"),
332
+ ref: z3.string()
288
333
  }).strict();
289
- var reproEvidenceSchema = z4.object({
290
- kind: z4.literal("repro"),
291
- steps: z4.string().max(2e3)
334
+ var reproEvidenceSchema = z3.object({
335
+ kind: z3.literal("repro"),
336
+ steps: z3.string().max(2e3)
292
337
  }).strict();
293
- var questionAnswerEvidenceSchema = z4.object({
294
- kind: z4.literal("question-answer"),
295
- question: z4.string().max(2e3),
296
- before: z4.string().max(2e3),
297
- after: z4.string().max(2e3)
338
+ var questionAnswerEvidenceSchema = z3.object({
339
+ kind: z3.literal("question-answer"),
340
+ question: z3.string().max(2e3),
341
+ before: z3.string().max(2e3),
342
+ after: z3.string().max(2e3)
298
343
  }).strict();
299
- var noOpEvidenceSchema = z4.object({
300
- kind: z4.literal("no-op"),
301
- inspected: z4.string().max(2e3),
302
- unchangedBecause: z4.string().max(2e3)
344
+ var noOpEvidenceSchema = z3.object({
345
+ kind: z3.literal("no-op"),
346
+ inspected: z3.string().max(2e3),
347
+ unchangedBecause: z3.string().max(2e3)
303
348
  }).strict();
304
- var evidenceSchema = z4.discriminatedUnion("kind", [
349
+ var evidenceSchema = z3.discriminatedUnion("kind", [
305
350
  anchorEvidenceSchema,
306
351
  diffEvidenceSchema,
307
352
  reproEvidenceSchema,
308
353
  questionAnswerEvidenceSchema,
309
354
  noOpEvidenceSchema
310
355
  ]);
311
- var stateRecordEnvelopeSchema = z4.object({
312
- format: z4.literal(STATE_RECORD_FORMAT),
356
+ var stateRecordEnvelopeSchema = z3.object({
357
+ format: z3.literal(STATE_RECORD_FORMAT),
313
358
  recordKind: recordKindSchema,
314
- recordId: z4.string().uuid(),
359
+ recordId: z3.string().uuid(),
315
360
  methodVersion: methodVersionSchema,
316
- engagementId: z4.string(),
361
+ engagementId: z3.string(),
317
362
  stepId: stepIdSchema,
318
363
  // Immutable: names when the thing happened, never when a later disposition
319
364
  // moved.
320
- occurredAt: utcIso86012,
365
+ occurredAt: utcIso8601,
321
366
  // Equal to `occurredAt` on first write; advances on each re-emit OF A FINDING.
322
367
  // Carried on the shared envelope so a "when did this last change" query is
323
368
  // kind-agnostic. Kept rather than generalised to `updatedAt` because
324
369
  // `disposition` is the only field any re-emit may change (§5.3).
325
- dispositionAt: utcIso86012,
370
+ dispositionAt: utcIso8601,
326
371
  evidence: evidenceSchema,
327
372
  // Which phase of the project this record belongs to, if any. Present on
328
373
  // records written during phase-scoped work; absent on records that run once
329
374
  // for the whole project rather than per phase. Absence is meaningful on its
330
375
  // own — it is never replaced with a placeholder value, and it is read as
331
376
  // "satisfies any phase", not as "unknown".
332
- phase: z4.string().optional()
377
+ phase: z3.string().optional()
333
378
  });
334
- var artefactRefSchema = z4.object({
335
- name: z4.string(),
336
- parent: z4.string()
379
+ var artefactRefSchema = z3.object({
380
+ name: z3.string(),
381
+ parent: z3.string()
337
382
  }).strict();
338
- var coverageSchema = z4.object({
339
- asked: z4.number().int().nonnegative(),
340
- answered: z4.number().int().nonnegative()
383
+ var coverageSchema = z3.object({
384
+ asked: z3.number().int().nonnegative(),
385
+ answered: z3.number().int().nonnegative()
341
386
  }).strict().superRefine((coverage, ctx) => {
342
387
  if (coverage.answered > coverage.asked) {
343
388
  ctx.addIssue({
344
- code: z4.ZodIssueCode.custom,
389
+ code: z3.ZodIssueCode.custom,
345
390
  path: ["answered"],
346
391
  message: "answered cannot be more than asked"
347
392
  });
348
393
  }
349
394
  });
350
395
  var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
351
- recordKind: z4.literal("artefact"),
396
+ recordKind: z3.literal("artefact"),
352
397
  artefactRef: artefactRefSchema,
353
398
  // Some layer wrote it, by construction — an empty array is not a legitimate
354
399
  // state, so the schema says so.
355
- writtenByLayers: z4.array(methodLayerSchema).min(1),
400
+ writtenByLayers: z3.array(methodLayerSchema).min(1),
356
401
  state: artefactStateSchema
357
402
  }).strict();
358
403
  var stepRunRecordSchema = stateRecordEnvelopeSchema.extend({
359
- recordKind: z4.literal("step-run"),
404
+ recordKind: z3.literal("step-run"),
360
405
  runOutcome: runOutcomeSchema,
361
- failureReason: z4.string().optional(),
362
- outcomeReason: z4.string().max(2e3).optional()
406
+ failureReason: z3.string().optional(),
407
+ outcomeReason: z3.string().max(2e3).optional()
363
408
  }).strict().superRefine((rec, ctx) => {
364
409
  if (rec.runOutcome === "attempted-failed" && rec.failureReason === void 0) {
365
410
  ctx.addIssue({
366
- code: z4.ZodIssueCode.custom,
411
+ code: z3.ZodIssueCode.custom,
367
412
  path: ["failureReason"],
368
413
  message: "failureReason is required when runOutcome is 'attempted-failed'"
369
414
  });
370
415
  }
371
416
  if (rec.runOutcome !== "attempted-failed" && rec.failureReason !== void 0) {
372
417
  ctx.addIssue({
373
- code: z4.ZodIssueCode.custom,
418
+ code: z3.ZodIssueCode.custom,
374
419
  path: ["failureReason"],
375
420
  message: "failureReason is only present when runOutcome is 'attempted-failed'"
376
421
  });
377
422
  }
378
423
  const issue = outcomeReasonIssue(rec.runOutcome, rec.outcomeReason);
379
424
  if (issue) {
380
- ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
425
+ ctx.addIssue({ code: z3.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
381
426
  }
382
427
  });
383
428
  var gateRecordSchema = stateRecordEnvelopeSchema.extend({
384
- recordKind: z4.literal("gate"),
429
+ recordKind: z3.literal("gate"),
385
430
  gateKind: gateKindSchema,
386
431
  verdict: gateVerdictSchema,
387
- actor: z4.string(),
388
- outcomeReason: z4.string().max(2e3).optional(),
432
+ actor: z3.string(),
433
+ outcomeReason: z3.string().max(2e3).optional(),
389
434
  coverage: coverageSchema.optional()
390
435
  }).strict().superRefine((rec, ctx) => {
391
436
  const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
392
437
  if (issue) {
393
- ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
438
+ ctx.addIssue({ code: z3.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
394
439
  }
395
440
  });
396
441
  var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
397
- recordKind: z4.literal("intervention"),
442
+ recordKind: z3.literal("intervention"),
398
443
  layer: methodLayerSchema,
399
444
  mechanism: mechanismSchema,
400
445
  severity: severitySchema,
401
446
  disposition: dispositionSchema,
402
- summary: z4.string().max(2e3),
447
+ summary: z3.string().max(2e3),
403
448
  coverage: coverageSchema.optional()
404
449
  }).strict();
405
- var stateRecordSchema = z4.discriminatedUnion("recordKind", [
450
+ var stateRecordSchema = z3.discriminatedUnion("recordKind", [
406
451
  artefactRecordSchema,
407
452
  stepRunRecordSchema,
408
453
  gateRecordSchema,
409
454
  interventionRecordSchema
410
455
  ]);
411
456
 
457
+ // ../events/dist/control-characters.js
458
+ var CONTROL_CHARACTER_CLASS = "\\p{Cc}";
459
+ var CONTROL_CHARACTER = new RegExp(CONTROL_CHARACTER_CLASS, "u");
460
+ function hasControlCharacter(text) {
461
+ return CONTROL_CHARACTER.test(text);
462
+ }
463
+
464
+ // ../events/dist/phase-identity.js
465
+ var PHASE_SEGMENT_PREFIX = "phase-";
466
+ var PHASE_NAME_MAX_BYTES = 200;
467
+ function isValidPhaseIdentity(identity) {
468
+ if (typeof identity === "number")
469
+ return Number.isInteger(identity) && identity >= 0;
470
+ if (typeof identity !== "string")
471
+ return false;
472
+ if (identity === "")
473
+ return false;
474
+ if (identity.includes("/"))
475
+ return false;
476
+ if (identity.includes("\\"))
477
+ return false;
478
+ if (identity.startsWith("."))
479
+ return false;
480
+ if (hasControlCharacter(identity))
481
+ return false;
482
+ if (utf8ByteLength(identity) > PHASE_NAME_MAX_BYTES)
483
+ return false;
484
+ return true;
485
+ }
486
+ function utf8ByteLength(text) {
487
+ let bytes = 0;
488
+ for (const ch of text) {
489
+ const cp = ch.codePointAt(0);
490
+ bytes += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
491
+ }
492
+ return bytes;
493
+ }
494
+ var InvalidPhaseIdentityError = class extends Error {
495
+ /** The value that was refused, exactly as supplied. */
496
+ identity;
497
+ constructor(identity, allowedLocation) {
498
+ const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
499
+ const where = allowedLocation === void 0 ? "" : `
500
+ Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
501
+ super(`that phase identity cannot name ${subject}.
502
+ identity: ${describeIdentity(identity)}` + where);
503
+ this.name = "InvalidPhaseIdentityError";
504
+ this.identity = identity;
505
+ }
506
+ };
507
+ function assertValidPhaseIdentity(identity, allowedLocation) {
508
+ if (!isValidPhaseIdentity(identity)) {
509
+ throw new InvalidPhaseIdentityError(identity, allowedLocation);
510
+ }
511
+ }
512
+ function renderPhaseSegment(identity) {
513
+ if (!isValidPhaseIdentity(identity)) {
514
+ throw new InvalidPhaseIdentityError(identity);
515
+ }
516
+ return `${PHASE_SEGMENT_PREFIX}${identity}`;
517
+ }
518
+ function describeIdentity(identity) {
519
+ if (identity === "")
520
+ return "(empty)";
521
+ if (typeof identity === "string")
522
+ return identity;
523
+ if (identity === null)
524
+ return "null";
525
+ if (identity === void 0)
526
+ return "none supplied";
527
+ if (typeof identity === "number" || typeof identity === "boolean")
528
+ return String(identity);
529
+ if (Array.isArray(identity))
530
+ return "(a list)";
531
+ if (typeof identity === "object")
532
+ return "(an object)";
533
+ return `(a ${typeof identity})`;
534
+ }
535
+
536
+ // ../events/dist/build-record.js
537
+ var BUILD_RECORD_V2_FORMAT = "halfcycle-build-record/v2";
538
+ var STATEMENT_MAX = 2e3;
539
+ var DEVIATION_MAX = 500;
540
+ var DEVIATIONS_MAX = 20;
541
+ var ACTOR_MAX = 200;
542
+ var FINDING_MAX = 500;
543
+ var FINDINGS_SHOWN_MAX = 50;
544
+ var PHASE_CLOSE_REASON_MAX = 1900;
545
+ var closeCommitSchema = z4.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, "must be a full commit hash: 40 or 64 lowercase hex characters");
546
+ var walkVerdictSchema = z4.enum(["clean", "defects"]);
547
+ var closeDecisionSchema = z4.enum(["done-when-met", "override"]);
548
+ function plainText(max) {
549
+ const base = max === void 0 ? z4.string() : z4.string().max(max);
550
+ return base.refine((v) => !hasControlCharacter(v), {
551
+ message: "must be one line with no control characters (no line breaks, tabs or NUL)"
552
+ }).refine((v) => !hasLoneSurrogate(v), { message: LONE_SURROGATE_MESSAGE });
553
+ }
554
+ function statementText(max) {
555
+ return plainText(max).refine((v) => !looksLikeSecret(v), {
556
+ message: "looks like a password or key \u2014 rephrase it"
557
+ });
558
+ }
559
+ var closeActorSchema = plainText(ACTOR_MAX);
560
+ var phaseNameSchema = z4.string().refine((v) => isValidPhaseIdentity(v), {
561
+ message: `must be a phase name: not empty, no slash or backslash, not starting with a dot, no control characters, at most ${PHASE_NAME_MAX_BYTES} bytes`
562
+ });
563
+ var closeStatementsSchema = z4.object({
564
+ thesis: statementText(STATEMENT_MAX).nullable(),
565
+ outcome: statementText(STATEMENT_MAX).nullable(),
566
+ deviations: z4.array(statementText(DEVIATION_MAX)).max(DEVIATIONS_MAX),
567
+ closeCommit: closeCommitSchema.nullable()
568
+ }).strict();
569
+ var closedBySchema = z4.discriminatedUnion("kind", [
570
+ z4.object({ kind: z4.literal("account"), email: plainText().nullable() }).strict(),
571
+ z4.object({ kind: z4.literal("no-account") }).strict(),
572
+ z4.object({ kind: z4.literal("account-removed") }).strict(),
573
+ z4.object({ kind: z4.literal("not-recorded") }).strict()
574
+ ]);
575
+ var unfinishedStepSchema = z4.object({
576
+ stepId: plainText(),
577
+ state: z4.enum(["parked", "failed"])
578
+ }).strict();
579
+ var crossedSchema = z4.discriminatedUnion("kind", [
580
+ z4.object({ kind: z4.literal("none") }).strict(),
581
+ z4.object({ kind: z4.literal("unfinished-steps"), steps: z4.array(unfinishedStepSchema).min(1) }).strict(),
582
+ z4.object({ kind: z4.literal("clean-over-findings"), findings: z4.number().int().min(1) }).strict(),
583
+ z4.object({ kind: z4.literal("not-recorded") }).strict()
584
+ ]);
585
+ var seatCoverageSchema = z4.object({
586
+ enrolled: z4.number().int().nonnegative(),
587
+ conformant: z4.number().int().nonnegative()
588
+ }).strict().refine((c) => c.conformant <= c.enrolled, {
589
+ message: "conformant cannot exceed enrolled",
590
+ path: ["conformant"]
591
+ });
592
+ var observedCloseSchema = z4.object({
593
+ sequence: z4.number().int().min(1),
594
+ closedAt: utcIso8601,
595
+ closedBy: closedBySchema,
596
+ crossed: crossedSchema,
597
+ seatCoverage: seatCoverageSchema.nullable()
598
+ }).strict();
599
+ var couldNotRunCheckSchema = z4.object({
600
+ check: plainText(),
601
+ description: plainText().nullable(),
602
+ reason: plainText()
603
+ }).strict();
604
+ var firedCheckSchema = z4.object({
605
+ check: plainText(),
606
+ description: plainText().nullable(),
607
+ severity: severitySchema,
608
+ explanation: plainText().nullable(),
609
+ occurrences: z4.number().int().min(1)
610
+ }).strict();
611
+ var phaseChecksSchema = z4.discriminatedUnion("state", [
612
+ z4.object({ state: z4.literal("not-observed") }).strict(),
613
+ z4.object({
614
+ state: z4.literal("nothing-fired"),
615
+ runs: z4.number().int().min(1),
616
+ couldNotRun: z4.array(couldNotRunCheckSchema)
617
+ }).strict(),
618
+ z4.object({
619
+ state: z4.literal("fired"),
620
+ runs: z4.number().int().min(1),
621
+ couldNotRun: z4.array(couldNotRunCheckSchema),
622
+ fired: z4.array(firedCheckSchema).min(1)
623
+ }).strict()
624
+ ]);
625
+ var observedSchema = z4.object({
626
+ engagementId: plainText(),
627
+ methodVersion: methodVersionSchema,
628
+ closes: z4.array(observedCloseSchema).min(1),
629
+ checks: phaseChecksSchema
630
+ }).strict().superRefine((o, ctx) => {
631
+ o.closes.forEach((close, i) => {
632
+ if (close.sequence !== i + 1) {
633
+ ctx.addIssue({
634
+ code: "custom",
635
+ message: "closes must be numbered 1, 2, 3 \u2026 in order with no gap",
636
+ path: ["closes", i, "sequence"]
637
+ });
638
+ }
639
+ });
640
+ });
641
+ var attestedWalkSchema = z4.object({
642
+ verdict: walkVerdictSchema,
643
+ findings: z4.array(plainText(FINDING_MAX)).max(FINDINGS_SHOWN_MAX),
644
+ findingsNotShown: z4.number().int().nonnegative()
645
+ }).strict();
646
+ var attestedStatementsSchema = z4.object({
647
+ thesis: plainText(STATEMENT_MAX).nullable(),
648
+ outcome: plainText(STATEMENT_MAX).nullable(),
649
+ deviations: z4.array(plainText(DEVIATION_MAX)).max(DEVIATIONS_MAX)
650
+ }).strict();
651
+ var attestedCloseSchema = z4.object({
652
+ sequence: z4.number().int().min(1),
653
+ decision: closeDecisionSchema,
654
+ actor: plainText(ACTOR_MAX).nullable(),
655
+ reason: plainText(PHASE_CLOSE_REASON_MAX).nullable(),
656
+ walk: attestedWalkSchema.nullable(),
657
+ closeCommit: closeCommitSchema.nullable(),
658
+ statements: attestedStatementsSchema
659
+ }).strict();
660
+ var attestedSchema = z4.object({
661
+ closes: z4.array(attestedCloseSchema)
662
+ }).strict();
663
+ var buildRecordV2Schema = z4.object({
664
+ format: z4.literal(BUILD_RECORD_V2_FORMAT),
665
+ phase: phaseNameSchema,
666
+ observed: observedSchema,
667
+ attested: attestedSchema
668
+ }).strict().superRefine((record2, ctx) => {
669
+ const observedBySequence = new Map(record2.observed.closes.map((c) => [c.sequence, c]));
670
+ const attestedSequences = record2.attested.closes.map((c) => c.sequence);
671
+ const sameSet = attestedSequences.length === observedBySequence.size && new Set(attestedSequences).size === attestedSequences.length && attestedSequences.every((s) => observedBySequence.has(s));
672
+ if (!sameSet) {
673
+ ctx.addIssue({
674
+ code: "custom",
675
+ message: "attested closes must match the observed closes one for one, by sequence",
676
+ path: ["attested", "closes"]
677
+ });
678
+ }
679
+ record2.attested.closes.forEach((close, i) => {
680
+ if (close.decision === "override" !== (close.reason !== null)) {
681
+ ctx.addIssue({
682
+ code: "custom",
683
+ message: "reason is given exactly when the decision is override",
684
+ path: ["attested", "closes", i, "reason"]
685
+ });
686
+ }
687
+ const observed = observedBySequence.get(close.sequence);
688
+ if (observed !== void 0 && observed.closedBy.kind === "account-removed" !== (close.actor === null)) {
689
+ ctx.addIssue({
690
+ code: "custom",
691
+ message: "actor is empty exactly when the closing account was removed",
692
+ path: ["attested", "closes", i, "actor"]
693
+ });
694
+ }
695
+ });
696
+ });
697
+
412
698
  // ../events/dist/crew.js
413
699
  import { z as z5 } from "zod";
414
700
  var crewGroupSchema = z5.enum(["builder", "reviewer", "always-on"]);
@@ -605,7 +891,7 @@ var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
605
891
 
606
892
  // ../events/dist/device-auth.js
607
893
  import { z as z6 } from "zod";
608
- var utcIso86013 = z6.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
894
+ var utcIso86012 = z6.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
609
895
  var DEVICE_AUTH_STATUS = {
610
896
  /** The poll: minted, nobody has acted yet. Keep polling. 200. */
611
897
  PENDING: "pending",
@@ -650,7 +936,7 @@ var deviceAuthStartResponseSchema = z6.object({
650
936
  deviceCode: z6.string().min(1),
651
937
  userCode: z6.string().min(1),
652
938
  verificationUrl: z6.string().min(1),
653
- expiresAt: utcIso86013,
939
+ expiresAt: utcIso86012,
654
940
  pollIntervalMs: z6.number().int().positive()
655
941
  }).strict();
656
942
  var devicePollPendingSchema = z6.object({
@@ -661,7 +947,7 @@ var devicePollApprovedSchema = z6.object({
661
947
  status: z6.literal(DEVICE_AUTH_STATUS.APPROVED),
662
948
  accountId: z6.string().min(1),
663
949
  credential: z6.string().min(1),
664
- expiresAt: utcIso86013
950
+ expiresAt: utcIso86012
665
951
  }).strict();
666
952
  var deviceAuthRefusalSchema = z6.object({
667
953
  status: z6.enum([
@@ -699,67 +985,6 @@ var deviceConfirmResponseSchema = z6.union([
699
985
  ]);
700
986
  var DASHBOARD_URL = "https://app.halfcycle.ai";
701
987
 
702
- // ../events/dist/phase-identity.js
703
- var PHASE_SEGMENT_PREFIX = "phase-";
704
- function isValidPhaseIdentity(identity) {
705
- if (typeof identity === "number")
706
- return Number.isInteger(identity) && identity >= 0;
707
- if (typeof identity !== "string")
708
- return false;
709
- if (identity === "")
710
- return false;
711
- if (identity.includes("/"))
712
- return false;
713
- if (identity.includes("\\"))
714
- return false;
715
- if (identity.includes("\0"))
716
- return false;
717
- if (identity.startsWith("."))
718
- return false;
719
- return true;
720
- }
721
- var InvalidPhaseIdentityError = class extends Error {
722
- /** The value that was refused, exactly as supplied. */
723
- identity;
724
- constructor(identity, allowedLocation) {
725
- const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
726
- const where = allowedLocation === void 0 ? "" : `
727
- Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
728
- super(`that phase identity cannot name ${subject}.
729
- identity: ${describeIdentity(identity)}` + where);
730
- this.name = "InvalidPhaseIdentityError";
731
- this.identity = identity;
732
- }
733
- };
734
- function assertValidPhaseIdentity(identity, allowedLocation) {
735
- if (!isValidPhaseIdentity(identity)) {
736
- throw new InvalidPhaseIdentityError(identity, allowedLocation);
737
- }
738
- }
739
- function renderPhaseSegment(identity) {
740
- if (!isValidPhaseIdentity(identity)) {
741
- throw new InvalidPhaseIdentityError(identity);
742
- }
743
- return `${PHASE_SEGMENT_PREFIX}${identity}`;
744
- }
745
- function describeIdentity(identity) {
746
- if (identity === "")
747
- return "(empty)";
748
- if (typeof identity === "string")
749
- return identity;
750
- if (identity === null)
751
- return "null";
752
- if (identity === void 0)
753
- return "none supplied";
754
- if (typeof identity === "number" || typeof identity === "boolean")
755
- return String(identity);
756
- if (Array.isArray(identity))
757
- return "(a list)";
758
- if (typeof identity === "object")
759
- return "(an object)";
760
- return `(a ${typeof identity})`;
761
- }
762
-
763
988
  // ../events/dist/credential-wire.js
764
989
  import { z as z7 } from "zod";
765
990
  var engagementCredentialResponseSchema = z7.object({
@@ -771,10 +996,47 @@ var engagementCredentialResponseSchema = z7.object({
771
996
  }).strict();
772
997
  var ciTokenExchangeResponseSchema = z7.object({
773
998
  token: z7.string(),
774
- expiresAt: utcIso86012,
999
+ expiresAt: utcIso8601,
775
1000
  engagementId: z7.string()
776
1001
  }).strict();
777
1002
 
1003
+ // ../events/dist/phase-record.js
1004
+ import { z as z8 } from "zod";
1005
+ var phaseRecordRefusalReasonSchema = z8.enum([
1006
+ "still-open",
1007
+ "reopened",
1008
+ "never-opened",
1009
+ "never-closed",
1010
+ "closed-before-records"
1011
+ ]);
1012
+ var phaseRecordRecordSchema = z8.object({
1013
+ outcome: z8.literal("record"),
1014
+ phase: phaseNameSchema,
1015
+ record: buildRecordV2Schema
1016
+ }).strict().refine((body) => body.record.phase === body.phase, {
1017
+ message: "the record is for a different phase than the one asked for",
1018
+ path: ["record", "phase"]
1019
+ });
1020
+ var phaseRecordWithheldSchema = z8.object({
1021
+ outcome: z8.literal("withheld"),
1022
+ phase: phaseNameSchema,
1023
+ sequence: z8.number().int().min(1),
1024
+ closedAt: utcIso8601,
1025
+ message: z8.string()
1026
+ }).strict();
1027
+ var phaseRecordRefusedSchema = wireErrorSchema.extend({
1028
+ outcome: z8.literal("refused"),
1029
+ phase: phaseNameSchema,
1030
+ statusCode: z8.literal(409),
1031
+ error: z8.literal("PhaseRecordRefused"),
1032
+ reason: phaseRecordRefusalReasonSchema
1033
+ }).strict();
1034
+ var phaseRecordResponseSchema = z8.discriminatedUnion("outcome", [
1035
+ phaseRecordRecordSchema,
1036
+ phaseRecordWithheldSchema,
1037
+ phaseRecordRefusedSchema
1038
+ ]);
1039
+
778
1040
  // dist/engagement-credential.js
779
1041
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
780
1042
  import { platform as platform2 } from "node:os";
@@ -925,14 +1187,36 @@ halfcycle_env_file() {
925
1187
  // dist/identity.js
926
1188
  import { execFileSync } from "node:child_process";
927
1189
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
928
- import { join as join3 } from "node:path";
1190
+ import { isAbsolute, join as join3 } from "node:path";
929
1191
  import { randomUUID } from "node:crypto";
930
1192
  var PROJECT_IDENTITY_NOTE = 'projectId is NOT the engagement id \u2014 it identifies this repository/worktree to Halfcycle, not an engagement. The engagement id lives in .halfcycle/bundle.json, in its "engagementId" field.';
931
- function git(targetRepoRoot, args) {
1193
+ var REPOSITORY_LOCATING_GIT_VARS = [
1194
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1195
+ "GIT_COMMON_DIR",
1196
+ "GIT_DIR",
1197
+ "GIT_GRAFT_FILE",
1198
+ "GIT_IMPLICIT_WORK_TREE",
1199
+ "GIT_INDEX_FILE",
1200
+ "GIT_NO_REPLACE_OBJECTS",
1201
+ "GIT_OBJECT_DIRECTORY",
1202
+ "GIT_PREFIX",
1203
+ "GIT_REPLACE_REF_BASE",
1204
+ "GIT_SHALLOW_FILE",
1205
+ "GIT_WORK_TREE"
1206
+ ];
1207
+ function gitEnvForNamedRepo() {
1208
+ const env = { ...process.env };
1209
+ for (const name of REPOSITORY_LOCATING_GIT_VARS)
1210
+ delete env[name];
1211
+ return env;
1212
+ }
1213
+ function git(targetRepoRoot, args, stdin) {
932
1214
  try {
933
1215
  const out = execFileSync("git", ["-C", targetRepoRoot, ...args], {
934
1216
  encoding: "utf-8",
935
- stdio: ["ignore", "pipe", "ignore"]
1217
+ env: gitEnvForNamedRepo(),
1218
+ ...stdin === void 0 ? {} : { input: stdin },
1219
+ stdio: [stdin === void 0 ? "ignore" : "pipe", "pipe", "ignore"]
936
1220
  });
937
1221
  const trimmed = out.trim();
938
1222
  return trimmed.length > 0 ? trimmed : null;
@@ -950,6 +1234,38 @@ function readRootCommit(targetRepoRoot) {
950
1234
  function readRemote(targetRepoRoot) {
951
1235
  return git(targetRepoRoot, ["remote", "get-url", "origin"]);
952
1236
  }
1237
+ function readIgnoredPaths(targetRepoRoot, paths) {
1238
+ if (paths.length === 0)
1239
+ return null;
1240
+ const out = git(targetRepoRoot, ["check-ignore", "-v", "-z", "--stdin"], paths.map((p) => `${p}\0`).join(""));
1241
+ if (out === null)
1242
+ return null;
1243
+ const fields = out.split("\0");
1244
+ if (fields[fields.length - 1] === "")
1245
+ fields.pop();
1246
+ if (fields.length % 4 !== 0)
1247
+ return null;
1248
+ const ignored = [];
1249
+ for (let i = 0; i < fields.length; i += 4) {
1250
+ const [source, line, pattern, path] = fields.slice(i, i + 4);
1251
+ if (pattern.startsWith("!"))
1252
+ continue;
1253
+ const lineNumber = Number.parseInt(line, 10);
1254
+ if (!Number.isFinite(lineNumber))
1255
+ return null;
1256
+ ignored.push({ path, pattern, source, line: lineNumber });
1257
+ }
1258
+ if (ignored.length === 0)
1259
+ return null;
1260
+ if (ignored.every((entry) => isAbsolute(entry.source)))
1261
+ return ignored;
1262
+ if (git(targetRepoRoot, ["rev-parse", "--show-prefix"]) === null)
1263
+ return ignored;
1264
+ const toplevel = git(targetRepoRoot, ["rev-parse", "--show-toplevel"]);
1265
+ if (toplevel === null)
1266
+ return null;
1267
+ return ignored.map((entry) => isAbsolute(entry.source) ? entry : { ...entry, source: join3(toplevel, entry.source) });
1268
+ }
953
1269
  function mintOrReadIdentity(targetRepoRoot) {
954
1270
  const path = join3(targetRepoRoot, ".halfcycle", "project.json");
955
1271
  if (existsSync(path)) {
@@ -1399,18 +1715,12 @@ var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1399
1715
  var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1400
1716
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
1401
1717
  var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1402
- var SETTINGS_REL = ".claude/settings.json";
1403
1718
  var GITIGNORE_REL = ".gitignore";
1404
1719
  function commandStubRel(name) {
1405
1720
  return `.claude/commands/${name}.md`;
1406
1721
  }
1407
1722
  var RETIRED_WRITE_SET_FILES = [];
1408
- var RETIRED_HOOK_COMMANDS = [];
1409
1723
  var RETIRED_DENY_PATTERNS = [];
1410
- function installerHookCommands() {
1411
- const settings = JSON.parse(generateSettingsJson());
1412
- return Object.values(settings.hooks ?? {}).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
1413
- }
1414
1724
  function closedWriteSet() {
1415
1725
  const files = /* @__PURE__ */ new Set([
1416
1726
  ...(readPluginManifest().commands ?? []).map((cmd) => commandStubRel(cmd.name)),
@@ -1436,7 +1746,7 @@ function closedWriteSet() {
1436
1746
  merged,
1437
1747
  dirs,
1438
1748
  ownedDirs,
1439
- hookCommands: /* @__PURE__ */ new Set([...installerHookCommands(), ...RETIRED_HOOK_COMMANDS]),
1749
+ hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
1440
1750
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
1441
1751
  gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
1442
1752
  };
@@ -1521,6 +1831,54 @@ function assertManifestInWriteSet(manifest, writeSet) {
1521
1831
  throw new Error(`[bundle install] the install record names paths outside the write set: ${strays.join(", ")}`);
1522
1832
  }
1523
1833
  }
1834
+ function findNotShared(targetRepo, manifest, collidedPaths) {
1835
+ const merged = [
1836
+ SETTINGS_REL,
1837
+ ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
1838
+ ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
1839
+ ];
1840
+ const collided = new Set(collidedPaths);
1841
+ const dirVerdicts = /* @__PURE__ */ new Map();
1842
+ const travelsThroughPlainDirs = (rel) => {
1843
+ for (let dir = posix.dirname(rel); dir !== "."; dir = posix.dirname(dir)) {
1844
+ let plain = dirVerdicts.get(dir);
1845
+ if (plain === void 0) {
1846
+ const abs = join5(targetRepo, dir);
1847
+ plain = !lstatOrNull(abs)?.isSymbolicLink() && lstatOrNull(join5(abs, ".git")) === null;
1848
+ dirVerdicts.set(dir, plain);
1849
+ }
1850
+ if (!plain)
1851
+ return false;
1852
+ }
1853
+ return true;
1854
+ };
1855
+ const paths = [
1856
+ .../* @__PURE__ */ new Set([
1857
+ ...manifest.files.filter((f) => !("perMachine" in f)).map((f) => f.path),
1858
+ ...merged,
1859
+ INSTALL_MANIFEST_REL
1860
+ ])
1861
+ ].filter((rel) => !collided.has(rel)).filter((rel) => lstatOrNull(join5(targetRepo, rel)) !== null).filter(travelsThroughPlainDirs).sort();
1862
+ const ignored = readIgnoredPaths(targetRepo, paths);
1863
+ if (ignored === null)
1864
+ return null;
1865
+ const sorted = [...ignored].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1866
+ const named = sorted.find((entry) => entry.path === SETTINGS_REL) ?? sorted[0];
1867
+ return {
1868
+ path: named.path,
1869
+ pattern: named.pattern,
1870
+ source: named.source,
1871
+ line: named.line,
1872
+ others: sorted.length - 1
1873
+ };
1874
+ }
1875
+ function lstatOrNull(path) {
1876
+ try {
1877
+ return lstatSync(path);
1878
+ } catch {
1879
+ return null;
1880
+ }
1881
+ }
1524
1882
  function isPlainObject(value) {
1525
1883
  return value !== null && typeof value === "object" && !Array.isArray(value);
1526
1884
  }
@@ -1635,9 +1993,15 @@ var DENY_PATTERNS = [
1635
1993
  "Bash(git push --no-verify:*)",
1636
1994
  "Bash(git commit --no-verify:*)"
1637
1995
  ];
1638
- var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
1639
1996
  var USER_PROMPT_REMINDER_SENTENCE = "Where this project stands, and what order to work in, is recorded in the Halfcycle service and nowhere in this repository \u2014 call halfcycle_resolve before you answer or plan, because the files here will look complete and current and still be wrong about position.";
1640
1997
  function generateSettingsJson() {
1998
+ const hooks = {};
1999
+ for (const { event, matcher, timeout, command } of HALFCYCLE_HOOK_WIRING) {
2000
+ (hooks[event] ??= []).push({
2001
+ ...matcher === null ? {} : { matcher },
2002
+ hooks: [{ type: "command", command, timeout }]
2003
+ });
2004
+ }
1641
2005
  const settings = {
1642
2006
  $schema: "https://json.schemastore.org/claude-code-settings.json",
1643
2007
  permissions: {
@@ -1645,88 +2009,10 @@ function generateSettingsJson() {
1645
2009
  // and is the machine owner's grant to make, not ours.
1646
2010
  deny: [...DENY_PATTERNS]
1647
2011
  },
1648
- hooks: {
1649
- SessionStart: [
1650
- {
1651
- hooks: [
1652
- {
1653
- type: "command",
1654
- command: "bash ./.claude/hooks/session-start-marker.sh",
1655
- timeout: 5
1656
- }
1657
- ]
1658
- },
1659
- {
1660
- hooks: [
1661
- {
1662
- type: "command",
1663
- command: "bash ./.claude/hooks/guard-runner.sh refresh-credential",
1664
- timeout: 10
1665
- }
1666
- ]
1667
- }
1668
- ],
1669
- UserPromptSubmit: [
1670
- {
1671
- hooks: [
1672
- {
1673
- type: "command",
1674
- command: "bash ./.claude/hooks/user-prompt-reminder.sh",
1675
- timeout: 5
1676
- }
1677
- ]
1678
- }
1679
- ],
1680
- PostToolUse: [
1681
- {
1682
- matcher: WRITE_CAPABLE_TOOLS.join("|"),
1683
- hooks: [
1684
- {
1685
- type: "command",
1686
- command: "bash ./.claude/hooks/guard-runner.sh hook post-tool-use",
1687
- timeout: 60
1688
- }
1689
- ]
1690
- }
1691
- ],
1692
- Stop: [
1693
- {
1694
- hooks: [
1695
- {
1696
- type: "command",
1697
- command: "bash ./.claude/hooks/guard-runner.sh hook stop",
1698
- timeout: 60
1699
- }
1700
- ]
1701
- }
1702
- ],
1703
- SubagentStop: [
1704
- {
1705
- hooks: [
1706
- {
1707
- type: "command",
1708
- command: "bash ./.claude/hooks/guard-runner.sh hook subagent-stop",
1709
- timeout: 60
1710
- }
1711
- ]
1712
- }
1713
- ],
1714
- SessionEnd: [
1715
- {
1716
- hooks: [
1717
- {
1718
- type: "command",
1719
- command: "bash ./.claude/hooks/session-end-marker.sh",
1720
- timeout: 5
1721
- }
1722
- ]
1723
- }
1724
- ]
1725
- }
2012
+ hooks
1726
2013
  };
1727
2014
  return JSON.stringify(settings, null, 2) + "\n";
1728
2015
  }
1729
- var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
1730
2016
  var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
1731
2017
  function generateGuardRunnerWrapper() {
1732
2018
  return `#!/usr/bin/env bash
@@ -1948,14 +2234,16 @@ var CI_STANZA_KNOWN_HEADER_HASHES = [
1948
2234
  function isTrackedAndClean(targetRepo, relPath) {
1949
2235
  try {
1950
2236
  execFileSync2("git", ["-C", targetRepo, "ls-files", "--error-unmatch", "--", relPath], {
1951
- stdio: "ignore"
2237
+ stdio: "ignore",
2238
+ env: gitEnvForNamedRepo()
1952
2239
  });
1953
2240
  } catch {
1954
2241
  return false;
1955
2242
  }
1956
2243
  try {
1957
2244
  execFileSync2("git", ["-C", targetRepo, "diff", "--quiet", "HEAD", "--", relPath], {
1958
- stdio: "ignore"
2245
+ stdio: "ignore",
2246
+ env: gitEnvForNamedRepo()
1959
2247
  });
1960
2248
  } catch {
1961
2249
  return false;
@@ -2533,6 +2821,7 @@ async function install(options) {
2533
2821
  const installManifest = finishManifest(draft);
2534
2822
  assertManifestInWriteSet(installManifest, writeSet);
2535
2823
  recordOwned(report, join5(targetRepo, INSTALL_MANIFEST_REL), targetRepo, serializeManifest(installManifest), INSTALL_MANIFEST_REL);
2824
+ const notShared = findNotShared(targetRepo, installManifest, report.collidedPaths);
2536
2825
  return {
2537
2826
  version: manifest.version,
2538
2827
  writtenPaths: report.writtenPaths,
@@ -2541,7 +2830,8 @@ async function install(options) {
2541
2830
  collidedPaths: report.collidedPaths,
2542
2831
  replacedPaths: report.replacedPaths,
2543
2832
  bootstrapScanRan: scanResult.ran,
2544
- legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null
2833
+ legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null,
2834
+ notShared
2545
2835
  };
2546
2836
  }
2547
2837
 
@@ -2722,7 +3012,7 @@ async function mintBoardEnterCode(baseUrl, sessionToken) {
2722
3012
  }
2723
3013
 
2724
3014
  // dist/setup/manifest.js
2725
- import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync4 } from "node:fs";
3015
+ import { existsSync as existsSync4, lstatSync as lstatSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync4 } from "node:fs";
2726
3016
  import { dirname as dirname3, join as join6, posix as posix2 } from "node:path";
2727
3017
  var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
2728
3018
  var DECLARED_DIR_PREFIX = "docs";
@@ -2810,7 +3100,7 @@ function firstSymlinkOnPath(targetRepo, path) {
2810
3100
  for (let i = 0; i < segments.length; i++) {
2811
3101
  walked = join6(walked, segments[i]);
2812
3102
  try {
2813
- if (lstatSync(walked).isSymbolicLink())
3103
+ if (lstatSync2(walked).isSymbolicLink())
2814
3104
  return segments.slice(0, i + 1).join("/");
2815
3105
  } catch {
2816
3106
  return null;
@@ -3170,446 +3460,485 @@ function buildEngagementRecord(input) {
3170
3460
  return { ok: true, document, rows };
3171
3461
  }
3172
3462
 
3173
- // dist/build-record/types.js
3174
- var BUILD_RECORD_FORMAT = "halfcycle-build-record/v1";
3175
- function projectSeedGuard(fired, includeExplanation) {
3176
- const seed = {
3177
- patternRef: fired.patternRef,
3178
- severity: fired.severity
3179
- };
3180
- if (includeExplanation && fired.explanation)
3181
- seed.explanation = fired.explanation;
3182
- return seed;
3183
- }
3184
-
3185
- // dist/build-record/sources.js
3186
- import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
3187
- import { createHash as createHash3 } from "node:crypto";
3188
- import { join as join10 } from "node:path";
3189
-
3190
- // dist/build-record/close-record.js
3191
- import { execFileSync as execFileSync3 } from "node:child_process";
3192
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
3193
- import { dirname as dirname4, join as join8 } from "node:path";
3194
- var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
3195
- var BUILD_RECORD_ZONE_B_DIR = join8(ZONE_B_DIR, "build-record");
3196
- var InvalidCloseRecordError = class extends Error {
3197
- constructor(path, detail) {
3198
- super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
3199
- this.name = "InvalidCloseRecordError";
3463
+ // dist/build-record/render-v2.js
3464
+ var HIDDEN = /[\p{Cc}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
3465
+ var MARKDOWN_SPECIAL = /* @__PURE__ */ new Set(["\\", "`", "*", "_", "[", "]", "<", "|", "~", "#", "&", "$"]);
3466
+ function printRecordText(text) {
3467
+ if (text === "")
3468
+ return "(blank)";
3469
+ let out = "";
3470
+ for (const ch of text) {
3471
+ if (HIDDEN.test(ch)) {
3472
+ out += `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
3473
+ } else if (MARKDOWN_SPECIAL.has(ch)) {
3474
+ out += `\\${ch}`;
3475
+ } else {
3476
+ out += ch;
3477
+ }
3200
3478
  }
3201
- };
3202
- function closeRecordPath(repoRoot, phase) {
3203
- assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
3204
- return join8(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
3479
+ return out;
3205
3480
  }
3206
- function resolveCloseAtHead(repoRoot) {
3207
- try {
3208
- const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
3209
- encoding: "utf-8",
3210
- stdio: ["ignore", "pipe", "ignore"]
3211
- }).trim();
3212
- const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
3213
- encoding: "utf-8",
3214
- stdio: ["ignore", "pipe", "ignore"]
3215
- }).trim();
3216
- if (closeCommit === "" || closedDate === "")
3217
- return null;
3218
- return { closeCommit, closedDate };
3219
- } catch {
3220
- return null;
3221
- }
3481
+ function makePrinter() {
3482
+ let hidden = false;
3483
+ return {
3484
+ text(value) {
3485
+ if (!hidden && HIDDEN.test(value))
3486
+ hidden = true;
3487
+ return printRecordText(value);
3488
+ },
3489
+ sawHidden() {
3490
+ return hidden;
3491
+ }
3492
+ };
3222
3493
  }
3223
- function writeCloseRecord(repoRoot, phase) {
3224
- let path;
3225
- try {
3226
- path = closeRecordPath(repoRoot, phase);
3227
- } catch (err) {
3228
- return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
3229
- }
3230
- const at = resolveCloseAtHead(repoRoot);
3231
- if (at === null) {
3232
- return {
3233
- recorded: false,
3234
- path,
3235
- reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
3236
- };
3494
+ function counted(n, one, many) {
3495
+ return `${String(n)} ${n === 1 ? one : many}`;
3496
+ }
3497
+ var TITLE_PREFIX = "# Build Record: ";
3498
+ var HEADER_WHAT = "This is the Build Record for one phase of a software project run with the Halfcycle method. A phase is a stage of the work with an agreed finish line; this record says how this one ended.";
3499
+ var HEADER_REGISTERS = "Two kinds of content are kept apart here. Lines under **Observed by Halfcycle** come from Halfcycle's own records, made as the work happened. Lines under **Stated by the client** are quoted, and are the client's own word: Halfcycle did not check them. Halfcycle witnessed the method being run. The client attests the code.";
3500
+ var HEADER_KEPT = "Halfcycle derived this record and kept it when the phase closed, and changes it afterwards in one way only: if an account that closed this phase is later deleted, its email is removed, and so is the name given at any close Halfcycle recorded as made by that account. The client's Halfcycle command-line tool wrote this file from that kept copy when the phase closed or when it was fetched again, so a later deletion changes the kept copy and reaches this file at the next fetch; the same record, as data, is in the .json file of the same name beside this one. If the phase is closed again, this file is replaced by a new record that describes every close, this one included.";
3501
+ var HEADER_HIDDEN = "Some text in this record contains characters that do not print. Each is shown as U+ and its code of four or more hex digits; the .json file holds the text exactly.";
3502
+ var OBSERVED_LABEL = "**Observed by Halfcycle**";
3503
+ var STATED_LABEL = "**Stated by the client** \u2014 Halfcycle did not check these.";
3504
+ var MISMATCHED_HALVES = "This Build Record could not be written: its two parts describe different closes.";
3505
+ function renderChecks(checks, p) {
3506
+ const blocks = ["## Checks during this phase", OBSERVED_LABEL];
3507
+ if (checks.state === "not-observed") {
3508
+ blocks.push("**Nothing was observed.** Halfcycle has no record of any check running during this phase, so this record cannot say whether the work had problems. This is not a clean result.");
3509
+ return blocks;
3510
+ }
3511
+ const runs = counted(checks.runs, "check run", "check runs");
3512
+ if (checks.state === "nothing-fired") {
3513
+ blocks.push(`**Checks ran and found nothing.** Halfcycle recorded ${runs} during this phase, and no check found a problem.`);
3514
+ } else {
3515
+ blocks.push(`**Checks found problems.** Halfcycle recorded ${runs} during this phase. Each check that found something is listed by what it checks, in plain words, then how serious it is, what it found, and the name your terminal prints for it.`);
3516
+ blocks.push(renderFired(checks.fired, p));
3237
3517
  }
3238
- const record2 = {
3239
- format: CLOSE_RECORD_FORMAT,
3240
- phase,
3241
- closeCommit: at.closeCommit,
3242
- closedDate: at.closedDate
3243
- };
3244
- try {
3245
- mkdirSync5(dirname4(path), { recursive: true });
3246
- writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
3247
- } catch (err) {
3248
- return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
3518
+ if (checks.couldNotRun.length === 0) {
3519
+ blocks.push("No check reported that it could not run.");
3520
+ } else {
3521
+ blocks.push("**Some checks could not run.** They neither passed nor failed during this phase:");
3522
+ blocks.push(renderCouldNotRun(checks.couldNotRun, p));
3249
3523
  }
3250
- return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
3524
+ return blocks;
3251
3525
  }
3252
- function parseCloseRecord(path, parsed) {
3253
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3254
- throw new InvalidCloseRecordError(path, "it does not hold a record");
3526
+ function renderFired(fired, p) {
3527
+ const lines = [];
3528
+ for (const entry of fired) {
3529
+ lines.push(entry.description === null ? "- Check: A check found something here, and no plain-language description of it is on record." : `- Check: ${p.text(entry.description)}`);
3530
+ lines.push(` - Seriousness: ${seriousness(entry.severity)}`);
3531
+ lines.push(` - Found: ${counted(entry.occurrences, "time", "times")} during this phase.`);
3532
+ lines.push(` - What it found: ${entry.explanation === null ? "Not included in this record." : p.text(entry.explanation)}`);
3533
+ lines.push(` - The name your terminal prints for this check: ${p.text(entry.check)}`);
3255
3534
  }
3256
- const record2 = parsed;
3257
- if (record2.format !== CLOSE_RECORD_FORMAT) {
3258
- throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
3535
+ return lines.join("\n");
3536
+ }
3537
+ function seriousness(severity) {
3538
+ switch (severity) {
3539
+ case "block":
3540
+ return "serious, it blocks the work.";
3541
+ case "warn":
3542
+ return "a warning.";
3543
+ case "info":
3544
+ return "for information only.";
3259
3545
  }
3260
- const commit = record2.closeCommit;
3261
- const date = record2.closedDate;
3262
- const missing = [];
3263
- if (typeof commit !== "string" || commit.trim() === "")
3264
- missing.push("closeCommit");
3265
- if (typeof date !== "string" || date.trim() === "")
3266
- missing.push("closedDate");
3267
- if (missing.length > 0) {
3268
- 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.`);
3546
+ }
3547
+ function renderCouldNotRun(entries, p) {
3548
+ const lines = [];
3549
+ for (const entry of entries) {
3550
+ lines.push(entry.description === null ? "- Check: A check could not run here, and no plain-language description of it is on record." : `- Check: ${p.text(entry.description)}`);
3551
+ lines.push(` - Why it could not run: ${p.text(entry.reason)}`);
3552
+ lines.push(` - The name your terminal prints for this check: ${p.text(entry.check)}`);
3269
3553
  }
3270
- if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
3271
- throw new InvalidCloseRecordError(path, "it does not say which phase closed");
3554
+ return lines.join("\n");
3555
+ }
3556
+ function headline(observed, attested) {
3557
+ const met = attested.decision === "done-when-met";
3558
+ switch (observed.crossed.kind) {
3559
+ case "none":
3560
+ return met ? "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met." : "**Closed by override.** Halfcycle saw nothing outstanding, and the person closing the phase chose to override rather than state its finish conditions were met.";
3561
+ case "unfinished-steps":
3562
+ case "clean-over-findings":
3563
+ return met ? "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase stated its finish conditions were met anyway." : "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase chose to override it.";
3564
+ case "not-recorded":
3565
+ return met ? "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase stated its finish conditions were met." : "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase chose to override.";
3566
+ }
3567
+ }
3568
+ function closedAtText(closedAt, p) {
3569
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/.exec(closedAt);
3570
+ return m === null ? p.text(closedAt) : `${m[1]} ${m[2]} UTC`;
3571
+ }
3572
+ function signedInAs(closedBy, p) {
3573
+ switch (closedBy.kind) {
3574
+ case "account":
3575
+ return closedBy.email === null ? "an account with no email address on file" : `the account ${p.text(closedBy.email)}`;
3576
+ case "no-account":
3577
+ return "a credential not linked to any person's account";
3578
+ case "account-removed":
3579
+ return "an account that has since been deleted";
3580
+ case "not-recorded":
3581
+ return "not recorded for this close";
3582
+ }
3583
+ }
3584
+ function outstanding(crossed, p) {
3585
+ switch (crossed.kind) {
3586
+ case "none":
3587
+ return ["- Outstanding work Halfcycle could see: none."];
3588
+ case "not-recorded":
3589
+ return ["- Outstanding work Halfcycle could see: not recorded for this close."];
3590
+ case "clean-over-findings":
3591
+ return [
3592
+ `- Outstanding work Halfcycle could see: ${counted(crossed.findings, "problem", "problems")} recorded against this phase.`
3593
+ ];
3594
+ case "unfinished-steps":
3595
+ return [
3596
+ `- Outstanding work Halfcycle could see: ${counted(crossed.steps.length, "step", "steps")} of the method left unfinished. Each is shown by the method's own label for it:`,
3597
+ ...crossed.steps.map((step) => ` - Step: ${p.text(step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
3598
+ ];
3599
+ }
3600
+ }
3601
+ function machines(coverage) {
3602
+ const label = "- Machines signed in on this project at the close: ";
3603
+ if (coverage === null)
3604
+ return `${label}not recorded.`;
3605
+ if (coverage.enrolled === 0)
3606
+ return `${label}none.`;
3607
+ return `${label}${String(coverage.enrolled)}, of which ${String(coverage.conformant)} had Halfcycle's checks working as set up.`;
3608
+ }
3609
+ function walkLines(walk, p) {
3610
+ const label = "> - Acceptance walk (the finished work tried as a user would, by someone other than its builder): ";
3611
+ if (walk === null)
3612
+ return [`${label}none reported.`];
3613
+ const lines = [
3614
+ `${label}${walk.verdict === "clean" ? "reported as finding no problems." : "reported as finding problems."}`
3615
+ ];
3616
+ walk.findings.forEach((finding, i) => {
3617
+ lines.push(`> - Finding ${String(i + 1)}: ${p.text(finding)}`);
3618
+ });
3619
+ if (walk.findingsNotShown > 0) {
3620
+ lines.push(`> - And ${counted(walk.findingsNotShown, "more finding", "more findings")} not shown here.`);
3272
3621
  }
3273
- return {
3274
- format: CLOSE_RECORD_FORMAT,
3275
- phase: record2.phase,
3276
- closeCommit: commit,
3277
- closedDate: date
3278
- };
3622
+ return lines;
3279
3623
  }
3280
-
3281
- // dist/build-record/write.js
3282
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
3283
- import { basename, join as join9, resolve, relative as relative2, isAbsolute } from "node:path";
3284
-
3285
- // dist/build-record/template.js
3286
- function severityLabel(seed) {
3287
- return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
3288
- }
3289
- var NOT_RECORDED = "not recorded";
3290
- function renderCloseLine(record2) {
3291
- const status = `**Status:** ${record2.status.toUpperCase()}`;
3292
- const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
3293
- const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
3294
- return `${status} \xB7 ${closed} \xB7 ${commit}`;
3295
- }
3296
- function renderCloseProvenance(record2) {
3297
- 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.*";
3298
- }
3299
- function renderDoneWhenRows(record2) {
3300
- const rows = [];
3301
- for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
3302
- const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
3303
- rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
3304
- }
3305
- return rows.join("\n");
3306
- }
3307
- function renderBuildRecordMarkdown(record2) {
3308
- const { phase } = record2;
3309
- const companion = `${renderPhaseSegment(phase.id)}.json`;
3310
- const guardsList = record2.seedGuards.map(severityLabel).join(", ");
3624
+ function statedOrNot(value, p) {
3625
+ return value === null ? "Not stated by the client." : p.text(value);
3626
+ }
3627
+ function renderStated(attested, p) {
3311
3628
  const lines = [
3312
- // Fixed prose (vault-canonical template) + projected title slots.
3313
- `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
3314
- "",
3315
- `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
3316
- "",
3317
- "> This Build Record was assembled automatically at phase close by `npx halfcycle close-phase`. Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
3318
- "",
3319
- `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
3320
- `**Phase:** ${phase.id} \u2014 ${phase.name}`,
3321
- renderCloseLine(record2),
3322
- `**Companion:** [\`${companion}\`](${companion})`,
3323
- "",
3324
- renderCloseProvenance(record2),
3325
- "",
3326
- "## What this phase proved",
3327
- "",
3328
- `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
3329
- "",
3330
- "## Delivered",
3331
- "",
3332
- `- **Packages:** ${record2.delivered.packages.join(", ")}`,
3333
- `- **Services:** ${record2.delivered.services.join(", ")}`,
3334
- `- **Tools:** ${record2.delivered.tools.join(", ")}`,
3335
- `- **Dogfood:** ${record2.delivered.dogfood}`,
3336
- "",
3337
- guardsList ? `Guards evaluated (results only): ${guardsList}. *(What a guard reported about this phase is recorded; the guard's own definition and its history are not.)*` : "No guards fired during this phase.",
3338
- "",
3339
- `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
3340
- "",
3341
- `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
3342
- "",
3343
- "| Done-when | Result | Evidence |",
3344
- "|---|---|---|",
3345
- renderDoneWhenRows(record2),
3346
- ""
3629
+ attested.decision === "done-when-met" ? "> - Decision: the phase's agreed finish conditions were met." : "> - Decision: close the phase by override, without stating its finish conditions were met.",
3630
+ `> - Name given: ${attested.actor === null ? "removed with the account that made this close" : p.text(attested.actor)}`,
3631
+ `> - Reason given: ${attested.reason === null ? "none." : p.text(attested.reason)}`,
3632
+ ...walkLines(attested.walk, p),
3633
+ `> - Code closed at (commit): ${statedOrNot(attested.closeCommit, p)}`,
3634
+ `> - What the phase set out to prove: ${statedOrNot(attested.statements.thesis, p)}`,
3635
+ `> - What came out of it: ${statedOrNot(attested.statements.outcome, p)}`
3347
3636
  ];
3348
- if (record2.acceptance.inv002ProdBypassProbe) {
3349
- lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
3637
+ const deviations = attested.statements.deviations;
3638
+ if (deviations.length === 0) {
3639
+ lines.push("> - Changes from the plan: None stated by the client.");
3640
+ } else {
3641
+ lines.push("> - Changes from the plan:");
3642
+ deviations.forEach((deviation, i) => {
3643
+ lines.push(`> - Change ${String(i + 1)}: ${p.text(deviation)}`);
3644
+ });
3350
3645
  }
3351
- 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"}`, "");
3352
3646
  return lines.join("\n");
3353
3647
  }
3648
+ function renderClose(heading, observed, attested, p) {
3649
+ return [
3650
+ heading,
3651
+ headline(observed, attested),
3652
+ OBSERVED_LABEL,
3653
+ [
3654
+ `- Closed at: ${closedAtText(observed.closedAt, p)}`,
3655
+ `- Signed in as: ${signedInAs(observed.closedBy, p)}`,
3656
+ ...outstanding(observed.crossed, p),
3657
+ machines(observed.seatCoverage)
3658
+ ].join("\n"),
3659
+ STATED_LABEL,
3660
+ renderStated(attested, p)
3661
+ ];
3662
+ }
3663
+ function renderBuildRecordV2Markdown(record2) {
3664
+ const p = makePrinter();
3665
+ const attestedBySequence = /* @__PURE__ */ new Map();
3666
+ for (const close of record2.attested.closes)
3667
+ attestedBySequence.set(close.sequence, close);
3668
+ const observedSequences = new Set(record2.observed.closes.map((c) => c.sequence));
3669
+ if (attestedBySequence.size !== record2.attested.closes.length || observedSequences.size !== record2.observed.closes.length || attestedBySequence.size !== observedSequences.size || [...observedSequences].some((s) => !attestedBySequence.has(s))) {
3670
+ throw new Error(MISMATCHED_HALVES);
3671
+ }
3672
+ const phase = p.text(record2.phase);
3673
+ const closes = [...record2.observed.closes].sort((a, b) => a.sequence - b.sequence);
3674
+ const total = closes.length;
3675
+ const latest = closes[total - 1];
3676
+ const body = [
3677
+ `Phase: ${phase} (the name the client gave it)`,
3678
+ OBSERVED_LABEL,
3679
+ [
3680
+ `- Method version: ${p.text(record2.observed.methodVersion)}. This is the version of the Halfcycle method the project was run under; it does not change during a project.`,
3681
+ `- Halfcycle's reference for this project: ${p.text(record2.observed.engagementId)}`,
3682
+ `- Record format: ${p.text(record2.format)}`
3683
+ ].join("\n"),
3684
+ total === 1 ? "This phase was closed once." : `This phase was closed ${String(total)} times. The latest close is described first; each earlier close follows, as it was recorded when it was made.`,
3685
+ ...renderChecks(record2.observed.checks, p),
3686
+ ...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), p)
3687
+ ];
3688
+ if (total > 1) {
3689
+ body.push("## Earlier closes");
3690
+ for (const close of closes.slice(0, -1)) {
3691
+ body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), p));
3692
+ }
3693
+ }
3694
+ const header = [`${TITLE_PREFIX}${phase}`, HEADER_WHAT, HEADER_REGISTERS, HEADER_KEPT];
3695
+ if (p.sawHidden())
3696
+ header.push(HEADER_HIDDEN);
3697
+ return `${[...header, ...body].join("\n\n")}
3698
+ `;
3699
+ }
3354
3700
 
3355
3701
  // dist/build-record/write.js
3356
- var BUILD_RECORD_DIR = join9("docs", "build-records");
3357
- function serialiseRecordJson(record2) {
3358
- return JSON.stringify(record2, null, 2) + "\n";
3359
- }
3702
+ import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, writeSync } from "node:fs";
3703
+ import { basename, join as join8, resolve, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
3704
+ var BUILD_RECORD_DIR = join8("docs", "build-records");
3360
3705
  function assertInsideOutDir(outDir, candidate, identity) {
3361
3706
  const rel = relative2(outDir, resolve(candidate));
3362
- const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute(rel) && rel === basename(rel);
3707
+ const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute2(rel) && rel === basename(rel);
3363
3708
  if (!isPlainFilename) {
3364
3709
  throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
3365
3710
  }
3366
3711
  }
3367
- function writeBuildRecord(repoRoot, record2) {
3368
- const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
3369
- const identity = record2.phase.id;
3370
- if (!isValidPhaseIdentity(identity)) {
3371
- throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
3712
+ function createExclusiveOnDisk(path, text) {
3713
+ const fd = openSync(path, "wx");
3714
+ try {
3715
+ writeSync(fd, text);
3716
+ } catch (err) {
3717
+ closeSync(fd);
3718
+ rmSync2(path, { force: true });
3719
+ throw err;
3372
3720
  }
3373
- const segment = renderPhaseSegment(identity);
3374
- const jsonPath = join9(outDir, `${segment}.json`);
3375
- const mdPath = join9(outDir, `${segment}.md`);
3376
- assertInsideOutDir(outDir, jsonPath, identity);
3377
- assertInsideOutDir(outDir, mdPath, identity);
3378
- mkdirSync6(outDir, { recursive: true });
3379
- writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
3380
- writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
3381
- return { jsonPath, mdPath };
3721
+ closeSync(fd);
3382
3722
  }
3383
-
3384
- // dist/build-record/sources.js
3385
- var MissingSourceError = class extends Error {
3386
- constructor(sourceName, detail) {
3387
- super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
3388
- this.name = "MissingSourceError";
3389
- }
3723
+ function renameOnDisk(from, to) {
3724
+ renameSync(from, to);
3725
+ }
3726
+ var NODE_RECORD_PAIR_IO = {
3727
+ createExclusive: createExclusiveOnDisk,
3728
+ rename: renameOnDisk
3390
3729
  };
3391
- function projectSeedGuards(runs) {
3392
- const byRef = /* @__PURE__ */ new Map();
3393
- for (const run of runs) {
3394
- if (!run.result)
3395
- continue;
3396
- for (const fired of run.result.guardsFired) {
3397
- const seed = projectSeedGuard(
3398
- fired,
3399
- /* includeExplanation */
3400
- false
3401
- );
3402
- const existing = byRef.get(seed.patternRef);
3403
- if (!existing) {
3404
- byRef.set(seed.patternRef, seed);
3405
- } else if (existing.severity !== seed.severity) {
3406
- byRef.set(seed.patternRef, pickHigherSeverity(existing, seed));
3407
- }
3408
- }
3409
- }
3410
- return [...byRef.values()].sort((a, b) => a.patternRef.localeCompare(b.patternRef));
3730
+ function serialiseServedRecord(rawRecord) {
3731
+ return JSON.stringify(rawRecord, null, 2) + "\n";
3411
3732
  }
3412
- var SEVERITY_ORDINAL = { info: 0, warn: 1, block: 2 };
3413
- function pickHigherSeverity(a, b) {
3414
- return (SEVERITY_ORDINAL[a.severity] ?? -1) >= (SEVERITY_ORDINAL[b.severity] ?? -1) ? a : b;
3733
+ function isEexist(err) {
3734
+ return typeof err === "object" && err !== null && err.code === "EEXIST";
3415
3735
  }
3416
- function normalizeLegacyLine(parsed) {
3417
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3418
- return parsed;
3736
+ function readIfPresent(path) {
3737
+ try {
3738
+ return readFileSync7(path, "utf-8");
3739
+ } catch {
3740
+ return null;
3419
3741
  }
3420
- const record2 = parsed;
3421
- if (record2["outcome"] !== void 0)
3422
- return parsed;
3423
- return {
3424
- ...record2,
3425
- runId: record2["runId"] ?? syntheticRunId(record2),
3426
- outcome: "evaluated"
3427
- };
3428
3742
  }
3429
- function syntheticRunId(record2) {
3430
- const key = [
3431
- record2["engagementId"],
3432
- record2["occurredAt"],
3433
- record2["runType"],
3434
- record2["phase"]
3435
- ].join("|");
3436
- const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
3437
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
3438
- }
3439
- function readGuardEvalLog(logDir, phaseId) {
3440
- const logPath = join10(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
3441
- if (!existsSync6(logPath)) {
3442
- throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
3443
- }
3444
- const raw = readFileSync7(logPath, "utf-8");
3445
- const runs = [];
3446
- for (const line of raw.split("\n")) {
3447
- const trimmed = line.trim();
3448
- if (trimmed === "")
3449
- continue;
3450
- const parsed = JSON.parse(trimmed);
3451
- runs.push(guardEvalRunSchema.parse(normalizeLegacyLine(parsed)));
3743
+ function isOwnEarlierRecord(existingText, served) {
3744
+ let parsedJson;
3745
+ try {
3746
+ parsedJson = JSON.parse(existingText);
3747
+ } catch {
3748
+ return false;
3452
3749
  }
3453
- return runs;
3750
+ const existing = buildRecordV2Schema.safeParse(parsedJson);
3751
+ if (!existing.success)
3752
+ return false;
3753
+ const old = existing.data;
3754
+ if (old.observed.engagementId !== served.observed.engagementId)
3755
+ return false;
3756
+ if (old.phase !== served.phase)
3757
+ return false;
3758
+ const oldCloses = old.observed.closes.length;
3759
+ const newCloses = served.observed.closes.length;
3760
+ if (oldCloses < newCloses)
3761
+ return true;
3762
+ if (oldCloses > newCloses)
3763
+ return false;
3764
+ const oldSequences = new Set(old.observed.closes.map((c) => c.sequence));
3765
+ return served.observed.closes.every((c) => oldSequences.has(c.sequence));
3454
3766
  }
3455
- function readOrchestrationState(phasesDir, phaseId) {
3456
- const statePath = join10(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
3457
- if (!existsSync6(statePath)) {
3458
- throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
3767
+ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_PAIR_IO) {
3768
+ if (!isValidPhaseIdentity(phase)) {
3769
+ throw new InvalidPhaseIdentityError(phase, BUILD_RECORD_DIR);
3459
3770
  }
3460
- const parsed = JSON.parse(readFileSync7(statePath, "utf-8"));
3461
- if (!parsed.tasks || typeof parsed.tasks !== "object") {
3462
- throw new MissingSourceError("orchestration state", `${statePath} has no tasks map`);
3771
+ const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
3772
+ const segment = renderPhaseSegment(phase);
3773
+ const jsonPath = join8(outDir, `${segment}.json`);
3774
+ const mdPath = join8(outDir, `${segment}.md`);
3775
+ const jsonTmp = `${jsonPath}.tmp`;
3776
+ const mdTmp = `${mdPath}.tmp`;
3777
+ for (const candidate of [jsonPath, mdPath, jsonTmp, mdTmp]) {
3778
+ assertInsideOutDir(outDir, candidate, phase);
3779
+ }
3780
+ const jsonText = serialiseServedRecord(rawRecord);
3781
+ const mdText = renderBuildRecordV2Markdown(record2);
3782
+ mkdirSync5(outDir, { recursive: true });
3783
+ let createdJson = false;
3784
+ try {
3785
+ io.createExclusive(jsonPath, jsonText);
3786
+ createdJson = true;
3787
+ io.createExclusive(mdPath, mdText);
3788
+ return { kind: "created", jsonPath, mdPath };
3789
+ } catch (err) {
3790
+ if (createdJson)
3791
+ rmSync2(jsonPath, { force: true });
3792
+ if (!isEexist(err))
3793
+ throw err;
3463
3794
  }
3464
- const tasks = {};
3465
- for (const [id, t] of Object.entries(parsed.tasks)) {
3466
- if (!t || typeof t.status !== "string") {
3467
- throw new MissingSourceError("orchestration state", `task ${id} has no status`);
3468
- }
3469
- tasks[id] = { status: t.status };
3795
+ const existingJson = readIfPresent(jsonPath);
3796
+ if (existingJson === null) {
3797
+ return { kind: "in-the-way", path: existsSync6(jsonPath) ? jsonPath : mdPath };
3470
3798
  }
3471
- return { tasks };
3472
- }
3473
- function readPhaseClose(repoRoot, phaseId) {
3474
- const path = closeRecordPath(repoRoot, phaseId);
3475
- if (!existsSync6(path))
3476
- return { closeCommit: null, closedDate: null };
3477
- let parsed;
3799
+ if (existingJson === jsonText) {
3800
+ replaceOne(io, mdTmp, mdPath, mdText);
3801
+ return { kind: "up-to-date", jsonPath, mdPath };
3802
+ }
3803
+ if (!isOwnEarlierRecord(existingJson, record2)) {
3804
+ return { kind: "in-the-way", path: jsonPath };
3805
+ }
3806
+ replaceOne(io, jsonTmp, jsonPath, jsonText);
3478
3807
  try {
3479
- parsed = JSON.parse(readFileSync7(path, "utf-8"));
3808
+ replaceOne(io, mdTmp, mdPath, mdText);
3480
3809
  } catch (err) {
3481
- throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
3482
- }
3483
- const record2 = parseCloseRecord(path, parsed);
3484
- if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
3485
- throw new InvalidCloseRecordError(path, `it records the close of ${String(record2.phase)}, not of ${String(phaseId)}`);
3810
+ try {
3811
+ replaceOne(io, jsonTmp, jsonPath, existingJson);
3812
+ } catch {
3813
+ rmSync2(jsonPath, { force: true });
3814
+ }
3815
+ throw err;
3486
3816
  }
3487
- return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
3817
+ return { kind: "replaced", jsonPath, mdPath };
3488
3818
  }
3489
- function validateNarrated(narrated) {
3490
- const req = (v, name) => {
3491
- if (v === void 0 || v === null || v === "") {
3492
- throw new MissingSourceError("narrated artefact", `${name} is missing`);
3493
- }
3494
- };
3495
- req(narrated.engagement, "engagement");
3496
- req(narrated.engagementType, "engagementType");
3497
- req(narrated.phase?.name, "phase.name");
3498
- assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
3499
- req(narrated.status, "status");
3500
- req(narrated.thesis, "thesis");
3501
- req(narrated.delivered, "delivered");
3502
- req(narrated.acceptance, "acceptance");
3503
- req(narrated.acceptance?.verdict, "acceptance.verdict");
3504
- req(narrated.gatesEarnedKeep, "gatesEarnedKeep");
3505
- req(narrated.instruments, "instruments");
3506
- if (!Array.isArray(narrated.deviations)) {
3507
- throw new MissingSourceError("narrated artefact", "deviations must be an array");
3508
- }
3509
- return narrated;
3510
- }
3511
- function loadSources(opts) {
3512
- const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
3513
- const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
3514
- const close = readPhaseClose(opts.repoRoot, opts.phaseId);
3515
- const narrated = validateNarrated(opts.narrated);
3516
- if (!Array.isArray(opts.touchedInvariants)) {
3517
- throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
3819
+ function replaceOne(io, tmp, finalPath, text) {
3820
+ rmSync2(tmp, { force: true });
3821
+ try {
3822
+ io.createExclusive(tmp, text);
3823
+ io.rename(tmp, finalPath);
3824
+ } catch (err) {
3825
+ rmSync2(tmp, { force: true });
3826
+ throw err;
3518
3827
  }
3519
- return {
3520
- orchestration,
3521
- narrated,
3522
- close,
3523
- guardEvalRuns,
3524
- touchedInvariants: opts.touchedInvariants
3525
- };
3526
3828
  }
3527
3829
 
3528
- // dist/build-record/assemble.js
3529
- var LANDED_STATES = /* @__PURE__ */ new Set(["merged", "done"]);
3530
- var CANCELLED_STATE = "cancelled";
3531
- function deriveTasks(orchestration) {
3532
- const ids = Object.keys(orchestration.tasks);
3533
- const cancelled = ids.filter((id) => orchestration.tasks[id].status === CANCELLED_STATE).sort((a, b) => a.localeCompare(b));
3534
- const landed = ids.filter((id) => LANDED_STATES.has(orchestration.tasks[id].status)).length;
3535
- return { planned: ids.length, landed, cancelled };
3536
- }
3537
- function deriveThesisHeld(acceptance) {
3538
- if (acceptance.verdict !== "clean")
3539
- return false;
3540
- for (const dw of Object.values(acceptance.doneWhen)) {
3541
- if (dw.result === "dropped")
3542
- continue;
3543
- if (dw.result !== "pass")
3544
- return false;
3545
- }
3546
- return true;
3830
+ // dist/build-record/obtain.js
3831
+ import { relative as relative3 } from "node:path";
3832
+ var REFUSAL_FOLLOW_UP = {
3833
+ "still-open": "Nothing was written. The record is produced when the phase closes.",
3834
+ "reopened": "Nothing was written. A new record is produced when the phase closes again.",
3835
+ "never-opened": "Nothing was written. Check the name: it must match the phase name exactly as it was opened.",
3836
+ "never-closed": "Nothing was written.",
3837
+ "closed-before-records": "Nothing was written."
3838
+ };
3839
+ var REFUSAL_REASONS = phaseRecordRefusalReasonSchema.options;
3840
+ function buildRecordUrl(credential, phase) {
3841
+ return `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phases/${encodeURIComponent(phase)}/build-record`;
3547
3842
  }
3548
- function deriveInvariantsExercised(touched) {
3549
- return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
3843
+ function isMissingRoute(status, body) {
3844
+ if (status !== 404 || typeof body !== "object" || body === null)
3845
+ return false;
3846
+ const { error, message } = body;
3847
+ return error === "Not Found" && typeof message === "string" && /^Route GET .* not found\.$/s.test(message);
3550
3848
  }
3551
- function assembleBuildRecord(sources) {
3552
- const { narrated, close, orchestration } = sources;
3849
+ async function obtainBuildRecord(credential, repoRoot, phase, io = NODE_RECORD_PAIR_IO) {
3850
+ const url = buildRecordUrl(credential, phase);
3851
+ let res;
3852
+ let text;
3853
+ try {
3854
+ res = await fetch(url, {
3855
+ method: "GET",
3856
+ headers: { authorization: `Bearer ${credential.token}` }
3857
+ });
3858
+ text = await res.text();
3859
+ } catch (err) {
3860
+ return {
3861
+ kind: "unavailable",
3862
+ why: `could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}.`
3863
+ };
3864
+ }
3865
+ let raw = null;
3866
+ try {
3867
+ raw = JSON.parse(text);
3868
+ } catch {
3869
+ raw = null;
3870
+ }
3871
+ const parsed = phaseRecordResponseSchema.safeParse(raw);
3872
+ if (parsed.success) {
3873
+ const answer = parsed.data;
3874
+ if (answer.phase !== phase) {
3875
+ return {
3876
+ kind: "unavailable",
3877
+ why: `the service answered for a different phase than the one asked for, so nothing was written.`
3878
+ };
3879
+ }
3880
+ if (answer.outcome === "withheld")
3881
+ return { kind: "withheld", message: answer.message };
3882
+ if (answer.outcome === "refused") {
3883
+ return { kind: "refused", reason: answer.reason, message: answer.message };
3884
+ }
3885
+ const rawRecord = raw.record;
3886
+ let result;
3887
+ try {
3888
+ result = writeRecordPair(repoRoot, phase, rawRecord, answer.record, io);
3889
+ } catch (err) {
3890
+ return {
3891
+ kind: "unavailable",
3892
+ why: `the record arrived but could not be written: ${err instanceof Error ? err.message : String(err)}. Nothing was left half written.`
3893
+ };
3894
+ }
3895
+ if (result.kind === "in-the-way") {
3896
+ return { kind: "exists", path: relative3(repoRoot, result.path) };
3897
+ }
3898
+ return {
3899
+ kind: "written",
3900
+ jsonPath: relative3(repoRoot, result.jsonPath),
3901
+ mdPath: relative3(repoRoot, result.mdPath)
3902
+ };
3903
+ }
3904
+ if (isMissingRoute(res.status, raw)) {
3905
+ return { kind: "unavailable", why: "this Halfcycle service does not provide Build Records yet." };
3906
+ }
3907
+ const message = typeof raw === "object" && raw !== null && typeof raw.message === "string" ? raw.message : null;
3908
+ if (res.ok) {
3909
+ return {
3910
+ kind: "unavailable",
3911
+ why: `the service's answer was not a Build Record this version of the CLI can read, so nothing was written.`
3912
+ };
3913
+ }
3553
3914
  return {
3554
- format: BUILD_RECORD_FORMAT,
3555
- engagement: narrated.engagement,
3556
- engagementType: narrated.engagementType,
3557
- phase: { id: narrated.phase.id, name: narrated.phase.name },
3558
- status: narrated.status,
3559
- closedDate: close.closedDate,
3560
- closeCommit: close.closeCommit,
3561
- thesis: narrated.thesis,
3562
- thesisHeld: deriveThesisHeld(narrated.acceptance),
3563
- delivered: narrated.delivered,
3564
- seedGuards: projectSeedGuards(sources.guardEvalRuns),
3565
- acceptance: narrated.acceptance,
3566
- gatesEarnedKeep: narrated.gatesEarnedKeep,
3567
- instruments: narrated.instruments,
3568
- invariantsExercised: deriveInvariantsExercised(sources.touchedInvariants),
3569
- deviations: narrated.deviations,
3570
- tasks: deriveTasks(orchestration)
3915
+ kind: "unavailable",
3916
+ why: `the service answered ${res.status}${message === null ? "." : `: ${message}`}`
3571
3917
  };
3572
3918
  }
3573
-
3574
- // dist/build-record/index.js
3575
- function assemblePhaseBuildRecord(opts) {
3576
- const sources = loadSources({
3577
- repoRoot: opts.repoRoot,
3578
- phasesDir: opts.phasesDir,
3579
- guardEvalLogDir: opts.guardEvalLogDir,
3580
- phaseId: opts.phaseId,
3581
- narrated: opts.narrated,
3582
- touchedInvariants: opts.touchedInvariants
3583
- });
3584
- const record2 = assembleBuildRecord(sources);
3585
- return writeBuildRecord(opts.repoRoot, record2);
3919
+ function refetchCommand(phase) {
3920
+ const quoted = /^[A-Za-z0-9._+-]+$/.test(phase) ? phase : `'${phase.replace(/'/g, `'\\''`)}'`;
3921
+ return `npx halfcycle@latest build-record ${quoted}`;
3586
3922
  }
3587
3923
  export {
3588
3924
  BUILD_RECORD_DIR,
3589
- BUILD_RECORD_FORMAT,
3590
- BUILD_RECORD_ZONE_B_DIR,
3591
- CLOSE_RECORD_FORMAT,
3592
3925
  CreateEngagementRefused,
3593
3926
  FLAT_FILE_TRACKER_HOME,
3594
3927
  HALFCYCLE_STATE_FORMAT,
3595
- InvalidCloseRecordError,
3596
3928
  InvalidPhaseIdentityError,
3597
- MissingSourceError,
3929
+ NODE_RECORD_PAIR_IO,
3930
+ REFUSAL_FOLLOW_UP,
3931
+ REFUSAL_REASONS,
3598
3932
  SetupManifest,
3599
3933
  ZONE_B_DIR,
3600
3934
  agentsPointerRow,
3601
3935
  applySetupWrites,
3602
- assembleBuildRecord,
3603
- assemblePhaseBuildRecord,
3604
3936
  assertValidPhaseIdentity,
3605
3937
  buildEngagementRecord,
3938
+ buildRecordUrl,
3606
3939
  checkDrift,
3607
- closeRecordPath,
3608
3940
  contextIndexRow,
3609
3941
  createEngagement,
3610
- deriveInvariantsExercised,
3611
- deriveTasks,
3612
- deriveThesisHeld,
3613
3942
  discoverRepository,
3614
3943
  documentSectionRow,
3615
3944
  engagementRecordRow,
@@ -3617,31 +3946,24 @@ export {
3617
3946
  install,
3618
3947
  isValidPhaseIdentity,
3619
3948
  layerDocumentRow,
3620
- loadSources,
3621
3949
  mergeSettings,
3622
3950
  mergeWithRecords,
3623
3951
  mintBoardEnterCode,
3624
3952
  mintOrReadIdentity,
3625
- parseCloseRecord,
3626
- projectSeedGuard,
3627
- projectSeedGuards,
3953
+ obtainBuildRecord,
3628
3954
  questionsToPut,
3629
3955
  readBundlePin,
3630
- readGuardEvalLog,
3631
- readOrchestrationState,
3632
- readPhaseClose,
3633
3956
  readRemote,
3634
3957
  readRootCommit,
3958
+ refetchCommand,
3635
3959
  renderAnchor,
3636
- renderBuildRecordMarkdown,
3960
+ renderBuildRecordV2Markdown,
3637
3961
  renderDiscovery,
3638
3962
  renderPhaseSegment,
3639
3963
  runBootstrapScan,
3640
3964
  scanLayers,
3641
- serialiseRecordJson,
3965
+ serialiseServedRecord,
3642
3966
  trackerHomeRow,
3643
- validateNarrated,
3644
- writeBuildRecord,
3645
- writeCloseRecord
3967
+ writeRecordPair
3646
3968
  };
3647
3969
  //# sourceMappingURL=index.js.map