halfcycle 0.3.32 → 0.3.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -178,6 +178,31 @@ var HALFCYCLE_HOOK_WIRING_PATHS = [
178
178
  .../* @__PURE__ */ new Set([SETTINGS_REL, ...HALFCYCLE_HOOK_WIRING.map((entry) => entry.script), VENDORED_BIN_REL])
179
179
  ];
180
180
 
181
+ // ../core/dist/secret-shape.js
182
+ var SECRET_SHAPE_PATTERNS = [
183
+ // Assignment to a credential-named identifier: `apiKey = "..."`, `secret: '...'`,
184
+ // `password=...`, `token = ...`, `access_key: ...` — the value need not be quoted.
185
+ /\b(?:api[_-]?key|secret|password|passwd|token|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|bearer)\b\s*[:=]\s*\S+/i,
186
+ // Connection strings that embed credentials: `scheme://user:pass@host`.
187
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]+@/i,
188
+ // Bearer / authorization header values.
189
+ /\bauthorization\b\s*[:=]\s*['"]?\s*bearer\s+\S+/i,
190
+ // PEM / private-key block headers.
191
+ /-----BEGIN\s+(?:RSA\s+|EC\s+|OPENSSH\s+|DSA\s+)?PRIVATE\s+KEY-----/,
192
+ // JWT shape: three base64url segments separated by dots.
193
+ /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/,
194
+ // A long high-entropy hex/base64 run adjacent to a credential-ish word on the
195
+ // same line — a raw key literal. Kept last (broadest, most likely false-drop).
196
+ /\b(?:key|token|secret|credential)\b[\s\S]{0,40}[A-Za-z0-9+/_-]{32,}/i
197
+ ];
198
+ function looksLikeSecret(text) {
199
+ for (const pattern of SECRET_SHAPE_PATTERNS) {
200
+ if (pattern.test(text))
201
+ return true;
202
+ }
203
+ return false;
204
+ }
205
+
181
206
  // ../events/dist/result.js
182
207
  import { z as z2 } from "zod";
183
208
  var firedGuardSchema = z2.object({
@@ -237,74 +262,28 @@ var wireErrorSchema = z2.object({
237
262
  message: z2.string()
238
263
  }).strict();
239
264
 
240
- // ../events/dist/telemetry.js
241
- import { z as z3 } from "zod";
242
- 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");
243
- var guardEvalOutcomeSchema = z3.enum([
244
- "evaluated",
245
- "infra-error",
246
- "contract-error",
247
- "unconfigured"
248
- ]);
249
- var guardEvalRunSchema = z3.object({
250
- // `runId`/`outcome` are the E2 run-observability additions, declared
251
- // field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
252
- // migration. Every new record populated by the migrated writer (T-07)
253
- // carries both; a legacy line predating the amendment carries neither and
254
- // must still parse (the mixed-era guard-eval log the Build-Record reader
255
- // assembles from, and the runner emit not yet migrated). So the presence
256
- // refinement below is skipped for a legacy line (no `outcome`), and the
257
- // per-writer guarantee lives with the writer + reader-normalisation (T-07),
258
- // where the schema may then be tightened to required. runId is uuid-checked
259
- // WHEN present; outcome is enum-checked when present.
260
- runId: z3.string().uuid().optional(),
261
- engagementId: z3.string().optional(),
262
- runType: z3.enum(["hook", "ci"]),
263
- occurredAt: utcIso8601,
264
- outcome: guardEvalOutcomeSchema.optional(),
265
- failureReason: z3.string().optional(),
266
- phase: z3.string().optional(),
267
- scope: z3.record(z3.string(), z3.unknown()),
268
- cost: z3.record(z3.string(), z3.unknown()).optional(),
269
- result: resultEnvelopeSchema.optional()
270
- }).strict().superRefine((run, ctx) => {
271
- if (run.outcome === void 0)
272
- return;
273
- if (run.outcome === "evaluated" && run.result === void 0) {
274
- ctx.addIssue({
275
- code: z3.ZodIssueCode.custom,
276
- path: ["result"],
277
- message: "result is required when outcome is 'evaluated'"
278
- });
279
- }
280
- if (run.outcome !== "evaluated" && run.result !== void 0) {
281
- ctx.addIssue({
282
- code: z3.ZodIssueCode.custom,
283
- path: ["result"],
284
- message: "result is only present when outcome is 'evaluated'"
285
- });
286
- }
287
- if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
288
- ctx.addIssue({
289
- code: z3.ZodIssueCode.custom,
290
- path: ["engagementId"],
291
- message: "engagementId is required unless outcome is 'unconfigured'"
292
- });
293
- }
294
- });
265
+ // ../events/dist/lone-surrogates.js
266
+ var LONE_SURROGATE = new RegExp("\\p{Cs}", "u");
267
+ function hasLoneSurrogate(text) {
268
+ return LONE_SURROGATE.test(text);
269
+ }
270
+ var LONE_SURROGATE_MESSAGE = "must be valid text: it holds half of a character pair with no other half";
295
271
 
296
- // ../events/dist/state.js
272
+ // ../events/dist/build-record.js
297
273
  import { z as z4 } from "zod";
298
- var utcIso86012 = z4.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
274
+
275
+ // ../events/dist/state.js
276
+ import { z as z3 } from "zod";
277
+ var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
299
278
  var STATE_RECORD_FORMAT = "halfcycle-state-record/v1";
300
- var stepIdSchema = z4.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
301
- var methodVersionSchema = z4.string().regex(/^\d+\.\d+\.\d+$/);
279
+ var stepIdSchema = z3.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
280
+ var methodVersionSchema = z3.string().regex(/^\d+\.\d+\.\d+$/);
302
281
  var anchorPathRegex = /^(?!\/)(?!.*#)(?!method\/)(?!(.*\/)?docs\/method\/).+$/;
303
- var recordKindSchema = z4.enum(["artefact", "step-run", "gate", "intervention"]);
304
- var methodLayerSchema = z4.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
305
- var evidenceKindSchema = z4.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
306
- var declarableEvidenceKindSchema = z4.enum(["anchor", "diff", "repro", "question-answer"]);
307
- var dispositionSchema = z4.enum([
282
+ var recordKindSchema = z3.enum(["artefact", "step-run", "gate", "intervention"]);
283
+ var methodLayerSchema = z3.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
284
+ var evidenceKindSchema = z3.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
285
+ var declarableEvidenceKindSchema = z3.enum(["anchor", "diff", "repro", "question-answer"]);
286
+ var dispositionSchema = z3.enum([
308
287
  "prevented",
309
288
  "corrected-in-spec",
310
289
  "corrected-in-scope",
@@ -314,20 +293,20 @@ var dispositionSchema = z4.enum([
314
293
  "surfaced",
315
294
  "filed-forward"
316
295
  ]);
317
- var mechanismSchema = z4.enum(["guard", "verdict", "served-step", "method-step"]);
318
- var artefactStateSchema = z4.enum(["created", "updated"]);
296
+ var mechanismSchema = z3.enum(["guard", "verdict", "served-step", "method-step"]);
297
+ var artefactStateSchema = z3.enum(["created", "updated"]);
319
298
  var QUALIFIED_OUTCOMES = ["declined", "parked"];
320
- var qualifiedOutcomeSchema = z4.enum(QUALIFIED_OUTCOMES);
299
+ var qualifiedOutcomeSchema = z3.enum(QUALIFIED_OUTCOMES);
321
300
  function isQualifiedOutcome(outcome) {
322
301
  return QUALIFIED_OUTCOMES.includes(outcome);
323
302
  }
324
- var runOutcomeSchema = z4.enum([
303
+ var runOutcomeSchema = z3.enum([
325
304
  "completed",
326
305
  "attempted-failed",
327
306
  ...QUALIFIED_OUTCOMES
328
307
  ]);
329
- var gateKindSchema = z4.enum(["approval", "mechanical"]);
330
- var gateVerdictSchema = z4.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
308
+ var gateKindSchema = z3.enum(["approval", "mechanical"]);
309
+ var gateVerdictSchema = z3.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
331
310
  function outcomeReasonIssue(outcome, reason) {
332
311
  if (isQualifiedOutcome(outcome) && reason === void 0) {
333
312
  return { message: `outcomeReason is required when the outcome is '${outcome}'` };
@@ -339,142 +318,383 @@ function outcomeReasonIssue(outcome, reason) {
339
318
  }
340
319
  return null;
341
320
  }
342
- var anchorEvidenceSchema = z4.object({
343
- kind: z4.literal("anchor"),
344
- path: z4.string().regex(anchorPathRegex),
321
+ var anchorEvidenceSchema = z3.object({
322
+ kind: z3.literal("anchor"),
323
+ path: z3.string().regex(anchorPathRegex),
345
324
  // Optional 1-based line number within the file at `path`. Present when the
346
325
  // writer knows the exact line; omitted — never `0`, never `null` — when it
347
326
  // does not, because a line number is not always obtainable. Absence is a
348
327
  // valid, permanent state here, not a gap meant to be filled in later.
349
- line: z4.number().optional()
328
+ line: z3.number().optional()
350
329
  }).strict();
351
- var diffEvidenceSchema = z4.object({
352
- kind: z4.literal("diff"),
353
- ref: z4.string()
330
+ var diffEvidenceSchema = z3.object({
331
+ kind: z3.literal("diff"),
332
+ ref: z3.string()
354
333
  }).strict();
355
- var reproEvidenceSchema = z4.object({
356
- kind: z4.literal("repro"),
357
- steps: z4.string().max(2e3)
334
+ var reproEvidenceSchema = z3.object({
335
+ kind: z3.literal("repro"),
336
+ steps: z3.string().max(2e3)
358
337
  }).strict();
359
- var questionAnswerEvidenceSchema = z4.object({
360
- kind: z4.literal("question-answer"),
361
- question: z4.string().max(2e3),
362
- before: z4.string().max(2e3),
363
- after: z4.string().max(2e3)
338
+ var questionAnswerEvidenceSchema = z3.object({
339
+ kind: z3.literal("question-answer"),
340
+ question: z3.string().max(2e3),
341
+ before: z3.string().max(2e3),
342
+ after: z3.string().max(2e3)
364
343
  }).strict();
365
- var noOpEvidenceSchema = z4.object({
366
- kind: z4.literal("no-op"),
367
- inspected: z4.string().max(2e3),
368
- unchangedBecause: z4.string().max(2e3)
344
+ var noOpEvidenceSchema = z3.object({
345
+ kind: z3.literal("no-op"),
346
+ inspected: z3.string().max(2e3),
347
+ unchangedBecause: z3.string().max(2e3)
369
348
  }).strict();
370
- var evidenceSchema = z4.discriminatedUnion("kind", [
349
+ var evidenceSchema = z3.discriminatedUnion("kind", [
371
350
  anchorEvidenceSchema,
372
351
  diffEvidenceSchema,
373
352
  reproEvidenceSchema,
374
353
  questionAnswerEvidenceSchema,
375
354
  noOpEvidenceSchema
376
355
  ]);
377
- var stateRecordEnvelopeSchema = z4.object({
378
- format: z4.literal(STATE_RECORD_FORMAT),
356
+ var stateRecordEnvelopeSchema = z3.object({
357
+ format: z3.literal(STATE_RECORD_FORMAT),
379
358
  recordKind: recordKindSchema,
380
- recordId: z4.string().uuid(),
359
+ recordId: z3.string().uuid(),
381
360
  methodVersion: methodVersionSchema,
382
- engagementId: z4.string(),
361
+ engagementId: z3.string(),
383
362
  stepId: stepIdSchema,
384
363
  // Immutable: names when the thing happened, never when a later disposition
385
364
  // moved.
386
- occurredAt: utcIso86012,
365
+ occurredAt: utcIso8601,
387
366
  // Equal to `occurredAt` on first write; advances on each re-emit OF A FINDING.
388
367
  // Carried on the shared envelope so a "when did this last change" query is
389
368
  // kind-agnostic. Kept rather than generalised to `updatedAt` because
390
369
  // `disposition` is the only field any re-emit may change (§5.3).
391
- dispositionAt: utcIso86012,
370
+ dispositionAt: utcIso8601,
392
371
  evidence: evidenceSchema,
393
372
  // Which phase of the project this record belongs to, if any. Present on
394
373
  // records written during phase-scoped work; absent on records that run once
395
374
  // for the whole project rather than per phase. Absence is meaningful on its
396
375
  // own — it is never replaced with a placeholder value, and it is read as
397
376
  // "satisfies any phase", not as "unknown".
398
- phase: z4.string().optional()
377
+ phase: z3.string().optional()
399
378
  });
400
- var artefactRefSchema = z4.object({
401
- name: z4.string(),
402
- parent: z4.string()
379
+ var artefactRefSchema = z3.object({
380
+ name: z3.string(),
381
+ parent: z3.string()
403
382
  }).strict();
404
- var coverageSchema = z4.object({
405
- asked: z4.number().int().nonnegative(),
406
- answered: z4.number().int().nonnegative()
383
+ var coverageSchema = z3.object({
384
+ asked: z3.number().int().nonnegative(),
385
+ answered: z3.number().int().nonnegative()
407
386
  }).strict().superRefine((coverage, ctx) => {
408
387
  if (coverage.answered > coverage.asked) {
409
388
  ctx.addIssue({
410
- code: z4.ZodIssueCode.custom,
389
+ code: z3.ZodIssueCode.custom,
411
390
  path: ["answered"],
412
391
  message: "answered cannot be more than asked"
413
392
  });
414
393
  }
415
394
  });
416
395
  var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
417
- recordKind: z4.literal("artefact"),
396
+ recordKind: z3.literal("artefact"),
418
397
  artefactRef: artefactRefSchema,
419
398
  // Some layer wrote it, by construction — an empty array is not a legitimate
420
399
  // state, so the schema says so.
421
- writtenByLayers: z4.array(methodLayerSchema).min(1),
400
+ writtenByLayers: z3.array(methodLayerSchema).min(1),
422
401
  state: artefactStateSchema
423
402
  }).strict();
424
403
  var stepRunRecordSchema = stateRecordEnvelopeSchema.extend({
425
- recordKind: z4.literal("step-run"),
404
+ recordKind: z3.literal("step-run"),
426
405
  runOutcome: runOutcomeSchema,
427
- failureReason: z4.string().optional(),
428
- outcomeReason: z4.string().max(2e3).optional()
406
+ failureReason: z3.string().optional(),
407
+ outcomeReason: z3.string().max(2e3).optional()
429
408
  }).strict().superRefine((rec, ctx) => {
430
409
  if (rec.runOutcome === "attempted-failed" && rec.failureReason === void 0) {
431
410
  ctx.addIssue({
432
- code: z4.ZodIssueCode.custom,
411
+ code: z3.ZodIssueCode.custom,
433
412
  path: ["failureReason"],
434
413
  message: "failureReason is required when runOutcome is 'attempted-failed'"
435
414
  });
436
415
  }
437
416
  if (rec.runOutcome !== "attempted-failed" && rec.failureReason !== void 0) {
438
417
  ctx.addIssue({
439
- code: z4.ZodIssueCode.custom,
418
+ code: z3.ZodIssueCode.custom,
440
419
  path: ["failureReason"],
441
420
  message: "failureReason is only present when runOutcome is 'attempted-failed'"
442
421
  });
443
422
  }
444
423
  const issue = outcomeReasonIssue(rec.runOutcome, rec.outcomeReason);
445
424
  if (issue) {
446
- ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
425
+ ctx.addIssue({ code: z3.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
447
426
  }
448
427
  });
449
428
  var gateRecordSchema = stateRecordEnvelopeSchema.extend({
450
- recordKind: z4.literal("gate"),
429
+ recordKind: z3.literal("gate"),
451
430
  gateKind: gateKindSchema,
452
431
  verdict: gateVerdictSchema,
453
- actor: z4.string(),
454
- outcomeReason: z4.string().max(2e3).optional(),
432
+ actor: z3.string(),
433
+ outcomeReason: z3.string().max(2e3).optional(),
455
434
  coverage: coverageSchema.optional()
456
435
  }).strict().superRefine((rec, ctx) => {
457
436
  const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
458
437
  if (issue) {
459
- ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
438
+ ctx.addIssue({ code: z3.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
460
439
  }
461
440
  });
462
441
  var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
463
- recordKind: z4.literal("intervention"),
442
+ recordKind: z3.literal("intervention"),
464
443
  layer: methodLayerSchema,
465
444
  mechanism: mechanismSchema,
466
445
  severity: severitySchema,
467
446
  disposition: dispositionSchema,
468
- summary: z4.string().max(2e3),
447
+ summary: z3.string().max(2e3),
469
448
  coverage: coverageSchema.optional()
470
449
  }).strict();
471
- var stateRecordSchema = z4.discriminatedUnion("recordKind", [
450
+ var stateRecordSchema = z3.discriminatedUnion("recordKind", [
472
451
  artefactRecordSchema,
473
452
  stepRunRecordSchema,
474
453
  gateRecordSchema,
475
454
  interventionRecordSchema
476
455
  ]);
477
456
 
457
+ // ../events/dist/control-characters.js
458
+ var CONTROL_CHARACTER_CLASS = "\\p{Cc}";
459
+ var CONTROL_CHARACTER = new RegExp(CONTROL_CHARACTER_CLASS, "u");
460
+ function hasControlCharacter(text) {
461
+ return CONTROL_CHARACTER.test(text);
462
+ }
463
+
464
+ // ../events/dist/phase-identity.js
465
+ var PHASE_SEGMENT_PREFIX = "phase-";
466
+ var PHASE_NAME_MAX_BYTES = 200;
467
+ function isValidPhaseIdentity(identity) {
468
+ if (typeof identity === "number")
469
+ return Number.isInteger(identity) && identity >= 0;
470
+ if (typeof identity !== "string")
471
+ return false;
472
+ if (identity === "")
473
+ return false;
474
+ if (identity.includes("/"))
475
+ return false;
476
+ if (identity.includes("\\"))
477
+ return false;
478
+ if (identity.startsWith("."))
479
+ return false;
480
+ if (hasControlCharacter(identity))
481
+ return false;
482
+ if (utf8ByteLength(identity) > PHASE_NAME_MAX_BYTES)
483
+ return false;
484
+ return true;
485
+ }
486
+ function utf8ByteLength(text) {
487
+ let bytes = 0;
488
+ for (const ch of text) {
489
+ const cp = ch.codePointAt(0);
490
+ bytes += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
491
+ }
492
+ return bytes;
493
+ }
494
+ var InvalidPhaseIdentityError = class extends Error {
495
+ /** The value that was refused, exactly as supplied. */
496
+ identity;
497
+ constructor(identity, allowedLocation) {
498
+ const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
499
+ const where = allowedLocation === void 0 ? "" : `
500
+ Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
501
+ super(`that phase identity cannot name ${subject}.
502
+ identity: ${describeIdentity(identity)}` + where);
503
+ this.name = "InvalidPhaseIdentityError";
504
+ this.identity = identity;
505
+ }
506
+ };
507
+ function assertValidPhaseIdentity(identity, allowedLocation) {
508
+ if (!isValidPhaseIdentity(identity)) {
509
+ throw new InvalidPhaseIdentityError(identity, allowedLocation);
510
+ }
511
+ }
512
+ function renderPhaseSegment(identity) {
513
+ if (!isValidPhaseIdentity(identity)) {
514
+ throw new InvalidPhaseIdentityError(identity);
515
+ }
516
+ return `${PHASE_SEGMENT_PREFIX}${identity}`;
517
+ }
518
+ function describeIdentity(identity) {
519
+ if (identity === "")
520
+ return "(empty)";
521
+ if (typeof identity === "string")
522
+ return identity;
523
+ if (identity === null)
524
+ return "null";
525
+ if (identity === void 0)
526
+ return "none supplied";
527
+ if (typeof identity === "number" || typeof identity === "boolean")
528
+ return String(identity);
529
+ if (Array.isArray(identity))
530
+ return "(a list)";
531
+ if (typeof identity === "object")
532
+ return "(an object)";
533
+ return `(a ${typeof identity})`;
534
+ }
535
+
536
+ // ../events/dist/build-record.js
537
+ var BUILD_RECORD_V2_FORMAT = "halfcycle-build-record/v2";
538
+ var STATEMENT_MAX = 2e3;
539
+ var DEVIATION_MAX = 500;
540
+ var DEVIATIONS_MAX = 20;
541
+ var ACTOR_MAX = 200;
542
+ var FINDING_MAX = 500;
543
+ var FINDINGS_SHOWN_MAX = 50;
544
+ var PHASE_CLOSE_REASON_MAX = 1900;
545
+ var closeCommitSchema = z4.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, "must be a full commit hash: 40 or 64 lowercase hex characters");
546
+ var walkVerdictSchema = z4.enum(["clean", "defects"]);
547
+ var closeDecisionSchema = z4.enum(["done-when-met", "override"]);
548
+ function plainText(max) {
549
+ const base = max === void 0 ? z4.string() : z4.string().max(max);
550
+ return base.refine((v) => !hasControlCharacter(v), {
551
+ message: "must be one line with no control characters (no line breaks, tabs or NUL)"
552
+ }).refine((v) => !hasLoneSurrogate(v), { message: LONE_SURROGATE_MESSAGE });
553
+ }
554
+ function statementText(max) {
555
+ return plainText(max).refine((v) => !looksLikeSecret(v), {
556
+ message: "looks like a password or key \u2014 rephrase it"
557
+ });
558
+ }
559
+ var closeActorSchema = plainText(ACTOR_MAX);
560
+ var phaseNameSchema = z4.string().refine((v) => isValidPhaseIdentity(v), {
561
+ message: `must be a phase name: not empty, no slash or backslash, not starting with a dot, no control characters, at most ${PHASE_NAME_MAX_BYTES} bytes`
562
+ });
563
+ var closeStatementsSchema = z4.object({
564
+ thesis: statementText(STATEMENT_MAX).nullable(),
565
+ outcome: statementText(STATEMENT_MAX).nullable(),
566
+ deviations: z4.array(statementText(DEVIATION_MAX)).max(DEVIATIONS_MAX),
567
+ closeCommit: closeCommitSchema.nullable()
568
+ }).strict();
569
+ var closedBySchema = z4.discriminatedUnion("kind", [
570
+ z4.object({ kind: z4.literal("account"), email: plainText().nullable() }).strict(),
571
+ z4.object({ kind: z4.literal("no-account") }).strict(),
572
+ z4.object({ kind: z4.literal("account-removed") }).strict(),
573
+ z4.object({ kind: z4.literal("not-recorded") }).strict()
574
+ ]);
575
+ var unfinishedStepSchema = z4.object({
576
+ stepId: plainText(),
577
+ state: z4.enum(["parked", "failed"])
578
+ }).strict();
579
+ var crossedSchema = z4.discriminatedUnion("kind", [
580
+ z4.object({ kind: z4.literal("none") }).strict(),
581
+ z4.object({ kind: z4.literal("unfinished-steps"), steps: z4.array(unfinishedStepSchema).min(1) }).strict(),
582
+ z4.object({ kind: z4.literal("clean-over-findings"), findings: z4.number().int().min(1) }).strict(),
583
+ z4.object({ kind: z4.literal("not-recorded") }).strict()
584
+ ]);
585
+ var seatCoverageSchema = z4.object({
586
+ enrolled: z4.number().int().nonnegative(),
587
+ conformant: z4.number().int().nonnegative()
588
+ }).strict().refine((c) => c.conformant <= c.enrolled, {
589
+ message: "conformant cannot exceed enrolled",
590
+ path: ["conformant"]
591
+ });
592
+ var observedCloseSchema = z4.object({
593
+ sequence: z4.number().int().min(1),
594
+ closedAt: utcIso8601,
595
+ closedBy: closedBySchema,
596
+ crossed: crossedSchema,
597
+ seatCoverage: seatCoverageSchema.nullable()
598
+ }).strict();
599
+ var couldNotRunCheckSchema = z4.object({
600
+ check: plainText(),
601
+ description: plainText().nullable(),
602
+ reason: plainText()
603
+ }).strict();
604
+ var firedCheckSchema = z4.object({
605
+ check: plainText(),
606
+ description: plainText().nullable(),
607
+ severity: severitySchema,
608
+ explanation: plainText().nullable(),
609
+ occurrences: z4.number().int().min(1)
610
+ }).strict();
611
+ var phaseChecksSchema = z4.discriminatedUnion("state", [
612
+ z4.object({ state: z4.literal("not-observed") }).strict(),
613
+ z4.object({
614
+ state: z4.literal("nothing-fired"),
615
+ runs: z4.number().int().min(1),
616
+ couldNotRun: z4.array(couldNotRunCheckSchema)
617
+ }).strict(),
618
+ z4.object({
619
+ state: z4.literal("fired"),
620
+ runs: z4.number().int().min(1),
621
+ couldNotRun: z4.array(couldNotRunCheckSchema),
622
+ fired: z4.array(firedCheckSchema).min(1)
623
+ }).strict()
624
+ ]);
625
+ var observedSchema = z4.object({
626
+ engagementId: plainText(),
627
+ methodVersion: methodVersionSchema,
628
+ closes: z4.array(observedCloseSchema).min(1),
629
+ checks: phaseChecksSchema
630
+ }).strict().superRefine((o, ctx) => {
631
+ o.closes.forEach((close, i) => {
632
+ if (close.sequence !== i + 1) {
633
+ ctx.addIssue({
634
+ code: "custom",
635
+ message: "closes must be numbered 1, 2, 3 \u2026 in order with no gap",
636
+ path: ["closes", i, "sequence"]
637
+ });
638
+ }
639
+ });
640
+ });
641
+ var attestedWalkSchema = z4.object({
642
+ verdict: walkVerdictSchema,
643
+ findings: z4.array(plainText(FINDING_MAX)).max(FINDINGS_SHOWN_MAX),
644
+ findingsNotShown: z4.number().int().nonnegative()
645
+ }).strict();
646
+ var attestedStatementsSchema = z4.object({
647
+ thesis: plainText(STATEMENT_MAX).nullable(),
648
+ outcome: plainText(STATEMENT_MAX).nullable(),
649
+ deviations: z4.array(plainText(DEVIATION_MAX)).max(DEVIATIONS_MAX)
650
+ }).strict();
651
+ var attestedCloseSchema = z4.object({
652
+ sequence: z4.number().int().min(1),
653
+ decision: closeDecisionSchema,
654
+ actor: plainText(ACTOR_MAX).nullable(),
655
+ reason: plainText(PHASE_CLOSE_REASON_MAX).nullable(),
656
+ walk: attestedWalkSchema.nullable(),
657
+ closeCommit: closeCommitSchema.nullable(),
658
+ statements: attestedStatementsSchema
659
+ }).strict();
660
+ var attestedSchema = z4.object({
661
+ closes: z4.array(attestedCloseSchema)
662
+ }).strict();
663
+ var buildRecordV2Schema = z4.object({
664
+ format: z4.literal(BUILD_RECORD_V2_FORMAT),
665
+ phase: phaseNameSchema,
666
+ observed: observedSchema,
667
+ attested: attestedSchema
668
+ }).strict().superRefine((record2, ctx) => {
669
+ const observedBySequence = new Map(record2.observed.closes.map((c) => [c.sequence, c]));
670
+ const attestedSequences = record2.attested.closes.map((c) => c.sequence);
671
+ const sameSet = attestedSequences.length === observedBySequence.size && new Set(attestedSequences).size === attestedSequences.length && attestedSequences.every((s) => observedBySequence.has(s));
672
+ if (!sameSet) {
673
+ ctx.addIssue({
674
+ code: "custom",
675
+ message: "attested closes must match the observed closes one for one, by sequence",
676
+ path: ["attested", "closes"]
677
+ });
678
+ }
679
+ record2.attested.closes.forEach((close, i) => {
680
+ if (close.decision === "override" !== (close.reason !== null)) {
681
+ ctx.addIssue({
682
+ code: "custom",
683
+ message: "reason is given exactly when the decision is override",
684
+ path: ["attested", "closes", i, "reason"]
685
+ });
686
+ }
687
+ const observed = observedBySequence.get(close.sequence);
688
+ if (observed !== void 0 && observed.closedBy.kind === "account-removed" !== (close.actor === null)) {
689
+ ctx.addIssue({
690
+ code: "custom",
691
+ message: "actor is empty exactly when the closing account was removed",
692
+ path: ["attested", "closes", i, "actor"]
693
+ });
694
+ }
695
+ });
696
+ });
697
+
478
698
  // ../events/dist/crew.js
479
699
  import { z as z5 } from "zod";
480
700
  var crewGroupSchema = z5.enum(["builder", "reviewer", "always-on"]);
@@ -671,7 +891,7 @@ var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
671
891
 
672
892
  // ../events/dist/device-auth.js
673
893
  import { z as z6 } from "zod";
674
- var utcIso86013 = z6.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
894
+ var utcIso86012 = z6.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
675
895
  var DEVICE_AUTH_STATUS = {
676
896
  /** The poll: minted, nobody has acted yet. Keep polling. 200. */
677
897
  PENDING: "pending",
@@ -716,7 +936,7 @@ var deviceAuthStartResponseSchema = z6.object({
716
936
  deviceCode: z6.string().min(1),
717
937
  userCode: z6.string().min(1),
718
938
  verificationUrl: z6.string().min(1),
719
- expiresAt: utcIso86013,
939
+ expiresAt: utcIso86012,
720
940
  pollIntervalMs: z6.number().int().positive()
721
941
  }).strict();
722
942
  var devicePollPendingSchema = z6.object({
@@ -727,7 +947,7 @@ var devicePollApprovedSchema = z6.object({
727
947
  status: z6.literal(DEVICE_AUTH_STATUS.APPROVED),
728
948
  accountId: z6.string().min(1),
729
949
  credential: z6.string().min(1),
730
- expiresAt: utcIso86013
950
+ expiresAt: utcIso86012
731
951
  }).strict();
732
952
  var deviceAuthRefusalSchema = z6.object({
733
953
  status: z6.enum([
@@ -765,67 +985,6 @@ var deviceConfirmResponseSchema = z6.union([
765
985
  ]);
766
986
  var DASHBOARD_URL = "https://app.halfcycle.ai";
767
987
 
768
- // ../events/dist/phase-identity.js
769
- var PHASE_SEGMENT_PREFIX = "phase-";
770
- function isValidPhaseIdentity(identity) {
771
- if (typeof identity === "number")
772
- return Number.isInteger(identity) && identity >= 0;
773
- if (typeof identity !== "string")
774
- return false;
775
- if (identity === "")
776
- return false;
777
- if (identity.includes("/"))
778
- return false;
779
- if (identity.includes("\\"))
780
- return false;
781
- if (identity.includes("\0"))
782
- return false;
783
- if (identity.startsWith("."))
784
- return false;
785
- return true;
786
- }
787
- var InvalidPhaseIdentityError = class extends Error {
788
- /** The value that was refused, exactly as supplied. */
789
- identity;
790
- constructor(identity, allowedLocation) {
791
- const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
792
- const where = allowedLocation === void 0 ? "" : `
793
- Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
794
- super(`that phase identity cannot name ${subject}.
795
- identity: ${describeIdentity(identity)}` + where);
796
- this.name = "InvalidPhaseIdentityError";
797
- this.identity = identity;
798
- }
799
- };
800
- function assertValidPhaseIdentity(identity, allowedLocation) {
801
- if (!isValidPhaseIdentity(identity)) {
802
- throw new InvalidPhaseIdentityError(identity, allowedLocation);
803
- }
804
- }
805
- function renderPhaseSegment(identity) {
806
- if (!isValidPhaseIdentity(identity)) {
807
- throw new InvalidPhaseIdentityError(identity);
808
- }
809
- return `${PHASE_SEGMENT_PREFIX}${identity}`;
810
- }
811
- function describeIdentity(identity) {
812
- if (identity === "")
813
- return "(empty)";
814
- if (typeof identity === "string")
815
- return identity;
816
- if (identity === null)
817
- return "null";
818
- if (identity === void 0)
819
- return "none supplied";
820
- if (typeof identity === "number" || typeof identity === "boolean")
821
- return String(identity);
822
- if (Array.isArray(identity))
823
- return "(a list)";
824
- if (typeof identity === "object")
825
- return "(an object)";
826
- return `(a ${typeof identity})`;
827
- }
828
-
829
988
  // ../events/dist/credential-wire.js
830
989
  import { z as z7 } from "zod";
831
990
  var engagementCredentialResponseSchema = z7.object({
@@ -837,10 +996,47 @@ var engagementCredentialResponseSchema = z7.object({
837
996
  }).strict();
838
997
  var ciTokenExchangeResponseSchema = z7.object({
839
998
  token: z7.string(),
840
- expiresAt: utcIso86012,
999
+ expiresAt: utcIso8601,
841
1000
  engagementId: z7.string()
842
1001
  }).strict();
843
1002
 
1003
+ // ../events/dist/phase-record.js
1004
+ import { z as z8 } from "zod";
1005
+ var phaseRecordRefusalReasonSchema = z8.enum([
1006
+ "still-open",
1007
+ "reopened",
1008
+ "never-opened",
1009
+ "never-closed",
1010
+ "closed-before-records"
1011
+ ]);
1012
+ var phaseRecordRecordSchema = z8.object({
1013
+ outcome: z8.literal("record"),
1014
+ phase: phaseNameSchema,
1015
+ record: buildRecordV2Schema
1016
+ }).strict().refine((body) => body.record.phase === body.phase, {
1017
+ message: "the record is for a different phase than the one asked for",
1018
+ path: ["record", "phase"]
1019
+ });
1020
+ var phaseRecordWithheldSchema = z8.object({
1021
+ outcome: z8.literal("withheld"),
1022
+ phase: phaseNameSchema,
1023
+ sequence: z8.number().int().min(1),
1024
+ closedAt: utcIso8601,
1025
+ message: z8.string()
1026
+ }).strict();
1027
+ var phaseRecordRefusedSchema = wireErrorSchema.extend({
1028
+ outcome: z8.literal("refused"),
1029
+ phase: phaseNameSchema,
1030
+ statusCode: z8.literal(409),
1031
+ error: z8.literal("PhaseRecordRefused"),
1032
+ reason: phaseRecordRefusalReasonSchema
1033
+ }).strict();
1034
+ var phaseRecordResponseSchema = z8.discriminatedUnion("outcome", [
1035
+ phaseRecordRecordSchema,
1036
+ phaseRecordWithheldSchema,
1037
+ phaseRecordRefusedSchema
1038
+ ]);
1039
+
844
1040
  // dist/engagement-credential.js
845
1041
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
846
1042
  import { platform as platform2 } from "node:os";
@@ -3264,193 +3460,253 @@ function buildEngagementRecord(input) {
3264
3460
  return { ok: true, document, rows };
3265
3461
  }
3266
3462
 
3267
- // dist/build-record/types.js
3268
- var BUILD_RECORD_FORMAT = "halfcycle-build-record/v1";
3269
- function projectSeedGuard(fired, includeExplanation) {
3270
- const seed = {
3271
- patternRef: fired.patternRef,
3272
- severity: fired.severity
3273
- };
3274
- if (includeExplanation && fired.explanation)
3275
- seed.explanation = fired.explanation;
3276
- return seed;
3277
- }
3278
-
3279
- // dist/build-record/sources.js
3280
- import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
3281
- import { createHash as createHash3 } from "node:crypto";
3282
- import { join as join10 } from "node:path";
3283
-
3284
- // dist/build-record/close-record.js
3285
- import { execFileSync as execFileSync3 } from "node:child_process";
3286
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
3287
- import { dirname as dirname4, join as join8 } from "node:path";
3288
- var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
3289
- var BUILD_RECORD_ZONE_B_DIR = join8(ZONE_B_DIR, "build-record");
3290
- var InvalidCloseRecordError = class extends Error {
3291
- constructor(path, detail) {
3292
- super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
3293
- this.name = "InvalidCloseRecordError";
3463
+ // dist/build-record/render-v2.js
3464
+ var HIDDEN = /[\p{Cc}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
3465
+ var MARKDOWN_SPECIAL = /* @__PURE__ */ new Set(["\\", "`", "*", "_", "[", "]", "<", "|", "~", "#", "&", "$"]);
3466
+ function printRecordText(text) {
3467
+ if (text === "")
3468
+ return "(blank)";
3469
+ let out = "";
3470
+ for (const ch of text) {
3471
+ if (HIDDEN.test(ch)) {
3472
+ out += `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
3473
+ } else if (MARKDOWN_SPECIAL.has(ch)) {
3474
+ out += `\\${ch}`;
3475
+ } else {
3476
+ out += ch;
3477
+ }
3294
3478
  }
3295
- };
3296
- function closeRecordPath(repoRoot, phase) {
3297
- assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
3298
- return join8(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
3479
+ return out;
3299
3480
  }
3300
- function resolveCloseAtHead(repoRoot) {
3301
- try {
3302
- const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
3303
- encoding: "utf-8",
3304
- stdio: ["ignore", "pipe", "ignore"]
3305
- }).trim();
3306
- const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
3307
- encoding: "utf-8",
3308
- stdio: ["ignore", "pipe", "ignore"]
3309
- }).trim();
3310
- if (closeCommit === "" || closedDate === "")
3311
- return null;
3312
- return { closeCommit, closedDate };
3313
- } catch {
3314
- return null;
3315
- }
3481
+ function makePrinter() {
3482
+ let hidden = false;
3483
+ return {
3484
+ text(value) {
3485
+ if (!hidden && HIDDEN.test(value))
3486
+ hidden = true;
3487
+ return printRecordText(value);
3488
+ },
3489
+ sawHidden() {
3490
+ return hidden;
3491
+ }
3492
+ };
3316
3493
  }
3317
- function writeCloseRecord(repoRoot, phase) {
3318
- let path;
3319
- try {
3320
- path = closeRecordPath(repoRoot, phase);
3321
- } catch (err) {
3322
- return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
3323
- }
3324
- const at = resolveCloseAtHead(repoRoot);
3325
- if (at === null) {
3326
- return {
3327
- recorded: false,
3328
- path,
3329
- reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
3330
- };
3494
+ function counted(n, one, many) {
3495
+ return `${String(n)} ${n === 1 ? one : many}`;
3496
+ }
3497
+ var TITLE_PREFIX = "# Build Record: ";
3498
+ var HEADER_WHAT = "This is the Build Record for one phase of a software project run with the Halfcycle method. The Halfcycle method is a way of building software that Halfcycle runs inside the project's AI coding agent: it works out what is being built, plans the work, reviews it, and checks it as it is built. A phase is one stage of that work. Its finish conditions are the tests of done set out in the phase's plan; this record does not list them. This record says how this phase ended.";
3499
+ var HEADER_REGISTERS = "The client is the person or team running this project. Halfcycle is the service that supplies the method and runs the checks. Two kinds of content are kept apart here. Lines under **Observed by Halfcycle** come from Halfcycle's own records, made as the work happened. Lines under **Stated by the client** are quoted, and are the client's own word: Halfcycle did not check them. Halfcycle witnessed the method being run. The client attests the code.";
3500
+ var HEADER_CHECKS = "Halfcycle's checks are automatic rules drawn from the ways real software builds break. They are set up to run each time the project's AI coding agent edits a file. A check that finds something either warns or stops the work, depending on how serious it is.";
3501
+ var HEADER_KEPT = "Halfcycle derived this record and kept a copy of it when the phase closed. It changes that copy afterwards in one way only. If an account that closed this phase is later deleted, its email is removed, and so is the name given at any close Halfcycle recorded as made by that account. The client's Halfcycle command-line tool wrote this file from the kept copy, when the phase closed or when the record was fetched again. So a later deletion reaches this file the next time the record is fetched. The same record, as data, is in the .json file of the same name beside this one. If the phase is closed again, this file is replaced by a new record that describes every close, this one included.";
3502
+ 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.";
3503
+ var OBSERVED_LABEL = "**Observed by Halfcycle**";
3504
+ var STATED_LABEL = "**Stated by the client** \u2014 Halfcycle did not check these.";
3505
+ var MISMATCHED_HALVES = "This Build Record could not be written: its two parts describe different closes.";
3506
+ function renderChecks(checks, p) {
3507
+ const blocks = ["## Checks during this phase", OBSERVED_LABEL];
3508
+ if (checks.state === "not-observed") {
3509
+ 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.");
3510
+ return blocks;
3511
+ }
3512
+ const runs = counted(checks.runs, "check run", "check runs");
3513
+ if (checks.state === "nothing-fired") {
3514
+ blocks.push(`**Checks ran and found nothing.** Halfcycle recorded ${runs} during this phase, and no check found a problem.`);
3515
+ } else {
3516
+ 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.`);
3517
+ blocks.push(renderFired(checks.fired, p));
3331
3518
  }
3332
- const record2 = {
3333
- format: CLOSE_RECORD_FORMAT,
3334
- phase,
3335
- closeCommit: at.closeCommit,
3336
- closedDate: at.closedDate
3337
- };
3338
- try {
3339
- mkdirSync5(dirname4(path), { recursive: true });
3340
- writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
3341
- } catch (err) {
3342
- return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
3519
+ if (checks.couldNotRun.length === 0) {
3520
+ blocks.push("No check reported that it could not run.");
3521
+ } else {
3522
+ blocks.push("**Some checks could not run.** They neither passed nor failed during this phase:");
3523
+ blocks.push(renderCouldNotRun(checks.couldNotRun, p));
3343
3524
  }
3344
- return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
3525
+ return blocks;
3345
3526
  }
3346
- function parseCloseRecord(path, parsed) {
3347
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3348
- throw new InvalidCloseRecordError(path, "it does not hold a record");
3527
+ function renderFired(fired, p) {
3528
+ const lines = [];
3529
+ for (const entry of fired) {
3530
+ 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)}`);
3531
+ lines.push(` - Seriousness: ${seriousness(entry.severity)}`);
3532
+ lines.push(` - Found: ${counted(entry.occurrences, "time", "times")} during this phase.`);
3533
+ lines.push(` - What it found: ${entry.explanation === null ? "Not included in this record." : p.text(entry.explanation)}`);
3534
+ lines.push(` - The name your terminal prints for this check: ${p.text(entry.check)}`);
3349
3535
  }
3350
- const record2 = parsed;
3351
- if (record2.format !== CLOSE_RECORD_FORMAT) {
3352
- throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
3536
+ return lines.join("\n");
3537
+ }
3538
+ function seriousness(severity) {
3539
+ switch (severity) {
3540
+ case "block":
3541
+ return "serious, it blocks the work.";
3542
+ case "warn":
3543
+ return "a warning.";
3544
+ case "info":
3545
+ return "for information only.";
3353
3546
  }
3354
- const commit = record2.closeCommit;
3355
- const date = record2.closedDate;
3356
- const missing = [];
3357
- if (typeof commit !== "string" || commit.trim() === "")
3358
- missing.push("closeCommit");
3359
- if (typeof date !== "string" || date.trim() === "")
3360
- missing.push("closedDate");
3361
- if (missing.length > 0) {
3362
- 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.`);
3547
+ }
3548
+ function renderCouldNotRun(entries, p) {
3549
+ const lines = [];
3550
+ for (const entry of entries) {
3551
+ 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)}`);
3552
+ lines.push(` - Why it could not run: ${p.text(entry.reason)}`);
3553
+ lines.push(` - The name your terminal prints for this check: ${p.text(entry.check)}`);
3363
3554
  }
3364
- if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
3365
- throw new InvalidCloseRecordError(path, "it does not say which phase closed");
3555
+ return lines.join("\n");
3556
+ }
3557
+ function headline(observed, attested) {
3558
+ const met = attested.decision === "done-when-met";
3559
+ const problemsReported = attested.walk !== null && attested.walk.verdict === "defects";
3560
+ switch (observed.crossed.kind) {
3561
+ case "none":
3562
+ if (!met) {
3563
+ return "**Closed by override.** Halfcycle saw nothing outstanding, and the person closing the phase chose to override rather than state its finish conditions were met.";
3564
+ }
3565
+ return problemsReported ? "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
3566
+ case "unfinished-steps":
3567
+ case "clean-over-findings":
3568
+ 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.";
3569
+ case "not-recorded":
3570
+ 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.";
3571
+ }
3572
+ }
3573
+ function closedAtText(closedAt, p) {
3574
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/.exec(closedAt);
3575
+ return m === null ? p.text(closedAt) : `${m[1]} ${m[2]} UTC`;
3576
+ }
3577
+ function signedInAs(closedBy, p) {
3578
+ switch (closedBy.kind) {
3579
+ case "account":
3580
+ return closedBy.email === null ? "an account with no email address on file" : `the account ${p.text(closedBy.email)}`;
3581
+ case "no-account":
3582
+ return "a credential not linked to any person's account";
3583
+ case "account-removed":
3584
+ return "an account that has since been deleted";
3585
+ case "not-recorded":
3586
+ return "not recorded for this close";
3587
+ }
3588
+ }
3589
+ function outstanding(crossed, p) {
3590
+ switch (crossed.kind) {
3591
+ case "none":
3592
+ return ["- Outstanding work Halfcycle could see: none."];
3593
+ case "not-recorded":
3594
+ return ["- Outstanding work Halfcycle could see: not recorded for this close."];
3595
+ case "clean-over-findings":
3596
+ return [
3597
+ `- Outstanding work Halfcycle could see: ${counted(crossed.findings, "problem", "problems")} recorded against this phase.`
3598
+ ];
3599
+ case "unfinished-steps":
3600
+ return [
3601
+ `- 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:`,
3602
+ ...crossed.steps.map((step) => ` - Step: ${p.text(step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
3603
+ ];
3604
+ }
3605
+ }
3606
+ function machines(coverage) {
3607
+ const label = "- Computers signed in to Halfcycle for this project when the phase closed: ";
3608
+ if (coverage === null)
3609
+ return `${label}not recorded.`;
3610
+ if (coverage.enrolled === 0)
3611
+ return `${label}none.`;
3612
+ return `${label}${String(coverage.enrolled)}. Of these, ${String(coverage.conformant)} had reported Halfcycle's checks as fully set up.`;
3613
+ }
3614
+ function walkLines(walk, p) {
3615
+ const label = "> - Acceptance walk (the finished work tried as a user would, by someone other than its builder): ";
3616
+ if (walk === null)
3617
+ return [`${label}none reported.`];
3618
+ const lines = [
3619
+ `${label}${walk.verdict === "clean" ? "reported as finding no problems." : "reported as finding problems."}`
3620
+ ];
3621
+ walk.findings.forEach((finding, i) => {
3622
+ lines.push(`> - Finding ${String(i + 1)}: ${p.text(finding)}`);
3623
+ });
3624
+ if (walk.findingsNotShown > 0) {
3625
+ lines.push(`> - And ${counted(walk.findingsNotShown, "more finding", "more findings")} not shown here.`);
3366
3626
  }
3367
- return {
3368
- format: CLOSE_RECORD_FORMAT,
3369
- phase: record2.phase,
3370
- closeCommit: commit,
3371
- closedDate: date
3372
- };
3627
+ return lines;
3373
3628
  }
3374
-
3375
- // dist/build-record/write.js
3376
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
3377
- import { basename, join as join9, resolve, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
3378
-
3379
- // dist/build-record/template.js
3380
- function severityLabel(seed) {
3381
- return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
3382
- }
3383
- var NOT_RECORDED = "not recorded";
3384
- function renderCloseLine(record2) {
3385
- const status = `**Status:** ${record2.status.toUpperCase()}`;
3386
- const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
3387
- const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
3388
- return `${status} \xB7 ${closed} \xB7 ${commit}`;
3389
- }
3390
- function renderCloseProvenance(record2) {
3391
- 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.*";
3392
- }
3393
- function renderDoneWhenRows(record2) {
3394
- const rows = [];
3395
- for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
3396
- const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
3397
- rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
3398
- }
3399
- return rows.join("\n");
3400
- }
3401
- function renderBuildRecordMarkdown(record2) {
3402
- const { phase } = record2;
3403
- const companion = `${renderPhaseSegment(phase.id)}.json`;
3404
- const guardsList = record2.seedGuards.map(severityLabel).join(", ");
3629
+ function statedOrNot(value, p) {
3630
+ return value === null ? "Not stated by the client." : p.text(value);
3631
+ }
3632
+ function renderStated(attested, p) {
3405
3633
  const lines = [
3406
- // Fixed prose (vault-canonical template) + projected title slots.
3407
- `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
3408
- "",
3409
- `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
3410
- "",
3411
- "> 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.",
3412
- "",
3413
- `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
3414
- `**Phase:** ${phase.id} \u2014 ${phase.name}`,
3415
- renderCloseLine(record2),
3416
- `**Companion:** [\`${companion}\`](${companion})`,
3417
- "",
3418
- renderCloseProvenance(record2),
3419
- "",
3420
- "## What this phase proved",
3421
- "",
3422
- `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
3423
- "",
3424
- "## Delivered",
3425
- "",
3426
- `- **Packages:** ${record2.delivered.packages.join(", ")}`,
3427
- `- **Services:** ${record2.delivered.services.join(", ")}`,
3428
- `- **Tools:** ${record2.delivered.tools.join(", ")}`,
3429
- `- **Dogfood:** ${record2.delivered.dogfood}`,
3430
- "",
3431
- 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.",
3432
- "",
3433
- `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
3434
- "",
3435
- `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
3436
- "",
3437
- "| Done-when | Result | Evidence |",
3438
- "|---|---|---|",
3439
- renderDoneWhenRows(record2),
3440
- ""
3634
+ 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.",
3635
+ `> - Name given: ${attested.actor === null ? "removed with the account that made this close" : p.text(attested.actor)}`,
3636
+ `> - Reason given: ${attested.reason === null ? "none." : p.text(attested.reason)}`,
3637
+ ...walkLines(attested.walk, p),
3638
+ `> - Code closed at (commit): ${statedOrNot(attested.closeCommit, p)}`,
3639
+ `> - What the phase set out to prove: ${statedOrNot(attested.statements.thesis, p)}`,
3640
+ `> - What came out of it: ${statedOrNot(attested.statements.outcome, p)}`
3441
3641
  ];
3442
- if (record2.acceptance.inv002ProdBypassProbe) {
3443
- lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
3642
+ const deviations = attested.statements.deviations;
3643
+ if (deviations.length === 0) {
3644
+ lines.push("> - Changes from the plan: None stated by the client.");
3645
+ } else {
3646
+ lines.push("> - Changes from the plan:");
3647
+ deviations.forEach((deviation, i) => {
3648
+ lines.push(`> - Change ${String(i + 1)}: ${p.text(deviation)}`);
3649
+ });
3444
3650
  }
3445
- 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"}`, "");
3446
3651
  return lines.join("\n");
3447
3652
  }
3653
+ function renderClose(heading, observed, attested, p) {
3654
+ return [
3655
+ heading,
3656
+ headline(observed, attested),
3657
+ OBSERVED_LABEL,
3658
+ [
3659
+ `- Closed at: ${closedAtText(observed.closedAt, p)}`,
3660
+ `- Signed in as: ${signedInAs(observed.closedBy, p)}`,
3661
+ ...outstanding(observed.crossed, p),
3662
+ machines(observed.seatCoverage)
3663
+ ].join("\n"),
3664
+ STATED_LABEL,
3665
+ renderStated(attested, p)
3666
+ ];
3667
+ }
3668
+ function renderBuildRecordV2Markdown(record2) {
3669
+ const p = makePrinter();
3670
+ const attestedBySequence = /* @__PURE__ */ new Map();
3671
+ for (const close of record2.attested.closes)
3672
+ attestedBySequence.set(close.sequence, close);
3673
+ const observedSequences = new Set(record2.observed.closes.map((c) => c.sequence));
3674
+ if (attestedBySequence.size !== record2.attested.closes.length || observedSequences.size !== record2.observed.closes.length || attestedBySequence.size !== observedSequences.size || [...observedSequences].some((s) => !attestedBySequence.has(s))) {
3675
+ throw new Error(MISMATCHED_HALVES);
3676
+ }
3677
+ const phase = p.text(record2.phase);
3678
+ const closes = [...record2.observed.closes].sort((a, b) => a.sequence - b.sequence);
3679
+ const total = closes.length;
3680
+ const latest = closes[total - 1];
3681
+ const body = [
3682
+ `Phase: ${phase} (the name the client gave it)`,
3683
+ OBSERVED_LABEL,
3684
+ [
3685
+ `- Method version: ${p.text(record2.observed.methodVersion)}. The Halfcycle method is updated from time to time, and each update has its own version number. This is the one the project was run under; it does not change during a project.`,
3686
+ `- Project identifier: ${p.text(record2.observed.engagementId)}. Halfcycle keeps this project's records under this identifier.`,
3687
+ `- Record format: ${p.text(record2.format)}. This names the layout this file and the .json file beside it follow, for software that reads them.`
3688
+ ].join("\n"),
3689
+ 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.`,
3690
+ ...renderChecks(record2.observed.checks, p),
3691
+ ...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), p)
3692
+ ];
3693
+ if (total > 1) {
3694
+ body.push("## Earlier closes");
3695
+ for (const close of closes.slice(0, -1)) {
3696
+ body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), p));
3697
+ }
3698
+ }
3699
+ const header = [`${TITLE_PREFIX}${phase}`, HEADER_WHAT, HEADER_REGISTERS, HEADER_CHECKS, HEADER_KEPT];
3700
+ if (p.sawHidden())
3701
+ header.push(HEADER_HIDDEN);
3702
+ return `${[...header, ...body].join("\n\n")}
3703
+ `;
3704
+ }
3448
3705
 
3449
3706
  // dist/build-record/write.js
3450
- var BUILD_RECORD_DIR = join9("docs", "build-records");
3451
- function serialiseRecordJson(record2) {
3452
- return JSON.stringify(record2, null, 2) + "\n";
3453
- }
3707
+ import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync2, writeSync } from "node:fs";
3708
+ import { basename, join as join8, resolve, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
3709
+ var BUILD_RECORD_DIR = join8("docs", "build-records");
3454
3710
  function assertInsideOutDir(outDir, candidate, identity) {
3455
3711
  const rel = relative2(outDir, resolve(candidate));
3456
3712
  const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute2(rel) && rel === basename(rel);
@@ -3458,252 +3714,236 @@ function assertInsideOutDir(outDir, candidate, identity) {
3458
3714
  throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
3459
3715
  }
3460
3716
  }
3461
- function writeBuildRecord(repoRoot, record2) {
3462
- const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
3463
- const identity = record2.phase.id;
3464
- if (!isValidPhaseIdentity(identity)) {
3465
- throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
3717
+ function createExclusiveOnDisk(path, text) {
3718
+ const fd = openSync(path, "wx");
3719
+ try {
3720
+ writeSync(fd, text);
3721
+ } catch (err) {
3722
+ closeSync(fd);
3723
+ rmSync2(path, { force: true });
3724
+ throw err;
3466
3725
  }
3467
- const segment = renderPhaseSegment(identity);
3468
- const jsonPath = join9(outDir, `${segment}.json`);
3469
- const mdPath = join9(outDir, `${segment}.md`);
3470
- assertInsideOutDir(outDir, jsonPath, identity);
3471
- assertInsideOutDir(outDir, mdPath, identity);
3472
- mkdirSync6(outDir, { recursive: true });
3473
- writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
3474
- writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
3475
- return { jsonPath, mdPath };
3726
+ closeSync(fd);
3476
3727
  }
3477
-
3478
- // dist/build-record/sources.js
3479
- var MissingSourceError = class extends Error {
3480
- constructor(sourceName, detail) {
3481
- super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
3482
- this.name = "MissingSourceError";
3483
- }
3728
+ function renameOnDisk(from, to) {
3729
+ renameSync(from, to);
3730
+ }
3731
+ var NODE_RECORD_PAIR_IO = {
3732
+ createExclusive: createExclusiveOnDisk,
3733
+ rename: renameOnDisk
3484
3734
  };
3485
- function projectSeedGuards(runs) {
3486
- const byRef = /* @__PURE__ */ new Map();
3487
- for (const run of runs) {
3488
- if (!run.result)
3489
- continue;
3490
- for (const fired of run.result.guardsFired) {
3491
- const seed = projectSeedGuard(
3492
- fired,
3493
- /* includeExplanation */
3494
- false
3495
- );
3496
- const existing = byRef.get(seed.patternRef);
3497
- if (!existing) {
3498
- byRef.set(seed.patternRef, seed);
3499
- } else if (existing.severity !== seed.severity) {
3500
- byRef.set(seed.patternRef, pickHigherSeverity(existing, seed));
3501
- }
3502
- }
3503
- }
3504
- return [...byRef.values()].sort((a, b) => a.patternRef.localeCompare(b.patternRef));
3735
+ function serialiseServedRecord(rawRecord) {
3736
+ return JSON.stringify(rawRecord, null, 2) + "\n";
3505
3737
  }
3506
- var SEVERITY_ORDINAL = { info: 0, warn: 1, block: 2 };
3507
- function pickHigherSeverity(a, b) {
3508
- return (SEVERITY_ORDINAL[a.severity] ?? -1) >= (SEVERITY_ORDINAL[b.severity] ?? -1) ? a : b;
3738
+ function isEexist(err) {
3739
+ return typeof err === "object" && err !== null && err.code === "EEXIST";
3509
3740
  }
3510
- function normalizeLegacyLine(parsed) {
3511
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3512
- return parsed;
3741
+ function readIfPresent(path) {
3742
+ try {
3743
+ return readFileSync7(path, "utf-8");
3744
+ } catch {
3745
+ return null;
3513
3746
  }
3514
- const record2 = parsed;
3515
- if (record2["outcome"] !== void 0)
3516
- return parsed;
3517
- return {
3518
- ...record2,
3519
- runId: record2["runId"] ?? syntheticRunId(record2),
3520
- outcome: "evaluated"
3521
- };
3522
3747
  }
3523
- function syntheticRunId(record2) {
3524
- const key = [
3525
- record2["engagementId"],
3526
- record2["occurredAt"],
3527
- record2["runType"],
3528
- record2["phase"]
3529
- ].join("|");
3530
- const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
3531
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
3532
- }
3533
- function readGuardEvalLog(logDir, phaseId) {
3534
- const logPath = join10(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
3535
- if (!existsSync6(logPath)) {
3536
- throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
3537
- }
3538
- const raw = readFileSync7(logPath, "utf-8");
3539
- const runs = [];
3540
- for (const line of raw.split("\n")) {
3541
- const trimmed = line.trim();
3542
- if (trimmed === "")
3543
- continue;
3544
- const parsed = JSON.parse(trimmed);
3545
- runs.push(guardEvalRunSchema.parse(normalizeLegacyLine(parsed)));
3748
+ function isOwnEarlierRecord(existingText, served) {
3749
+ let parsedJson;
3750
+ try {
3751
+ parsedJson = JSON.parse(existingText);
3752
+ } catch {
3753
+ return false;
3546
3754
  }
3547
- return runs;
3755
+ const existing = buildRecordV2Schema.safeParse(parsedJson);
3756
+ if (!existing.success)
3757
+ return false;
3758
+ const old = existing.data;
3759
+ if (old.observed.engagementId !== served.observed.engagementId)
3760
+ return false;
3761
+ if (old.phase !== served.phase)
3762
+ return false;
3763
+ const oldCloses = old.observed.closes.length;
3764
+ const newCloses = served.observed.closes.length;
3765
+ if (oldCloses < newCloses)
3766
+ return true;
3767
+ if (oldCloses > newCloses)
3768
+ return false;
3769
+ const oldSequences = new Set(old.observed.closes.map((c) => c.sequence));
3770
+ return served.observed.closes.every((c) => oldSequences.has(c.sequence));
3548
3771
  }
3549
- function readOrchestrationState(phasesDir, phaseId) {
3550
- const statePath = join10(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
3551
- if (!existsSync6(statePath)) {
3552
- throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
3772
+ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_PAIR_IO) {
3773
+ if (!isValidPhaseIdentity(phase)) {
3774
+ throw new InvalidPhaseIdentityError(phase, BUILD_RECORD_DIR);
3775
+ }
3776
+ const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
3777
+ const segment = renderPhaseSegment(phase);
3778
+ const jsonPath = join8(outDir, `${segment}.json`);
3779
+ const mdPath = join8(outDir, `${segment}.md`);
3780
+ const jsonTmp = `${jsonPath}.tmp`;
3781
+ const mdTmp = `${mdPath}.tmp`;
3782
+ for (const candidate of [jsonPath, mdPath, jsonTmp, mdTmp]) {
3783
+ assertInsideOutDir(outDir, candidate, phase);
3784
+ }
3785
+ const jsonText = serialiseServedRecord(rawRecord);
3786
+ const mdText = renderBuildRecordV2Markdown(record2);
3787
+ mkdirSync5(outDir, { recursive: true });
3788
+ let createdJson = false;
3789
+ try {
3790
+ io.createExclusive(jsonPath, jsonText);
3791
+ createdJson = true;
3792
+ io.createExclusive(mdPath, mdText);
3793
+ return { kind: "created", jsonPath, mdPath };
3794
+ } catch (err) {
3795
+ if (createdJson)
3796
+ rmSync2(jsonPath, { force: true });
3797
+ if (!isEexist(err))
3798
+ throw err;
3553
3799
  }
3554
- const parsed = JSON.parse(readFileSync7(statePath, "utf-8"));
3555
- if (!parsed.tasks || typeof parsed.tasks !== "object") {
3556
- throw new MissingSourceError("orchestration state", `${statePath} has no tasks map`);
3800
+ const existingJson = readIfPresent(jsonPath);
3801
+ if (existingJson === null) {
3802
+ return { kind: "in-the-way", path: existsSync6(jsonPath) ? jsonPath : mdPath };
3557
3803
  }
3558
- const tasks = {};
3559
- for (const [id, t] of Object.entries(parsed.tasks)) {
3560
- if (!t || typeof t.status !== "string") {
3561
- throw new MissingSourceError("orchestration state", `task ${id} has no status`);
3562
- }
3563
- tasks[id] = { status: t.status };
3804
+ if (existingJson === jsonText) {
3805
+ replaceOne(io, mdTmp, mdPath, mdText);
3806
+ return { kind: "up-to-date", jsonPath, mdPath };
3564
3807
  }
3565
- return { tasks };
3566
- }
3567
- function readPhaseClose(repoRoot, phaseId) {
3568
- const path = closeRecordPath(repoRoot, phaseId);
3569
- if (!existsSync6(path))
3570
- return { closeCommit: null, closedDate: null };
3571
- let parsed;
3808
+ if (!isOwnEarlierRecord(existingJson, record2)) {
3809
+ return { kind: "in-the-way", path: jsonPath };
3810
+ }
3811
+ replaceOne(io, jsonTmp, jsonPath, jsonText);
3572
3812
  try {
3573
- parsed = JSON.parse(readFileSync7(path, "utf-8"));
3813
+ replaceOne(io, mdTmp, mdPath, mdText);
3574
3814
  } catch (err) {
3575
- throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
3576
- }
3577
- const record2 = parseCloseRecord(path, parsed);
3578
- if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
3579
- throw new InvalidCloseRecordError(path, `it records the close of ${String(record2.phase)}, not of ${String(phaseId)}`);
3815
+ try {
3816
+ replaceOne(io, jsonTmp, jsonPath, existingJson);
3817
+ } catch {
3818
+ rmSync2(jsonPath, { force: true });
3819
+ }
3820
+ throw err;
3580
3821
  }
3581
- return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
3822
+ return { kind: "replaced", jsonPath, mdPath };
3582
3823
  }
3583
- function validateNarrated(narrated) {
3584
- const req = (v, name) => {
3585
- if (v === void 0 || v === null || v === "") {
3586
- throw new MissingSourceError("narrated artefact", `${name} is missing`);
3587
- }
3588
- };
3589
- req(narrated.engagement, "engagement");
3590
- req(narrated.engagementType, "engagementType");
3591
- req(narrated.phase?.name, "phase.name");
3592
- assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
3593
- req(narrated.status, "status");
3594
- req(narrated.thesis, "thesis");
3595
- req(narrated.delivered, "delivered");
3596
- req(narrated.acceptance, "acceptance");
3597
- req(narrated.acceptance?.verdict, "acceptance.verdict");
3598
- req(narrated.gatesEarnedKeep, "gatesEarnedKeep");
3599
- req(narrated.instruments, "instruments");
3600
- if (!Array.isArray(narrated.deviations)) {
3601
- throw new MissingSourceError("narrated artefact", "deviations must be an array");
3602
- }
3603
- return narrated;
3604
- }
3605
- function loadSources(opts) {
3606
- const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
3607
- const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
3608
- const close = readPhaseClose(opts.repoRoot, opts.phaseId);
3609
- const narrated = validateNarrated(opts.narrated);
3610
- if (!Array.isArray(opts.touchedInvariants)) {
3611
- throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
3824
+ function replaceOne(io, tmp, finalPath, text) {
3825
+ rmSync2(tmp, { force: true });
3826
+ try {
3827
+ io.createExclusive(tmp, text);
3828
+ io.rename(tmp, finalPath);
3829
+ } catch (err) {
3830
+ rmSync2(tmp, { force: true });
3831
+ throw err;
3612
3832
  }
3613
- return {
3614
- orchestration,
3615
- narrated,
3616
- close,
3617
- guardEvalRuns,
3618
- touchedInvariants: opts.touchedInvariants
3619
- };
3620
3833
  }
3621
3834
 
3622
- // dist/build-record/assemble.js
3623
- var LANDED_STATES = /* @__PURE__ */ new Set(["merged", "done"]);
3624
- var CANCELLED_STATE = "cancelled";
3625
- function deriveTasks(orchestration) {
3626
- const ids = Object.keys(orchestration.tasks);
3627
- const cancelled = ids.filter((id) => orchestration.tasks[id].status === CANCELLED_STATE).sort((a, b) => a.localeCompare(b));
3628
- const landed = ids.filter((id) => LANDED_STATES.has(orchestration.tasks[id].status)).length;
3629
- return { planned: ids.length, landed, cancelled };
3630
- }
3631
- function deriveThesisHeld(acceptance) {
3632
- if (acceptance.verdict !== "clean")
3633
- return false;
3634
- for (const dw of Object.values(acceptance.doneWhen)) {
3635
- if (dw.result === "dropped")
3636
- continue;
3637
- if (dw.result !== "pass")
3638
- return false;
3639
- }
3640
- return true;
3835
+ // dist/build-record/obtain.js
3836
+ import { relative as relative3 } from "node:path";
3837
+ var REFUSAL_FOLLOW_UP = {
3838
+ "still-open": "Nothing was written. The record is produced when the phase closes.",
3839
+ "reopened": "Nothing was written. A new record is produced when the phase closes again.",
3840
+ "never-opened": "Nothing was written. Check the name: it must match the phase name exactly as it was opened.",
3841
+ "never-closed": "Nothing was written.",
3842
+ "closed-before-records": "Nothing was written."
3843
+ };
3844
+ var REFUSAL_REASONS = phaseRecordRefusalReasonSchema.options;
3845
+ function buildRecordUrl(credential, phase) {
3846
+ return `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phases/${encodeURIComponent(phase)}/build-record`;
3641
3847
  }
3642
- function deriveInvariantsExercised(touched) {
3643
- return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
3848
+ function isMissingRoute(status, body) {
3849
+ if (status !== 404 || typeof body !== "object" || body === null)
3850
+ return false;
3851
+ const { error, message } = body;
3852
+ return error === "Not Found" && typeof message === "string" && /^Route GET .* not found\.$/s.test(message);
3644
3853
  }
3645
- function assembleBuildRecord(sources) {
3646
- const { narrated, close, orchestration } = sources;
3854
+ async function obtainBuildRecord(credential, repoRoot, phase, io = NODE_RECORD_PAIR_IO) {
3855
+ const url = buildRecordUrl(credential, phase);
3856
+ let res;
3857
+ let text;
3858
+ try {
3859
+ res = await fetch(url, {
3860
+ method: "GET",
3861
+ headers: { authorization: `Bearer ${credential.token}` }
3862
+ });
3863
+ text = await res.text();
3864
+ } catch (err) {
3865
+ return {
3866
+ kind: "unavailable",
3867
+ why: `could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}.`
3868
+ };
3869
+ }
3870
+ let raw = null;
3871
+ try {
3872
+ raw = JSON.parse(text);
3873
+ } catch {
3874
+ raw = null;
3875
+ }
3876
+ const parsed = phaseRecordResponseSchema.safeParse(raw);
3877
+ if (parsed.success) {
3878
+ const answer = parsed.data;
3879
+ if (answer.phase !== phase) {
3880
+ return {
3881
+ kind: "unavailable",
3882
+ why: `the service answered for a different phase than the one asked for, so nothing was written.`
3883
+ };
3884
+ }
3885
+ if (answer.outcome === "withheld")
3886
+ return { kind: "withheld", message: answer.message };
3887
+ if (answer.outcome === "refused") {
3888
+ return { kind: "refused", reason: answer.reason, message: answer.message };
3889
+ }
3890
+ const rawRecord = raw.record;
3891
+ let result;
3892
+ try {
3893
+ result = writeRecordPair(repoRoot, phase, rawRecord, answer.record, io);
3894
+ } catch (err) {
3895
+ return {
3896
+ kind: "unavailable",
3897
+ why: `the record arrived but could not be written: ${err instanceof Error ? err.message : String(err)}. Nothing was left half written.`
3898
+ };
3899
+ }
3900
+ if (result.kind === "in-the-way") {
3901
+ return { kind: "exists", path: relative3(repoRoot, result.path) };
3902
+ }
3903
+ return {
3904
+ kind: "written",
3905
+ jsonPath: relative3(repoRoot, result.jsonPath),
3906
+ mdPath: relative3(repoRoot, result.mdPath)
3907
+ };
3908
+ }
3909
+ if (isMissingRoute(res.status, raw)) {
3910
+ return { kind: "unavailable", why: "this Halfcycle service does not provide Build Records yet." };
3911
+ }
3912
+ const message = typeof raw === "object" && raw !== null && typeof raw.message === "string" ? raw.message : null;
3913
+ if (res.ok) {
3914
+ return {
3915
+ kind: "unavailable",
3916
+ why: `the service's answer was not a Build Record this version of the CLI can read, so nothing was written.`
3917
+ };
3918
+ }
3647
3919
  return {
3648
- format: BUILD_RECORD_FORMAT,
3649
- engagement: narrated.engagement,
3650
- engagementType: narrated.engagementType,
3651
- phase: { id: narrated.phase.id, name: narrated.phase.name },
3652
- status: narrated.status,
3653
- closedDate: close.closedDate,
3654
- closeCommit: close.closeCommit,
3655
- thesis: narrated.thesis,
3656
- thesisHeld: deriveThesisHeld(narrated.acceptance),
3657
- delivered: narrated.delivered,
3658
- seedGuards: projectSeedGuards(sources.guardEvalRuns),
3659
- acceptance: narrated.acceptance,
3660
- gatesEarnedKeep: narrated.gatesEarnedKeep,
3661
- instruments: narrated.instruments,
3662
- invariantsExercised: deriveInvariantsExercised(sources.touchedInvariants),
3663
- deviations: narrated.deviations,
3664
- tasks: deriveTasks(orchestration)
3920
+ kind: "unavailable",
3921
+ why: `the service answered ${res.status}${message === null ? "." : `: ${message}`}`
3665
3922
  };
3666
3923
  }
3667
-
3668
- // dist/build-record/index.js
3669
- function assemblePhaseBuildRecord(opts) {
3670
- const sources = loadSources({
3671
- repoRoot: opts.repoRoot,
3672
- phasesDir: opts.phasesDir,
3673
- guardEvalLogDir: opts.guardEvalLogDir,
3674
- phaseId: opts.phaseId,
3675
- narrated: opts.narrated,
3676
- touchedInvariants: opts.touchedInvariants
3677
- });
3678
- const record2 = assembleBuildRecord(sources);
3679
- return writeBuildRecord(opts.repoRoot, record2);
3924
+ function refetchCommand(phase) {
3925
+ const quoted = /^[A-Za-z0-9._+-]+$/.test(phase) ? phase : `'${phase.replace(/'/g, `'\\''`)}'`;
3926
+ return `npx halfcycle@latest build-record ${quoted}`;
3680
3927
  }
3681
3928
  export {
3682
3929
  BUILD_RECORD_DIR,
3683
- BUILD_RECORD_FORMAT,
3684
- BUILD_RECORD_ZONE_B_DIR,
3685
- CLOSE_RECORD_FORMAT,
3686
3930
  CreateEngagementRefused,
3687
3931
  FLAT_FILE_TRACKER_HOME,
3688
3932
  HALFCYCLE_STATE_FORMAT,
3689
- InvalidCloseRecordError,
3690
3933
  InvalidPhaseIdentityError,
3691
- MissingSourceError,
3934
+ NODE_RECORD_PAIR_IO,
3935
+ REFUSAL_FOLLOW_UP,
3936
+ REFUSAL_REASONS,
3692
3937
  SetupManifest,
3693
3938
  ZONE_B_DIR,
3694
3939
  agentsPointerRow,
3695
3940
  applySetupWrites,
3696
- assembleBuildRecord,
3697
- assemblePhaseBuildRecord,
3698
3941
  assertValidPhaseIdentity,
3699
3942
  buildEngagementRecord,
3943
+ buildRecordUrl,
3700
3944
  checkDrift,
3701
- closeRecordPath,
3702
3945
  contextIndexRow,
3703
3946
  createEngagement,
3704
- deriveInvariantsExercised,
3705
- deriveTasks,
3706
- deriveThesisHeld,
3707
3947
  discoverRepository,
3708
3948
  documentSectionRow,
3709
3949
  engagementRecordRow,
@@ -3711,31 +3951,24 @@ export {
3711
3951
  install,
3712
3952
  isValidPhaseIdentity,
3713
3953
  layerDocumentRow,
3714
- loadSources,
3715
3954
  mergeSettings,
3716
3955
  mergeWithRecords,
3717
3956
  mintBoardEnterCode,
3718
3957
  mintOrReadIdentity,
3719
- parseCloseRecord,
3720
- projectSeedGuard,
3721
- projectSeedGuards,
3958
+ obtainBuildRecord,
3722
3959
  questionsToPut,
3723
3960
  readBundlePin,
3724
- readGuardEvalLog,
3725
- readOrchestrationState,
3726
- readPhaseClose,
3727
3961
  readRemote,
3728
3962
  readRootCommit,
3963
+ refetchCommand,
3729
3964
  renderAnchor,
3730
- renderBuildRecordMarkdown,
3965
+ renderBuildRecordV2Markdown,
3731
3966
  renderDiscovery,
3732
3967
  renderPhaseSegment,
3733
3968
  runBootstrapScan,
3734
3969
  scanLayers,
3735
- serialiseRecordJson,
3970
+ serialiseServedRecord,
3736
3971
  trackerHomeRow,
3737
- validateNarrated,
3738
- writeBuildRecord,
3739
- writeCloseRecord
3972
+ writeRecordPair
3740
3973
  };
3741
3974
  //# sourceMappingURL=index.js.map