halfcycle 0.3.32 → 0.3.34

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 execFileSync5 } from "node:child_process";
5
5
  import { existsSync as existsSync5, readFileSync as readFileSync10, statSync } from "node:fs";
6
- import { join as join12 } from "node:path";
6
+ import { join as join10 } from "node:path";
7
7
 
8
8
  // dist/install.js
9
9
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -203,6 +203,31 @@ var HALFCYCLE_HOOK_WIRING_PATHS = [
203
203
  .../* @__PURE__ */ new Set([SETTINGS_REL, ...HALFCYCLE_HOOK_WIRING.map((entry) => entry.script), VENDORED_BIN_REL])
204
204
  ];
205
205
 
206
+ // ../core/dist/secret-shape.js
207
+ var SECRET_SHAPE_PATTERNS = [
208
+ // Assignment to a credential-named identifier: `apiKey = "..."`, `secret: '...'`,
209
+ // `password=...`, `token = ...`, `access_key: ...` — the value need not be quoted.
210
+ /\b(?:api[_-]?key|secret|password|passwd|token|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|bearer)\b\s*[:=]\s*\S+/i,
211
+ // Connection strings that embed credentials: `scheme://user:pass@host`.
212
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]+@/i,
213
+ // Bearer / authorization header values.
214
+ /\bauthorization\b\s*[:=]\s*['"]?\s*bearer\s+\S+/i,
215
+ // PEM / private-key block headers.
216
+ /-----BEGIN\s+(?:RSA\s+|EC\s+|OPENSSH\s+|DSA\s+)?PRIVATE\s+KEY-----/,
217
+ // JWT shape: three base64url segments separated by dots.
218
+ /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/,
219
+ // A long high-entropy hex/base64 run adjacent to a credential-ish word on the
220
+ // same line — a raw key literal. Kept last (broadest, most likely false-drop).
221
+ /\b(?:key|token|secret|credential)\b[\s\S]{0,40}[A-Za-z0-9+/_-]{32,}/i
222
+ ];
223
+ function looksLikeSecret(text) {
224
+ for (const pattern of SECRET_SHAPE_PATTERNS) {
225
+ if (pattern.test(text))
226
+ return true;
227
+ }
228
+ return false;
229
+ }
230
+
206
231
  // ../events/dist/result.js
207
232
  import { z as z2 } from "zod";
208
233
  var firedGuardSchema = z2.object({
@@ -262,219 +287,31 @@ var wireErrorSchema = z2.object({
262
287
  message: z2.string()
263
288
  }).strict();
264
289
 
265
- // ../events/dist/telemetry.js
266
- import { z as z3 } from "zod";
267
- 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");
268
- var guardEvalOutcomeSchema = z3.enum([
269
- "evaluated",
270
- "infra-error",
271
- "contract-error",
272
- "unconfigured"
273
- ]);
274
- var guardEvalRunSchema = z3.object({
275
- // `runId`/`outcome` are the E2 run-observability additions, declared
276
- // field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
277
- // migration. Every new record populated by the migrated writer (T-07)
278
- // carries both; a legacy line predating the amendment carries neither and
279
- // must still parse (the mixed-era guard-eval log the Build-Record reader
280
- // assembles from, and the runner emit not yet migrated). So the presence
281
- // refinement below is skipped for a legacy line (no `outcome`), and the
282
- // per-writer guarantee lives with the writer + reader-normalisation (T-07),
283
- // where the schema may then be tightened to required. runId is uuid-checked
284
- // WHEN present; outcome is enum-checked when present.
285
- runId: z3.string().uuid().optional(),
286
- engagementId: z3.string().optional(),
287
- runType: z3.enum(["hook", "ci"]),
288
- occurredAt: utcIso8601,
289
- outcome: guardEvalOutcomeSchema.optional(),
290
- failureReason: z3.string().optional(),
291
- phase: z3.string().optional(),
292
- scope: z3.record(z3.string(), z3.unknown()),
293
- cost: z3.record(z3.string(), z3.unknown()).optional(),
294
- result: resultEnvelopeSchema.optional()
295
- }).strict().superRefine((run, ctx) => {
296
- if (run.outcome === void 0)
297
- return;
298
- if (run.outcome === "evaluated" && run.result === void 0) {
299
- ctx.addIssue({
300
- code: z3.ZodIssueCode.custom,
301
- path: ["result"],
302
- message: "result is required when outcome is 'evaluated'"
303
- });
304
- }
305
- if (run.outcome !== "evaluated" && run.result !== void 0) {
306
- ctx.addIssue({
307
- code: z3.ZodIssueCode.custom,
308
- path: ["result"],
309
- message: "result is only present when outcome is 'evaluated'"
310
- });
311
- }
312
- if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
313
- ctx.addIssue({
314
- code: z3.ZodIssueCode.custom,
315
- path: ["engagementId"],
316
- message: "engagementId is required unless outcome is 'unconfigured'"
317
- });
318
- }
319
- });
320
-
321
290
  // ../events/dist/engagement-lifecycle.js
291
+ import { z as z5 } from "zod";
292
+
293
+ // ../events/dist/lone-surrogates.js
294
+ var LONE_SURROGATE = new RegExp("\\p{Cs}", "u");
295
+ function hasLoneSurrogate(text) {
296
+ return LONE_SURROGATE.test(text);
297
+ }
298
+ var LONE_SURROGATE_MESSAGE = "must be valid text: it holds half of a character pair with no other half";
299
+
300
+ // ../events/dist/build-record.js
322
301
  import { z as z4 } from "zod";
323
- 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");
324
- var engagementTypeSchema = z4.enum(["client"]);
325
- var lifecycleStatusSchema = z4.enum(["active", "delivering", "closed"]);
326
- var createEngagementRequestSchema = z4.object({
327
- name: z4.string(),
328
- region: z4.string(),
329
- engagementType: engagementTypeSchema
330
- }).strict();
331
- var createEngagementResponseSchema = z4.object({
332
- engagementId: z4.string(),
333
- name: z4.string(),
334
- region: z4.string(),
335
- status: lifecycleStatusSchema,
336
- createdAt: utcIso86012,
337
- sessionToken: z4.string()
338
- }).strict();
339
- var engagementStatusResponseSchema = z4.object({
340
- engagementId: z4.string(),
341
- name: z4.string(),
342
- region: z4.string(),
343
- status: lifecycleStatusSchema,
344
- installLinked: z4.boolean(),
345
- createdAt: utcIso86012,
346
- updatedAt: utcIso86012
347
- }).strict();
348
- var linkInstallationRequestSchema = z4.object({
349
- installationId: z4.string(),
350
- engagementId: z4.string()
351
- }).strict();
352
- var startBuildRequestSchema = z4.object({
353
- engagementId: z4.string()
354
- }).strict();
355
- var startBuildResponseSchema = z4.object({
356
- engagementId: z4.string(),
357
- status: lifecycleStatusSchema,
358
- enqueuedAt: utcIso86012
359
- }).strict();
360
- var acceptanceOutcomeSchema = z4.object({
361
- verdict: z4.enum(["clean", "defects"]),
362
- findings: z4.number().int().nonnegative(),
363
- findingsDetail: z4.array(z4.object({ summary: z4.string() }).strict())
364
- }).strict();
365
- var PHASE_CLOSE_REASON_MAX = 1900;
366
- var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
367
- z4.object({
368
- decision: z4.literal("done-when-met"),
369
- /** Who is closing the phase. */
370
- actor: z4.string()
371
- }).strict(),
372
- z4.object({
373
- decision: z4.literal("override"),
374
- /** Who is closing it anyway. */
375
- actor: z4.string(),
376
- /**
377
- * Why, in their own words. Bounded: the reason is kept on the phase's
378
- * permanent record of its own close, and that record refuses anything
379
- * longer — so an over-long reason is refused here, before the close is
380
- * attempted, rather than failing the close itself.
381
- */
382
- reason: z4.string().max(PHASE_CLOSE_REASON_MAX)
383
- }).strict()
384
- ]);
385
- var acceptPhaseRequestSchema = z4.object({
386
- phase: z4.string(),
387
- acceptanceOutcome: acceptanceOutcomeSchema,
388
- close: phaseCloseDecisionSchema.optional()
389
- }).strict();
390
- var coeFindingRequestSchema = z4.object({
391
- phase: z4.string(),
392
- description: z4.string(),
393
- addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
394
- mappedPatternRef: z4.string().nullable()
395
- }).strict();
396
- var coeFindingResponseSchema = z4.object({
397
- findingId: z4.string(),
398
- engagementId: z4.string(),
399
- phase: z4.string(),
400
- description: z4.string(),
401
- addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
402
- mappedPatternRef: z4.string().nullable(),
403
- createdAt: utcIso86012
404
- }).strict();
405
- var operatorFindingResponseSchema = z4.object({
406
- // The finding just recorded.
407
- finding: coeFindingResponseSchema,
408
- // Every finding recorded for that phase, oldest first, including this one.
409
- phaseFindings: z4.array(coeFindingResponseSchema),
410
- // Whether the phase's finding rate was recalculated to include this finding.
411
- figuresRecomputed: z4.boolean()
412
- }).strict();
413
- var phaseNotOnRecordRefusalSchema = wireErrorSchema.extend({
414
- // Always this value for this refusal.
415
- error: z4.literal("PhaseNotOnRecord"),
416
- // The phases that can take a finding, sorted.
417
- phases: z4.array(z4.string())
418
- }).strict();
419
- var phaseEntryDecisionSchema = z4.discriminatedUnion("decision", [
420
- z4.object({
421
- decision: z4.literal("entry-condition-met"),
422
- /** Who attests. */
423
- actor: z4.string(),
424
- /** What makes the entry condition hold, in the attester's own words. */
425
- evidence: z4.string()
426
- }).strict(),
427
- z4.object({
428
- decision: z4.literal("override"),
429
- /** Who is opening it anyway. */
430
- actor: z4.string(),
431
- /** Why, in the operator's own words. */
432
- reason: z4.string()
433
- }).strict()
434
- ]);
435
- var PHASE_ENTRY_DECISION_KINDS = [
436
- "entry-condition-met",
437
- "override"
438
- ];
439
- var PHASE_CLOSE_DECISION_KINDS = ["phase-closed", "phase-close-override"];
440
- var phaseDecisionKindSchema = z4.enum([
441
- ...PHASE_ENTRY_DECISION_KINDS,
442
- ...PHASE_CLOSE_DECISION_KINDS
443
- ]);
444
- var openPhaseRequestSchema = z4.object({
445
- phase: z4.string().nullable(),
446
- entry: phaseEntryDecisionSchema
447
- }).strict();
448
- var phaseEntryDecisionRecordSchema = z4.object({
449
- decisionId: z4.string(),
450
- decision: z4.enum(PHASE_ENTRY_DECISION_KINDS),
451
- actor: z4.string(),
452
- /** The evidence (attestation) or the reason (override) — one basis per row. */
453
- justification: z4.string(),
454
- blockingFinding: z4.string().nullable(),
455
- decidedAt: utcIso86012
456
- }).strict();
457
- var openPhaseResponseSchema = z4.object({
458
- engagement: engagementStatusResponseSchema,
459
- /** What is open now. `null` is project scope. */
460
- phase: z4.string().nullable(),
461
- /** What was open before this decision. `null` is project scope. */
462
- previousPhase: z4.string().nullable(),
463
- decision: phaseEntryDecisionRecordSchema
464
- }).strict();
465
302
 
466
303
  // ../events/dist/state.js
467
- import { z as z5 } from "zod";
468
- var utcIso86013 = z5.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");
304
+ import { z as z3 } from "zod";
305
+ 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");
469
306
  var STATE_RECORD_FORMAT = "halfcycle-state-record/v1";
470
- var stepIdSchema = z5.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
471
- var methodVersionSchema = z5.string().regex(/^\d+\.\d+\.\d+$/);
307
+ var stepIdSchema = z3.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
308
+ var methodVersionSchema = z3.string().regex(/^\d+\.\d+\.\d+$/);
472
309
  var anchorPathRegex = /^(?!\/)(?!.*#)(?!method\/)(?!(.*\/)?docs\/method\/).+$/;
473
- var recordKindSchema = z5.enum(["artefact", "step-run", "gate", "intervention"]);
474
- var methodLayerSchema = z5.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
475
- var evidenceKindSchema = z5.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
476
- var declarableEvidenceKindSchema = z5.enum(["anchor", "diff", "repro", "question-answer"]);
477
- var dispositionSchema = z5.enum([
310
+ var recordKindSchema = z3.enum(["artefact", "step-run", "gate", "intervention"]);
311
+ var methodLayerSchema = z3.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
312
+ var evidenceKindSchema = z3.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
313
+ var declarableEvidenceKindSchema = z3.enum(["anchor", "diff", "repro", "question-answer"]);
314
+ var dispositionSchema = z3.enum([
478
315
  "prevented",
479
316
  "corrected-in-spec",
480
317
  "corrected-in-scope",
@@ -484,20 +321,20 @@ var dispositionSchema = z5.enum([
484
321
  "surfaced",
485
322
  "filed-forward"
486
323
  ]);
487
- var mechanismSchema = z5.enum(["guard", "verdict", "served-step", "method-step"]);
488
- var artefactStateSchema = z5.enum(["created", "updated"]);
324
+ var mechanismSchema = z3.enum(["guard", "verdict", "served-step", "method-step"]);
325
+ var artefactStateSchema = z3.enum(["created", "updated"]);
489
326
  var QUALIFIED_OUTCOMES = ["declined", "parked"];
490
- var qualifiedOutcomeSchema = z5.enum(QUALIFIED_OUTCOMES);
327
+ var qualifiedOutcomeSchema = z3.enum(QUALIFIED_OUTCOMES);
491
328
  function isQualifiedOutcome(outcome) {
492
329
  return QUALIFIED_OUTCOMES.includes(outcome);
493
330
  }
494
- var runOutcomeSchema = z5.enum([
331
+ var runOutcomeSchema = z3.enum([
495
332
  "completed",
496
333
  "attempted-failed",
497
334
  ...QUALIFIED_OUTCOMES
498
335
  ]);
499
- var gateKindSchema = z5.enum(["approval", "mechanical"]);
500
- var gateVerdictSchema = z5.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
336
+ var gateKindSchema = z3.enum(["approval", "mechanical"]);
337
+ var gateVerdictSchema = z3.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
501
338
  function outcomeReasonIssue(outcome, reason) {
502
339
  if (isQualifiedOutcome(outcome) && reason === void 0) {
503
340
  return { message: `outcomeReason is required when the outcome is '${outcome}'` };
@@ -509,142 +346,527 @@ function outcomeReasonIssue(outcome, reason) {
509
346
  }
510
347
  return null;
511
348
  }
512
- var anchorEvidenceSchema = z5.object({
513
- kind: z5.literal("anchor"),
514
- path: z5.string().regex(anchorPathRegex),
349
+ var anchorEvidenceSchema = z3.object({
350
+ kind: z3.literal("anchor"),
351
+ path: z3.string().regex(anchorPathRegex),
515
352
  // Optional 1-based line number within the file at `path`. Present when the
516
353
  // writer knows the exact line; omitted — never `0`, never `null` — when it
517
354
  // does not, because a line number is not always obtainable. Absence is a
518
355
  // valid, permanent state here, not a gap meant to be filled in later.
519
- line: z5.number().optional()
356
+ line: z3.number().optional()
520
357
  }).strict();
521
- var diffEvidenceSchema = z5.object({
522
- kind: z5.literal("diff"),
523
- ref: z5.string()
358
+ var diffEvidenceSchema = z3.object({
359
+ kind: z3.literal("diff"),
360
+ ref: z3.string()
524
361
  }).strict();
525
- var reproEvidenceSchema = z5.object({
526
- kind: z5.literal("repro"),
527
- steps: z5.string().max(2e3)
362
+ var reproEvidenceSchema = z3.object({
363
+ kind: z3.literal("repro"),
364
+ steps: z3.string().max(2e3)
528
365
  }).strict();
529
- var questionAnswerEvidenceSchema = z5.object({
530
- kind: z5.literal("question-answer"),
531
- question: z5.string().max(2e3),
532
- before: z5.string().max(2e3),
533
- after: z5.string().max(2e3)
366
+ var questionAnswerEvidenceSchema = z3.object({
367
+ kind: z3.literal("question-answer"),
368
+ question: z3.string().max(2e3),
369
+ before: z3.string().max(2e3),
370
+ after: z3.string().max(2e3)
534
371
  }).strict();
535
- var noOpEvidenceSchema = z5.object({
536
- kind: z5.literal("no-op"),
537
- inspected: z5.string().max(2e3),
538
- unchangedBecause: z5.string().max(2e3)
372
+ var noOpEvidenceSchema = z3.object({
373
+ kind: z3.literal("no-op"),
374
+ inspected: z3.string().max(2e3),
375
+ unchangedBecause: z3.string().max(2e3)
539
376
  }).strict();
540
- var evidenceSchema = z5.discriminatedUnion("kind", [
377
+ var evidenceSchema = z3.discriminatedUnion("kind", [
541
378
  anchorEvidenceSchema,
542
379
  diffEvidenceSchema,
543
380
  reproEvidenceSchema,
544
381
  questionAnswerEvidenceSchema,
545
382
  noOpEvidenceSchema
546
383
  ]);
547
- var stateRecordEnvelopeSchema = z5.object({
548
- format: z5.literal(STATE_RECORD_FORMAT),
384
+ var stateRecordEnvelopeSchema = z3.object({
385
+ format: z3.literal(STATE_RECORD_FORMAT),
549
386
  recordKind: recordKindSchema,
550
- recordId: z5.string().uuid(),
387
+ recordId: z3.string().uuid(),
551
388
  methodVersion: methodVersionSchema,
552
- engagementId: z5.string(),
389
+ engagementId: z3.string(),
553
390
  stepId: stepIdSchema,
554
391
  // Immutable: names when the thing happened, never when a later disposition
555
392
  // moved.
556
- occurredAt: utcIso86013,
393
+ occurredAt: utcIso8601,
557
394
  // Equal to `occurredAt` on first write; advances on each re-emit OF A FINDING.
558
395
  // Carried on the shared envelope so a "when did this last change" query is
559
396
  // kind-agnostic. Kept rather than generalised to `updatedAt` because
560
397
  // `disposition` is the only field any re-emit may change (§5.3).
561
- dispositionAt: utcIso86013,
398
+ dispositionAt: utcIso8601,
562
399
  evidence: evidenceSchema,
563
400
  // Which phase of the project this record belongs to, if any. Present on
564
401
  // records written during phase-scoped work; absent on records that run once
565
402
  // for the whole project rather than per phase. Absence is meaningful on its
566
403
  // own — it is never replaced with a placeholder value, and it is read as
567
404
  // "satisfies any phase", not as "unknown".
568
- phase: z5.string().optional()
405
+ phase: z3.string().optional()
569
406
  });
570
- var artefactRefSchema = z5.object({
571
- name: z5.string(),
572
- parent: z5.string()
407
+ var artefactRefSchema = z3.object({
408
+ name: z3.string(),
409
+ parent: z3.string()
573
410
  }).strict();
574
- var coverageSchema = z5.object({
575
- asked: z5.number().int().nonnegative(),
576
- answered: z5.number().int().nonnegative()
411
+ var coverageSchema = z3.object({
412
+ asked: z3.number().int().nonnegative(),
413
+ answered: z3.number().int().nonnegative()
577
414
  }).strict().superRefine((coverage, ctx) => {
578
415
  if (coverage.answered > coverage.asked) {
579
416
  ctx.addIssue({
580
- code: z5.ZodIssueCode.custom,
417
+ code: z3.ZodIssueCode.custom,
581
418
  path: ["answered"],
582
419
  message: "answered cannot be more than asked"
583
420
  });
584
421
  }
585
422
  });
586
423
  var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
587
- recordKind: z5.literal("artefact"),
424
+ recordKind: z3.literal("artefact"),
588
425
  artefactRef: artefactRefSchema,
589
426
  // Some layer wrote it, by construction — an empty array is not a legitimate
590
427
  // state, so the schema says so.
591
- writtenByLayers: z5.array(methodLayerSchema).min(1),
428
+ writtenByLayers: z3.array(methodLayerSchema).min(1),
592
429
  state: artefactStateSchema
593
430
  }).strict();
594
431
  var stepRunRecordSchema = stateRecordEnvelopeSchema.extend({
595
- recordKind: z5.literal("step-run"),
432
+ recordKind: z3.literal("step-run"),
596
433
  runOutcome: runOutcomeSchema,
597
- failureReason: z5.string().optional(),
598
- outcomeReason: z5.string().max(2e3).optional()
434
+ failureReason: z3.string().optional(),
435
+ outcomeReason: z3.string().max(2e3).optional()
599
436
  }).strict().superRefine((rec, ctx) => {
600
437
  if (rec.runOutcome === "attempted-failed" && rec.failureReason === void 0) {
601
438
  ctx.addIssue({
602
- code: z5.ZodIssueCode.custom,
439
+ code: z3.ZodIssueCode.custom,
603
440
  path: ["failureReason"],
604
441
  message: "failureReason is required when runOutcome is 'attempted-failed'"
605
442
  });
606
443
  }
607
444
  if (rec.runOutcome !== "attempted-failed" && rec.failureReason !== void 0) {
608
445
  ctx.addIssue({
609
- code: z5.ZodIssueCode.custom,
446
+ code: z3.ZodIssueCode.custom,
610
447
  path: ["failureReason"],
611
448
  message: "failureReason is only present when runOutcome is 'attempted-failed'"
612
449
  });
613
450
  }
614
451
  const issue = outcomeReasonIssue(rec.runOutcome, rec.outcomeReason);
615
452
  if (issue) {
616
- ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
453
+ ctx.addIssue({ code: z3.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
617
454
  }
618
455
  });
619
456
  var gateRecordSchema = stateRecordEnvelopeSchema.extend({
620
- recordKind: z5.literal("gate"),
457
+ recordKind: z3.literal("gate"),
621
458
  gateKind: gateKindSchema,
622
459
  verdict: gateVerdictSchema,
623
- actor: z5.string(),
624
- outcomeReason: z5.string().max(2e3).optional(),
460
+ actor: z3.string(),
461
+ outcomeReason: z3.string().max(2e3).optional(),
625
462
  coverage: coverageSchema.optional()
626
463
  }).strict().superRefine((rec, ctx) => {
627
464
  const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
628
465
  if (issue) {
629
- ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
466
+ ctx.addIssue({ code: z3.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
630
467
  }
631
468
  });
632
469
  var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
633
- recordKind: z5.literal("intervention"),
470
+ recordKind: z3.literal("intervention"),
634
471
  layer: methodLayerSchema,
635
472
  mechanism: mechanismSchema,
636
473
  severity: severitySchema,
637
474
  disposition: dispositionSchema,
638
- summary: z5.string().max(2e3),
475
+ summary: z3.string().max(2e3),
639
476
  coverage: coverageSchema.optional()
640
477
  }).strict();
641
- var stateRecordSchema = z5.discriminatedUnion("recordKind", [
478
+ var stateRecordSchema = z3.discriminatedUnion("recordKind", [
642
479
  artefactRecordSchema,
643
480
  stepRunRecordSchema,
644
481
  gateRecordSchema,
645
482
  interventionRecordSchema
646
483
  ]);
647
484
 
485
+ // ../events/dist/control-characters.js
486
+ var CONTROL_CHARACTER_CLASS = "\\p{Cc}";
487
+ var CONTROL_CHARACTER = new RegExp(CONTROL_CHARACTER_CLASS, "u");
488
+ function hasControlCharacter(text) {
489
+ return CONTROL_CHARACTER.test(text);
490
+ }
491
+
492
+ // ../events/dist/phase-identity.js
493
+ var PHASE_SEGMENT_PREFIX = "phase-";
494
+ var PHASE_NAME_MAX_BYTES = 200;
495
+ function isValidPhaseIdentity(identity) {
496
+ if (typeof identity === "number")
497
+ return Number.isInteger(identity) && identity >= 0;
498
+ if (typeof identity !== "string")
499
+ return false;
500
+ if (identity === "")
501
+ return false;
502
+ if (identity.includes("/"))
503
+ return false;
504
+ if (identity.includes("\\"))
505
+ return false;
506
+ if (identity.startsWith("."))
507
+ return false;
508
+ if (hasControlCharacter(identity))
509
+ return false;
510
+ if (utf8ByteLength(identity) > PHASE_NAME_MAX_BYTES)
511
+ return false;
512
+ return true;
513
+ }
514
+ function utf8ByteLength(text) {
515
+ let bytes = 0;
516
+ for (const ch of text) {
517
+ const cp = ch.codePointAt(0);
518
+ bytes += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
519
+ }
520
+ return bytes;
521
+ }
522
+ var InvalidPhaseIdentityError = class extends Error {
523
+ /** The value that was refused, exactly as supplied. */
524
+ identity;
525
+ constructor(identity, allowedLocation) {
526
+ const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
527
+ const where = allowedLocation === void 0 ? "" : `
528
+ Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
529
+ super(`that phase identity cannot name ${subject}.
530
+ identity: ${describeIdentity(identity)}` + where);
531
+ this.name = "InvalidPhaseIdentityError";
532
+ this.identity = identity;
533
+ }
534
+ };
535
+ function assertValidPhaseIdentity(identity, allowedLocation) {
536
+ if (!isValidPhaseIdentity(identity)) {
537
+ throw new InvalidPhaseIdentityError(identity, allowedLocation);
538
+ }
539
+ }
540
+ function renderPhaseSegment(identity) {
541
+ if (!isValidPhaseIdentity(identity)) {
542
+ throw new InvalidPhaseIdentityError(identity);
543
+ }
544
+ return `${PHASE_SEGMENT_PREFIX}${identity}`;
545
+ }
546
+ function describeIdentity(identity) {
547
+ if (identity === "")
548
+ return "(empty)";
549
+ if (typeof identity === "string")
550
+ return identity;
551
+ if (identity === null)
552
+ return "null";
553
+ if (identity === void 0)
554
+ return "none supplied";
555
+ if (typeof identity === "number" || typeof identity === "boolean")
556
+ return String(identity);
557
+ if (Array.isArray(identity))
558
+ return "(a list)";
559
+ if (typeof identity === "object")
560
+ return "(an object)";
561
+ return `(a ${typeof identity})`;
562
+ }
563
+
564
+ // ../events/dist/build-record.js
565
+ var BUILD_RECORD_V2_FORMAT = "halfcycle-build-record/v2";
566
+ var STATEMENT_MAX = 2e3;
567
+ var DEVIATION_MAX = 500;
568
+ var DEVIATIONS_MAX = 20;
569
+ var ACTOR_MAX = 200;
570
+ var FINDING_MAX = 500;
571
+ var FINDINGS_SHOWN_MAX = 50;
572
+ var PHASE_CLOSE_REASON_MAX = 1900;
573
+ 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");
574
+ var walkVerdictSchema = z4.enum(["clean", "defects"]);
575
+ var closeDecisionSchema = z4.enum(["done-when-met", "override"]);
576
+ function plainText(max) {
577
+ const base = max === void 0 ? z4.string() : z4.string().max(max);
578
+ return base.refine((v) => !hasControlCharacter(v), {
579
+ message: "must be one line with no control characters (no line breaks, tabs or NUL)"
580
+ }).refine((v) => !hasLoneSurrogate(v), { message: LONE_SURROGATE_MESSAGE });
581
+ }
582
+ function statementText(max) {
583
+ return plainText(max).refine((v) => !looksLikeSecret(v), {
584
+ message: "looks like a password or key \u2014 rephrase it"
585
+ });
586
+ }
587
+ var closeActorSchema = plainText(ACTOR_MAX);
588
+ var phaseNameSchema = z4.string().refine((v) => isValidPhaseIdentity(v), {
589
+ 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`
590
+ });
591
+ var closeStatementsSchema = z4.object({
592
+ thesis: statementText(STATEMENT_MAX).nullable(),
593
+ outcome: statementText(STATEMENT_MAX).nullable(),
594
+ deviations: z4.array(statementText(DEVIATION_MAX)).max(DEVIATIONS_MAX),
595
+ closeCommit: closeCommitSchema.nullable()
596
+ }).strict();
597
+ var closedBySchema = z4.discriminatedUnion("kind", [
598
+ z4.object({ kind: z4.literal("account"), email: plainText().nullable() }).strict(),
599
+ z4.object({ kind: z4.literal("no-account") }).strict(),
600
+ z4.object({ kind: z4.literal("account-removed") }).strict(),
601
+ z4.object({ kind: z4.literal("not-recorded") }).strict()
602
+ ]);
603
+ var unfinishedStepSchema = z4.object({
604
+ stepId: plainText(),
605
+ state: z4.enum(["parked", "failed"])
606
+ }).strict();
607
+ var crossedSchema = z4.discriminatedUnion("kind", [
608
+ z4.object({ kind: z4.literal("none") }).strict(),
609
+ z4.object({ kind: z4.literal("unfinished-steps"), steps: z4.array(unfinishedStepSchema).min(1) }).strict(),
610
+ z4.object({ kind: z4.literal("clean-over-findings"), findings: z4.number().int().min(1) }).strict(),
611
+ z4.object({ kind: z4.literal("not-recorded") }).strict()
612
+ ]);
613
+ var seatCoverageSchema = z4.object({
614
+ enrolled: z4.number().int().nonnegative(),
615
+ conformant: z4.number().int().nonnegative()
616
+ }).strict().refine((c) => c.conformant <= c.enrolled, {
617
+ message: "conformant cannot exceed enrolled",
618
+ path: ["conformant"]
619
+ });
620
+ var observedCloseSchema = z4.object({
621
+ sequence: z4.number().int().min(1),
622
+ closedAt: utcIso8601,
623
+ closedBy: closedBySchema,
624
+ crossed: crossedSchema,
625
+ seatCoverage: seatCoverageSchema.nullable()
626
+ }).strict();
627
+ var couldNotRunCheckSchema = z4.object({
628
+ check: plainText(),
629
+ description: plainText().nullable(),
630
+ reason: plainText()
631
+ }).strict();
632
+ var firedCheckSchema = z4.object({
633
+ check: plainText(),
634
+ description: plainText().nullable(),
635
+ severity: severitySchema,
636
+ explanation: plainText().nullable(),
637
+ occurrences: z4.number().int().min(1)
638
+ }).strict();
639
+ var phaseChecksSchema = z4.discriminatedUnion("state", [
640
+ z4.object({ state: z4.literal("not-observed") }).strict(),
641
+ z4.object({
642
+ state: z4.literal("nothing-fired"),
643
+ runs: z4.number().int().min(1),
644
+ couldNotRun: z4.array(couldNotRunCheckSchema)
645
+ }).strict(),
646
+ z4.object({
647
+ state: z4.literal("fired"),
648
+ runs: z4.number().int().min(1),
649
+ couldNotRun: z4.array(couldNotRunCheckSchema),
650
+ fired: z4.array(firedCheckSchema).min(1)
651
+ }).strict()
652
+ ]);
653
+ var observedSchema = z4.object({
654
+ engagementId: plainText(),
655
+ methodVersion: methodVersionSchema,
656
+ closes: z4.array(observedCloseSchema).min(1),
657
+ checks: phaseChecksSchema
658
+ }).strict().superRefine((o, ctx) => {
659
+ o.closes.forEach((close, i) => {
660
+ if (close.sequence !== i + 1) {
661
+ ctx.addIssue({
662
+ code: "custom",
663
+ message: "closes must be numbered 1, 2, 3 \u2026 in order with no gap",
664
+ path: ["closes", i, "sequence"]
665
+ });
666
+ }
667
+ });
668
+ });
669
+ var attestedWalkSchema = z4.object({
670
+ verdict: walkVerdictSchema,
671
+ findings: z4.array(plainText(FINDING_MAX)).max(FINDINGS_SHOWN_MAX),
672
+ findingsNotShown: z4.number().int().nonnegative()
673
+ }).strict();
674
+ var attestedStatementsSchema = z4.object({
675
+ thesis: plainText(STATEMENT_MAX).nullable(),
676
+ outcome: plainText(STATEMENT_MAX).nullable(),
677
+ deviations: z4.array(plainText(DEVIATION_MAX)).max(DEVIATIONS_MAX)
678
+ }).strict();
679
+ var attestedCloseSchema = z4.object({
680
+ sequence: z4.number().int().min(1),
681
+ decision: closeDecisionSchema,
682
+ actor: plainText(ACTOR_MAX).nullable(),
683
+ reason: plainText(PHASE_CLOSE_REASON_MAX).nullable(),
684
+ walk: attestedWalkSchema.nullable(),
685
+ closeCommit: closeCommitSchema.nullable(),
686
+ statements: attestedStatementsSchema
687
+ }).strict();
688
+ var attestedSchema = z4.object({
689
+ closes: z4.array(attestedCloseSchema)
690
+ }).strict();
691
+ var buildRecordV2Schema = z4.object({
692
+ format: z4.literal(BUILD_RECORD_V2_FORMAT),
693
+ phase: phaseNameSchema,
694
+ observed: observedSchema,
695
+ attested: attestedSchema
696
+ }).strict().superRefine((record2, ctx) => {
697
+ const observedBySequence = new Map(record2.observed.closes.map((c) => [c.sequence, c]));
698
+ const attestedSequences = record2.attested.closes.map((c) => c.sequence);
699
+ const sameSet = attestedSequences.length === observedBySequence.size && new Set(attestedSequences).size === attestedSequences.length && attestedSequences.every((s) => observedBySequence.has(s));
700
+ if (!sameSet) {
701
+ ctx.addIssue({
702
+ code: "custom",
703
+ message: "attested closes must match the observed closes one for one, by sequence",
704
+ path: ["attested", "closes"]
705
+ });
706
+ }
707
+ record2.attested.closes.forEach((close, i) => {
708
+ if (close.decision === "override" !== (close.reason !== null)) {
709
+ ctx.addIssue({
710
+ code: "custom",
711
+ message: "reason is given exactly when the decision is override",
712
+ path: ["attested", "closes", i, "reason"]
713
+ });
714
+ }
715
+ const observed = observedBySequence.get(close.sequence);
716
+ if (observed !== void 0 && observed.closedBy.kind === "account-removed" !== (close.actor === null)) {
717
+ ctx.addIssue({
718
+ code: "custom",
719
+ message: "actor is empty exactly when the closing account was removed",
720
+ path: ["attested", "closes", i, "actor"]
721
+ });
722
+ }
723
+ });
724
+ });
725
+
726
+ // ../events/dist/engagement-lifecycle.js
727
+ var utcIso86012 = z5.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");
728
+ var engagementTypeSchema = z5.enum(["client"]);
729
+ var lifecycleStatusSchema = z5.enum(["active", "delivering", "closed"]);
730
+ var createEngagementRequestSchema = z5.object({
731
+ name: z5.string(),
732
+ region: z5.string(),
733
+ engagementType: engagementTypeSchema
734
+ }).strict();
735
+ var createEngagementResponseSchema = z5.object({
736
+ engagementId: z5.string(),
737
+ name: z5.string(),
738
+ region: z5.string(),
739
+ status: lifecycleStatusSchema,
740
+ createdAt: utcIso86012,
741
+ sessionToken: z5.string()
742
+ }).strict();
743
+ var engagementStatusResponseSchema = z5.object({
744
+ engagementId: z5.string(),
745
+ name: z5.string(),
746
+ region: z5.string(),
747
+ status: lifecycleStatusSchema,
748
+ installLinked: z5.boolean(),
749
+ createdAt: utcIso86012,
750
+ updatedAt: utcIso86012
751
+ }).strict();
752
+ var linkInstallationRequestSchema = z5.object({
753
+ installationId: z5.string(),
754
+ engagementId: z5.string()
755
+ }).strict();
756
+ var startBuildRequestSchema = z5.object({
757
+ engagementId: z5.string()
758
+ }).strict();
759
+ var startBuildResponseSchema = z5.object({
760
+ engagementId: z5.string(),
761
+ status: lifecycleStatusSchema,
762
+ enqueuedAt: utcIso86012
763
+ }).strict();
764
+ var acceptanceOutcomeSchema = z5.object({
765
+ verdict: walkVerdictSchema,
766
+ findings: z5.number().int().nonnegative(),
767
+ findingsDetail: z5.array(z5.object({ summary: z5.string().refine((v) => !hasLoneSurrogate(v), { message: LONE_SURROGATE_MESSAGE }) }).strict())
768
+ }).strict();
769
+ var phaseCloseDecisionSchema = z5.discriminatedUnion("decision", [
770
+ z5.object({
771
+ decision: z5.literal(closeDecisionSchema.enum["done-when-met"]),
772
+ /** Who is closing the phase. */
773
+ actor: closeActorSchema
774
+ }).strict(),
775
+ z5.object({
776
+ decision: z5.literal(closeDecisionSchema.enum.override),
777
+ /** Who is closing it anyway. */
778
+ actor: closeActorSchema,
779
+ /**
780
+ * Why, in their own words. Bounded: the reason is kept on the phase's
781
+ * permanent record of its own close, and that record refuses anything
782
+ * longer — so an over-long reason is refused here, before the close is
783
+ * attempted, rather than failing the close itself.
784
+ */
785
+ reason: z5.string().max(PHASE_CLOSE_REASON_MAX).refine((v) => !hasLoneSurrogate(v), { message: LONE_SURROGATE_MESSAGE })
786
+ }).strict()
787
+ ]);
788
+ var acceptPhaseRequestSchema = z5.object({
789
+ phase: phaseNameSchema,
790
+ acceptanceOutcome: acceptanceOutcomeSchema,
791
+ close: phaseCloseDecisionSchema.optional(),
792
+ statements: closeStatementsSchema.optional()
793
+ }).strict();
794
+ var coeFindingRequestSchema = z5.object({
795
+ phase: z5.string(),
796
+ description: z5.string(),
797
+ addRateTag: z5.enum(["seam-new", "seam-repeat", "model-limitation"]),
798
+ mappedPatternRef: z5.string().nullable()
799
+ }).strict();
800
+ var coeFindingResponseSchema = z5.object({
801
+ findingId: z5.string(),
802
+ engagementId: z5.string(),
803
+ phase: z5.string(),
804
+ description: z5.string(),
805
+ addRateTag: z5.enum(["seam-new", "seam-repeat", "model-limitation"]),
806
+ mappedPatternRef: z5.string().nullable(),
807
+ createdAt: utcIso86012
808
+ }).strict();
809
+ var operatorFindingResponseSchema = z5.object({
810
+ // The finding just recorded.
811
+ finding: coeFindingResponseSchema,
812
+ // Every finding recorded for that phase, oldest first, including this one.
813
+ phaseFindings: z5.array(coeFindingResponseSchema),
814
+ // Whether the phase's finding rate was recalculated to include this finding.
815
+ figuresRecomputed: z5.boolean()
816
+ }).strict();
817
+ var phaseNotOnRecordRefusalSchema = wireErrorSchema.extend({
818
+ // Always this value for this refusal.
819
+ error: z5.literal("PhaseNotOnRecord"),
820
+ // The phases that can take a finding, sorted.
821
+ phases: z5.array(z5.string())
822
+ }).strict();
823
+ var phaseEntryDecisionSchema = z5.discriminatedUnion("decision", [
824
+ z5.object({
825
+ decision: z5.literal("entry-condition-met"),
826
+ /** Who attests. */
827
+ actor: z5.string(),
828
+ /** What makes the entry condition hold, in the attester's own words. */
829
+ evidence: z5.string()
830
+ }).strict(),
831
+ z5.object({
832
+ decision: z5.literal("override"),
833
+ /** Who is opening it anyway. */
834
+ actor: z5.string(),
835
+ /** Why, in the operator's own words. */
836
+ reason: z5.string()
837
+ }).strict()
838
+ ]);
839
+ var PHASE_ENTRY_DECISION_KINDS = [
840
+ "entry-condition-met",
841
+ "override"
842
+ ];
843
+ var PHASE_CLOSE_DECISION_KINDS = ["phase-closed", "phase-close-override"];
844
+ var phaseDecisionKindSchema = z5.enum([
845
+ ...PHASE_ENTRY_DECISION_KINDS,
846
+ ...PHASE_CLOSE_DECISION_KINDS
847
+ ]);
848
+ var openPhaseRequestSchema = z5.object({
849
+ phase: phaseNameSchema.nullable(),
850
+ entry: phaseEntryDecisionSchema
851
+ }).strict();
852
+ var phaseEntryDecisionRecordSchema = z5.object({
853
+ decisionId: z5.string(),
854
+ decision: z5.enum(PHASE_ENTRY_DECISION_KINDS),
855
+ actor: z5.string(),
856
+ /** The evidence (attestation) or the reason (override) — one basis per row. */
857
+ justification: z5.string(),
858
+ blockingFinding: z5.string().nullable(),
859
+ decidedAt: utcIso86012
860
+ }).strict();
861
+ var openPhaseResponseSchema = z5.object({
862
+ engagement: engagementStatusResponseSchema,
863
+ /** What is open now. `null` is project scope. */
864
+ phase: z5.string().nullable(),
865
+ /** What was open before this decision. `null` is project scope. */
866
+ previousPhase: z5.string().nullable(),
867
+ decision: phaseEntryDecisionRecordSchema
868
+ }).strict();
869
+
648
870
  // ../events/dist/crew.js
649
871
  import { z as z6 } from "zod";
650
872
  var crewGroupSchema = z6.enum(["builder", "reviewer", "always-on"]);
@@ -841,7 +1063,7 @@ var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
841
1063
 
842
1064
  // ../events/dist/device-auth.js
843
1065
  import { z as z7 } from "zod";
844
- var utcIso86014 = z7.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");
1066
+ var utcIso86013 = z7.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");
845
1067
  var DEVICE_AUTH_STATUS = {
846
1068
  /** The poll: minted, nobody has acted yet. Keep polling. 200. */
847
1069
  PENDING: "pending",
@@ -886,7 +1108,7 @@ var deviceAuthStartResponseSchema = z7.object({
886
1108
  deviceCode: z7.string().min(1),
887
1109
  userCode: z7.string().min(1),
888
1110
  verificationUrl: z7.string().min(1),
889
- expiresAt: utcIso86014,
1111
+ expiresAt: utcIso86013,
890
1112
  pollIntervalMs: z7.number().int().positive()
891
1113
  }).strict();
892
1114
  var devicePollPendingSchema = z7.object({
@@ -897,7 +1119,7 @@ var devicePollApprovedSchema = z7.object({
897
1119
  status: z7.literal(DEVICE_AUTH_STATUS.APPROVED),
898
1120
  accountId: z7.string().min(1),
899
1121
  credential: z7.string().min(1),
900
- expiresAt: utcIso86014
1122
+ expiresAt: utcIso86013
901
1123
  }).strict();
902
1124
  var deviceAuthRefusalSchema = z7.object({
903
1125
  status: z7.enum([
@@ -962,85 +1184,24 @@ import { z as z8 } from "zod";
962
1184
  var NOT_THIS_ACCOUNT_REASON = "not-this-account";
963
1185
  var JOIN_REFUSAL_STATUS = 403;
964
1186
  var notThisAccountRefusalSchema = z8.object({
965
- statusCode: z8.literal(JOIN_REFUSAL_STATUS),
966
- error: z8.string(),
967
- message: z8.string(),
968
- reason: z8.literal(NOT_THIS_ACCOUNT_REASON)
969
- }).strict();
970
- var joinRefusalSchema = z8.union([notThisAccountRefusalSchema, wireErrorSchema]);
971
- function isNotThisAccountRefusal(body) {
972
- return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
973
- }
974
-
975
- // ../events/dist/account-identity.js
976
- import { z as z9 } from "zod";
977
- var accountIdentitySchema = z9.object({
978
- accountId: z9.string().min(1),
979
- email: z9.string().min(1).optional()
980
- }).strict();
981
- function safeParseAccountIdentity(payload) {
982
- return accountIdentitySchema.safeParse(payload);
983
- }
984
-
985
- // ../events/dist/phase-identity.js
986
- var PHASE_SEGMENT_PREFIX = "phase-";
987
- function isValidPhaseIdentity(identity) {
988
- if (typeof identity === "number")
989
- return Number.isInteger(identity) && identity >= 0;
990
- if (typeof identity !== "string")
991
- return false;
992
- if (identity === "")
993
- return false;
994
- if (identity.includes("/"))
995
- return false;
996
- if (identity.includes("\\"))
997
- return false;
998
- if (identity.includes("\0"))
999
- return false;
1000
- if (identity.startsWith("."))
1001
- return false;
1002
- return true;
1003
- }
1004
- var InvalidPhaseIdentityError = class extends Error {
1005
- /** The value that was refused, exactly as supplied. */
1006
- identity;
1007
- constructor(identity, allowedLocation) {
1008
- const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
1009
- const where = allowedLocation === void 0 ? "" : `
1010
- Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
1011
- super(`that phase identity cannot name ${subject}.
1012
- identity: ${describeIdentity(identity)}` + where);
1013
- this.name = "InvalidPhaseIdentityError";
1014
- this.identity = identity;
1015
- }
1016
- };
1017
- function assertValidPhaseIdentity(identity, allowedLocation) {
1018
- if (!isValidPhaseIdentity(identity)) {
1019
- throw new InvalidPhaseIdentityError(identity, allowedLocation);
1020
- }
1021
- }
1022
- function renderPhaseSegment(identity) {
1023
- if (!isValidPhaseIdentity(identity)) {
1024
- throw new InvalidPhaseIdentityError(identity);
1025
- }
1026
- return `${PHASE_SEGMENT_PREFIX}${identity}`;
1187
+ statusCode: z8.literal(JOIN_REFUSAL_STATUS),
1188
+ error: z8.string(),
1189
+ message: z8.string(),
1190
+ reason: z8.literal(NOT_THIS_ACCOUNT_REASON)
1191
+ }).strict();
1192
+ var joinRefusalSchema = z8.union([notThisAccountRefusalSchema, wireErrorSchema]);
1193
+ function isNotThisAccountRefusal(body) {
1194
+ return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
1027
1195
  }
1028
- function describeIdentity(identity) {
1029
- if (identity === "")
1030
- return "(empty)";
1031
- if (typeof identity === "string")
1032
- return identity;
1033
- if (identity === null)
1034
- return "null";
1035
- if (identity === void 0)
1036
- return "none supplied";
1037
- if (typeof identity === "number" || typeof identity === "boolean")
1038
- return String(identity);
1039
- if (Array.isArray(identity))
1040
- return "(a list)";
1041
- if (typeof identity === "object")
1042
- return "(an object)";
1043
- return `(a ${typeof identity})`;
1196
+
1197
+ // ../events/dist/account-identity.js
1198
+ import { z as z9 } from "zod";
1199
+ var accountIdentitySchema = z9.object({
1200
+ accountId: z9.string().min(1),
1201
+ email: z9.string().min(1).optional()
1202
+ }).strict();
1203
+ function safeParseAccountIdentity(payload) {
1204
+ return accountIdentitySchema.safeParse(payload);
1044
1205
  }
1045
1206
 
1046
1207
  // ../events/dist/credential-wire.js
@@ -1054,10 +1215,47 @@ var engagementCredentialResponseSchema = z10.object({
1054
1215
  }).strict();
1055
1216
  var ciTokenExchangeResponseSchema = z10.object({
1056
1217
  token: z10.string(),
1057
- expiresAt: utcIso86013,
1218
+ expiresAt: utcIso8601,
1058
1219
  engagementId: z10.string()
1059
1220
  }).strict();
1060
1221
 
1222
+ // ../events/dist/phase-record.js
1223
+ import { z as z11 } from "zod";
1224
+ var phaseRecordRefusalReasonSchema = z11.enum([
1225
+ "still-open",
1226
+ "reopened",
1227
+ "never-opened",
1228
+ "never-closed",
1229
+ "closed-before-records"
1230
+ ]);
1231
+ var phaseRecordRecordSchema = z11.object({
1232
+ outcome: z11.literal("record"),
1233
+ phase: phaseNameSchema,
1234
+ record: buildRecordV2Schema
1235
+ }).strict().refine((body) => body.record.phase === body.phase, {
1236
+ message: "the record is for a different phase than the one asked for",
1237
+ path: ["record", "phase"]
1238
+ });
1239
+ var phaseRecordWithheldSchema = z11.object({
1240
+ outcome: z11.literal("withheld"),
1241
+ phase: phaseNameSchema,
1242
+ sequence: z11.number().int().min(1),
1243
+ closedAt: utcIso8601,
1244
+ message: z11.string()
1245
+ }).strict();
1246
+ var phaseRecordRefusedSchema = wireErrorSchema.extend({
1247
+ outcome: z11.literal("refused"),
1248
+ phase: phaseNameSchema,
1249
+ statusCode: z11.literal(409),
1250
+ error: z11.literal("PhaseRecordRefused"),
1251
+ reason: phaseRecordRefusalReasonSchema
1252
+ }).strict();
1253
+ var phaseRecordResponseSchema = z11.discriminatedUnion("outcome", [
1254
+ phaseRecordRecordSchema,
1255
+ phaseRecordWithheldSchema,
1256
+ phaseRecordRefusedSchema
1257
+ ]);
1258
+
1061
1259
  // dist/engagement-credential.js
1062
1260
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1063
1261
  import { platform as platform2 } from "node:os";
@@ -2989,12 +3187,12 @@ function escapeHtml(value) {
2989
3187
  }
2990
3188
  function completionPage(result, engagementId) {
2991
3189
  const approved = result === LOOPBACK_RESULT.APPROVED;
2992
- const headline = approved ? "Signed in. You can close this tab and return to your terminal." : "Sign-in declined. Nothing was created. You can close this tab.";
3190
+ const headline2 = approved ? "Signed in. You can close this tab and return to your terminal." : "Sign-in declined. Nothing was created. You can close this tab.";
2993
3191
  const onward = approved ? onwardTarget(engagementId) : null;
2994
3192
  const href = onward === null ? "" : escapeHtml(onward.href);
2995
3193
  const refresh = onward === null ? "" : `<meta http-equiv="refresh" content="${COMPLETION_REDIRECT_SECONDS};url=${href}">`;
2996
3194
  const successLink = onward === null ? "" : `<p><a href="${href}" style="color:#19d3ff">${escapeHtml(onward.label)}</a></p>`;
2997
- return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer">' + refresh + `<title>Halfcycle</title></head><body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0a0a0a;color:#fafafa;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif"><div style="max-width:26rem;padding:2rem;text-align:center"><p>${headline}</p>${successLink}</div></body></html>`;
3195
+ return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer">' + refresh + `<title>Halfcycle</title></head><body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0a0a0a;color:#fafafa;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif"><div style="max-width:26rem;padding:2rem;text-align:center"><p>${headline2}</p>${successLink}</div></body></html>`;
2998
3196
  }
2999
3197
  function isLoopbackPeer(remoteAddress) {
3000
3198
  if (remoteAddress === void 0)
@@ -4220,193 +4418,255 @@ async function probeMcpOrigin(mcpUrl) {
4220
4418
  return { reached: true, serverName: name };
4221
4419
  }
4222
4420
 
4223
- // dist/build-record/types.js
4224
- var BUILD_RECORD_FORMAT = "halfcycle-build-record/v1";
4225
- function projectSeedGuard(fired, includeExplanation) {
4226
- const seed = {
4227
- patternRef: fired.patternRef,
4228
- severity: fired.severity
4229
- };
4230
- if (includeExplanation && fired.explanation)
4231
- seed.explanation = fired.explanation;
4232
- return seed;
4233
- }
4234
-
4235
- // dist/build-record/sources.js
4236
- import { readFileSync as readFileSync6, existsSync as existsSync4 } from "node:fs";
4237
- import { createHash as createHash3 } from "node:crypto";
4238
- import { join as join8 } from "node:path";
4421
+ // dist/build-record/write.js
4422
+ import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeSync } from "node:fs";
4423
+ import { basename as basename2, join as join6, resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
4239
4424
 
4240
- // dist/build-record/close-record.js
4241
- import { execFileSync as execFileSync3 } from "node:child_process";
4242
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
4243
- import { dirname as dirname3, join as join6 } from "node:path";
4244
- var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
4245
- var BUILD_RECORD_ZONE_B_DIR = join6(ZONE_B_DIR, "build-record");
4246
- var InvalidCloseRecordError = class extends Error {
4247
- constructor(path, detail) {
4248
- super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
4249
- this.name = "InvalidCloseRecordError";
4250
- }
4251
- };
4252
- function closeRecordPath(repoRoot, phase) {
4253
- assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
4254
- return join6(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
4255
- }
4256
- function resolveCloseAtHead(repoRoot) {
4257
- try {
4258
- const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
4259
- encoding: "utf-8",
4260
- stdio: ["ignore", "pipe", "ignore"]
4261
- }).trim();
4262
- const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
4263
- encoding: "utf-8",
4264
- stdio: ["ignore", "pipe", "ignore"]
4265
- }).trim();
4266
- if (closeCommit === "" || closedDate === "")
4267
- return null;
4268
- return { closeCommit, closedDate };
4269
- } catch {
4270
- return null;
4425
+ // dist/build-record/render-v2.js
4426
+ var HIDDEN = /[\p{Cc}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
4427
+ var MARKDOWN_SPECIAL = /* @__PURE__ */ new Set(["\\", "`", "*", "_", "[", "]", "<", "|", "~", "#", "&", "$"]);
4428
+ function printRecordText(text) {
4429
+ if (text === "")
4430
+ return "(blank)";
4431
+ let out = "";
4432
+ for (const ch of text) {
4433
+ if (HIDDEN.test(ch)) {
4434
+ out += `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
4435
+ } else if (MARKDOWN_SPECIAL.has(ch)) {
4436
+ out += `\\${ch}`;
4437
+ } else {
4438
+ out += ch;
4439
+ }
4271
4440
  }
4441
+ return out;
4272
4442
  }
4273
- function writeCloseRecord(repoRoot, phase) {
4274
- let path;
4275
- try {
4276
- path = closeRecordPath(repoRoot, phase);
4277
- } catch (err) {
4278
- return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
4279
- }
4280
- const at = resolveCloseAtHead(repoRoot);
4281
- if (at === null) {
4282
- return {
4283
- recorded: false,
4284
- path,
4285
- reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
4286
- };
4287
- }
4288
- const record2 = {
4289
- format: CLOSE_RECORD_FORMAT,
4290
- phase,
4291
- closeCommit: at.closeCommit,
4292
- closedDate: at.closedDate
4443
+ function makePrinter() {
4444
+ let hidden = false;
4445
+ return {
4446
+ text(value) {
4447
+ if (!hidden && HIDDEN.test(value))
4448
+ hidden = true;
4449
+ return printRecordText(value);
4450
+ },
4451
+ sawHidden() {
4452
+ return hidden;
4453
+ }
4293
4454
  };
4294
- try {
4295
- mkdirSync5(dirname3(path), { recursive: true });
4296
- writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
4297
- } catch (err) {
4298
- return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
4299
- }
4300
- return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
4301
4455
  }
4302
- function parseCloseRecord(path, parsed) {
4303
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4304
- throw new InvalidCloseRecordError(path, "it does not hold a record");
4305
- }
4306
- const record2 = parsed;
4307
- if (record2.format !== CLOSE_RECORD_FORMAT) {
4308
- throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
4309
- }
4310
- const commit = record2.closeCommit;
4311
- const date = record2.closedDate;
4312
- const missing = [];
4313
- if (typeof commit !== "string" || commit.trim() === "")
4314
- missing.push("closeCommit");
4315
- if (typeof date !== "string" || date.trim() === "")
4316
- missing.push("closedDate");
4317
- if (missing.length > 0) {
4318
- 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.`);
4456
+ function counted(n, one, many) {
4457
+ return `${String(n)} ${n === 1 ? one : many}`;
4458
+ }
4459
+ var TITLE_PREFIX = "# Build Record: ";
4460
+ var HEADER_WHAT = "This is the Build Record for one phase of a software project run with the Halfcycle method. The Halfcycle method is a way of building software that Halfcycle runs inside the project's AI coding agent: it works out what is being built, plans the work, reviews it, and checks it as it is built. A phase is one stage of that work. Its finish conditions are the tests of done set out in the phase's plan; this record does not list them. This record says how this phase ended.";
4461
+ var HEADER_REGISTERS = "The client is the person or team running this project. Halfcycle is the service that supplies the method and runs the checks. 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.";
4462
+ var HEADER_CHECKS = "Halfcycle's checks are automatic rules drawn from the ways real software builds break. They are set up to run each time the project's AI coding agent edits a file. A check that finds something either warns or stops the work, depending on how serious it is.";
4463
+ var HEADER_KEPT = "Halfcycle derived this record and kept a copy of it when the phase closed. It changes that copy 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 the kept copy, when the phase closed or when the record was fetched again. So a later deletion reaches this file the next time the record is fetched. 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.";
4464
+ 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.";
4465
+ var OBSERVED_LABEL = "**Observed by Halfcycle**";
4466
+ var STATED_LABEL = "**Stated by the client** \u2014 Halfcycle did not check these.";
4467
+ var MISMATCHED_HALVES = "This Build Record could not be written: its two parts describe different closes.";
4468
+ function renderChecks(checks, p) {
4469
+ const blocks = ["## Checks during this phase", OBSERVED_LABEL];
4470
+ if (checks.state === "not-observed") {
4471
+ 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.");
4472
+ return blocks;
4473
+ }
4474
+ const runs = counted(checks.runs, "check run", "check runs");
4475
+ if (checks.state === "nothing-fired") {
4476
+ blocks.push(`**Checks ran and found nothing.** Halfcycle recorded ${runs} during this phase, and no check found a problem.`);
4477
+ } else {
4478
+ 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.`);
4479
+ blocks.push(renderFired(checks.fired, p));
4319
4480
  }
4320
- if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
4321
- throw new InvalidCloseRecordError(path, "it does not say which phase closed");
4481
+ if (checks.couldNotRun.length === 0) {
4482
+ blocks.push("No check reported that it could not run.");
4483
+ } else {
4484
+ blocks.push("**Some checks could not run.** They neither passed nor failed during this phase:");
4485
+ blocks.push(renderCouldNotRun(checks.couldNotRun, p));
4322
4486
  }
4323
- return {
4324
- format: CLOSE_RECORD_FORMAT,
4325
- phase: record2.phase,
4326
- closeCommit: commit,
4327
- closedDate: date
4328
- };
4487
+ return blocks;
4329
4488
  }
4330
-
4331
- // dist/build-record/write.js
4332
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
4333
- import { basename as basename2, join as join7, resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
4334
-
4335
- // dist/build-record/template.js
4336
- function severityLabel(seed) {
4337
- return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
4489
+ function renderFired(fired, p) {
4490
+ const lines = [];
4491
+ for (const entry of fired) {
4492
+ 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)}`);
4493
+ lines.push(` - Seriousness: ${seriousness(entry.severity)}`);
4494
+ lines.push(` - Found: ${counted(entry.occurrences, "time", "times")} during this phase.`);
4495
+ lines.push(` - What it found: ${entry.explanation === null ? "Not included in this record." : p.text(entry.explanation)}`);
4496
+ lines.push(` - The name your terminal prints for this check: ${p.text(entry.check)}`);
4497
+ }
4498
+ return lines.join("\n");
4338
4499
  }
4339
- var NOT_RECORDED = "not recorded";
4340
- function renderCloseLine(record2) {
4341
- const status = `**Status:** ${record2.status.toUpperCase()}`;
4342
- const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
4343
- const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
4344
- return `${status} \xB7 ${closed} \xB7 ${commit}`;
4500
+ function seriousness(severity) {
4501
+ switch (severity) {
4502
+ case "block":
4503
+ return "serious, it blocks the work.";
4504
+ case "warn":
4505
+ return "a warning.";
4506
+ case "info":
4507
+ return "for information only.";
4508
+ }
4345
4509
  }
4346
- function renderCloseProvenance(record2) {
4347
- 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.*";
4510
+ function renderCouldNotRun(entries, p) {
4511
+ const lines = [];
4512
+ for (const entry of entries) {
4513
+ 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)}`);
4514
+ lines.push(` - Why it could not run: ${p.text(entry.reason)}`);
4515
+ lines.push(` - The name your terminal prints for this check: ${p.text(entry.check)}`);
4516
+ }
4517
+ return lines.join("\n");
4348
4518
  }
4349
- function renderDoneWhenRows(record2) {
4350
- const rows = [];
4351
- for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
4352
- const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
4353
- rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
4519
+ function headline(observed, attested) {
4520
+ const met = attested.decision === "done-when-met";
4521
+ const problemsReported = attested.walk !== null && attested.walk.verdict === "defects";
4522
+ switch (observed.crossed.kind) {
4523
+ case "none":
4524
+ if (!met) {
4525
+ return "**Closed by override.** Halfcycle saw nothing outstanding, and the person closing the phase chose to override rather than state its finish conditions were met.";
4526
+ }
4527
+ return problemsReported ? "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
4528
+ case "unfinished-steps":
4529
+ case "clean-over-findings":
4530
+ 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.";
4531
+ case "not-recorded":
4532
+ 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.";
4533
+ }
4534
+ }
4535
+ function closedAtText(closedAt, p) {
4536
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/.exec(closedAt);
4537
+ return m === null ? p.text(closedAt) : `${m[1]} ${m[2]} UTC`;
4538
+ }
4539
+ function signedInAs(closedBy, p) {
4540
+ switch (closedBy.kind) {
4541
+ case "account":
4542
+ return closedBy.email === null ? "an account with no email address on file" : `the account ${p.text(closedBy.email)}`;
4543
+ case "no-account":
4544
+ return "a credential not linked to any person's account";
4545
+ case "account-removed":
4546
+ return "an account that has since been deleted";
4547
+ case "not-recorded":
4548
+ return "not recorded for this close";
4549
+ }
4550
+ }
4551
+ function outstanding(crossed, p) {
4552
+ switch (crossed.kind) {
4553
+ case "none":
4554
+ return ["- Outstanding work Halfcycle could see: none."];
4555
+ case "not-recorded":
4556
+ return ["- Outstanding work Halfcycle could see: not recorded for this close."];
4557
+ case "clean-over-findings":
4558
+ return [
4559
+ `- Outstanding work Halfcycle could see: ${counted(crossed.findings, "problem", "problems")} recorded against this phase.`
4560
+ ];
4561
+ case "unfinished-steps":
4562
+ return [
4563
+ `- 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:`,
4564
+ ...crossed.steps.map((step) => ` - Step: ${p.text(step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
4565
+ ];
4566
+ }
4567
+ }
4568
+ function machines(coverage) {
4569
+ const label = "- Computers signed in to Halfcycle for this project when the phase closed: ";
4570
+ if (coverage === null)
4571
+ return `${label}not recorded.`;
4572
+ if (coverage.enrolled === 0)
4573
+ return `${label}none.`;
4574
+ return `${label}${String(coverage.enrolled)}. Of these, ${String(coverage.conformant)} had reported Halfcycle's checks as fully set up.`;
4575
+ }
4576
+ function walkLines(walk, p) {
4577
+ const label = "> - Acceptance walk (the finished work tried as a user would, by someone other than its builder): ";
4578
+ if (walk === null)
4579
+ return [`${label}none reported.`];
4580
+ const lines = [
4581
+ `${label}${walk.verdict === "clean" ? "reported as finding no problems." : "reported as finding problems."}`
4582
+ ];
4583
+ walk.findings.forEach((finding, i) => {
4584
+ lines.push(`> - Finding ${String(i + 1)}: ${p.text(finding)}`);
4585
+ });
4586
+ if (walk.findingsNotShown > 0) {
4587
+ lines.push(`> - And ${counted(walk.findingsNotShown, "more finding", "more findings")} not shown here.`);
4354
4588
  }
4355
- return rows.join("\n");
4589
+ return lines;
4590
+ }
4591
+ function statedOrNot(value, p) {
4592
+ return value === null ? "Not stated by the client." : p.text(value);
4356
4593
  }
4357
- function renderBuildRecordMarkdown(record2) {
4358
- const { phase } = record2;
4359
- const companion = `${renderPhaseSegment(phase.id)}.json`;
4360
- const guardsList = record2.seedGuards.map(severityLabel).join(", ");
4594
+ function renderStated(attested, p) {
4361
4595
  const lines = [
4362
- // Fixed prose (vault-canonical template) + projected title slots.
4363
- `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
4364
- "",
4365
- `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
4366
- "",
4367
- "> 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.",
4368
- "",
4369
- `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
4370
- `**Phase:** ${phase.id} \u2014 ${phase.name}`,
4371
- renderCloseLine(record2),
4372
- `**Companion:** [\`${companion}\`](${companion})`,
4373
- "",
4374
- renderCloseProvenance(record2),
4375
- "",
4376
- "## What this phase proved",
4377
- "",
4378
- `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
4379
- "",
4380
- "## Delivered",
4381
- "",
4382
- `- **Packages:** ${record2.delivered.packages.join(", ")}`,
4383
- `- **Services:** ${record2.delivered.services.join(", ")}`,
4384
- `- **Tools:** ${record2.delivered.tools.join(", ")}`,
4385
- `- **Dogfood:** ${record2.delivered.dogfood}`,
4386
- "",
4387
- 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 guard firing was recorded on this machine during this phase.",
4388
- "",
4389
- `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
4390
- "",
4391
- `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
4392
- "",
4393
- "| Done-when | Result | Evidence |",
4394
- "|---|---|---|",
4395
- renderDoneWhenRows(record2),
4396
- ""
4596
+ 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.",
4597
+ `> - Name given: ${attested.actor === null ? "removed with the account that made this close" : p.text(attested.actor)}`,
4598
+ `> - Reason given: ${attested.reason === null ? "none." : p.text(attested.reason)}`,
4599
+ ...walkLines(attested.walk, p),
4600
+ `> - Code closed at (commit): ${statedOrNot(attested.closeCommit, p)}`,
4601
+ `> - What the phase set out to prove: ${statedOrNot(attested.statements.thesis, p)}`,
4602
+ `> - What came out of it: ${statedOrNot(attested.statements.outcome, p)}`
4397
4603
  ];
4398
- if (record2.acceptance.inv002ProdBypassProbe) {
4399
- lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
4604
+ const deviations = attested.statements.deviations;
4605
+ if (deviations.length === 0) {
4606
+ lines.push("> - Changes from the plan: None stated by the client.");
4607
+ } else {
4608
+ lines.push("> - Changes from the plan:");
4609
+ deviations.forEach((deviation, i) => {
4610
+ lines.push(`> - Change ${String(i + 1)}: ${p.text(deviation)}`);
4611
+ });
4400
4612
  }
4401
- 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"}`, "");
4402
4613
  return lines.join("\n");
4403
4614
  }
4615
+ function renderClose(heading, observed, attested, p) {
4616
+ return [
4617
+ heading,
4618
+ headline(observed, attested),
4619
+ OBSERVED_LABEL,
4620
+ [
4621
+ `- Closed at: ${closedAtText(observed.closedAt, p)}`,
4622
+ `- Signed in as: ${signedInAs(observed.closedBy, p)}`,
4623
+ ...outstanding(observed.crossed, p),
4624
+ machines(observed.seatCoverage)
4625
+ ].join("\n"),
4626
+ STATED_LABEL,
4627
+ renderStated(attested, p)
4628
+ ];
4629
+ }
4630
+ function renderBuildRecordV2Markdown(record2) {
4631
+ const p = makePrinter();
4632
+ const attestedBySequence = /* @__PURE__ */ new Map();
4633
+ for (const close of record2.attested.closes)
4634
+ attestedBySequence.set(close.sequence, close);
4635
+ const observedSequences = new Set(record2.observed.closes.map((c) => c.sequence));
4636
+ if (attestedBySequence.size !== record2.attested.closes.length || observedSequences.size !== record2.observed.closes.length || attestedBySequence.size !== observedSequences.size || [...observedSequences].some((s) => !attestedBySequence.has(s))) {
4637
+ throw new Error(MISMATCHED_HALVES);
4638
+ }
4639
+ const phase = p.text(record2.phase);
4640
+ const closes = [...record2.observed.closes].sort((a, b) => a.sequence - b.sequence);
4641
+ const total = closes.length;
4642
+ const latest = closes[total - 1];
4643
+ const body = [
4644
+ `Phase: ${phase} (the name the client gave it)`,
4645
+ OBSERVED_LABEL,
4646
+ [
4647
+ `- Method version: ${p.text(record2.observed.methodVersion)}. The Halfcycle method is updated from time to time, and each update has its own version number. This is the one the project was run under; it does not change during a project.`,
4648
+ `- Project identifier: ${p.text(record2.observed.engagementId)}. Halfcycle keeps this project's records under this identifier.`,
4649
+ `- Record format: ${p.text(record2.format)}. This names the layout this file and the .json file beside it follow, for software that reads them.`
4650
+ ].join("\n"),
4651
+ 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.`,
4652
+ ...renderChecks(record2.observed.checks, p),
4653
+ ...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), p)
4654
+ ];
4655
+ if (total > 1) {
4656
+ body.push("## Earlier closes");
4657
+ for (const close of closes.slice(0, -1)) {
4658
+ body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), p));
4659
+ }
4660
+ }
4661
+ const header = [`${TITLE_PREFIX}${phase}`, HEADER_WHAT, HEADER_REGISTERS, HEADER_CHECKS, HEADER_KEPT];
4662
+ if (p.sawHidden())
4663
+ header.push(HEADER_HIDDEN);
4664
+ return `${[...header, ...body].join("\n\n")}
4665
+ `;
4666
+ }
4404
4667
 
4405
4668
  // dist/build-record/write.js
4406
- var BUILD_RECORD_DIR = join7("docs", "build-records");
4407
- function serialiseRecordJson(record2) {
4408
- return JSON.stringify(record2, null, 2) + "\n";
4409
- }
4669
+ var BUILD_RECORD_DIR = join6("docs", "build-records");
4410
4670
  function assertInsideOutDir(outDir, candidate, identity) {
4411
4671
  const rel = relative2(outDir, resolve2(candidate));
4412
4672
  const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute2(rel) && rel === basename2(rel);
@@ -4414,230 +4674,221 @@ function assertInsideOutDir(outDir, candidate, identity) {
4414
4674
  throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
4415
4675
  }
4416
4676
  }
4417
- function writeBuildRecord(repoRoot, record2) {
4418
- const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
4419
- const identity = record2.phase.id;
4420
- if (!isValidPhaseIdentity(identity)) {
4421
- throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
4677
+ function createExclusiveOnDisk(path, text) {
4678
+ const fd = openSync(path, "wx");
4679
+ try {
4680
+ writeSync(fd, text);
4681
+ } catch (err) {
4682
+ closeSync(fd);
4683
+ rmSync2(path, { force: true });
4684
+ throw err;
4422
4685
  }
4423
- const segment = renderPhaseSegment(identity);
4424
- const jsonPath = join7(outDir, `${segment}.json`);
4425
- const mdPath = join7(outDir, `${segment}.md`);
4426
- assertInsideOutDir(outDir, jsonPath, identity);
4427
- assertInsideOutDir(outDir, mdPath, identity);
4428
- mkdirSync6(outDir, { recursive: true });
4429
- writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
4430
- writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
4431
- return { jsonPath, mdPath };
4686
+ closeSync(fd);
4432
4687
  }
4433
-
4434
- // dist/build-record/sources.js
4435
- var MissingSourceError = class extends Error {
4436
- constructor(sourceName, detail) {
4437
- super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
4438
- this.name = "MissingSourceError";
4439
- }
4688
+ function renameOnDisk(from, to) {
4689
+ renameSync(from, to);
4690
+ }
4691
+ var NODE_RECORD_PAIR_IO = {
4692
+ createExclusive: createExclusiveOnDisk,
4693
+ rename: renameOnDisk
4440
4694
  };
4441
- function projectSeedGuards(runs) {
4442
- const byRef = /* @__PURE__ */ new Map();
4443
- for (const run of runs) {
4444
- if (!run.result)
4445
- continue;
4446
- for (const fired of run.result.guardsFired) {
4447
- const seed = projectSeedGuard(
4448
- fired,
4449
- /* includeExplanation */
4450
- false
4451
- );
4452
- const existing = byRef.get(seed.patternRef);
4453
- if (!existing) {
4454
- byRef.set(seed.patternRef, seed);
4455
- } else if (existing.severity !== seed.severity) {
4456
- byRef.set(seed.patternRef, pickHigherSeverity(existing, seed));
4457
- }
4458
- }
4459
- }
4460
- return [...byRef.values()].sort((a, b) => a.patternRef.localeCompare(b.patternRef));
4695
+ function serialiseServedRecord(rawRecord) {
4696
+ return JSON.stringify(rawRecord, null, 2) + "\n";
4461
4697
  }
4462
- var SEVERITY_ORDINAL = { info: 0, warn: 1, block: 2 };
4463
- function pickHigherSeverity(a, b) {
4464
- return (SEVERITY_ORDINAL[a.severity] ?? -1) >= (SEVERITY_ORDINAL[b.severity] ?? -1) ? a : b;
4698
+ function isEexist(err) {
4699
+ return typeof err === "object" && err !== null && err.code === "EEXIST";
4465
4700
  }
4466
- function normalizeLegacyLine(parsed) {
4467
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4468
- return parsed;
4701
+ function readIfPresent(path) {
4702
+ try {
4703
+ return readFileSync6(path, "utf-8");
4704
+ } catch {
4705
+ return null;
4469
4706
  }
4470
- const record2 = parsed;
4471
- if (record2["outcome"] !== void 0)
4472
- return parsed;
4473
- return {
4474
- ...record2,
4475
- runId: record2["runId"] ?? syntheticRunId(record2),
4476
- outcome: "evaluated"
4477
- };
4478
4707
  }
4479
- function syntheticRunId(record2) {
4480
- const key = [
4481
- record2["engagementId"],
4482
- record2["occurredAt"],
4483
- record2["runType"],
4484
- record2["phase"]
4485
- ].join("|");
4486
- const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
4487
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
4488
- }
4489
- function readGuardEvalLog(logDir, phaseId) {
4490
- const logPath = join8(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
4491
- if (!existsSync4(logPath)) {
4492
- throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
4493
- }
4494
- const raw = readFileSync6(logPath, "utf-8");
4495
- const runs = [];
4496
- for (const line of raw.split("\n")) {
4497
- const trimmed = line.trim();
4498
- if (trimmed === "")
4499
- continue;
4500
- const parsed = JSON.parse(trimmed);
4501
- runs.push(guardEvalRunSchema.parse(normalizeLegacyLine(parsed)));
4708
+ function isOwnEarlierRecord(existingText, served) {
4709
+ let parsedJson;
4710
+ try {
4711
+ parsedJson = JSON.parse(existingText);
4712
+ } catch {
4713
+ return false;
4502
4714
  }
4503
- return runs;
4715
+ const existing = buildRecordV2Schema.safeParse(parsedJson);
4716
+ if (!existing.success)
4717
+ return false;
4718
+ const old = existing.data;
4719
+ if (old.observed.engagementId !== served.observed.engagementId)
4720
+ return false;
4721
+ if (old.phase !== served.phase)
4722
+ return false;
4723
+ const oldCloses = old.observed.closes.length;
4724
+ const newCloses = served.observed.closes.length;
4725
+ if (oldCloses < newCloses)
4726
+ return true;
4727
+ if (oldCloses > newCloses)
4728
+ return false;
4729
+ const oldSequences = new Set(old.observed.closes.map((c) => c.sequence));
4730
+ return served.observed.closes.every((c) => oldSequences.has(c.sequence));
4504
4731
  }
4505
- function readOrchestrationState(phasesDir, phaseId) {
4506
- const statePath = join8(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
4507
- if (!existsSync4(statePath)) {
4508
- throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
4732
+ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_PAIR_IO) {
4733
+ if (!isValidPhaseIdentity(phase)) {
4734
+ throw new InvalidPhaseIdentityError(phase, BUILD_RECORD_DIR);
4735
+ }
4736
+ const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
4737
+ const segment = renderPhaseSegment(phase);
4738
+ const jsonPath = join6(outDir, `${segment}.json`);
4739
+ const mdPath = join6(outDir, `${segment}.md`);
4740
+ const jsonTmp = `${jsonPath}.tmp`;
4741
+ const mdTmp = `${mdPath}.tmp`;
4742
+ for (const candidate of [jsonPath, mdPath, jsonTmp, mdTmp]) {
4743
+ assertInsideOutDir(outDir, candidate, phase);
4744
+ }
4745
+ const jsonText = serialiseServedRecord(rawRecord);
4746
+ const mdText = renderBuildRecordV2Markdown(record2);
4747
+ mkdirSync5(outDir, { recursive: true });
4748
+ let createdJson = false;
4749
+ try {
4750
+ io.createExclusive(jsonPath, jsonText);
4751
+ createdJson = true;
4752
+ io.createExclusive(mdPath, mdText);
4753
+ return { kind: "created", jsonPath, mdPath };
4754
+ } catch (err) {
4755
+ if (createdJson)
4756
+ rmSync2(jsonPath, { force: true });
4757
+ if (!isEexist(err))
4758
+ throw err;
4509
4759
  }
4510
- const parsed = JSON.parse(readFileSync6(statePath, "utf-8"));
4511
- if (!parsed.tasks || typeof parsed.tasks !== "object") {
4512
- throw new MissingSourceError("orchestration state", `${statePath} has no tasks map`);
4760
+ const existingJson = readIfPresent(jsonPath);
4761
+ if (existingJson === null) {
4762
+ return { kind: "in-the-way", path: existsSync4(jsonPath) ? jsonPath : mdPath };
4513
4763
  }
4514
- const tasks = {};
4515
- for (const [id, t] of Object.entries(parsed.tasks)) {
4516
- if (!t || typeof t.status !== "string") {
4517
- throw new MissingSourceError("orchestration state", `task ${id} has no status`);
4518
- }
4519
- tasks[id] = { status: t.status };
4764
+ if (existingJson === jsonText) {
4765
+ replaceOne(io, mdTmp, mdPath, mdText);
4766
+ return { kind: "up-to-date", jsonPath, mdPath };
4520
4767
  }
4521
- return { tasks };
4522
- }
4523
- function readPhaseClose(repoRoot, phaseId) {
4524
- const path = closeRecordPath(repoRoot, phaseId);
4525
- if (!existsSync4(path))
4526
- return { closeCommit: null, closedDate: null };
4527
- let parsed;
4768
+ if (!isOwnEarlierRecord(existingJson, record2)) {
4769
+ return { kind: "in-the-way", path: jsonPath };
4770
+ }
4771
+ replaceOne(io, jsonTmp, jsonPath, jsonText);
4528
4772
  try {
4529
- parsed = JSON.parse(readFileSync6(path, "utf-8"));
4773
+ replaceOne(io, mdTmp, mdPath, mdText);
4530
4774
  } catch (err) {
4531
- throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
4532
- }
4533
- const record2 = parseCloseRecord(path, parsed);
4534
- if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
4535
- throw new InvalidCloseRecordError(path, `it records the close of ${String(record2.phase)}, not of ${String(phaseId)}`);
4775
+ try {
4776
+ replaceOne(io, jsonTmp, jsonPath, existingJson);
4777
+ } catch {
4778
+ rmSync2(jsonPath, { force: true });
4779
+ }
4780
+ throw err;
4536
4781
  }
4537
- return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
4782
+ return { kind: "replaced", jsonPath, mdPath };
4538
4783
  }
4539
- function validateNarrated(narrated) {
4540
- const req = (v, name) => {
4541
- if (v === void 0 || v === null || v === "") {
4542
- throw new MissingSourceError("narrated artefact", `${name} is missing`);
4543
- }
4544
- };
4545
- req(narrated.engagement, "engagement");
4546
- req(narrated.engagementType, "engagementType");
4547
- req(narrated.phase?.name, "phase.name");
4548
- assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
4549
- req(narrated.status, "status");
4550
- req(narrated.thesis, "thesis");
4551
- req(narrated.delivered, "delivered");
4552
- req(narrated.acceptance, "acceptance");
4553
- req(narrated.acceptance?.verdict, "acceptance.verdict");
4554
- req(narrated.gatesEarnedKeep, "gatesEarnedKeep");
4555
- req(narrated.instruments, "instruments");
4556
- if (!Array.isArray(narrated.deviations)) {
4557
- throw new MissingSourceError("narrated artefact", "deviations must be an array");
4558
- }
4559
- return narrated;
4560
- }
4561
- function loadSources(opts) {
4562
- const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
4563
- const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
4564
- const close = readPhaseClose(opts.repoRoot, opts.phaseId);
4565
- const narrated = validateNarrated(opts.narrated);
4566
- if (!Array.isArray(opts.touchedInvariants)) {
4567
- throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
4784
+ function replaceOne(io, tmp, finalPath, text) {
4785
+ rmSync2(tmp, { force: true });
4786
+ try {
4787
+ io.createExclusive(tmp, text);
4788
+ io.rename(tmp, finalPath);
4789
+ } catch (err) {
4790
+ rmSync2(tmp, { force: true });
4791
+ throw err;
4568
4792
  }
4569
- return {
4570
- orchestration,
4571
- narrated,
4572
- close,
4573
- guardEvalRuns,
4574
- touchedInvariants: opts.touchedInvariants
4575
- };
4576
4793
  }
4577
4794
 
4578
- // dist/build-record/assemble.js
4579
- var LANDED_STATES = /* @__PURE__ */ new Set(["merged", "done"]);
4580
- var CANCELLED_STATE = "cancelled";
4581
- function deriveTasks(orchestration) {
4582
- const ids = Object.keys(orchestration.tasks);
4583
- const cancelled = ids.filter((id) => orchestration.tasks[id].status === CANCELLED_STATE).sort((a, b) => a.localeCompare(b));
4584
- const landed = ids.filter((id) => LANDED_STATES.has(orchestration.tasks[id].status)).length;
4585
- return { planned: ids.length, landed, cancelled };
4586
- }
4587
- function deriveThesisHeld(acceptance) {
4588
- if (acceptance.verdict !== "clean")
4589
- return false;
4590
- for (const dw of Object.values(acceptance.doneWhen)) {
4591
- if (dw.result === "dropped")
4592
- continue;
4593
- if (dw.result !== "pass")
4594
- return false;
4595
- }
4596
- return true;
4795
+ // dist/build-record/obtain.js
4796
+ import { relative as relative3 } from "node:path";
4797
+ var REFUSAL_FOLLOW_UP = {
4798
+ "still-open": "Nothing was written. The record is produced when the phase closes.",
4799
+ "reopened": "Nothing was written. A new record is produced when the phase closes again.",
4800
+ "never-opened": "Nothing was written. Check the name: it must match the phase name exactly as it was opened.",
4801
+ "never-closed": "Nothing was written.",
4802
+ "closed-before-records": "Nothing was written."
4803
+ };
4804
+ var REFUSAL_REASONS = phaseRecordRefusalReasonSchema.options;
4805
+ function buildRecordUrl(credential, phase) {
4806
+ return `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phases/${encodeURIComponent(phase)}/build-record`;
4597
4807
  }
4598
- function deriveInvariantsExercised(touched) {
4599
- return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
4808
+ function isMissingRoute(status, body) {
4809
+ if (status !== 404 || typeof body !== "object" || body === null)
4810
+ return false;
4811
+ const { error, message } = body;
4812
+ return error === "Not Found" && typeof message === "string" && /^Route GET .* not found\.$/s.test(message);
4600
4813
  }
4601
- function assembleBuildRecord(sources) {
4602
- const { narrated, close, orchestration } = sources;
4814
+ async function obtainBuildRecord(credential, repoRoot, phase, io = NODE_RECORD_PAIR_IO) {
4815
+ const url = buildRecordUrl(credential, phase);
4816
+ let res;
4817
+ let text;
4818
+ try {
4819
+ res = await fetch(url, {
4820
+ method: "GET",
4821
+ headers: { authorization: `Bearer ${credential.token}` }
4822
+ });
4823
+ text = await res.text();
4824
+ } catch (err) {
4825
+ return {
4826
+ kind: "unavailable",
4827
+ why: `could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}.`
4828
+ };
4829
+ }
4830
+ let raw = null;
4831
+ try {
4832
+ raw = JSON.parse(text);
4833
+ } catch {
4834
+ raw = null;
4835
+ }
4836
+ const parsed = phaseRecordResponseSchema.safeParse(raw);
4837
+ if (parsed.success) {
4838
+ const answer = parsed.data;
4839
+ if (answer.phase !== phase) {
4840
+ return {
4841
+ kind: "unavailable",
4842
+ why: `the service answered for a different phase than the one asked for, so nothing was written.`
4843
+ };
4844
+ }
4845
+ if (answer.outcome === "withheld")
4846
+ return { kind: "withheld", message: answer.message };
4847
+ if (answer.outcome === "refused") {
4848
+ return { kind: "refused", reason: answer.reason, message: answer.message };
4849
+ }
4850
+ const rawRecord = raw.record;
4851
+ let result;
4852
+ try {
4853
+ result = writeRecordPair(repoRoot, phase, rawRecord, answer.record, io);
4854
+ } catch (err) {
4855
+ return {
4856
+ kind: "unavailable",
4857
+ why: `the record arrived but could not be written: ${err instanceof Error ? err.message : String(err)}. Nothing was left half written.`
4858
+ };
4859
+ }
4860
+ if (result.kind === "in-the-way") {
4861
+ return { kind: "exists", path: relative3(repoRoot, result.path) };
4862
+ }
4863
+ return {
4864
+ kind: "written",
4865
+ jsonPath: relative3(repoRoot, result.jsonPath),
4866
+ mdPath: relative3(repoRoot, result.mdPath)
4867
+ };
4868
+ }
4869
+ if (isMissingRoute(res.status, raw)) {
4870
+ return { kind: "unavailable", why: "this Halfcycle service does not provide Build Records yet." };
4871
+ }
4872
+ const message = typeof raw === "object" && raw !== null && typeof raw.message === "string" ? raw.message : null;
4873
+ if (res.ok) {
4874
+ return {
4875
+ kind: "unavailable",
4876
+ why: `the service's answer was not a Build Record this version of the CLI can read, so nothing was written.`
4877
+ };
4878
+ }
4603
4879
  return {
4604
- format: BUILD_RECORD_FORMAT,
4605
- engagement: narrated.engagement,
4606
- engagementType: narrated.engagementType,
4607
- phase: { id: narrated.phase.id, name: narrated.phase.name },
4608
- status: narrated.status,
4609
- closedDate: close.closedDate,
4610
- closeCommit: close.closeCommit,
4611
- thesis: narrated.thesis,
4612
- thesisHeld: deriveThesisHeld(narrated.acceptance),
4613
- delivered: narrated.delivered,
4614
- seedGuards: projectSeedGuards(sources.guardEvalRuns),
4615
- acceptance: narrated.acceptance,
4616
- gatesEarnedKeep: narrated.gatesEarnedKeep,
4617
- instruments: narrated.instruments,
4618
- invariantsExercised: deriveInvariantsExercised(sources.touchedInvariants),
4619
- deviations: narrated.deviations,
4620
- tasks: deriveTasks(orchestration)
4880
+ kind: "unavailable",
4881
+ why: `the service answered ${res.status}${message === null ? "." : `: ${message}`}`
4621
4882
  };
4622
4883
  }
4623
-
4624
- // dist/build-record/index.js
4625
- function assemblePhaseBuildRecord(opts) {
4626
- const sources = loadSources({
4627
- repoRoot: opts.repoRoot,
4628
- phasesDir: opts.phasesDir,
4629
- guardEvalLogDir: opts.guardEvalLogDir,
4630
- phaseId: opts.phaseId,
4631
- narrated: opts.narrated,
4632
- touchedInvariants: opts.touchedInvariants
4633
- });
4634
- const record2 = assembleBuildRecord(sources);
4635
- return writeBuildRecord(opts.repoRoot, record2);
4884
+ function refetchCommand(phase) {
4885
+ const quoted = /^[A-Za-z0-9._+-]+$/.test(phase) ? phase : `'${phase.replace(/'/g, `'\\''`)}'`;
4886
+ return `npx halfcycle@latest build-record ${quoted}`;
4636
4887
  }
4637
4888
 
4638
4889
  // dist/open-phase.js
4639
4890
  import { readFileSync as readFileSync7 } from "node:fs";
4640
- import { join as join9 } from "node:path";
4891
+ import { join as join7 } from "node:path";
4641
4892
  var OpenPhaseRefused = class extends Error {
4642
4893
  status;
4643
4894
  finding;
@@ -4658,7 +4909,7 @@ function readRepoCredential(repoRoot, env = process.env, home) {
4658
4909
  if (pinned !== null)
4659
4910
  assertEngagementId(pinned);
4660
4911
  const fromStore = pinned === null ? null : readEngagementEnv(pinned, home);
4661
- const looked = pinned === null ? `${join9(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
4912
+ const looked = pinned === null ? `${join7(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
4662
4913
  const values = {};
4663
4914
  for (const key of OPEN_PHASE_ENV_KEYS) {
4664
4915
  const value = (env[key] ?? fromStore?.[key] ?? "").trim();
@@ -4675,7 +4926,7 @@ function readRepoCredential(repoRoot, env = process.env, home) {
4675
4926
  }
4676
4927
  function readPinnedEngagementId(repoRoot) {
4677
4928
  try {
4678
- const pin = JSON.parse(readFileSync7(join9(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
4929
+ const pin = JSON.parse(readFileSync7(join7(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
4679
4930
  const id = pin[PIN_ENGAGEMENT_ID_FIELD];
4680
4931
  return typeof id === "string" && id !== "" ? id : null;
4681
4932
  } catch {
@@ -4739,6 +4990,35 @@ async function openPhase(credential, phase, entry, home) {
4739
4990
  }
4740
4991
 
4741
4992
  // dist/close-phase.js
4993
+ import { execFileSync as execFileSync3 } from "node:child_process";
4994
+ var NO_STATEMENTS = { thesis: null, outcome: null, deviations: [] };
4995
+ function readCloseCommit(repoRoot) {
4996
+ if (repoRoot === void 0) {
4997
+ return {
4998
+ read: false,
4999
+ reason: "no repository was named for this close, so there was nowhere to read a commit from"
5000
+ };
5001
+ }
5002
+ let out;
5003
+ try {
5004
+ out = execFileSync3("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
5005
+ encoding: "utf-8",
5006
+ env: gitEnvForNamedRepo(),
5007
+ stdio: ["ignore", "pipe", "ignore"]
5008
+ }).trim();
5009
+ } catch (err) {
5010
+ const missingGit = typeof err === "object" && err !== null && err.code === "ENOENT";
5011
+ return {
5012
+ read: false,
5013
+ reason: missingGit ? "git is not available on this machine" : "this folder is not a git repository, or it has no commit yet"
5014
+ };
5015
+ }
5016
+ const checked = closeCommitSchema.safeParse(out);
5017
+ if (!checked.success) {
5018
+ return { read: false, reason: "git did not answer with a full commit hash" };
5019
+ }
5020
+ return { read: true, commit: checked.data };
5021
+ }
4742
5022
  var ClosePhaseRefused = class extends Error {
4743
5023
  status;
4744
5024
  finding;
@@ -4751,14 +5031,28 @@ var ClosePhaseRefused = class extends Error {
4751
5031
  this.name = "ClosePhaseRefused";
4752
5032
  }
4753
5033
  };
4754
- async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4755
- const parsed = acceptanceOutcomeSchema.safeParse(outcome);
4756
- if (!parsed.success) {
4757
- throw new Error(`[halfcycle] That is not an acceptance outcome this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
5034
+ var CloseRequestInvalid = class extends Error {
5035
+ constructor(message) {
5036
+ super(message);
5037
+ this.name = "CloseRequestInvalid";
4758
5038
  }
4759
- const parsedClose = phaseCloseDecisionSchema.safeParse(close);
4760
- if (!parsedClose.success) {
4761
- throw new Error(`[halfcycle] That is not a phase-close decision this platform accepts: ${parsedClose.error.issues.map((i) => `${i.path.join(".") || "(decision)"}: ${i.message}`).join("; ")}.`);
5039
+ };
5040
+ async function closePhase(credential, phase, outcome, close, home, repoRoot, stated = NO_STATEMENTS) {
5041
+ const closeCommit = readCloseCommit(repoRoot);
5042
+ const body = {
5043
+ phase,
5044
+ acceptanceOutcome: outcome,
5045
+ close,
5046
+ statements: {
5047
+ thesis: stated.thesis,
5048
+ outcome: stated.outcome,
5049
+ deviations: stated.deviations,
5050
+ closeCommit: closeCommit.read ? closeCommit.commit : null
5051
+ }
5052
+ };
5053
+ const parsed = acceptPhaseRequestSchema.safeParse(body);
5054
+ if (!parsed.success) {
5055
+ throw new CloseRequestInvalid(`that close is not one this platform accepts, so nothing was sent: ${parsed.error.issues.map((i) => `${describeIssuePath(i.path)}: ${i.message}`).join("; ")}.`);
4762
5056
  }
4763
5057
  const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/accept`;
4764
5058
  let res;
@@ -4769,7 +5063,7 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4769
5063
  "content-type": "application/json",
4770
5064
  authorization: `Bearer ${credential.token}`
4771
5065
  },
4772
- body: JSON.stringify({ phase, acceptanceOutcome: outcome, close })
5066
+ body: JSON.stringify(body)
4773
5067
  });
4774
5068
  } catch (err) {
4775
5069
  throw new Error(`[halfcycle] Could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}. The phase was not closed.`);
@@ -4781,17 +5075,24 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4781
5075
  const code = typeof body2?.error === "string" ? body2.error : void 0;
4782
5076
  throw new ClosePhaseRefused(res.status, message, finding, code);
4783
5077
  }
4784
- const body = await res.json().catch(() => null);
4785
- if (!body || typeof body.engagementId !== "string" || typeof body.status !== "string") {
5078
+ const answer = await res.json().catch(() => null);
5079
+ if (!answer || typeof answer.engagementId !== "string" || typeof answer.status !== "string") {
4786
5080
  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.`);
4787
5081
  }
4788
- const closeRecord = repoRoot === void 0 ? {
4789
- recorded: false,
4790
- path: null,
4791
- reason: "no repository was named for this close, so there was nowhere to read a commit from"
4792
- } : writeCloseRecord(repoRoot, phase);
4793
5082
  const stampFailure = writePhaseStamp(credential.engagementId, null, home);
4794
- return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
5083
+ return { engagementId: answer.engagementId, status: answer.status, stampFailure, closeCommit };
5084
+ }
5085
+ function describeIssuePath(path) {
5086
+ const [head, field] = path;
5087
+ if (head === "statements" && field === "thesis")
5088
+ return "--thesis";
5089
+ if (head === "statements" && field === "outcome")
5090
+ return "--outcome";
5091
+ if (head === "statements" && field === "deviations")
5092
+ return "--deviation";
5093
+ if (head === "phase")
5094
+ return "the phase name";
5095
+ return path.map(String).join(".") || "(the close)";
4795
5096
  }
4796
5097
 
4797
5098
  // dist/cli-contract.js
@@ -4908,9 +5209,9 @@ var PLACEHOLDERS = {
4908
5209
  };
4909
5210
 
4910
5211
  // dist/uninstall.js
4911
- import { lstatSync as lstatSync2, readFileSync as readFileSync8, readdirSync, rmSync as rmSync2, rmdirSync, writeFileSync as writeFileSync7 } from "node:fs";
5212
+ import { lstatSync as lstatSync2, readFileSync as readFileSync8, readdirSync, rmSync as rmSync3, rmdirSync, writeFileSync as writeFileSync5 } from "node:fs";
4912
5213
  import { homedir as homedir2 } from "node:os";
4913
- import { dirname as dirname4, join as join10, relative as relative3, sep } from "node:path";
5214
+ import { dirname as dirname3, join as join8, relative as relative4, sep } from "node:path";
4914
5215
  var REMOVE_CREDENTIAL_FLAG = "--remove-credential";
4915
5216
  function parseUninstallArgs(args2) {
4916
5217
  let removeCredential = false;
@@ -4936,7 +5237,7 @@ function lexists(path) {
4936
5237
  function symlinkOnPath(root, rel) {
4937
5238
  let current = root;
4938
5239
  for (const segment of rel.split("/")) {
4939
- current = join10(current, segment);
5240
+ current = join8(current, segment);
4940
5241
  try {
4941
5242
  if (lstatSync2(current).isSymbolicLink())
4942
5243
  return true;
@@ -4957,7 +5258,7 @@ var Refusal = class extends Error {
4957
5258
  function readMergedJson(targetRepo, rel) {
4958
5259
  if (symlinkOnPath(targetRepo, rel))
4959
5260
  return { state: "symlink" };
4960
- const path = join10(targetRepo, rel);
5261
+ const path = join8(targetRepo, rel);
4961
5262
  if (!lexists(path))
4962
5263
  return { state: "absent" };
4963
5264
  let value;
@@ -4973,8 +5274,8 @@ function readMergedJson(targetRepo, rel) {
4973
5274
  }
4974
5275
  function validate(options) {
4975
5276
  const { targetRepo, removeCredential, home } = options;
4976
- const manifestPath = join10(targetRepo, INSTALL_MANIFEST_REL);
4977
- const pinPath = join10(targetRepo, BUNDLE_PIN_REL);
5277
+ const manifestPath = join8(targetRepo, INSTALL_MANIFEST_REL);
5278
+ const pinPath = join8(targetRepo, BUNDLE_PIN_REL);
4978
5279
  if (symlinkOnPath(targetRepo, INSTALL_MANIFEST_REL) || symlinkOnPath(targetRepo, BUNDLE_PIN_REL)) {
4979
5280
  throw new Refusal(`.halfcycle, or a file Halfcycle keeps in it, is a symbolic link here, so nothing was followed and nothing was removed.`);
4980
5281
  }
@@ -5018,8 +5319,8 @@ function validate(options) {
5018
5319
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} is not a valid Halfcycle project id, so it cannot name a folder on this machine. Nothing was removed, here or under ~/.halfcycle.`);
5019
5320
  }
5020
5321
  if (engagementId !== void 0) {
5021
- const expectedParent = join10(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
5022
- if (dirname4(engagementStateDir(engagementId, home)) !== expectedParent) {
5322
+ const expectedParent = join8(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
5323
+ if (dirname3(engagementStateDir(engagementId, home)) !== expectedParent) {
5023
5324
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} does not name a folder under ~/.halfcycle/engagements. Nothing was removed.`);
5024
5325
  }
5025
5326
  }
@@ -5170,7 +5471,7 @@ function trimGitignore(targetRepo, text, rec, writeSet) {
5170
5471
  }
5171
5472
  function coveredPathExists(targetRepo, line) {
5172
5473
  const path = line.trim().replace(/^\//, "").replace(/\/$/, "");
5173
- return path !== "" && lexists(join10(targetRepo, path));
5474
+ return path !== "" && lexists(join8(targetRepo, path));
5174
5475
  }
5175
5476
  function uninstall(options) {
5176
5477
  const result = {
@@ -5228,7 +5529,7 @@ function uninstall(options) {
5228
5529
  removeRecordedFile(targetRepo, entry, writeSet, result, name);
5229
5530
  }
5230
5531
  for (const path of manifest.leftAlone) {
5231
- if (lexists(join10(targetRepo, path)))
5532
+ if (lexists(join8(targetRepo, path)))
5232
5533
  name(result.leftAlone, path, path);
5233
5534
  }
5234
5535
  switch (removeLegacyCiStanza(targetRepo)) {
@@ -5260,10 +5561,10 @@ function uninstall(options) {
5260
5561
  const pinEntry = manifest.files.find((f) => f.path === BUNDLE_PIN_REL);
5261
5562
  if (pinEntry !== void 0)
5262
5563
  removeRecordedFile(targetRepo, pinEntry, writeSet, result, name);
5263
- else if (lexists(join10(targetRepo, BUNDLE_PIN_REL)))
5564
+ else if (lexists(join8(targetRepo, BUNDLE_PIN_REL)))
5264
5565
  name(result.leftAlone, BUNDLE_PIN_REL, BUNDLE_PIN_REL);
5265
5566
  try {
5266
- rmSync2(join10(targetRepo, INSTALL_MANIFEST_REL), { force: true });
5567
+ rmSync3(join8(targetRepo, INSTALL_MANIFEST_REL), { force: true });
5267
5568
  named.add(INSTALL_MANIFEST_REL);
5268
5569
  } catch {
5269
5570
  name(result.failed, INSTALL_MANIFEST_REL, INSTALL_MANIFEST_REL);
@@ -5293,14 +5594,14 @@ function applyMergedJson(targetRepo, rel, file, lists, name, edit) {
5293
5594
  const { changed, created, adopted, keptNote } = edit(value);
5294
5595
  if (keptNote !== void 0)
5295
5596
  name(lists.kept, keptNote, rel);
5296
- const path = join10(targetRepo, rel);
5597
+ const path = join8(targetRepo, rel);
5297
5598
  const empty = Object.keys(value).length === 0;
5298
5599
  try {
5299
5600
  if (empty && created && !adopted) {
5300
- rmSync2(path);
5601
+ rmSync3(path);
5301
5602
  name(lists.removed, rel, rel);
5302
5603
  } else if (changed) {
5303
- writeFileSync7(path, serializeJson(value), "utf-8");
5604
+ writeFileSync5(path, serializeJson(value), "utf-8");
5304
5605
  name(lists.restored, rel, rel);
5305
5606
  }
5306
5607
  if (adopted && lexists(path)) {
@@ -5320,7 +5621,7 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5320
5621
  name(lists.leftAlone, `${path} (a symbolic link, or inside one \u2014 not followed)`, path);
5321
5622
  return;
5322
5623
  }
5323
- const abs = join10(targetRepo, path);
5624
+ const abs = join8(targetRepo, path);
5324
5625
  let isFile;
5325
5626
  try {
5326
5627
  isFile = lstatSync2(abs).isFile();
@@ -5345,7 +5646,7 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5345
5646
  }
5346
5647
  }
5347
5648
  try {
5348
- rmSync2(abs);
5649
+ rmSync3(abs);
5349
5650
  name(lists.removed, path, path);
5350
5651
  } catch {
5351
5652
  name(lists.failed, path, path);
@@ -5354,7 +5655,7 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5354
5655
  function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
5355
5656
  if (!writeSet.dirs.has(dir) || symlinkOnPath(targetRepo, dir))
5356
5657
  return;
5357
- const abs = join10(targetRepo, dir);
5658
+ const abs = join8(targetRepo, dir);
5358
5659
  try {
5359
5660
  if (!lstatSync2(abs).isDirectory() || readdirSync(abs).length > 0)
5360
5661
  return;
@@ -5374,7 +5675,7 @@ function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5374
5675
  name(lists.leftAlone, `${GITIGNORE_REL} (a symbolic link \u2014 not followed)`, GITIGNORE_REL);
5375
5676
  return;
5376
5677
  }
5377
- const path = join10(targetRepo, GITIGNORE_REL);
5678
+ const path = join8(targetRepo, GITIGNORE_REL);
5378
5679
  let before;
5379
5680
  try {
5380
5681
  before = readFileSync8(path, "utf-8");
@@ -5392,10 +5693,10 @@ function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5392
5693
  return;
5393
5694
  try {
5394
5695
  if (text === "" && rec.created) {
5395
- rmSync2(path);
5696
+ rmSync3(path);
5396
5697
  name(lists.removed, GITIGNORE_REL, GITIGNORE_REL);
5397
5698
  } else {
5398
- writeFileSync7(path, text, "utf-8");
5699
+ writeFileSync5(path, text, "utf-8");
5399
5700
  name(lists.restored, GITIGNORE_REL, GITIGNORE_REL);
5400
5701
  }
5401
5702
  } catch {
@@ -5421,8 +5722,8 @@ function handleCredential(options, checked) {
5421
5722
  try {
5422
5723
  if (lstatSync2(dir).isSymbolicLink())
5423
5724
  return { kind: "failed", dir, reason: "it is a symbolic link" };
5424
- rmSync2(engagementEnvPath(engagementId, home), { force: true });
5425
- rmSync2(notThisAccountMarkerPath(engagementId, home), { force: true });
5725
+ rmSync3(engagementEnvPath(engagementId, home), { force: true });
5726
+ rmSync3(notThisAccountMarkerPath(engagementId, home), { force: true });
5426
5727
  if (readdirSync(dir).length === 0)
5427
5728
  rmdirSync(dir);
5428
5729
  } catch (err) {
@@ -5435,16 +5736,16 @@ function filesUnder(root, rel) {
5435
5736
  const walk = (dirRel) => {
5436
5737
  let names;
5437
5738
  try {
5438
- if (!lstatSync2(join10(root, dirRel)).isDirectory())
5739
+ if (!lstatSync2(join8(root, dirRel)).isDirectory())
5439
5740
  return;
5440
- names = readdirSync(join10(root, dirRel));
5741
+ names = readdirSync(join8(root, dirRel));
5441
5742
  } catch {
5442
5743
  return;
5443
5744
  }
5444
5745
  for (const entry of names.sort()) {
5445
5746
  const childRel = `${dirRel}/${entry}`;
5446
5747
  try {
5447
- if (lstatSync2(join10(root, childRel)).isDirectory())
5748
+ if (lstatSync2(join8(root, childRel)).isDirectory())
5448
5749
  walk(childRel);
5449
5750
  else
5450
5751
  out.push(childRel);
@@ -5458,7 +5759,7 @@ function filesUnder(root, rel) {
5458
5759
  }
5459
5760
  function shownPath(path, home) {
5460
5761
  const base = home ?? homedir2();
5461
- const rel = relative3(base, path);
5762
+ const rel = relative4(base, path);
5462
5763
  const under = rel !== "" && !rel.startsWith("..") && !rel.startsWith(sep);
5463
5764
  return `${under ? `~/${rel.split(sep).join("/")}` : path}/`;
5464
5765
  }
@@ -5518,7 +5819,7 @@ function renderUninstallReport(result, home) {
5518
5819
  // dist/banner-facts.js
5519
5820
  import { execFileSync as execFileSync4 } from "node:child_process";
5520
5821
  import { readFileSync as readFileSync9 } from "node:fs";
5521
- import { basename as basename3, join as join11, resolve as resolve3 } from "node:path";
5822
+ import { basename as basename3, join as join9, resolve as resolve3 } from "node:path";
5522
5823
  var BRAND_URL = "halfcycle.ai";
5523
5824
  var TAGLINE = [
5524
5825
  "Fewer cycles.",
@@ -5531,7 +5832,7 @@ var TAGLINE_NARROW = [
5531
5832
  ];
5532
5833
  function bundleVersion() {
5533
5834
  try {
5534
- const pkg = JSON.parse(readFileSync9(join11(BUNDLE_ROOT, "package.json"), "utf-8"));
5835
+ const pkg = JSON.parse(readFileSync9(join9(BUNDLE_ROOT, "package.json"), "utf-8"));
5535
5836
  return typeof pkg.version === "string" ? pkg.version : void 0;
5536
5837
  } catch {
5537
5838
  return void 0;
@@ -5681,8 +5982,11 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
5681
5982
  --yes / -y: use this machine's saved sign-in without being asked
5682
5983
  halfcycle check-drift <target-repo>
5683
5984
  halfcycle build-record <phase> [--repo <root>]
5985
+ fetch a closed phase's Build Record again and write it into docs/build-records/
5684
5986
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
5685
5987
  halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
5988
+ [--thesis "\u2026"] [--outcome "\u2026"] [--deviation "\u2026"]\u2026
5989
+ close the phase and write its Build Record into docs/build-records/
5686
5990
  halfcycle uninstall [${REMOVE_CREDENTIAL_FLAG}]
5687
5991
  take out what the installer added to this project, keeping anything you changed
5688
5992
  ${REMOVE_CREDENTIAL_FLAG}: also remove this machine's credential for the project, which every
@@ -5695,7 +5999,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
5695
5999
  `;
5696
6000
  function isHalfcycleMonorepo(dir) {
5697
6001
  try {
5698
- const pkg = JSON.parse(readFileSync10(join12(dir, "package.json"), "utf-8"));
6002
+ const pkg = JSON.parse(readFileSync10(join10(dir, "package.json"), "utf-8"));
5699
6003
  return pkg.name === "halfcycle-monorepo";
5700
6004
  } catch {
5701
6005
  return false;
@@ -6002,31 +6306,24 @@ ${USAGE}`);
6002
6306
  return;
6003
6307
  }
6004
6308
  if (cmd === "build-record") {
6005
- const phaseArg = rest[0];
6006
- const repoRoot = rest.includes("--repo") ? rest[rest.indexOf("--repo") + 1] : process.cwd();
6309
+ const phaseArg = firstPositional(rest);
6310
+ const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
6007
6311
  if (phaseArg === void 0) {
6008
6312
  process.stderr.write("halfcycle: usage: halfcycle build-record <phase> [--repo <root>]\n");
6009
6313
  process.exit(1);
6010
6314
  return;
6011
6315
  }
6012
- const phaseId = phaseArg;
6013
6316
  try {
6014
- assertValidPhaseIdentity(phaseId, BUILD_RECORD_DIR);
6015
- const inputPath = join12(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
6016
- const input = JSON.parse(readFileSync10(inputPath, "utf-8"));
6017
- const result = assemblePhaseBuildRecord({
6018
- repoRoot,
6019
- phasesDir: join12(repoRoot, "docs", "phases"),
6020
- guardEvalLogDir: input.guardEvalLogDir ?? join12(repoRoot, ".workbench", "guard-eval-log"),
6021
- phaseId,
6022
- narrated: input.narrated,
6023
- touchedInvariants: input.touchedInvariants
6024
- });
6025
- process.stdout.write(`[halfcycle] Build Record written:
6026
- ${result.jsonPath}
6027
- ${result.mdPath}
6028
- `);
6029
- process.exit(0);
6317
+ assertValidPhaseIdentity(phaseArg, BUILD_RECORD_DIR);
6318
+ const credential = readRepoCredential(repoRoot);
6319
+ const obtained = await obtainBuildRecord(credential, repoRoot, phaseArg);
6320
+ if (obtained.kind === "written") {
6321
+ process.stdout.write(writtenLines(obtained));
6322
+ process.exit(0);
6323
+ return;
6324
+ }
6325
+ process.stderr.write(notWrittenLines("build-record", obtained, phaseArg));
6326
+ process.exit(1);
6030
6327
  } catch (err) {
6031
6328
  process.stderr.write(`halfcycle build-record failed: ${err instanceof Error ? err.message : String(err)}
6032
6329
  `);
@@ -6159,26 +6456,36 @@ halfcycle open-phase: a phase name has to work as a single folder name on this m
6159
6456
  process.exit(2);
6160
6457
  return;
6161
6458
  }
6459
+ const stated = readStatementFlags(rest);
6460
+ if (typeof stated === "string") {
6461
+ process.stderr.write(`halfcycle close-phase: ${stated} needs a value after it.
6462
+ `);
6463
+ process.exit(2);
6464
+ return;
6465
+ }
6162
6466
  try {
6163
6467
  const credential = readRepoCredential(repoRoot);
6164
6468
  const closed = await closePhase(credential, phaseArg, {
6165
6469
  verdict,
6166
6470
  findings: findingsDetail.length,
6167
6471
  findingsDetail
6168
- }, closeDecision, void 0, repoRoot);
6169
- 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}.
6472
+ }, closeDecision, void 0, repoRoot, stated);
6473
+ process.stdout.write(`[halfcycle] Phase "${phaseArg}" closed as ${verdict}${findingsDetail.length > 0 ? ` with ${findingsDetail.length} finding(s)` : ""} by ${closeDecision.actor}. The next phase can be opened.
6170
6474
  `);
6171
6475
  if (closeDecision.decision === "override") {
6172
6476
  process.stdout.write(`[halfcycle] This close was an OVERRIDE: ${closeDecision.reason}
6173
6477
  `);
6174
6478
  }
6175
- if (closed.closeRecord.recorded) {
6176
- process.stdout.write(`[halfcycle] Closed at commit ${closed.closeRecord.closeCommit} (${closed.closeRecord.closedDate}). The Build Record for this phase will name it.
6479
+ if (!closed.closeCommit.read) {
6480
+ process.stderr.write(`halfcycle close-phase: the commit this phase closed at could not be read (${closed.closeCommit.reason}), so its Build Record shows the commit as not stated.
6177
6481
  `);
6482
+ }
6483
+ const obtained = await obtainBuildRecord(credential, repoRoot, phaseArg);
6484
+ const recordWritten = obtained.kind === "written";
6485
+ if (obtained.kind === "written") {
6486
+ process.stdout.write(writtenLines(obtained));
6178
6487
  } else {
6179
- process.stderr.write(`halfcycle close-phase: the phase closed, but the commit it closed at was not recorded on this machine: ${closed.closeRecord.reason}
6180
- halfcycle close-phase: this phase's Build Record will say the close commit and the close date were not recorded.
6181
- `);
6488
+ process.stderr.write(notWrittenLines("close-phase", obtained, phaseArg));
6182
6489
  }
6183
6490
  process.stdout.write(`[halfcycle] Open the next phase with: halfcycle open-phase <phase> --actor "you" --evidence "\u2026"
6184
6491
  `);
@@ -6186,21 +6493,28 @@ halfcycle close-phase: this phase's Build Record will say the close commit and t
6186
6493
  process.stderr.write(`halfcycle close-phase: the phase closed, but this machine's record of it could not be cleared: ${closed.stampFailure.reason}
6187
6494
  halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard evaluations here are still filed under the phase you just closed.
6188
6495
  `);
6189
- process.exit(1);
6190
- return;
6191
6496
  }
6192
- process.exit(0);
6497
+ process.exit(recordWritten && !closed.stampFailure ? 0 : 1);
6193
6498
  } catch (err) {
6499
+ if (err instanceof CloseRequestInvalid) {
6500
+ process.stderr.write(`halfcycle close-phase: ${err.message}
6501
+ `);
6502
+ process.exit(2);
6503
+ return;
6504
+ }
6194
6505
  if (err instanceof ClosePhaseRefused) {
6195
6506
  process.stderr.write(`halfcycle close-phase: refused (${err.status}). ${err.message}
6196
6507
  `);
6508
+ if (err.status === 400) {
6509
+ process.stderr.write("halfcycle close-phase: Nothing was closed.\n");
6510
+ }
6197
6511
  if (err.finding) {
6198
6512
  process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${err.finding}
6199
6513
  `);
6200
6514
  process.stderr.write(`halfcycle close-phase: close it anyway with \`halfcycle close-phase ${phaseArg} --verdict ${verdict} --actor "${closeActor}" --override --reason "\u2026"\` \u2014 recorded, not waived.
6201
6515
  `);
6202
6516
  } else if (err.code === "PhaseAlreadyClosed") {
6203
- process.stderr.write("halfcycle close-phase: this phase is already closed \u2014 open the next one instead of accepting it twice.\n");
6517
+ process.stderr.write("halfcycle close-phase: to close a phase again, open it again first.\nhalfcycle close-phase: To get a closed phase's Build Record: npx halfcycle@latest build-record <the phase that closed>\n");
6204
6518
  }
6205
6519
  process.exit(1);
6206
6520
  return;
@@ -6217,6 +6531,56 @@ Usage:
6217
6531
  ${USAGE}`);
6218
6532
  process.exit(1);
6219
6533
  }
6534
+ function readStatementFlags(argv) {
6535
+ let thesis = null;
6536
+ let outcome = null;
6537
+ const deviations = [];
6538
+ for (let i = 0; i < argv.length; i++) {
6539
+ const flag = argv[i];
6540
+ if (flag !== "--thesis" && flag !== "--outcome" && flag !== "--deviation")
6541
+ continue;
6542
+ const value = argv[i + 1];
6543
+ if (value === void 0 || value.startsWith("--"))
6544
+ return flag;
6545
+ if (flag === "--thesis")
6546
+ thesis = value;
6547
+ else if (flag === "--outcome")
6548
+ outcome = value;
6549
+ else
6550
+ deviations.push(value);
6551
+ i++;
6552
+ }
6553
+ return { thesis, outcome, deviations };
6554
+ }
6555
+ function writtenLines(written) {
6556
+ return `[halfcycle] Build Record written:
6557
+ ${written.mdPath}
6558
+ ${written.jsonPath}
6559
+ `;
6560
+ }
6561
+ function notWrittenLines(verb, outcome, phase) {
6562
+ const prefix = `halfcycle ${verb}:`;
6563
+ const lead = verb === "close-phase" ? `${prefix} the phase closed, but no Build Record was written. ` : `${prefix} `;
6564
+ switch (outcome.kind) {
6565
+ case "exists":
6566
+ return `${lead}A file is already at ${outcome.path} and it is not this phase's earlier record, so it was left as it is.
6567
+ ${prefix} Move it away, then run: ${refetchCommand(phase)}
6568
+ `;
6569
+ case "withheld":
6570
+ return `${lead}${outcome.message}
6571
+ ${prefix} Fetch it later with: ${refetchCommand(phase)}
6572
+ `;
6573
+ case "refused":
6574
+ return `${lead}${outcome.message}
6575
+ ${prefix} ${REFUSAL_FOLLOW_UP[outcome.reason]}
6576
+ ` + (verb === "close-phase" ? `${prefix} Fetch it later with: ${refetchCommand(phase)}
6577
+ ` : "");
6578
+ case "unavailable":
6579
+ return `${lead}The Build Record could not be fetched: ${outcome.why}
6580
+ ${prefix} Fetch it later with: ${refetchCommand(phase)}
6581
+ `;
6582
+ }
6583
+ }
6220
6584
  function flagValue(argv, name) {
6221
6585
  const i = argv.indexOf(name);
6222
6586
  if (i < 0 || i + 1 >= argv.length)