halfcycle 0.3.32 → 0.3.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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([
@@ -961,86 +1183,25 @@ function loopbackVerificationUrl(verificationUrl, target) {
961
1183
  import { z as z8 } from "zod";
962
1184
  var NOT_THIS_ACCOUNT_REASON = "not-this-account";
963
1185
  var JOIN_REFUSAL_STATUS = 403;
964
- 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}`;
1186
+ var notThisAccountRefusalSchema = z8.object({
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,250 @@ 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. A phase is a stage of the work with an agreed finish line; this record says how this one ended.";
4461
+ var HEADER_REGISTERS = "Two kinds of content are kept apart here. Lines under **Observed by Halfcycle** come from Halfcycle's own records, made as the work happened. Lines under **Stated by the client** are quoted, and are the client's own word: Halfcycle did not check them. Halfcycle witnessed the method being run. The client attests the code.";
4462
+ var HEADER_KEPT = "Halfcycle derived this record and kept it when the phase closed, and changes it afterwards in one way only: if an account that closed this phase is later deleted, its email is removed, and so is the name given at any close Halfcycle recorded as made by that account. The client's Halfcycle command-line tool wrote this file from that kept copy when the phase closed or when it was fetched again, so a later deletion changes the kept copy and reaches this file at the next fetch; the same record, as data, is in the .json file of the same name beside this one. If the phase is closed again, this file is replaced by a new record that describes every close, this one included.";
4463
+ 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.";
4464
+ var OBSERVED_LABEL = "**Observed by Halfcycle**";
4465
+ var STATED_LABEL = "**Stated by the client** \u2014 Halfcycle did not check these.";
4466
+ var MISMATCHED_HALVES = "This Build Record could not be written: its two parts describe different closes.";
4467
+ function renderChecks(checks, p) {
4468
+ const blocks = ["## Checks during this phase", OBSERVED_LABEL];
4469
+ if (checks.state === "not-observed") {
4470
+ 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.");
4471
+ return blocks;
4472
+ }
4473
+ const runs = counted(checks.runs, "check run", "check runs");
4474
+ if (checks.state === "nothing-fired") {
4475
+ blocks.push(`**Checks ran and found nothing.** Halfcycle recorded ${runs} during this phase, and no check found a problem.`);
4476
+ } else {
4477
+ 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.`);
4478
+ blocks.push(renderFired(checks.fired, p));
4319
4479
  }
4320
- if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
4321
- throw new InvalidCloseRecordError(path, "it does not say which phase closed");
4480
+ if (checks.couldNotRun.length === 0) {
4481
+ blocks.push("No check reported that it could not run.");
4482
+ } else {
4483
+ blocks.push("**Some checks could not run.** They neither passed nor failed during this phase:");
4484
+ blocks.push(renderCouldNotRun(checks.couldNotRun, p));
4322
4485
  }
4323
- return {
4324
- format: CLOSE_RECORD_FORMAT,
4325
- phase: record2.phase,
4326
- closeCommit: commit,
4327
- closedDate: date
4328
- };
4486
+ return blocks;
4329
4487
  }
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}\`)`;
4488
+ function renderFired(fired, p) {
4489
+ const lines = [];
4490
+ for (const entry of fired) {
4491
+ 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)}`);
4492
+ lines.push(` - Seriousness: ${seriousness(entry.severity)}`);
4493
+ lines.push(` - Found: ${counted(entry.occurrences, "time", "times")} during this phase.`);
4494
+ lines.push(` - What it found: ${entry.explanation === null ? "Not included in this record." : p.text(entry.explanation)}`);
4495
+ lines.push(` - The name your terminal prints for this check: ${p.text(entry.check)}`);
4496
+ }
4497
+ return lines.join("\n");
4338
4498
  }
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}`;
4499
+ function seriousness(severity) {
4500
+ switch (severity) {
4501
+ case "block":
4502
+ return "serious, it blocks the work.";
4503
+ case "warn":
4504
+ return "a warning.";
4505
+ case "info":
4506
+ return "for information only.";
4507
+ }
4345
4508
  }
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.*";
4509
+ function renderCouldNotRun(entries, p) {
4510
+ const lines = [];
4511
+ for (const entry of entries) {
4512
+ 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)}`);
4513
+ lines.push(` - Why it could not run: ${p.text(entry.reason)}`);
4514
+ lines.push(` - The name your terminal prints for this check: ${p.text(entry.check)}`);
4515
+ }
4516
+ return lines.join("\n");
4348
4517
  }
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} |`);
4518
+ function headline(observed, attested) {
4519
+ const met = attested.decision === "done-when-met";
4520
+ switch (observed.crossed.kind) {
4521
+ case "none":
4522
+ return met ? "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met." : "**Closed by override.** Halfcycle saw nothing outstanding, and the person closing the phase chose to override rather than state its finish conditions were met.";
4523
+ case "unfinished-steps":
4524
+ case "clean-over-findings":
4525
+ 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.";
4526
+ case "not-recorded":
4527
+ 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.";
4528
+ }
4529
+ }
4530
+ function closedAtText(closedAt, p) {
4531
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/.exec(closedAt);
4532
+ return m === null ? p.text(closedAt) : `${m[1]} ${m[2]} UTC`;
4533
+ }
4534
+ function signedInAs(closedBy, p) {
4535
+ switch (closedBy.kind) {
4536
+ case "account":
4537
+ return closedBy.email === null ? "an account with no email address on file" : `the account ${p.text(closedBy.email)}`;
4538
+ case "no-account":
4539
+ return "a credential not linked to any person's account";
4540
+ case "account-removed":
4541
+ return "an account that has since been deleted";
4542
+ case "not-recorded":
4543
+ return "not recorded for this close";
4544
+ }
4545
+ }
4546
+ function outstanding(crossed, p) {
4547
+ switch (crossed.kind) {
4548
+ case "none":
4549
+ return ["- Outstanding work Halfcycle could see: none."];
4550
+ case "not-recorded":
4551
+ return ["- Outstanding work Halfcycle could see: not recorded for this close."];
4552
+ case "clean-over-findings":
4553
+ return [
4554
+ `- Outstanding work Halfcycle could see: ${counted(crossed.findings, "problem", "problems")} recorded against this phase.`
4555
+ ];
4556
+ case "unfinished-steps":
4557
+ return [
4558
+ `- 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:`,
4559
+ ...crossed.steps.map((step) => ` - Step: ${p.text(step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
4560
+ ];
4561
+ }
4562
+ }
4563
+ function machines(coverage) {
4564
+ const label = "- Machines signed in on this project at the close: ";
4565
+ if (coverage === null)
4566
+ return `${label}not recorded.`;
4567
+ if (coverage.enrolled === 0)
4568
+ return `${label}none.`;
4569
+ return `${label}${String(coverage.enrolled)}, of which ${String(coverage.conformant)} had Halfcycle's checks working as set up.`;
4570
+ }
4571
+ function walkLines(walk, p) {
4572
+ const label = "> - Acceptance walk (the finished work tried as a user would, by someone other than its builder): ";
4573
+ if (walk === null)
4574
+ return [`${label}none reported.`];
4575
+ const lines = [
4576
+ `${label}${walk.verdict === "clean" ? "reported as finding no problems." : "reported as finding problems."}`
4577
+ ];
4578
+ walk.findings.forEach((finding, i) => {
4579
+ lines.push(`> - Finding ${String(i + 1)}: ${p.text(finding)}`);
4580
+ });
4581
+ if (walk.findingsNotShown > 0) {
4582
+ lines.push(`> - And ${counted(walk.findingsNotShown, "more finding", "more findings")} not shown here.`);
4354
4583
  }
4355
- return rows.join("\n");
4584
+ return lines;
4585
+ }
4586
+ function statedOrNot(value, p) {
4587
+ return value === null ? "Not stated by the client." : p.text(value);
4356
4588
  }
4357
- function renderBuildRecordMarkdown(record2) {
4358
- const { phase } = record2;
4359
- const companion = `${renderPhaseSegment(phase.id)}.json`;
4360
- const guardsList = record2.seedGuards.map(severityLabel).join(", ");
4589
+ function renderStated(attested, p) {
4361
4590
  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
- ""
4591
+ 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.",
4592
+ `> - Name given: ${attested.actor === null ? "removed with the account that made this close" : p.text(attested.actor)}`,
4593
+ `> - Reason given: ${attested.reason === null ? "none." : p.text(attested.reason)}`,
4594
+ ...walkLines(attested.walk, p),
4595
+ `> - Code closed at (commit): ${statedOrNot(attested.closeCommit, p)}`,
4596
+ `> - What the phase set out to prove: ${statedOrNot(attested.statements.thesis, p)}`,
4597
+ `> - What came out of it: ${statedOrNot(attested.statements.outcome, p)}`
4397
4598
  ];
4398
- if (record2.acceptance.inv002ProdBypassProbe) {
4399
- lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
4599
+ const deviations = attested.statements.deviations;
4600
+ if (deviations.length === 0) {
4601
+ lines.push("> - Changes from the plan: None stated by the client.");
4602
+ } else {
4603
+ lines.push("> - Changes from the plan:");
4604
+ deviations.forEach((deviation, i) => {
4605
+ lines.push(`> - Change ${String(i + 1)}: ${p.text(deviation)}`);
4606
+ });
4400
4607
  }
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
4608
  return lines.join("\n");
4403
4609
  }
4610
+ function renderClose(heading, observed, attested, p) {
4611
+ return [
4612
+ heading,
4613
+ headline(observed, attested),
4614
+ OBSERVED_LABEL,
4615
+ [
4616
+ `- Closed at: ${closedAtText(observed.closedAt, p)}`,
4617
+ `- Signed in as: ${signedInAs(observed.closedBy, p)}`,
4618
+ ...outstanding(observed.crossed, p),
4619
+ machines(observed.seatCoverage)
4620
+ ].join("\n"),
4621
+ STATED_LABEL,
4622
+ renderStated(attested, p)
4623
+ ];
4624
+ }
4625
+ function renderBuildRecordV2Markdown(record2) {
4626
+ const p = makePrinter();
4627
+ const attestedBySequence = /* @__PURE__ */ new Map();
4628
+ for (const close of record2.attested.closes)
4629
+ attestedBySequence.set(close.sequence, close);
4630
+ const observedSequences = new Set(record2.observed.closes.map((c) => c.sequence));
4631
+ if (attestedBySequence.size !== record2.attested.closes.length || observedSequences.size !== record2.observed.closes.length || attestedBySequence.size !== observedSequences.size || [...observedSequences].some((s) => !attestedBySequence.has(s))) {
4632
+ throw new Error(MISMATCHED_HALVES);
4633
+ }
4634
+ const phase = p.text(record2.phase);
4635
+ const closes = [...record2.observed.closes].sort((a, b) => a.sequence - b.sequence);
4636
+ const total = closes.length;
4637
+ const latest = closes[total - 1];
4638
+ const body = [
4639
+ `Phase: ${phase} (the name the client gave it)`,
4640
+ OBSERVED_LABEL,
4641
+ [
4642
+ `- Method version: ${p.text(record2.observed.methodVersion)}. This is the version of the Halfcycle method the project was run under; it does not change during a project.`,
4643
+ `- Halfcycle's reference for this project: ${p.text(record2.observed.engagementId)}`,
4644
+ `- Record format: ${p.text(record2.format)}`
4645
+ ].join("\n"),
4646
+ 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.`,
4647
+ ...renderChecks(record2.observed.checks, p),
4648
+ ...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), p)
4649
+ ];
4650
+ if (total > 1) {
4651
+ body.push("## Earlier closes");
4652
+ for (const close of closes.slice(0, -1)) {
4653
+ body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), p));
4654
+ }
4655
+ }
4656
+ const header = [`${TITLE_PREFIX}${phase}`, HEADER_WHAT, HEADER_REGISTERS, HEADER_KEPT];
4657
+ if (p.sawHidden())
4658
+ header.push(HEADER_HIDDEN);
4659
+ return `${[...header, ...body].join("\n\n")}
4660
+ `;
4661
+ }
4404
4662
 
4405
4663
  // 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
- }
4664
+ var BUILD_RECORD_DIR = join6("docs", "build-records");
4410
4665
  function assertInsideOutDir(outDir, candidate, identity) {
4411
4666
  const rel = relative2(outDir, resolve2(candidate));
4412
4667
  const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute2(rel) && rel === basename2(rel);
@@ -4414,230 +4669,221 @@ function assertInsideOutDir(outDir, candidate, identity) {
4414
4669
  throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
4415
4670
  }
4416
4671
  }
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);
4672
+ function createExclusiveOnDisk(path, text) {
4673
+ const fd = openSync(path, "wx");
4674
+ try {
4675
+ writeSync(fd, text);
4676
+ } catch (err) {
4677
+ closeSync(fd);
4678
+ rmSync2(path, { force: true });
4679
+ throw err;
4422
4680
  }
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 };
4681
+ closeSync(fd);
4432
4682
  }
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
- }
4683
+ function renameOnDisk(from, to) {
4684
+ renameSync(from, to);
4685
+ }
4686
+ var NODE_RECORD_PAIR_IO = {
4687
+ createExclusive: createExclusiveOnDisk,
4688
+ rename: renameOnDisk
4440
4689
  };
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));
4690
+ function serialiseServedRecord(rawRecord) {
4691
+ return JSON.stringify(rawRecord, null, 2) + "\n";
4461
4692
  }
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;
4693
+ function isEexist(err) {
4694
+ return typeof err === "object" && err !== null && err.code === "EEXIST";
4465
4695
  }
4466
- function normalizeLegacyLine(parsed) {
4467
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4468
- return parsed;
4696
+ function readIfPresent(path) {
4697
+ try {
4698
+ return readFileSync6(path, "utf-8");
4699
+ } catch {
4700
+ return null;
4469
4701
  }
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
4702
  }
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)));
4703
+ function isOwnEarlierRecord(existingText, served) {
4704
+ let parsedJson;
4705
+ try {
4706
+ parsedJson = JSON.parse(existingText);
4707
+ } catch {
4708
+ return false;
4502
4709
  }
4503
- return runs;
4710
+ const existing = buildRecordV2Schema.safeParse(parsedJson);
4711
+ if (!existing.success)
4712
+ return false;
4713
+ const old = existing.data;
4714
+ if (old.observed.engagementId !== served.observed.engagementId)
4715
+ return false;
4716
+ if (old.phase !== served.phase)
4717
+ return false;
4718
+ const oldCloses = old.observed.closes.length;
4719
+ const newCloses = served.observed.closes.length;
4720
+ if (oldCloses < newCloses)
4721
+ return true;
4722
+ if (oldCloses > newCloses)
4723
+ return false;
4724
+ const oldSequences = new Set(old.observed.closes.map((c) => c.sequence));
4725
+ return served.observed.closes.every((c) => oldSequences.has(c.sequence));
4504
4726
  }
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}`);
4727
+ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_PAIR_IO) {
4728
+ if (!isValidPhaseIdentity(phase)) {
4729
+ throw new InvalidPhaseIdentityError(phase, BUILD_RECORD_DIR);
4730
+ }
4731
+ const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
4732
+ const segment = renderPhaseSegment(phase);
4733
+ const jsonPath = join6(outDir, `${segment}.json`);
4734
+ const mdPath = join6(outDir, `${segment}.md`);
4735
+ const jsonTmp = `${jsonPath}.tmp`;
4736
+ const mdTmp = `${mdPath}.tmp`;
4737
+ for (const candidate of [jsonPath, mdPath, jsonTmp, mdTmp]) {
4738
+ assertInsideOutDir(outDir, candidate, phase);
4739
+ }
4740
+ const jsonText = serialiseServedRecord(rawRecord);
4741
+ const mdText = renderBuildRecordV2Markdown(record2);
4742
+ mkdirSync5(outDir, { recursive: true });
4743
+ let createdJson = false;
4744
+ try {
4745
+ io.createExclusive(jsonPath, jsonText);
4746
+ createdJson = true;
4747
+ io.createExclusive(mdPath, mdText);
4748
+ return { kind: "created", jsonPath, mdPath };
4749
+ } catch (err) {
4750
+ if (createdJson)
4751
+ rmSync2(jsonPath, { force: true });
4752
+ if (!isEexist(err))
4753
+ throw err;
4509
4754
  }
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`);
4755
+ const existingJson = readIfPresent(jsonPath);
4756
+ if (existingJson === null) {
4757
+ return { kind: "in-the-way", path: existsSync4(jsonPath) ? jsonPath : mdPath };
4513
4758
  }
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 };
4759
+ if (existingJson === jsonText) {
4760
+ replaceOne(io, mdTmp, mdPath, mdText);
4761
+ return { kind: "up-to-date", jsonPath, mdPath };
4520
4762
  }
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;
4763
+ if (!isOwnEarlierRecord(existingJson, record2)) {
4764
+ return { kind: "in-the-way", path: jsonPath };
4765
+ }
4766
+ replaceOne(io, jsonTmp, jsonPath, jsonText);
4528
4767
  try {
4529
- parsed = JSON.parse(readFileSync6(path, "utf-8"));
4768
+ replaceOne(io, mdTmp, mdPath, mdText);
4530
4769
  } 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)}`);
4770
+ try {
4771
+ replaceOne(io, jsonTmp, jsonPath, existingJson);
4772
+ } catch {
4773
+ rmSync2(jsonPath, { force: true });
4774
+ }
4775
+ throw err;
4536
4776
  }
4537
- return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
4777
+ return { kind: "replaced", jsonPath, mdPath };
4538
4778
  }
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");
4779
+ function replaceOne(io, tmp, finalPath, text) {
4780
+ rmSync2(tmp, { force: true });
4781
+ try {
4782
+ io.createExclusive(tmp, text);
4783
+ io.rename(tmp, finalPath);
4784
+ } catch (err) {
4785
+ rmSync2(tmp, { force: true });
4786
+ throw err;
4568
4787
  }
4569
- return {
4570
- orchestration,
4571
- narrated,
4572
- close,
4573
- guardEvalRuns,
4574
- touchedInvariants: opts.touchedInvariants
4575
- };
4576
4788
  }
4577
4789
 
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;
4790
+ // dist/build-record/obtain.js
4791
+ import { relative as relative3 } from "node:path";
4792
+ var REFUSAL_FOLLOW_UP = {
4793
+ "still-open": "Nothing was written. The record is produced when the phase closes.",
4794
+ "reopened": "Nothing was written. A new record is produced when the phase closes again.",
4795
+ "never-opened": "Nothing was written. Check the name: it must match the phase name exactly as it was opened.",
4796
+ "never-closed": "Nothing was written.",
4797
+ "closed-before-records": "Nothing was written."
4798
+ };
4799
+ var REFUSAL_REASONS = phaseRecordRefusalReasonSchema.options;
4800
+ function buildRecordUrl(credential, phase) {
4801
+ return `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phases/${encodeURIComponent(phase)}/build-record`;
4597
4802
  }
4598
- function deriveInvariantsExercised(touched) {
4599
- return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
4803
+ function isMissingRoute(status, body) {
4804
+ if (status !== 404 || typeof body !== "object" || body === null)
4805
+ return false;
4806
+ const { error, message } = body;
4807
+ return error === "Not Found" && typeof message === "string" && /^Route GET .* not found\.$/s.test(message);
4600
4808
  }
4601
- function assembleBuildRecord(sources) {
4602
- const { narrated, close, orchestration } = sources;
4809
+ async function obtainBuildRecord(credential, repoRoot, phase, io = NODE_RECORD_PAIR_IO) {
4810
+ const url = buildRecordUrl(credential, phase);
4811
+ let res;
4812
+ let text;
4813
+ try {
4814
+ res = await fetch(url, {
4815
+ method: "GET",
4816
+ headers: { authorization: `Bearer ${credential.token}` }
4817
+ });
4818
+ text = await res.text();
4819
+ } catch (err) {
4820
+ return {
4821
+ kind: "unavailable",
4822
+ why: `could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}.`
4823
+ };
4824
+ }
4825
+ let raw = null;
4826
+ try {
4827
+ raw = JSON.parse(text);
4828
+ } catch {
4829
+ raw = null;
4830
+ }
4831
+ const parsed = phaseRecordResponseSchema.safeParse(raw);
4832
+ if (parsed.success) {
4833
+ const answer = parsed.data;
4834
+ if (answer.phase !== phase) {
4835
+ return {
4836
+ kind: "unavailable",
4837
+ why: `the service answered for a different phase than the one asked for, so nothing was written.`
4838
+ };
4839
+ }
4840
+ if (answer.outcome === "withheld")
4841
+ return { kind: "withheld", message: answer.message };
4842
+ if (answer.outcome === "refused") {
4843
+ return { kind: "refused", reason: answer.reason, message: answer.message };
4844
+ }
4845
+ const rawRecord = raw.record;
4846
+ let result;
4847
+ try {
4848
+ result = writeRecordPair(repoRoot, phase, rawRecord, answer.record, io);
4849
+ } catch (err) {
4850
+ return {
4851
+ kind: "unavailable",
4852
+ why: `the record arrived but could not be written: ${err instanceof Error ? err.message : String(err)}. Nothing was left half written.`
4853
+ };
4854
+ }
4855
+ if (result.kind === "in-the-way") {
4856
+ return { kind: "exists", path: relative3(repoRoot, result.path) };
4857
+ }
4858
+ return {
4859
+ kind: "written",
4860
+ jsonPath: relative3(repoRoot, result.jsonPath),
4861
+ mdPath: relative3(repoRoot, result.mdPath)
4862
+ };
4863
+ }
4864
+ if (isMissingRoute(res.status, raw)) {
4865
+ return { kind: "unavailable", why: "this Halfcycle service does not provide Build Records yet." };
4866
+ }
4867
+ const message = typeof raw === "object" && raw !== null && typeof raw.message === "string" ? raw.message : null;
4868
+ if (res.ok) {
4869
+ return {
4870
+ kind: "unavailable",
4871
+ why: `the service's answer was not a Build Record this version of the CLI can read, so nothing was written.`
4872
+ };
4873
+ }
4603
4874
  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)
4875
+ kind: "unavailable",
4876
+ why: `the service answered ${res.status}${message === null ? "." : `: ${message}`}`
4621
4877
  };
4622
4878
  }
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);
4879
+ function refetchCommand(phase) {
4880
+ const quoted = /^[A-Za-z0-9._+-]+$/.test(phase) ? phase : `'${phase.replace(/'/g, `'\\''`)}'`;
4881
+ return `npx halfcycle@latest build-record ${quoted}`;
4636
4882
  }
4637
4883
 
4638
4884
  // dist/open-phase.js
4639
4885
  import { readFileSync as readFileSync7 } from "node:fs";
4640
- import { join as join9 } from "node:path";
4886
+ import { join as join7 } from "node:path";
4641
4887
  var OpenPhaseRefused = class extends Error {
4642
4888
  status;
4643
4889
  finding;
@@ -4658,7 +4904,7 @@ function readRepoCredential(repoRoot, env = process.env, home) {
4658
4904
  if (pinned !== null)
4659
4905
  assertEngagementId(pinned);
4660
4906
  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);
4907
+ const looked = pinned === null ? `${join7(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
4662
4908
  const values = {};
4663
4909
  for (const key of OPEN_PHASE_ENV_KEYS) {
4664
4910
  const value = (env[key] ?? fromStore?.[key] ?? "").trim();
@@ -4675,7 +4921,7 @@ function readRepoCredential(repoRoot, env = process.env, home) {
4675
4921
  }
4676
4922
  function readPinnedEngagementId(repoRoot) {
4677
4923
  try {
4678
- const pin = JSON.parse(readFileSync7(join9(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
4924
+ const pin = JSON.parse(readFileSync7(join7(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
4679
4925
  const id = pin[PIN_ENGAGEMENT_ID_FIELD];
4680
4926
  return typeof id === "string" && id !== "" ? id : null;
4681
4927
  } catch {
@@ -4739,6 +4985,35 @@ async function openPhase(credential, phase, entry, home) {
4739
4985
  }
4740
4986
 
4741
4987
  // dist/close-phase.js
4988
+ import { execFileSync as execFileSync3 } from "node:child_process";
4989
+ var NO_STATEMENTS = { thesis: null, outcome: null, deviations: [] };
4990
+ function readCloseCommit(repoRoot) {
4991
+ if (repoRoot === void 0) {
4992
+ return {
4993
+ read: false,
4994
+ reason: "no repository was named for this close, so there was nowhere to read a commit from"
4995
+ };
4996
+ }
4997
+ let out;
4998
+ try {
4999
+ out = execFileSync3("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
5000
+ encoding: "utf-8",
5001
+ env: gitEnvForNamedRepo(),
5002
+ stdio: ["ignore", "pipe", "ignore"]
5003
+ }).trim();
5004
+ } catch (err) {
5005
+ const missingGit = typeof err === "object" && err !== null && err.code === "ENOENT";
5006
+ return {
5007
+ read: false,
5008
+ reason: missingGit ? "git is not available on this machine" : "this folder is not a git repository, or it has no commit yet"
5009
+ };
5010
+ }
5011
+ const checked = closeCommitSchema.safeParse(out);
5012
+ if (!checked.success) {
5013
+ return { read: false, reason: "git did not answer with a full commit hash" };
5014
+ }
5015
+ return { read: true, commit: checked.data };
5016
+ }
4742
5017
  var ClosePhaseRefused = class extends Error {
4743
5018
  status;
4744
5019
  finding;
@@ -4751,14 +5026,28 @@ var ClosePhaseRefused = class extends Error {
4751
5026
  this.name = "ClosePhaseRefused";
4752
5027
  }
4753
5028
  };
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("; ")}.`);
5029
+ var CloseRequestInvalid = class extends Error {
5030
+ constructor(message) {
5031
+ super(message);
5032
+ this.name = "CloseRequestInvalid";
4758
5033
  }
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("; ")}.`);
5034
+ };
5035
+ async function closePhase(credential, phase, outcome, close, home, repoRoot, stated = NO_STATEMENTS) {
5036
+ const closeCommit = readCloseCommit(repoRoot);
5037
+ const body = {
5038
+ phase,
5039
+ acceptanceOutcome: outcome,
5040
+ close,
5041
+ statements: {
5042
+ thesis: stated.thesis,
5043
+ outcome: stated.outcome,
5044
+ deviations: stated.deviations,
5045
+ closeCommit: closeCommit.read ? closeCommit.commit : null
5046
+ }
5047
+ };
5048
+ const parsed = acceptPhaseRequestSchema.safeParse(body);
5049
+ if (!parsed.success) {
5050
+ 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
5051
  }
4763
5052
  const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/accept`;
4764
5053
  let res;
@@ -4769,7 +5058,7 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4769
5058
  "content-type": "application/json",
4770
5059
  authorization: `Bearer ${credential.token}`
4771
5060
  },
4772
- body: JSON.stringify({ phase, acceptanceOutcome: outcome, close })
5061
+ body: JSON.stringify(body)
4773
5062
  });
4774
5063
  } catch (err) {
4775
5064
  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 +5070,24 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4781
5070
  const code = typeof body2?.error === "string" ? body2.error : void 0;
4782
5071
  throw new ClosePhaseRefused(res.status, message, finding, code);
4783
5072
  }
4784
- const body = await res.json().catch(() => null);
4785
- if (!body || typeof body.engagementId !== "string" || typeof body.status !== "string") {
5073
+ const answer = await res.json().catch(() => null);
5074
+ if (!answer || typeof answer.engagementId !== "string" || typeof answer.status !== "string") {
4786
5075
  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
5076
  }
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
5077
  const stampFailure = writePhaseStamp(credential.engagementId, null, home);
4794
- return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
5078
+ return { engagementId: answer.engagementId, status: answer.status, stampFailure, closeCommit };
5079
+ }
5080
+ function describeIssuePath(path) {
5081
+ const [head, field] = path;
5082
+ if (head === "statements" && field === "thesis")
5083
+ return "--thesis";
5084
+ if (head === "statements" && field === "outcome")
5085
+ return "--outcome";
5086
+ if (head === "statements" && field === "deviations")
5087
+ return "--deviation";
5088
+ if (head === "phase")
5089
+ return "the phase name";
5090
+ return path.map(String).join(".") || "(the close)";
4795
5091
  }
4796
5092
 
4797
5093
  // dist/cli-contract.js
@@ -4908,9 +5204,9 @@ var PLACEHOLDERS = {
4908
5204
  };
4909
5205
 
4910
5206
  // dist/uninstall.js
4911
- import { lstatSync as lstatSync2, readFileSync as readFileSync8, readdirSync, rmSync as rmSync2, rmdirSync, writeFileSync as writeFileSync7 } from "node:fs";
5207
+ import { lstatSync as lstatSync2, readFileSync as readFileSync8, readdirSync, rmSync as rmSync3, rmdirSync, writeFileSync as writeFileSync5 } from "node:fs";
4912
5208
  import { homedir as homedir2 } from "node:os";
4913
- import { dirname as dirname4, join as join10, relative as relative3, sep } from "node:path";
5209
+ import { dirname as dirname3, join as join8, relative as relative4, sep } from "node:path";
4914
5210
  var REMOVE_CREDENTIAL_FLAG = "--remove-credential";
4915
5211
  function parseUninstallArgs(args2) {
4916
5212
  let removeCredential = false;
@@ -4936,7 +5232,7 @@ function lexists(path) {
4936
5232
  function symlinkOnPath(root, rel) {
4937
5233
  let current = root;
4938
5234
  for (const segment of rel.split("/")) {
4939
- current = join10(current, segment);
5235
+ current = join8(current, segment);
4940
5236
  try {
4941
5237
  if (lstatSync2(current).isSymbolicLink())
4942
5238
  return true;
@@ -4957,7 +5253,7 @@ var Refusal = class extends Error {
4957
5253
  function readMergedJson(targetRepo, rel) {
4958
5254
  if (symlinkOnPath(targetRepo, rel))
4959
5255
  return { state: "symlink" };
4960
- const path = join10(targetRepo, rel);
5256
+ const path = join8(targetRepo, rel);
4961
5257
  if (!lexists(path))
4962
5258
  return { state: "absent" };
4963
5259
  let value;
@@ -4973,8 +5269,8 @@ function readMergedJson(targetRepo, rel) {
4973
5269
  }
4974
5270
  function validate(options) {
4975
5271
  const { targetRepo, removeCredential, home } = options;
4976
- const manifestPath = join10(targetRepo, INSTALL_MANIFEST_REL);
4977
- const pinPath = join10(targetRepo, BUNDLE_PIN_REL);
5272
+ const manifestPath = join8(targetRepo, INSTALL_MANIFEST_REL);
5273
+ const pinPath = join8(targetRepo, BUNDLE_PIN_REL);
4978
5274
  if (symlinkOnPath(targetRepo, INSTALL_MANIFEST_REL) || symlinkOnPath(targetRepo, BUNDLE_PIN_REL)) {
4979
5275
  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
5276
  }
@@ -5018,8 +5314,8 @@ function validate(options) {
5018
5314
  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
5315
  }
5020
5316
  if (engagementId !== void 0) {
5021
- const expectedParent = join10(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
5022
- if (dirname4(engagementStateDir(engagementId, home)) !== expectedParent) {
5317
+ const expectedParent = join8(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
5318
+ if (dirname3(engagementStateDir(engagementId, home)) !== expectedParent) {
5023
5319
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} does not name a folder under ~/.halfcycle/engagements. Nothing was removed.`);
5024
5320
  }
5025
5321
  }
@@ -5170,7 +5466,7 @@ function trimGitignore(targetRepo, text, rec, writeSet) {
5170
5466
  }
5171
5467
  function coveredPathExists(targetRepo, line) {
5172
5468
  const path = line.trim().replace(/^\//, "").replace(/\/$/, "");
5173
- return path !== "" && lexists(join10(targetRepo, path));
5469
+ return path !== "" && lexists(join8(targetRepo, path));
5174
5470
  }
5175
5471
  function uninstall(options) {
5176
5472
  const result = {
@@ -5228,7 +5524,7 @@ function uninstall(options) {
5228
5524
  removeRecordedFile(targetRepo, entry, writeSet, result, name);
5229
5525
  }
5230
5526
  for (const path of manifest.leftAlone) {
5231
- if (lexists(join10(targetRepo, path)))
5527
+ if (lexists(join8(targetRepo, path)))
5232
5528
  name(result.leftAlone, path, path);
5233
5529
  }
5234
5530
  switch (removeLegacyCiStanza(targetRepo)) {
@@ -5260,10 +5556,10 @@ function uninstall(options) {
5260
5556
  const pinEntry = manifest.files.find((f) => f.path === BUNDLE_PIN_REL);
5261
5557
  if (pinEntry !== void 0)
5262
5558
  removeRecordedFile(targetRepo, pinEntry, writeSet, result, name);
5263
- else if (lexists(join10(targetRepo, BUNDLE_PIN_REL)))
5559
+ else if (lexists(join8(targetRepo, BUNDLE_PIN_REL)))
5264
5560
  name(result.leftAlone, BUNDLE_PIN_REL, BUNDLE_PIN_REL);
5265
5561
  try {
5266
- rmSync2(join10(targetRepo, INSTALL_MANIFEST_REL), { force: true });
5562
+ rmSync3(join8(targetRepo, INSTALL_MANIFEST_REL), { force: true });
5267
5563
  named.add(INSTALL_MANIFEST_REL);
5268
5564
  } catch {
5269
5565
  name(result.failed, INSTALL_MANIFEST_REL, INSTALL_MANIFEST_REL);
@@ -5293,14 +5589,14 @@ function applyMergedJson(targetRepo, rel, file, lists, name, edit) {
5293
5589
  const { changed, created, adopted, keptNote } = edit(value);
5294
5590
  if (keptNote !== void 0)
5295
5591
  name(lists.kept, keptNote, rel);
5296
- const path = join10(targetRepo, rel);
5592
+ const path = join8(targetRepo, rel);
5297
5593
  const empty = Object.keys(value).length === 0;
5298
5594
  try {
5299
5595
  if (empty && created && !adopted) {
5300
- rmSync2(path);
5596
+ rmSync3(path);
5301
5597
  name(lists.removed, rel, rel);
5302
5598
  } else if (changed) {
5303
- writeFileSync7(path, serializeJson(value), "utf-8");
5599
+ writeFileSync5(path, serializeJson(value), "utf-8");
5304
5600
  name(lists.restored, rel, rel);
5305
5601
  }
5306
5602
  if (adopted && lexists(path)) {
@@ -5320,7 +5616,7 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5320
5616
  name(lists.leftAlone, `${path} (a symbolic link, or inside one \u2014 not followed)`, path);
5321
5617
  return;
5322
5618
  }
5323
- const abs = join10(targetRepo, path);
5619
+ const abs = join8(targetRepo, path);
5324
5620
  let isFile;
5325
5621
  try {
5326
5622
  isFile = lstatSync2(abs).isFile();
@@ -5345,7 +5641,7 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5345
5641
  }
5346
5642
  }
5347
5643
  try {
5348
- rmSync2(abs);
5644
+ rmSync3(abs);
5349
5645
  name(lists.removed, path, path);
5350
5646
  } catch {
5351
5647
  name(lists.failed, path, path);
@@ -5354,7 +5650,7 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5354
5650
  function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
5355
5651
  if (!writeSet.dirs.has(dir) || symlinkOnPath(targetRepo, dir))
5356
5652
  return;
5357
- const abs = join10(targetRepo, dir);
5653
+ const abs = join8(targetRepo, dir);
5358
5654
  try {
5359
5655
  if (!lstatSync2(abs).isDirectory() || readdirSync(abs).length > 0)
5360
5656
  return;
@@ -5374,7 +5670,7 @@ function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5374
5670
  name(lists.leftAlone, `${GITIGNORE_REL} (a symbolic link \u2014 not followed)`, GITIGNORE_REL);
5375
5671
  return;
5376
5672
  }
5377
- const path = join10(targetRepo, GITIGNORE_REL);
5673
+ const path = join8(targetRepo, GITIGNORE_REL);
5378
5674
  let before;
5379
5675
  try {
5380
5676
  before = readFileSync8(path, "utf-8");
@@ -5392,10 +5688,10 @@ function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5392
5688
  return;
5393
5689
  try {
5394
5690
  if (text === "" && rec.created) {
5395
- rmSync2(path);
5691
+ rmSync3(path);
5396
5692
  name(lists.removed, GITIGNORE_REL, GITIGNORE_REL);
5397
5693
  } else {
5398
- writeFileSync7(path, text, "utf-8");
5694
+ writeFileSync5(path, text, "utf-8");
5399
5695
  name(lists.restored, GITIGNORE_REL, GITIGNORE_REL);
5400
5696
  }
5401
5697
  } catch {
@@ -5421,8 +5717,8 @@ function handleCredential(options, checked) {
5421
5717
  try {
5422
5718
  if (lstatSync2(dir).isSymbolicLink())
5423
5719
  return { kind: "failed", dir, reason: "it is a symbolic link" };
5424
- rmSync2(engagementEnvPath(engagementId, home), { force: true });
5425
- rmSync2(notThisAccountMarkerPath(engagementId, home), { force: true });
5720
+ rmSync3(engagementEnvPath(engagementId, home), { force: true });
5721
+ rmSync3(notThisAccountMarkerPath(engagementId, home), { force: true });
5426
5722
  if (readdirSync(dir).length === 0)
5427
5723
  rmdirSync(dir);
5428
5724
  } catch (err) {
@@ -5435,16 +5731,16 @@ function filesUnder(root, rel) {
5435
5731
  const walk = (dirRel) => {
5436
5732
  let names;
5437
5733
  try {
5438
- if (!lstatSync2(join10(root, dirRel)).isDirectory())
5734
+ if (!lstatSync2(join8(root, dirRel)).isDirectory())
5439
5735
  return;
5440
- names = readdirSync(join10(root, dirRel));
5736
+ names = readdirSync(join8(root, dirRel));
5441
5737
  } catch {
5442
5738
  return;
5443
5739
  }
5444
5740
  for (const entry of names.sort()) {
5445
5741
  const childRel = `${dirRel}/${entry}`;
5446
5742
  try {
5447
- if (lstatSync2(join10(root, childRel)).isDirectory())
5743
+ if (lstatSync2(join8(root, childRel)).isDirectory())
5448
5744
  walk(childRel);
5449
5745
  else
5450
5746
  out.push(childRel);
@@ -5458,7 +5754,7 @@ function filesUnder(root, rel) {
5458
5754
  }
5459
5755
  function shownPath(path, home) {
5460
5756
  const base = home ?? homedir2();
5461
- const rel = relative3(base, path);
5757
+ const rel = relative4(base, path);
5462
5758
  const under = rel !== "" && !rel.startsWith("..") && !rel.startsWith(sep);
5463
5759
  return `${under ? `~/${rel.split(sep).join("/")}` : path}/`;
5464
5760
  }
@@ -5518,7 +5814,7 @@ function renderUninstallReport(result, home) {
5518
5814
  // dist/banner-facts.js
5519
5815
  import { execFileSync as execFileSync4 } from "node:child_process";
5520
5816
  import { readFileSync as readFileSync9 } from "node:fs";
5521
- import { basename as basename3, join as join11, resolve as resolve3 } from "node:path";
5817
+ import { basename as basename3, join as join9, resolve as resolve3 } from "node:path";
5522
5818
  var BRAND_URL = "halfcycle.ai";
5523
5819
  var TAGLINE = [
5524
5820
  "Fewer cycles.",
@@ -5531,7 +5827,7 @@ var TAGLINE_NARROW = [
5531
5827
  ];
5532
5828
  function bundleVersion() {
5533
5829
  try {
5534
- const pkg = JSON.parse(readFileSync9(join11(BUNDLE_ROOT, "package.json"), "utf-8"));
5830
+ const pkg = JSON.parse(readFileSync9(join9(BUNDLE_ROOT, "package.json"), "utf-8"));
5535
5831
  return typeof pkg.version === "string" ? pkg.version : void 0;
5536
5832
  } catch {
5537
5833
  return void 0;
@@ -5681,8 +5977,11 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
5681
5977
  --yes / -y: use this machine's saved sign-in without being asked
5682
5978
  halfcycle check-drift <target-repo>
5683
5979
  halfcycle build-record <phase> [--repo <root>]
5980
+ fetch a closed phase's Build Record again and write it into docs/build-records/
5684
5981
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
5685
5982
  halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
5983
+ [--thesis "\u2026"] [--outcome "\u2026"] [--deviation "\u2026"]\u2026
5984
+ close the phase and write its Build Record into docs/build-records/
5686
5985
  halfcycle uninstall [${REMOVE_CREDENTIAL_FLAG}]
5687
5986
  take out what the installer added to this project, keeping anything you changed
5688
5987
  ${REMOVE_CREDENTIAL_FLAG}: also remove this machine's credential for the project, which every
@@ -5695,7 +5994,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
5695
5994
  `;
5696
5995
  function isHalfcycleMonorepo(dir) {
5697
5996
  try {
5698
- const pkg = JSON.parse(readFileSync10(join12(dir, "package.json"), "utf-8"));
5997
+ const pkg = JSON.parse(readFileSync10(join10(dir, "package.json"), "utf-8"));
5699
5998
  return pkg.name === "halfcycle-monorepo";
5700
5999
  } catch {
5701
6000
  return false;
@@ -6002,31 +6301,24 @@ ${USAGE}`);
6002
6301
  return;
6003
6302
  }
6004
6303
  if (cmd === "build-record") {
6005
- const phaseArg = rest[0];
6006
- const repoRoot = rest.includes("--repo") ? rest[rest.indexOf("--repo") + 1] : process.cwd();
6304
+ const phaseArg = firstPositional(rest);
6305
+ const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
6007
6306
  if (phaseArg === void 0) {
6008
6307
  process.stderr.write("halfcycle: usage: halfcycle build-record <phase> [--repo <root>]\n");
6009
6308
  process.exit(1);
6010
6309
  return;
6011
6310
  }
6012
- const phaseId = phaseArg;
6013
6311
  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);
6312
+ assertValidPhaseIdentity(phaseArg, BUILD_RECORD_DIR);
6313
+ const credential = readRepoCredential(repoRoot);
6314
+ const obtained = await obtainBuildRecord(credential, repoRoot, phaseArg);
6315
+ if (obtained.kind === "written") {
6316
+ process.stdout.write(writtenLines(obtained));
6317
+ process.exit(0);
6318
+ return;
6319
+ }
6320
+ process.stderr.write(notWrittenLines("build-record", obtained, phaseArg));
6321
+ process.exit(1);
6030
6322
  } catch (err) {
6031
6323
  process.stderr.write(`halfcycle build-record failed: ${err instanceof Error ? err.message : String(err)}
6032
6324
  `);
@@ -6159,26 +6451,36 @@ halfcycle open-phase: a phase name has to work as a single folder name on this m
6159
6451
  process.exit(2);
6160
6452
  return;
6161
6453
  }
6454
+ const stated = readStatementFlags(rest);
6455
+ if (typeof stated === "string") {
6456
+ process.stderr.write(`halfcycle close-phase: ${stated} needs a value after it.
6457
+ `);
6458
+ process.exit(2);
6459
+ return;
6460
+ }
6162
6461
  try {
6163
6462
  const credential = readRepoCredential(repoRoot);
6164
6463
  const closed = await closePhase(credential, phaseArg, {
6165
6464
  verdict,
6166
6465
  findings: findingsDetail.length,
6167
6466
  findingsDetail
6168
- }, closeDecision, void 0, repoRoot);
6467
+ }, closeDecision, void 0, repoRoot, stated);
6169
6468
  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}.
6170
6469
  `);
6171
6470
  if (closeDecision.decision === "override") {
6172
6471
  process.stdout.write(`[halfcycle] This close was an OVERRIDE: ${closeDecision.reason}
6173
6472
  `);
6174
6473
  }
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.
6474
+ if (!closed.closeCommit.read) {
6475
+ 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
6476
  `);
6477
+ }
6478
+ const obtained = await obtainBuildRecord(credential, repoRoot, phaseArg);
6479
+ const recordWritten = obtained.kind === "written";
6480
+ if (obtained.kind === "written") {
6481
+ process.stdout.write(writtenLines(obtained));
6178
6482
  } 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
- `);
6483
+ process.stderr.write(notWrittenLines("close-phase", obtained, phaseArg));
6182
6484
  }
6183
6485
  process.stdout.write(`[halfcycle] Open the next phase with: halfcycle open-phase <phase> --actor "you" --evidence "\u2026"
6184
6486
  `);
@@ -6186,21 +6488,28 @@ halfcycle close-phase: this phase's Build Record will say the close commit and t
6186
6488
  process.stderr.write(`halfcycle close-phase: the phase closed, but this machine's record of it could not be cleared: ${closed.stampFailure.reason}
6187
6489
  halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard evaluations here are still filed under the phase you just closed.
6188
6490
  `);
6189
- process.exit(1);
6190
- return;
6191
6491
  }
6192
- process.exit(0);
6492
+ process.exit(recordWritten && !closed.stampFailure ? 0 : 1);
6193
6493
  } catch (err) {
6494
+ if (err instanceof CloseRequestInvalid) {
6495
+ process.stderr.write(`halfcycle close-phase: ${err.message}
6496
+ `);
6497
+ process.exit(2);
6498
+ return;
6499
+ }
6194
6500
  if (err instanceof ClosePhaseRefused) {
6195
6501
  process.stderr.write(`halfcycle close-phase: refused (${err.status}). ${err.message}
6196
6502
  `);
6503
+ if (err.status === 400) {
6504
+ process.stderr.write("halfcycle close-phase: Nothing was closed.\n");
6505
+ }
6197
6506
  if (err.finding) {
6198
6507
  process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${err.finding}
6199
6508
  `);
6200
6509
  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
6510
  `);
6202
6511
  } 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");
6512
+ 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
6513
  }
6205
6514
  process.exit(1);
6206
6515
  return;
@@ -6217,6 +6526,56 @@ Usage:
6217
6526
  ${USAGE}`);
6218
6527
  process.exit(1);
6219
6528
  }
6529
+ function readStatementFlags(argv) {
6530
+ let thesis = null;
6531
+ let outcome = null;
6532
+ const deviations = [];
6533
+ for (let i = 0; i < argv.length; i++) {
6534
+ const flag = argv[i];
6535
+ if (flag !== "--thesis" && flag !== "--outcome" && flag !== "--deviation")
6536
+ continue;
6537
+ const value = argv[i + 1];
6538
+ if (value === void 0 || value.startsWith("--"))
6539
+ return flag;
6540
+ if (flag === "--thesis")
6541
+ thesis = value;
6542
+ else if (flag === "--outcome")
6543
+ outcome = value;
6544
+ else
6545
+ deviations.push(value);
6546
+ i++;
6547
+ }
6548
+ return { thesis, outcome, deviations };
6549
+ }
6550
+ function writtenLines(written) {
6551
+ return `[halfcycle] Build Record written:
6552
+ ${written.mdPath}
6553
+ ${written.jsonPath}
6554
+ `;
6555
+ }
6556
+ function notWrittenLines(verb, outcome, phase) {
6557
+ const prefix = `halfcycle ${verb}:`;
6558
+ const lead = verb === "close-phase" ? `${prefix} the phase closed, but no Build Record was written. ` : `${prefix} `;
6559
+ switch (outcome.kind) {
6560
+ case "exists":
6561
+ 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.
6562
+ ${prefix} Move it away, then run: ${refetchCommand(phase)}
6563
+ `;
6564
+ case "withheld":
6565
+ return `${lead}${outcome.message}
6566
+ ${prefix} Fetch it later with: ${refetchCommand(phase)}
6567
+ `;
6568
+ case "refused":
6569
+ return `${lead}${outcome.message}
6570
+ ${prefix} ${REFUSAL_FOLLOW_UP[outcome.reason]}
6571
+ ` + (verb === "close-phase" ? `${prefix} Fetch it later with: ${refetchCommand(phase)}
6572
+ ` : "");
6573
+ case "unavailable":
6574
+ return `${lead}The Build Record could not be fetched: ${outcome.why}
6575
+ ${prefix} Fetch it later with: ${refetchCommand(phase)}
6576
+ `;
6577
+ }
6578
+ }
6220
6579
  function flagValue(argv, name) {
6221
6580
  const i = argv.indexOf(name);
6222
6581
  if (i < 0 || i + 1 >= argv.length)