halfcycle 0.3.31 → 0.3.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -3,18 +3,15 @@
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";
10
10
  import { createHash as createHash2 } from "node:crypto";
11
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
11
+ import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
12
12
  import { dirname as dirname2, join as join5, posix, relative } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
 
15
- // ../events/dist/result.js
16
- import { z as z2 } from "zod";
17
-
18
15
  // ../core/dist/guard-record.js
19
16
  import { z } from "zod";
20
17
  var severitySchema = z.enum(["info", "warn", "block"]);
@@ -138,7 +135,101 @@ function parseAccountStoreText(raw) {
138
135
  }
139
136
  }
140
137
 
138
+ // ../core/dist/hook-wiring.js
139
+ var SETTINGS_REL = ".claude/settings.json";
140
+ var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
141
+ var SESSION_START_MARKER_REL = ".claude/hooks/session-start-marker.sh";
142
+ var GUARD_RUNNER_REL = ".claude/hooks/guard-runner.sh";
143
+ var USER_PROMPT_REMINDER_REL = ".claude/hooks/user-prompt-reminder.sh";
144
+ var SESSION_END_MARKER_REL = ".claude/hooks/session-end-marker.sh";
145
+ var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
146
+ var HALFCYCLE_HOOK_WIRING = [
147
+ {
148
+ event: "SessionStart",
149
+ matcher: null,
150
+ timeout: 5,
151
+ command: `bash ./${SESSION_START_MARKER_REL}`,
152
+ script: SESSION_START_MARKER_REL
153
+ },
154
+ {
155
+ event: "SessionStart",
156
+ matcher: null,
157
+ timeout: 10,
158
+ command: `bash ./${GUARD_RUNNER_REL} refresh-credential`,
159
+ script: GUARD_RUNNER_REL
160
+ },
161
+ {
162
+ event: "UserPromptSubmit",
163
+ matcher: null,
164
+ timeout: 5,
165
+ command: `bash ./${USER_PROMPT_REMINDER_REL}`,
166
+ script: USER_PROMPT_REMINDER_REL
167
+ },
168
+ {
169
+ event: "PostToolUse",
170
+ matcher: WRITE_CAPABLE_TOOLS.join("|"),
171
+ timeout: 60,
172
+ command: `bash ./${GUARD_RUNNER_REL} hook post-tool-use`,
173
+ script: GUARD_RUNNER_REL
174
+ },
175
+ {
176
+ event: "Stop",
177
+ matcher: null,
178
+ timeout: 60,
179
+ command: `bash ./${GUARD_RUNNER_REL} hook stop`,
180
+ script: GUARD_RUNNER_REL
181
+ },
182
+ {
183
+ event: "SubagentStop",
184
+ matcher: null,
185
+ timeout: 60,
186
+ command: `bash ./${GUARD_RUNNER_REL} hook subagent-stop`,
187
+ script: GUARD_RUNNER_REL
188
+ },
189
+ {
190
+ event: "SessionEnd",
191
+ matcher: null,
192
+ timeout: 5,
193
+ command: `bash ./${SESSION_END_MARKER_REL}`,
194
+ script: SESSION_END_MARKER_REL
195
+ }
196
+ ];
197
+ var RETIRED_HOOK_COMMANDS = [];
198
+ var HALFCYCLE_HOOK_COMMANDS = /* @__PURE__ */ new Set([
199
+ ...HALFCYCLE_HOOK_WIRING.map((entry) => entry.command),
200
+ ...RETIRED_HOOK_COMMANDS
201
+ ]);
202
+ var HALFCYCLE_HOOK_WIRING_PATHS = [
203
+ .../* @__PURE__ */ new Set([SETTINGS_REL, ...HALFCYCLE_HOOK_WIRING.map((entry) => entry.script), VENDORED_BIN_REL])
204
+ ];
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
+
141
231
  // ../events/dist/result.js
232
+ import { z as z2 } from "zod";
142
233
  var firedGuardSchema = z2.object({
143
234
  guardId: z2.string(),
144
235
  patternRef: z2.string(),
@@ -196,219 +287,31 @@ var wireErrorSchema = z2.object({
196
287
  message: z2.string()
197
288
  }).strict();
198
289
 
199
- // ../events/dist/telemetry.js
200
- import { z as z3 } from "zod";
201
- 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");
202
- var guardEvalOutcomeSchema = z3.enum([
203
- "evaluated",
204
- "infra-error",
205
- "contract-error",
206
- "unconfigured"
207
- ]);
208
- var guardEvalRunSchema = z3.object({
209
- // `runId`/`outcome` are the E2 run-observability additions, declared
210
- // field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
211
- // migration. Every new record populated by the migrated writer (T-07)
212
- // carries both; a legacy line predating the amendment carries neither and
213
- // must still parse (the mixed-era guard-eval log the Build-Record reader
214
- // assembles from, and the runner emit not yet migrated). So the presence
215
- // refinement below is skipped for a legacy line (no `outcome`), and the
216
- // per-writer guarantee lives with the writer + reader-normalisation (T-07),
217
- // where the schema may then be tightened to required. runId is uuid-checked
218
- // WHEN present; outcome is enum-checked when present.
219
- runId: z3.string().uuid().optional(),
220
- engagementId: z3.string().optional(),
221
- runType: z3.enum(["hook", "ci"]),
222
- occurredAt: utcIso8601,
223
- outcome: guardEvalOutcomeSchema.optional(),
224
- failureReason: z3.string().optional(),
225
- phase: z3.string().optional(),
226
- scope: z3.record(z3.string(), z3.unknown()),
227
- cost: z3.record(z3.string(), z3.unknown()).optional(),
228
- result: resultEnvelopeSchema.optional()
229
- }).strict().superRefine((run, ctx) => {
230
- if (run.outcome === void 0)
231
- return;
232
- if (run.outcome === "evaluated" && run.result === void 0) {
233
- ctx.addIssue({
234
- code: z3.ZodIssueCode.custom,
235
- path: ["result"],
236
- message: "result is required when outcome is 'evaluated'"
237
- });
238
- }
239
- if (run.outcome !== "evaluated" && run.result !== void 0) {
240
- ctx.addIssue({
241
- code: z3.ZodIssueCode.custom,
242
- path: ["result"],
243
- message: "result is only present when outcome is 'evaluated'"
244
- });
245
- }
246
- if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
247
- ctx.addIssue({
248
- code: z3.ZodIssueCode.custom,
249
- path: ["engagementId"],
250
- message: "engagementId is required unless outcome is 'unconfigured'"
251
- });
252
- }
253
- });
254
-
255
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
256
301
  import { z as z4 } from "zod";
257
- 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");
258
- var engagementTypeSchema = z4.enum(["client"]);
259
- var lifecycleStatusSchema = z4.enum(["active", "delivering", "closed"]);
260
- var createEngagementRequestSchema = z4.object({
261
- name: z4.string(),
262
- region: z4.string(),
263
- engagementType: engagementTypeSchema
264
- }).strict();
265
- var createEngagementResponseSchema = z4.object({
266
- engagementId: z4.string(),
267
- name: z4.string(),
268
- region: z4.string(),
269
- status: lifecycleStatusSchema,
270
- createdAt: utcIso86012,
271
- sessionToken: z4.string()
272
- }).strict();
273
- var engagementStatusResponseSchema = z4.object({
274
- engagementId: z4.string(),
275
- name: z4.string(),
276
- region: z4.string(),
277
- status: lifecycleStatusSchema,
278
- installLinked: z4.boolean(),
279
- createdAt: utcIso86012,
280
- updatedAt: utcIso86012
281
- }).strict();
282
- var linkInstallationRequestSchema = z4.object({
283
- installationId: z4.string(),
284
- engagementId: z4.string()
285
- }).strict();
286
- var startBuildRequestSchema = z4.object({
287
- engagementId: z4.string()
288
- }).strict();
289
- var startBuildResponseSchema = z4.object({
290
- engagementId: z4.string(),
291
- status: lifecycleStatusSchema,
292
- enqueuedAt: utcIso86012
293
- }).strict();
294
- var acceptanceOutcomeSchema = z4.object({
295
- verdict: z4.enum(["clean", "defects"]),
296
- findings: z4.number().int().nonnegative(),
297
- findingsDetail: z4.array(z4.object({ summary: z4.string() }).strict())
298
- }).strict();
299
- var PHASE_CLOSE_REASON_MAX = 1900;
300
- var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
301
- z4.object({
302
- decision: z4.literal("done-when-met"),
303
- /** Who is closing the phase. */
304
- actor: z4.string()
305
- }).strict(),
306
- z4.object({
307
- decision: z4.literal("override"),
308
- /** Who is closing it anyway. */
309
- actor: z4.string(),
310
- /**
311
- * Why, in their own words. Bounded: the reason is kept on the phase's
312
- * permanent record of its own close, and that record refuses anything
313
- * longer — so an over-long reason is refused here, before the close is
314
- * attempted, rather than failing the close itself.
315
- */
316
- reason: z4.string().max(PHASE_CLOSE_REASON_MAX)
317
- }).strict()
318
- ]);
319
- var acceptPhaseRequestSchema = z4.object({
320
- phase: z4.string(),
321
- acceptanceOutcome: acceptanceOutcomeSchema,
322
- close: phaseCloseDecisionSchema.optional()
323
- }).strict();
324
- var coeFindingRequestSchema = z4.object({
325
- phase: z4.string(),
326
- description: z4.string(),
327
- addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
328
- mappedPatternRef: z4.string().nullable()
329
- }).strict();
330
- var coeFindingResponseSchema = z4.object({
331
- findingId: z4.string(),
332
- engagementId: z4.string(),
333
- phase: z4.string(),
334
- description: z4.string(),
335
- addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
336
- mappedPatternRef: z4.string().nullable(),
337
- createdAt: utcIso86012
338
- }).strict();
339
- var operatorFindingResponseSchema = z4.object({
340
- // The finding just recorded.
341
- finding: coeFindingResponseSchema,
342
- // Every finding recorded for that phase, oldest first, including this one.
343
- phaseFindings: z4.array(coeFindingResponseSchema),
344
- // Whether the phase's finding rate was recalculated to include this finding.
345
- figuresRecomputed: z4.boolean()
346
- }).strict();
347
- var phaseNotOnRecordRefusalSchema = wireErrorSchema.extend({
348
- // Always this value for this refusal.
349
- error: z4.literal("PhaseNotOnRecord"),
350
- // The phases that can take a finding, sorted.
351
- phases: z4.array(z4.string())
352
- }).strict();
353
- var phaseEntryDecisionSchema = z4.discriminatedUnion("decision", [
354
- z4.object({
355
- decision: z4.literal("entry-condition-met"),
356
- /** Who attests. */
357
- actor: z4.string(),
358
- /** What makes the entry condition hold, in the attester's own words. */
359
- evidence: z4.string()
360
- }).strict(),
361
- z4.object({
362
- decision: z4.literal("override"),
363
- /** Who is opening it anyway. */
364
- actor: z4.string(),
365
- /** Why, in the operator's own words. */
366
- reason: z4.string()
367
- }).strict()
368
- ]);
369
- var PHASE_ENTRY_DECISION_KINDS = [
370
- "entry-condition-met",
371
- "override"
372
- ];
373
- var PHASE_CLOSE_DECISION_KINDS = ["phase-closed", "phase-close-override"];
374
- var phaseDecisionKindSchema = z4.enum([
375
- ...PHASE_ENTRY_DECISION_KINDS,
376
- ...PHASE_CLOSE_DECISION_KINDS
377
- ]);
378
- var openPhaseRequestSchema = z4.object({
379
- phase: z4.string().nullable(),
380
- entry: phaseEntryDecisionSchema
381
- }).strict();
382
- var phaseEntryDecisionRecordSchema = z4.object({
383
- decisionId: z4.string(),
384
- decision: z4.enum(PHASE_ENTRY_DECISION_KINDS),
385
- actor: z4.string(),
386
- /** The evidence (attestation) or the reason (override) — one basis per row. */
387
- justification: z4.string(),
388
- blockingFinding: z4.string().nullable(),
389
- decidedAt: utcIso86012
390
- }).strict();
391
- var openPhaseResponseSchema = z4.object({
392
- engagement: engagementStatusResponseSchema,
393
- /** What is open now. `null` is project scope. */
394
- phase: z4.string().nullable(),
395
- /** What was open before this decision. `null` is project scope. */
396
- previousPhase: z4.string().nullable(),
397
- decision: phaseEntryDecisionRecordSchema
398
- }).strict();
399
302
 
400
303
  // ../events/dist/state.js
401
- import { z as z5 } from "zod";
402
- 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");
403
306
  var STATE_RECORD_FORMAT = "halfcycle-state-record/v1";
404
- var stepIdSchema = z5.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
405
- 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+$/);
406
309
  var anchorPathRegex = /^(?!\/)(?!.*#)(?!method\/)(?!(.*\/)?docs\/method\/).+$/;
407
- var recordKindSchema = z5.enum(["artefact", "step-run", "gate", "intervention"]);
408
- var methodLayerSchema = z5.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
409
- var evidenceKindSchema = z5.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
410
- var declarableEvidenceKindSchema = z5.enum(["anchor", "diff", "repro", "question-answer"]);
411
- 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([
412
315
  "prevented",
413
316
  "corrected-in-spec",
414
317
  "corrected-in-scope",
@@ -418,20 +321,20 @@ var dispositionSchema = z5.enum([
418
321
  "surfaced",
419
322
  "filed-forward"
420
323
  ]);
421
- var mechanismSchema = z5.enum(["guard", "verdict", "served-step", "method-step"]);
422
- var artefactStateSchema = z5.enum(["created", "updated"]);
324
+ var mechanismSchema = z3.enum(["guard", "verdict", "served-step", "method-step"]);
325
+ var artefactStateSchema = z3.enum(["created", "updated"]);
423
326
  var QUALIFIED_OUTCOMES = ["declined", "parked"];
424
- var qualifiedOutcomeSchema = z5.enum(QUALIFIED_OUTCOMES);
327
+ var qualifiedOutcomeSchema = z3.enum(QUALIFIED_OUTCOMES);
425
328
  function isQualifiedOutcome(outcome) {
426
329
  return QUALIFIED_OUTCOMES.includes(outcome);
427
330
  }
428
- var runOutcomeSchema = z5.enum([
331
+ var runOutcomeSchema = z3.enum([
429
332
  "completed",
430
333
  "attempted-failed",
431
334
  ...QUALIFIED_OUTCOMES
432
335
  ]);
433
- var gateKindSchema = z5.enum(["approval", "mechanical"]);
434
- var gateVerdictSchema = z5.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
336
+ var gateKindSchema = z3.enum(["approval", "mechanical"]);
337
+ var gateVerdictSchema = z3.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
435
338
  function outcomeReasonIssue(outcome, reason) {
436
339
  if (isQualifiedOutcome(outcome) && reason === void 0) {
437
340
  return { message: `outcomeReason is required when the outcome is '${outcome}'` };
@@ -443,142 +346,527 @@ function outcomeReasonIssue(outcome, reason) {
443
346
  }
444
347
  return null;
445
348
  }
446
- var anchorEvidenceSchema = z5.object({
447
- kind: z5.literal("anchor"),
448
- path: z5.string().regex(anchorPathRegex),
349
+ var anchorEvidenceSchema = z3.object({
350
+ kind: z3.literal("anchor"),
351
+ path: z3.string().regex(anchorPathRegex),
449
352
  // Optional 1-based line number within the file at `path`. Present when the
450
353
  // writer knows the exact line; omitted — never `0`, never `null` — when it
451
354
  // does not, because a line number is not always obtainable. Absence is a
452
355
  // valid, permanent state here, not a gap meant to be filled in later.
453
- line: z5.number().optional()
356
+ line: z3.number().optional()
454
357
  }).strict();
455
- var diffEvidenceSchema = z5.object({
456
- kind: z5.literal("diff"),
457
- ref: z5.string()
358
+ var diffEvidenceSchema = z3.object({
359
+ kind: z3.literal("diff"),
360
+ ref: z3.string()
458
361
  }).strict();
459
- var reproEvidenceSchema = z5.object({
460
- kind: z5.literal("repro"),
461
- steps: z5.string().max(2e3)
362
+ var reproEvidenceSchema = z3.object({
363
+ kind: z3.literal("repro"),
364
+ steps: z3.string().max(2e3)
462
365
  }).strict();
463
- var questionAnswerEvidenceSchema = z5.object({
464
- kind: z5.literal("question-answer"),
465
- question: z5.string().max(2e3),
466
- before: z5.string().max(2e3),
467
- 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)
468
371
  }).strict();
469
- var noOpEvidenceSchema = z5.object({
470
- kind: z5.literal("no-op"),
471
- inspected: z5.string().max(2e3),
472
- 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)
473
376
  }).strict();
474
- var evidenceSchema = z5.discriminatedUnion("kind", [
377
+ var evidenceSchema = z3.discriminatedUnion("kind", [
475
378
  anchorEvidenceSchema,
476
379
  diffEvidenceSchema,
477
380
  reproEvidenceSchema,
478
381
  questionAnswerEvidenceSchema,
479
382
  noOpEvidenceSchema
480
383
  ]);
481
- var stateRecordEnvelopeSchema = z5.object({
482
- format: z5.literal(STATE_RECORD_FORMAT),
384
+ var stateRecordEnvelopeSchema = z3.object({
385
+ format: z3.literal(STATE_RECORD_FORMAT),
483
386
  recordKind: recordKindSchema,
484
- recordId: z5.string().uuid(),
387
+ recordId: z3.string().uuid(),
485
388
  methodVersion: methodVersionSchema,
486
- engagementId: z5.string(),
389
+ engagementId: z3.string(),
487
390
  stepId: stepIdSchema,
488
391
  // Immutable: names when the thing happened, never when a later disposition
489
392
  // moved.
490
- occurredAt: utcIso86013,
393
+ occurredAt: utcIso8601,
491
394
  // Equal to `occurredAt` on first write; advances on each re-emit OF A FINDING.
492
395
  // Carried on the shared envelope so a "when did this last change" query is
493
396
  // kind-agnostic. Kept rather than generalised to `updatedAt` because
494
397
  // `disposition` is the only field any re-emit may change (§5.3).
495
- dispositionAt: utcIso86013,
398
+ dispositionAt: utcIso8601,
496
399
  evidence: evidenceSchema,
497
400
  // Which phase of the project this record belongs to, if any. Present on
498
401
  // records written during phase-scoped work; absent on records that run once
499
402
  // for the whole project rather than per phase. Absence is meaningful on its
500
403
  // own — it is never replaced with a placeholder value, and it is read as
501
404
  // "satisfies any phase", not as "unknown".
502
- phase: z5.string().optional()
405
+ phase: z3.string().optional()
503
406
  });
504
- var artefactRefSchema = z5.object({
505
- name: z5.string(),
506
- parent: z5.string()
407
+ var artefactRefSchema = z3.object({
408
+ name: z3.string(),
409
+ parent: z3.string()
507
410
  }).strict();
508
- var coverageSchema = z5.object({
509
- asked: z5.number().int().nonnegative(),
510
- answered: z5.number().int().nonnegative()
411
+ var coverageSchema = z3.object({
412
+ asked: z3.number().int().nonnegative(),
413
+ answered: z3.number().int().nonnegative()
511
414
  }).strict().superRefine((coverage, ctx) => {
512
415
  if (coverage.answered > coverage.asked) {
513
416
  ctx.addIssue({
514
- code: z5.ZodIssueCode.custom,
417
+ code: z3.ZodIssueCode.custom,
515
418
  path: ["answered"],
516
419
  message: "answered cannot be more than asked"
517
420
  });
518
421
  }
519
422
  });
520
423
  var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
521
- recordKind: z5.literal("artefact"),
424
+ recordKind: z3.literal("artefact"),
522
425
  artefactRef: artefactRefSchema,
523
426
  // Some layer wrote it, by construction — an empty array is not a legitimate
524
427
  // state, so the schema says so.
525
- writtenByLayers: z5.array(methodLayerSchema).min(1),
428
+ writtenByLayers: z3.array(methodLayerSchema).min(1),
526
429
  state: artefactStateSchema
527
430
  }).strict();
528
431
  var stepRunRecordSchema = stateRecordEnvelopeSchema.extend({
529
- recordKind: z5.literal("step-run"),
432
+ recordKind: z3.literal("step-run"),
530
433
  runOutcome: runOutcomeSchema,
531
- failureReason: z5.string().optional(),
532
- outcomeReason: z5.string().max(2e3).optional()
434
+ failureReason: z3.string().optional(),
435
+ outcomeReason: z3.string().max(2e3).optional()
533
436
  }).strict().superRefine((rec, ctx) => {
534
437
  if (rec.runOutcome === "attempted-failed" && rec.failureReason === void 0) {
535
438
  ctx.addIssue({
536
- code: z5.ZodIssueCode.custom,
439
+ code: z3.ZodIssueCode.custom,
537
440
  path: ["failureReason"],
538
441
  message: "failureReason is required when runOutcome is 'attempted-failed'"
539
442
  });
540
443
  }
541
444
  if (rec.runOutcome !== "attempted-failed" && rec.failureReason !== void 0) {
542
445
  ctx.addIssue({
543
- code: z5.ZodIssueCode.custom,
446
+ code: z3.ZodIssueCode.custom,
544
447
  path: ["failureReason"],
545
448
  message: "failureReason is only present when runOutcome is 'attempted-failed'"
546
449
  });
547
450
  }
548
451
  const issue = outcomeReasonIssue(rec.runOutcome, rec.outcomeReason);
549
452
  if (issue) {
550
- ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
453
+ ctx.addIssue({ code: z3.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
551
454
  }
552
455
  });
553
456
  var gateRecordSchema = stateRecordEnvelopeSchema.extend({
554
- recordKind: z5.literal("gate"),
457
+ recordKind: z3.literal("gate"),
555
458
  gateKind: gateKindSchema,
556
459
  verdict: gateVerdictSchema,
557
- actor: z5.string(),
558
- outcomeReason: z5.string().max(2e3).optional(),
460
+ actor: z3.string(),
461
+ outcomeReason: z3.string().max(2e3).optional(),
559
462
  coverage: coverageSchema.optional()
560
463
  }).strict().superRefine((rec, ctx) => {
561
464
  const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
562
465
  if (issue) {
563
- ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
466
+ ctx.addIssue({ code: z3.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
564
467
  }
565
468
  });
566
469
  var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
567
- recordKind: z5.literal("intervention"),
470
+ recordKind: z3.literal("intervention"),
568
471
  layer: methodLayerSchema,
569
472
  mechanism: mechanismSchema,
570
473
  severity: severitySchema,
571
474
  disposition: dispositionSchema,
572
- summary: z5.string().max(2e3),
475
+ summary: z3.string().max(2e3),
573
476
  coverage: coverageSchema.optional()
574
477
  }).strict();
575
- var stateRecordSchema = z5.discriminatedUnion("recordKind", [
478
+ var stateRecordSchema = z3.discriminatedUnion("recordKind", [
576
479
  artefactRecordSchema,
577
480
  stepRunRecordSchema,
578
481
  gateRecordSchema,
579
482
  interventionRecordSchema
580
483
  ]);
581
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
+
582
870
  // ../events/dist/crew.js
583
871
  import { z as z6 } from "zod";
584
872
  var crewGroupSchema = z6.enum(["builder", "reviewer", "always-on"]);
@@ -775,7 +1063,7 @@ var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
775
1063
 
776
1064
  // ../events/dist/device-auth.js
777
1065
  import { z as z7 } from "zod";
778
- 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");
779
1067
  var DEVICE_AUTH_STATUS = {
780
1068
  /** The poll: minted, nobody has acted yet. Keep polling. 200. */
781
1069
  PENDING: "pending",
@@ -820,7 +1108,7 @@ var deviceAuthStartResponseSchema = z7.object({
820
1108
  deviceCode: z7.string().min(1),
821
1109
  userCode: z7.string().min(1),
822
1110
  verificationUrl: z7.string().min(1),
823
- expiresAt: utcIso86014,
1111
+ expiresAt: utcIso86013,
824
1112
  pollIntervalMs: z7.number().int().positive()
825
1113
  }).strict();
826
1114
  var devicePollPendingSchema = z7.object({
@@ -831,7 +1119,7 @@ var devicePollApprovedSchema = z7.object({
831
1119
  status: z7.literal(DEVICE_AUTH_STATUS.APPROVED),
832
1120
  accountId: z7.string().min(1),
833
1121
  credential: z7.string().min(1),
834
- expiresAt: utcIso86014
1122
+ expiresAt: utcIso86013
835
1123
  }).strict();
836
1124
  var deviceAuthRefusalSchema = z7.object({
837
1125
  status: z7.enum([
@@ -916,67 +1204,6 @@ function safeParseAccountIdentity(payload) {
916
1204
  return accountIdentitySchema.safeParse(payload);
917
1205
  }
918
1206
 
919
- // ../events/dist/phase-identity.js
920
- var PHASE_SEGMENT_PREFIX = "phase-";
921
- function isValidPhaseIdentity(identity) {
922
- if (typeof identity === "number")
923
- return Number.isInteger(identity) && identity >= 0;
924
- if (typeof identity !== "string")
925
- return false;
926
- if (identity === "")
927
- return false;
928
- if (identity.includes("/"))
929
- return false;
930
- if (identity.includes("\\"))
931
- return false;
932
- if (identity.includes("\0"))
933
- return false;
934
- if (identity.startsWith("."))
935
- return false;
936
- return true;
937
- }
938
- var InvalidPhaseIdentityError = class extends Error {
939
- /** The value that was refused, exactly as supplied. */
940
- identity;
941
- constructor(identity, allowedLocation) {
942
- const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
943
- const where = allowedLocation === void 0 ? "" : `
944
- Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
945
- super(`that phase identity cannot name ${subject}.
946
- identity: ${describeIdentity(identity)}` + where);
947
- this.name = "InvalidPhaseIdentityError";
948
- this.identity = identity;
949
- }
950
- };
951
- function assertValidPhaseIdentity(identity, allowedLocation) {
952
- if (!isValidPhaseIdentity(identity)) {
953
- throw new InvalidPhaseIdentityError(identity, allowedLocation);
954
- }
955
- }
956
- function renderPhaseSegment(identity) {
957
- if (!isValidPhaseIdentity(identity)) {
958
- throw new InvalidPhaseIdentityError(identity);
959
- }
960
- return `${PHASE_SEGMENT_PREFIX}${identity}`;
961
- }
962
- function describeIdentity(identity) {
963
- if (identity === "")
964
- return "(empty)";
965
- if (typeof identity === "string")
966
- return identity;
967
- if (identity === null)
968
- return "null";
969
- if (identity === void 0)
970
- return "none supplied";
971
- if (typeof identity === "number" || typeof identity === "boolean")
972
- return String(identity);
973
- if (Array.isArray(identity))
974
- return "(a list)";
975
- if (typeof identity === "object")
976
- return "(an object)";
977
- return `(a ${typeof identity})`;
978
- }
979
-
980
1207
  // ../events/dist/credential-wire.js
981
1208
  import { z as z10 } from "zod";
982
1209
  var engagementCredentialResponseSchema = z10.object({
@@ -988,10 +1215,47 @@ var engagementCredentialResponseSchema = z10.object({
988
1215
  }).strict();
989
1216
  var ciTokenExchangeResponseSchema = z10.object({
990
1217
  token: z10.string(),
991
- expiresAt: utcIso86013,
1218
+ expiresAt: utcIso8601,
992
1219
  engagementId: z10.string()
993
1220
  }).strict();
994
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
+
995
1259
  // dist/engagement-credential.js
996
1260
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
997
1261
  import { platform as platform2 } from "node:os";
@@ -1204,14 +1468,36 @@ halfcycle_env_file() {
1204
1468
  // dist/identity.js
1205
1469
  import { execFileSync } from "node:child_process";
1206
1470
  import { existsSync, readFileSync as readFileSync3 } from "node:fs";
1207
- import { join as join3 } from "node:path";
1471
+ import { isAbsolute, join as join3 } from "node:path";
1208
1472
  import { randomUUID } from "node:crypto";
1209
1473
  var PROJECT_IDENTITY_NOTE = 'projectId is NOT the engagement id \u2014 it identifies this repository/worktree to Halfcycle, not an engagement. The engagement id lives in .halfcycle/bundle.json, in its "engagementId" field.';
1210
- function git(targetRepoRoot, args2) {
1474
+ var REPOSITORY_LOCATING_GIT_VARS = [
1475
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1476
+ "GIT_COMMON_DIR",
1477
+ "GIT_DIR",
1478
+ "GIT_GRAFT_FILE",
1479
+ "GIT_IMPLICIT_WORK_TREE",
1480
+ "GIT_INDEX_FILE",
1481
+ "GIT_NO_REPLACE_OBJECTS",
1482
+ "GIT_OBJECT_DIRECTORY",
1483
+ "GIT_PREFIX",
1484
+ "GIT_REPLACE_REF_BASE",
1485
+ "GIT_SHALLOW_FILE",
1486
+ "GIT_WORK_TREE"
1487
+ ];
1488
+ function gitEnvForNamedRepo() {
1489
+ const env = { ...process.env };
1490
+ for (const name of REPOSITORY_LOCATING_GIT_VARS)
1491
+ delete env[name];
1492
+ return env;
1493
+ }
1494
+ function git(targetRepoRoot, args2, stdin) {
1211
1495
  try {
1212
1496
  const out = execFileSync("git", ["-C", targetRepoRoot, ...args2], {
1213
1497
  encoding: "utf-8",
1214
- stdio: ["ignore", "pipe", "ignore"]
1498
+ env: gitEnvForNamedRepo(),
1499
+ ...stdin === void 0 ? {} : { input: stdin },
1500
+ stdio: [stdin === void 0 ? "ignore" : "pipe", "pipe", "ignore"]
1215
1501
  });
1216
1502
  const trimmed = out.trim();
1217
1503
  return trimmed.length > 0 ? trimmed : null;
@@ -1236,6 +1522,38 @@ function readCommitCount(targetRepoRoot) {
1236
1522
  const count = Number.parseInt(out, 10);
1237
1523
  return Number.isFinite(count) ? count : null;
1238
1524
  }
1525
+ function readIgnoredPaths(targetRepoRoot, paths) {
1526
+ if (paths.length === 0)
1527
+ return null;
1528
+ const out = git(targetRepoRoot, ["check-ignore", "-v", "-z", "--stdin"], paths.map((p) => `${p}\0`).join(""));
1529
+ if (out === null)
1530
+ return null;
1531
+ const fields = out.split("\0");
1532
+ if (fields[fields.length - 1] === "")
1533
+ fields.pop();
1534
+ if (fields.length % 4 !== 0)
1535
+ return null;
1536
+ const ignored = [];
1537
+ for (let i = 0; i < fields.length; i += 4) {
1538
+ const [source, line, pattern, path] = fields.slice(i, i + 4);
1539
+ if (pattern.startsWith("!"))
1540
+ continue;
1541
+ const lineNumber = Number.parseInt(line, 10);
1542
+ if (!Number.isFinite(lineNumber))
1543
+ return null;
1544
+ ignored.push({ path, pattern, source, line: lineNumber });
1545
+ }
1546
+ if (ignored.length === 0)
1547
+ return null;
1548
+ if (ignored.every((entry) => isAbsolute(entry.source)))
1549
+ return ignored;
1550
+ if (git(targetRepoRoot, ["rev-parse", "--show-prefix"]) === null)
1551
+ return ignored;
1552
+ const toplevel = git(targetRepoRoot, ["rev-parse", "--show-toplevel"]);
1553
+ if (toplevel === null)
1554
+ return null;
1555
+ return ignored.map((entry) => isAbsolute(entry.source) ? entry : { ...entry, source: join3(toplevel, entry.source) });
1556
+ }
1239
1557
  function mintOrReadIdentity(targetRepoRoot) {
1240
1558
  const path = join3(targetRepoRoot, ".halfcycle", "project.json");
1241
1559
  if (existsSync(path)) {
@@ -1619,6 +1937,13 @@ function runBootstrapScan(targetRepo) {
1619
1937
  }
1620
1938
 
1621
1939
  // dist/install.js
1940
+ function renderNotShared(finding) {
1941
+ const rule = `rule "${finding.pattern}" in ${finding.source}, line ${finding.line}`;
1942
+ if (finding.others === 0) {
1943
+ return `[halfcycle] Not shared: git ignores ${finding.path} (${rule}), which Halfcycle set up here, so it will not reach anyone else who gets a copy of this project.`;
1944
+ }
1945
+ return `[halfcycle] Not shared: git ignores ${finding.path} (${rule}) and ${finding.others} other file${finding.others === 1 ? "" : "s"} Halfcycle set up here, so they will not reach anyone else who gets a copy of this project.`;
1946
+ }
1622
1947
  var ZONE_B_DIR = ".workbench";
1623
1948
  var __filename = fileURLToPath(import.meta.url);
1624
1949
  var __dirname = dirname2(__filename);
@@ -1670,18 +1995,12 @@ var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1670
1995
  var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1671
1996
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
1672
1997
  var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1673
- var SETTINGS_REL = ".claude/settings.json";
1674
1998
  var GITIGNORE_REL = ".gitignore";
1675
1999
  function commandStubRel(name) {
1676
2000
  return `.claude/commands/${name}.md`;
1677
2001
  }
1678
2002
  var RETIRED_WRITE_SET_FILES = [];
1679
- var RETIRED_HOOK_COMMANDS = [];
1680
2003
  var RETIRED_DENY_PATTERNS = [];
1681
- function installerHookCommands() {
1682
- const settings = JSON.parse(generateSettingsJson());
1683
- return Object.values(settings.hooks ?? {}).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
1684
- }
1685
2004
  function closedWriteSet() {
1686
2005
  const files = /* @__PURE__ */ new Set([
1687
2006
  ...(readPluginManifest().commands ?? []).map((cmd) => commandStubRel(cmd.name)),
@@ -1707,7 +2026,7 @@ function closedWriteSet() {
1707
2026
  merged,
1708
2027
  dirs,
1709
2028
  ownedDirs,
1710
- hookCommands: /* @__PURE__ */ new Set([...installerHookCommands(), ...RETIRED_HOOK_COMMANDS]),
2029
+ hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
1711
2030
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
1712
2031
  gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
1713
2032
  };
@@ -1792,6 +2111,54 @@ function assertManifestInWriteSet(manifest, writeSet) {
1792
2111
  throw new Error(`[bundle install] the install record names paths outside the write set: ${strays.join(", ")}`);
1793
2112
  }
1794
2113
  }
2114
+ function findNotShared(targetRepo, manifest, collidedPaths) {
2115
+ const merged = [
2116
+ SETTINGS_REL,
2117
+ ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
2118
+ ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
2119
+ ];
2120
+ const collided = new Set(collidedPaths);
2121
+ const dirVerdicts = /* @__PURE__ */ new Map();
2122
+ const travelsThroughPlainDirs = (rel) => {
2123
+ for (let dir = posix.dirname(rel); dir !== "."; dir = posix.dirname(dir)) {
2124
+ let plain = dirVerdicts.get(dir);
2125
+ if (plain === void 0) {
2126
+ const abs = join5(targetRepo, dir);
2127
+ plain = !lstatOrNull(abs)?.isSymbolicLink() && lstatOrNull(join5(abs, ".git")) === null;
2128
+ dirVerdicts.set(dir, plain);
2129
+ }
2130
+ if (!plain)
2131
+ return false;
2132
+ }
2133
+ return true;
2134
+ };
2135
+ const paths = [
2136
+ .../* @__PURE__ */ new Set([
2137
+ ...manifest.files.filter((f) => !("perMachine" in f)).map((f) => f.path),
2138
+ ...merged,
2139
+ INSTALL_MANIFEST_REL
2140
+ ])
2141
+ ].filter((rel) => !collided.has(rel)).filter((rel) => lstatOrNull(join5(targetRepo, rel)) !== null).filter(travelsThroughPlainDirs).sort();
2142
+ const ignored = readIgnoredPaths(targetRepo, paths);
2143
+ if (ignored === null)
2144
+ return null;
2145
+ const sorted = [...ignored].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2146
+ const named = sorted.find((entry) => entry.path === SETTINGS_REL) ?? sorted[0];
2147
+ return {
2148
+ path: named.path,
2149
+ pattern: named.pattern,
2150
+ source: named.source,
2151
+ line: named.line,
2152
+ others: sorted.length - 1
2153
+ };
2154
+ }
2155
+ function lstatOrNull(path) {
2156
+ try {
2157
+ return lstatSync(path);
2158
+ } catch {
2159
+ return null;
2160
+ }
2161
+ }
1795
2162
  function isPlainObject(value) {
1796
2163
  return value !== null && typeof value === "object" && !Array.isArray(value);
1797
2164
  }
@@ -1906,9 +2273,15 @@ var DENY_PATTERNS = [
1906
2273
  "Bash(git push --no-verify:*)",
1907
2274
  "Bash(git commit --no-verify:*)"
1908
2275
  ];
1909
- var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
1910
2276
  var USER_PROMPT_REMINDER_SENTENCE = "Where this project stands, and what order to work in, is recorded in the Halfcycle service and nowhere in this repository \u2014 call halfcycle_resolve before you answer or plan, because the files here will look complete and current and still be wrong about position.";
1911
2277
  function generateSettingsJson() {
2278
+ const hooks = {};
2279
+ for (const { event, matcher, timeout, command } of HALFCYCLE_HOOK_WIRING) {
2280
+ (hooks[event] ??= []).push({
2281
+ ...matcher === null ? {} : { matcher },
2282
+ hooks: [{ type: "command", command, timeout }]
2283
+ });
2284
+ }
1912
2285
  const settings = {
1913
2286
  $schema: "https://json.schemastore.org/claude-code-settings.json",
1914
2287
  permissions: {
@@ -1916,88 +2289,10 @@ function generateSettingsJson() {
1916
2289
  // and is the machine owner's grant to make, not ours.
1917
2290
  deny: [...DENY_PATTERNS]
1918
2291
  },
1919
- hooks: {
1920
- SessionStart: [
1921
- {
1922
- hooks: [
1923
- {
1924
- type: "command",
1925
- command: "bash ./.claude/hooks/session-start-marker.sh",
1926
- timeout: 5
1927
- }
1928
- ]
1929
- },
1930
- {
1931
- hooks: [
1932
- {
1933
- type: "command",
1934
- command: "bash ./.claude/hooks/guard-runner.sh refresh-credential",
1935
- timeout: 10
1936
- }
1937
- ]
1938
- }
1939
- ],
1940
- UserPromptSubmit: [
1941
- {
1942
- hooks: [
1943
- {
1944
- type: "command",
1945
- command: "bash ./.claude/hooks/user-prompt-reminder.sh",
1946
- timeout: 5
1947
- }
1948
- ]
1949
- }
1950
- ],
1951
- PostToolUse: [
1952
- {
1953
- matcher: WRITE_CAPABLE_TOOLS.join("|"),
1954
- hooks: [
1955
- {
1956
- type: "command",
1957
- command: "bash ./.claude/hooks/guard-runner.sh hook post-tool-use",
1958
- timeout: 60
1959
- }
1960
- ]
1961
- }
1962
- ],
1963
- Stop: [
1964
- {
1965
- hooks: [
1966
- {
1967
- type: "command",
1968
- command: "bash ./.claude/hooks/guard-runner.sh hook stop",
1969
- timeout: 60
1970
- }
1971
- ]
1972
- }
1973
- ],
1974
- SubagentStop: [
1975
- {
1976
- hooks: [
1977
- {
1978
- type: "command",
1979
- command: "bash ./.claude/hooks/guard-runner.sh hook subagent-stop",
1980
- timeout: 60
1981
- }
1982
- ]
1983
- }
1984
- ],
1985
- SessionEnd: [
1986
- {
1987
- hooks: [
1988
- {
1989
- type: "command",
1990
- command: "bash ./.claude/hooks/session-end-marker.sh",
1991
- timeout: 5
1992
- }
1993
- ]
1994
- }
1995
- ]
1996
- }
2292
+ hooks
1997
2293
  };
1998
2294
  return JSON.stringify(settings, null, 2) + "\n";
1999
2295
  }
2000
- var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
2001
2296
  var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
2002
2297
  function generateGuardRunnerWrapper() {
2003
2298
  return `#!/usr/bin/env bash
@@ -2219,14 +2514,16 @@ var CI_STANZA_KNOWN_HEADER_HASHES = [
2219
2514
  function isTrackedAndClean(targetRepo, relPath) {
2220
2515
  try {
2221
2516
  execFileSync2("git", ["-C", targetRepo, "ls-files", "--error-unmatch", "--", relPath], {
2222
- stdio: "ignore"
2517
+ stdio: "ignore",
2518
+ env: gitEnvForNamedRepo()
2223
2519
  });
2224
2520
  } catch {
2225
2521
  return false;
2226
2522
  }
2227
2523
  try {
2228
2524
  execFileSync2("git", ["-C", targetRepo, "diff", "--quiet", "HEAD", "--", relPath], {
2229
- stdio: "ignore"
2525
+ stdio: "ignore",
2526
+ env: gitEnvForNamedRepo()
2230
2527
  });
2231
2528
  } catch {
2232
2529
  return false;
@@ -2842,6 +3139,7 @@ async function install(options) {
2842
3139
  const installManifest = finishManifest(draft);
2843
3140
  assertManifestInWriteSet(installManifest, writeSet);
2844
3141
  recordOwned(report, join5(targetRepo, INSTALL_MANIFEST_REL), targetRepo, serializeManifest(installManifest), INSTALL_MANIFEST_REL);
3142
+ const notShared = findNotShared(targetRepo, installManifest, report.collidedPaths);
2845
3143
  return {
2846
3144
  version: manifest.version,
2847
3145
  writtenPaths: report.writtenPaths,
@@ -2850,7 +3148,8 @@ async function install(options) {
2850
3148
  collidedPaths: report.collidedPaths,
2851
3149
  replacedPaths: report.replacedPaths,
2852
3150
  bootstrapScanRan: scanResult.ran,
2853
- legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null
3151
+ legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null,
3152
+ notShared
2854
3153
  };
2855
3154
  }
2856
3155
 
@@ -2888,12 +3187,12 @@ function escapeHtml(value) {
2888
3187
  }
2889
3188
  function completionPage(result, engagementId) {
2890
3189
  const approved = result === LOOPBACK_RESULT.APPROVED;
2891
- 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.";
2892
3191
  const onward = approved ? onwardTarget(engagementId) : null;
2893
3192
  const href = onward === null ? "" : escapeHtml(onward.href);
2894
3193
  const refresh = onward === null ? "" : `<meta http-equiv="refresh" content="${COMPLETION_REDIRECT_SECONDS};url=${href}">`;
2895
3194
  const successLink = onward === null ? "" : `<p><a href="${href}" style="color:#19d3ff">${escapeHtml(onward.label)}</a></p>`;
2896
- 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>`;
2897
3196
  }
2898
3197
  function isLoopbackPeer(remoteAddress) {
2899
3198
  if (remoteAddress === void 0)
@@ -4119,424 +4418,472 @@ async function probeMcpOrigin(mcpUrl) {
4119
4418
  return { reached: true, serverName: name };
4120
4419
  }
4121
4420
 
4122
- // dist/build-record/types.js
4123
- var BUILD_RECORD_FORMAT = "halfcycle-build-record/v1";
4124
- function projectSeedGuard(fired, includeExplanation) {
4125
- const seed = {
4126
- patternRef: fired.patternRef,
4127
- severity: fired.severity
4128
- };
4129
- if (includeExplanation && fired.explanation)
4130
- seed.explanation = fired.explanation;
4131
- return seed;
4132
- }
4133
-
4134
- // dist/build-record/sources.js
4135
- import { readFileSync as readFileSync6, existsSync as existsSync4 } from "node:fs";
4136
- import { createHash as createHash3 } from "node:crypto";
4137
- 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";
4138
4424
 
4139
- // dist/build-record/close-record.js
4140
- import { execFileSync as execFileSync3 } from "node:child_process";
4141
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
4142
- import { dirname as dirname3, join as join6 } from "node:path";
4143
- var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
4144
- var BUILD_RECORD_ZONE_B_DIR = join6(ZONE_B_DIR, "build-record");
4145
- var InvalidCloseRecordError = class extends Error {
4146
- constructor(path, detail) {
4147
- super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
4148
- this.name = "InvalidCloseRecordError";
4149
- }
4150
- };
4151
- function closeRecordPath(repoRoot, phase) {
4152
- assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
4153
- return join6(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
4154
- }
4155
- function resolveCloseAtHead(repoRoot) {
4156
- try {
4157
- const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
4158
- encoding: "utf-8",
4159
- stdio: ["ignore", "pipe", "ignore"]
4160
- }).trim();
4161
- const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
4162
- encoding: "utf-8",
4163
- stdio: ["ignore", "pipe", "ignore"]
4164
- }).trim();
4165
- if (closeCommit === "" || closedDate === "")
4166
- return null;
4167
- return { closeCommit, closedDate };
4168
- } catch {
4169
- 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
+ }
4170
4440
  }
4441
+ return out;
4171
4442
  }
4172
- function writeCloseRecord(repoRoot, phase) {
4173
- let path;
4174
- try {
4175
- path = closeRecordPath(repoRoot, phase);
4176
- } catch (err) {
4177
- return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
4178
- }
4179
- const at = resolveCloseAtHead(repoRoot);
4180
- if (at === null) {
4181
- return {
4182
- recorded: false,
4183
- path,
4184
- reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
4185
- };
4186
- }
4187
- const record2 = {
4188
- format: CLOSE_RECORD_FORMAT,
4189
- phase,
4190
- closeCommit: at.closeCommit,
4191
- 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
+ }
4192
4454
  };
4193
- try {
4194
- mkdirSync5(dirname3(path), { recursive: true });
4195
- writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
4196
- } catch (err) {
4197
- return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
4198
- }
4199
- return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
4200
4455
  }
4201
- function parseCloseRecord(path, parsed) {
4202
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4203
- throw new InvalidCloseRecordError(path, "it does not hold a record");
4204
- }
4205
- const record2 = parsed;
4206
- if (record2.format !== CLOSE_RECORD_FORMAT) {
4207
- throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
4208
- }
4209
- const commit = record2.closeCommit;
4210
- const date = record2.closedDate;
4211
- const missing = [];
4212
- if (typeof commit !== "string" || commit.trim() === "")
4213
- missing.push("closeCommit");
4214
- if (typeof date !== "string" || date.trim() === "")
4215
- missing.push("closedDate");
4216
- if (missing.length > 0) {
4217
- 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));
4218
4479
  }
4219
- if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
4220
- 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));
4221
4485
  }
4222
- return {
4223
- format: CLOSE_RECORD_FORMAT,
4224
- phase: record2.phase,
4225
- closeCommit: commit,
4226
- closedDate: date
4227
- };
4486
+ return blocks;
4228
4487
  }
4229
-
4230
- // dist/build-record/write.js
4231
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
4232
- import { basename as basename2, join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
4233
-
4234
- // dist/build-record/template.js
4235
- function severityLabel(seed) {
4236
- 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");
4237
4498
  }
4238
- var NOT_RECORDED = "not recorded";
4239
- function renderCloseLine(record2) {
4240
- const status = `**Status:** ${record2.status.toUpperCase()}`;
4241
- const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
4242
- const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
4243
- 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
+ }
4244
4508
  }
4245
- function renderCloseProvenance(record2) {
4246
- 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");
4247
4517
  }
4248
- function renderDoneWhenRows(record2) {
4249
- const rows = [];
4250
- for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
4251
- const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
4252
- 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.`);
4253
4583
  }
4254
- 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);
4255
4588
  }
4256
- function renderBuildRecordMarkdown(record2) {
4257
- const { phase } = record2;
4258
- const companion = `${renderPhaseSegment(phase.id)}.json`;
4259
- const guardsList = record2.seedGuards.map(severityLabel).join(", ");
4589
+ function renderStated(attested, p) {
4260
4590
  const lines = [
4261
- // Fixed prose (vault-canonical template) + projected title slots.
4262
- `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
4263
- "",
4264
- `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
4265
- "",
4266
- "> 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.",
4267
- "",
4268
- `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
4269
- `**Phase:** ${phase.id} \u2014 ${phase.name}`,
4270
- renderCloseLine(record2),
4271
- `**Companion:** [\`${companion}\`](${companion})`,
4272
- "",
4273
- renderCloseProvenance(record2),
4274
- "",
4275
- "## What this phase proved",
4276
- "",
4277
- `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
4278
- "",
4279
- "## Delivered",
4280
- "",
4281
- `- **Packages:** ${record2.delivered.packages.join(", ")}`,
4282
- `- **Services:** ${record2.delivered.services.join(", ")}`,
4283
- `- **Tools:** ${record2.delivered.tools.join(", ")}`,
4284
- `- **Dogfood:** ${record2.delivered.dogfood}`,
4285
- "",
4286
- guardsList ? `Guards evaluated (results only): ${guardsList}. *(What a guard reported about this phase is recorded; the guard's own definition and its history are not.)*` : "No guards fired during this phase.",
4287
- "",
4288
- `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
4289
- "",
4290
- `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
4291
- "",
4292
- "| Done-when | Result | Evidence |",
4293
- "|---|---|---|",
4294
- renderDoneWhenRows(record2),
4295
- ""
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)}`
4296
4598
  ];
4297
- if (record2.acceptance.inv002ProdBypassProbe) {
4298
- 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
+ });
4299
4607
  }
4300
- 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"}`, "");
4301
4608
  return lines.join("\n");
4302
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
+ }
4303
4662
 
4304
4663
  // dist/build-record/write.js
4305
- var BUILD_RECORD_DIR = join7("docs", "build-records");
4306
- function serialiseRecordJson(record2) {
4307
- return JSON.stringify(record2, null, 2) + "\n";
4308
- }
4664
+ var BUILD_RECORD_DIR = join6("docs", "build-records");
4309
4665
  function assertInsideOutDir(outDir, candidate, identity) {
4310
4666
  const rel = relative2(outDir, resolve2(candidate));
4311
- const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute(rel) && rel === basename2(rel);
4667
+ const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute2(rel) && rel === basename2(rel);
4312
4668
  if (!isPlainFilename) {
4313
4669
  throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
4314
4670
  }
4315
4671
  }
4316
- function writeBuildRecord(repoRoot, record2) {
4317
- const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
4318
- const identity = record2.phase.id;
4319
- if (!isValidPhaseIdentity(identity)) {
4320
- 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;
4321
4680
  }
4322
- const segment = renderPhaseSegment(identity);
4323
- const jsonPath = join7(outDir, `${segment}.json`);
4324
- const mdPath = join7(outDir, `${segment}.md`);
4325
- assertInsideOutDir(outDir, jsonPath, identity);
4326
- assertInsideOutDir(outDir, mdPath, identity);
4327
- mkdirSync6(outDir, { recursive: true });
4328
- writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
4329
- writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
4330
- return { jsonPath, mdPath };
4681
+ closeSync(fd);
4331
4682
  }
4332
-
4333
- // dist/build-record/sources.js
4334
- var MissingSourceError = class extends Error {
4335
- constructor(sourceName, detail) {
4336
- super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
4337
- this.name = "MissingSourceError";
4338
- }
4683
+ function renameOnDisk(from, to) {
4684
+ renameSync(from, to);
4685
+ }
4686
+ var NODE_RECORD_PAIR_IO = {
4687
+ createExclusive: createExclusiveOnDisk,
4688
+ rename: renameOnDisk
4339
4689
  };
4340
- function projectSeedGuards(runs) {
4341
- const byRef = /* @__PURE__ */ new Map();
4342
- for (const run of runs) {
4343
- if (!run.result)
4344
- continue;
4345
- for (const fired of run.result.guardsFired) {
4346
- const seed = projectSeedGuard(
4347
- fired,
4348
- /* includeExplanation */
4349
- false
4350
- );
4351
- const existing = byRef.get(seed.patternRef);
4352
- if (!existing) {
4353
- byRef.set(seed.patternRef, seed);
4354
- } else if (existing.severity !== seed.severity) {
4355
- byRef.set(seed.patternRef, pickHigherSeverity(existing, seed));
4356
- }
4357
- }
4358
- }
4359
- return [...byRef.values()].sort((a, b) => a.patternRef.localeCompare(b.patternRef));
4690
+ function serialiseServedRecord(rawRecord) {
4691
+ return JSON.stringify(rawRecord, null, 2) + "\n";
4360
4692
  }
4361
- var SEVERITY_ORDINAL = { info: 0, warn: 1, block: 2 };
4362
- function pickHigherSeverity(a, b) {
4363
- 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";
4364
4695
  }
4365
- function normalizeLegacyLine(parsed) {
4366
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4367
- return parsed;
4696
+ function readIfPresent(path) {
4697
+ try {
4698
+ return readFileSync6(path, "utf-8");
4699
+ } catch {
4700
+ return null;
4368
4701
  }
4369
- const record2 = parsed;
4370
- if (record2["outcome"] !== void 0)
4371
- return parsed;
4372
- return {
4373
- ...record2,
4374
- runId: record2["runId"] ?? syntheticRunId(record2),
4375
- outcome: "evaluated"
4376
- };
4377
4702
  }
4378
- function syntheticRunId(record2) {
4379
- const key = [
4380
- record2["engagementId"],
4381
- record2["occurredAt"],
4382
- record2["runType"],
4383
- record2["phase"]
4384
- ].join("|");
4385
- const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
4386
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
4387
- }
4388
- function readGuardEvalLog(logDir, phaseId) {
4389
- const logPath = join8(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
4390
- if (!existsSync4(logPath)) {
4391
- throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
4392
- }
4393
- const raw = readFileSync6(logPath, "utf-8");
4394
- const runs = [];
4395
- for (const line of raw.split("\n")) {
4396
- const trimmed = line.trim();
4397
- if (trimmed === "")
4398
- continue;
4399
- const parsed = JSON.parse(trimmed);
4400
- 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;
4401
4709
  }
4402
- 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));
4403
4726
  }
4404
- function readOrchestrationState(phasesDir, phaseId) {
4405
- const statePath = join8(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
4406
- if (!existsSync4(statePath)) {
4407
- 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;
4408
4754
  }
4409
- const parsed = JSON.parse(readFileSync6(statePath, "utf-8"));
4410
- if (!parsed.tasks || typeof parsed.tasks !== "object") {
4411
- 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 };
4412
4758
  }
4413
- const tasks = {};
4414
- for (const [id, t] of Object.entries(parsed.tasks)) {
4415
- if (!t || typeof t.status !== "string") {
4416
- throw new MissingSourceError("orchestration state", `task ${id} has no status`);
4417
- }
4418
- tasks[id] = { status: t.status };
4759
+ if (existingJson === jsonText) {
4760
+ replaceOne(io, mdTmp, mdPath, mdText);
4761
+ return { kind: "up-to-date", jsonPath, mdPath };
4419
4762
  }
4420
- return { tasks };
4421
- }
4422
- function readPhaseClose(repoRoot, phaseId) {
4423
- const path = closeRecordPath(repoRoot, phaseId);
4424
- if (!existsSync4(path))
4425
- return { closeCommit: null, closedDate: null };
4426
- let parsed;
4763
+ if (!isOwnEarlierRecord(existingJson, record2)) {
4764
+ return { kind: "in-the-way", path: jsonPath };
4765
+ }
4766
+ replaceOne(io, jsonTmp, jsonPath, jsonText);
4427
4767
  try {
4428
- parsed = JSON.parse(readFileSync6(path, "utf-8"));
4768
+ replaceOne(io, mdTmp, mdPath, mdText);
4429
4769
  } catch (err) {
4430
- throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
4431
- }
4432
- const record2 = parseCloseRecord(path, parsed);
4433
- if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
4434
- 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;
4435
4776
  }
4436
- return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
4777
+ return { kind: "replaced", jsonPath, mdPath };
4437
4778
  }
4438
- function validateNarrated(narrated) {
4439
- const req = (v, name) => {
4440
- if (v === void 0 || v === null || v === "") {
4441
- throw new MissingSourceError("narrated artefact", `${name} is missing`);
4442
- }
4443
- };
4444
- req(narrated.engagement, "engagement");
4445
- req(narrated.engagementType, "engagementType");
4446
- req(narrated.phase?.name, "phase.name");
4447
- assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
4448
- req(narrated.status, "status");
4449
- req(narrated.thesis, "thesis");
4450
- req(narrated.delivered, "delivered");
4451
- req(narrated.acceptance, "acceptance");
4452
- req(narrated.acceptance?.verdict, "acceptance.verdict");
4453
- req(narrated.gatesEarnedKeep, "gatesEarnedKeep");
4454
- req(narrated.instruments, "instruments");
4455
- if (!Array.isArray(narrated.deviations)) {
4456
- throw new MissingSourceError("narrated artefact", "deviations must be an array");
4457
- }
4458
- return narrated;
4459
- }
4460
- function loadSources(opts) {
4461
- const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
4462
- const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
4463
- const close = readPhaseClose(opts.repoRoot, opts.phaseId);
4464
- const narrated = validateNarrated(opts.narrated);
4465
- if (!Array.isArray(opts.touchedInvariants)) {
4466
- 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;
4467
4787
  }
4468
- return {
4469
- orchestration,
4470
- narrated,
4471
- close,
4472
- guardEvalRuns,
4473
- touchedInvariants: opts.touchedInvariants
4474
- };
4475
4788
  }
4476
4789
 
4477
- // dist/build-record/assemble.js
4478
- var LANDED_STATES = /* @__PURE__ */ new Set(["merged", "done"]);
4479
- var CANCELLED_STATE = "cancelled";
4480
- function deriveTasks(orchestration) {
4481
- const ids = Object.keys(orchestration.tasks);
4482
- const cancelled = ids.filter((id) => orchestration.tasks[id].status === CANCELLED_STATE).sort((a, b) => a.localeCompare(b));
4483
- const landed = ids.filter((id) => LANDED_STATES.has(orchestration.tasks[id].status)).length;
4484
- return { planned: ids.length, landed, cancelled };
4485
- }
4486
- function deriveThesisHeld(acceptance) {
4487
- if (acceptance.verdict !== "clean")
4488
- return false;
4489
- for (const dw of Object.values(acceptance.doneWhen)) {
4490
- if (dw.result === "dropped")
4491
- continue;
4492
- if (dw.result !== "pass")
4493
- return false;
4494
- }
4495
- 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`;
4496
4802
  }
4497
- function deriveInvariantsExercised(touched) {
4498
- 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);
4499
4808
  }
4500
- function assembleBuildRecord(sources) {
4501
- 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
+ }
4502
4874
  return {
4503
- format: BUILD_RECORD_FORMAT,
4504
- engagement: narrated.engagement,
4505
- engagementType: narrated.engagementType,
4506
- phase: { id: narrated.phase.id, name: narrated.phase.name },
4507
- status: narrated.status,
4508
- closedDate: close.closedDate,
4509
- closeCommit: close.closeCommit,
4510
- thesis: narrated.thesis,
4511
- thesisHeld: deriveThesisHeld(narrated.acceptance),
4512
- delivered: narrated.delivered,
4513
- seedGuards: projectSeedGuards(sources.guardEvalRuns),
4514
- acceptance: narrated.acceptance,
4515
- gatesEarnedKeep: narrated.gatesEarnedKeep,
4516
- instruments: narrated.instruments,
4517
- invariantsExercised: deriveInvariantsExercised(sources.touchedInvariants),
4518
- deviations: narrated.deviations,
4519
- tasks: deriveTasks(orchestration)
4875
+ kind: "unavailable",
4876
+ why: `the service answered ${res.status}${message === null ? "." : `: ${message}`}`
4520
4877
  };
4521
4878
  }
4522
-
4523
- // dist/build-record/index.js
4524
- function assemblePhaseBuildRecord(opts) {
4525
- const sources = loadSources({
4526
- repoRoot: opts.repoRoot,
4527
- phasesDir: opts.phasesDir,
4528
- guardEvalLogDir: opts.guardEvalLogDir,
4529
- phaseId: opts.phaseId,
4530
- narrated: opts.narrated,
4531
- touchedInvariants: opts.touchedInvariants
4532
- });
4533
- const record2 = assembleBuildRecord(sources);
4534
- 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}`;
4535
4882
  }
4536
4883
 
4537
4884
  // dist/open-phase.js
4538
4885
  import { readFileSync as readFileSync7 } from "node:fs";
4539
- import { join as join9 } from "node:path";
4886
+ import { join as join7 } from "node:path";
4540
4887
  var OpenPhaseRefused = class extends Error {
4541
4888
  status;
4542
4889
  finding;
@@ -4557,7 +4904,7 @@ function readRepoCredential(repoRoot, env = process.env, home) {
4557
4904
  if (pinned !== null)
4558
4905
  assertEngagementId(pinned);
4559
4906
  const fromStore = pinned === null ? null : readEngagementEnv(pinned, home);
4560
- 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);
4561
4908
  const values = {};
4562
4909
  for (const key of OPEN_PHASE_ENV_KEYS) {
4563
4910
  const value = (env[key] ?? fromStore?.[key] ?? "").trim();
@@ -4574,7 +4921,7 @@ function readRepoCredential(repoRoot, env = process.env, home) {
4574
4921
  }
4575
4922
  function readPinnedEngagementId(repoRoot) {
4576
4923
  try {
4577
- 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"));
4578
4925
  const id = pin[PIN_ENGAGEMENT_ID_FIELD];
4579
4926
  return typeof id === "string" && id !== "" ? id : null;
4580
4927
  } catch {
@@ -4638,6 +4985,35 @@ async function openPhase(credential, phase, entry, home) {
4638
4985
  }
4639
4986
 
4640
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
+ }
4641
5017
  var ClosePhaseRefused = class extends Error {
4642
5018
  status;
4643
5019
  finding;
@@ -4650,14 +5026,28 @@ var ClosePhaseRefused = class extends Error {
4650
5026
  this.name = "ClosePhaseRefused";
4651
5027
  }
4652
5028
  };
4653
- async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4654
- const parsed = acceptanceOutcomeSchema.safeParse(outcome);
4655
- if (!parsed.success) {
4656
- 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";
4657
5033
  }
4658
- const parsedClose = phaseCloseDecisionSchema.safeParse(close);
4659
- if (!parsedClose.success) {
4660
- 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("; ")}.`);
4661
5051
  }
4662
5052
  const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/accept`;
4663
5053
  let res;
@@ -4668,7 +5058,7 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4668
5058
  "content-type": "application/json",
4669
5059
  authorization: `Bearer ${credential.token}`
4670
5060
  },
4671
- body: JSON.stringify({ phase, acceptanceOutcome: outcome, close })
5061
+ body: JSON.stringify(body)
4672
5062
  });
4673
5063
  } catch (err) {
4674
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.`);
@@ -4680,17 +5070,24 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4680
5070
  const code = typeof body2?.error === "string" ? body2.error : void 0;
4681
5071
  throw new ClosePhaseRefused(res.status, message, finding, code);
4682
5072
  }
4683
- const body = await res.json().catch(() => null);
4684
- 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") {
4685
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.`);
4686
5076
  }
4687
- const closeRecord = repoRoot === void 0 ? {
4688
- recorded: false,
4689
- path: null,
4690
- reason: "no repository was named for this close, so there was nowhere to read a commit from"
4691
- } : writeCloseRecord(repoRoot, phase);
4692
5077
  const stampFailure = writePhaseStamp(credential.engagementId, null, home);
4693
- 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)";
4694
5091
  }
4695
5092
 
4696
5093
  // dist/cli-contract.js
@@ -4807,9 +5204,9 @@ var PLACEHOLDERS = {
4807
5204
  };
4808
5205
 
4809
5206
  // dist/uninstall.js
4810
- import { lstatSync, 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";
4811
5208
  import { homedir as homedir2 } from "node:os";
4812
- 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";
4813
5210
  var REMOVE_CREDENTIAL_FLAG = "--remove-credential";
4814
5211
  function parseUninstallArgs(args2) {
4815
5212
  let removeCredential = false;
@@ -4826,7 +5223,7 @@ function parseUninstallArgs(args2) {
4826
5223
  }
4827
5224
  function lexists(path) {
4828
5225
  try {
4829
- lstatSync(path);
5226
+ lstatSync2(path);
4830
5227
  return true;
4831
5228
  } catch {
4832
5229
  return false;
@@ -4835,9 +5232,9 @@ function lexists(path) {
4835
5232
  function symlinkOnPath(root, rel) {
4836
5233
  let current = root;
4837
5234
  for (const segment of rel.split("/")) {
4838
- current = join10(current, segment);
5235
+ current = join8(current, segment);
4839
5236
  try {
4840
- if (lstatSync(current).isSymbolicLink())
5237
+ if (lstatSync2(current).isSymbolicLink())
4841
5238
  return true;
4842
5239
  } catch {
4843
5240
  return false;
@@ -4856,7 +5253,7 @@ var Refusal = class extends Error {
4856
5253
  function readMergedJson(targetRepo, rel) {
4857
5254
  if (symlinkOnPath(targetRepo, rel))
4858
5255
  return { state: "symlink" };
4859
- const path = join10(targetRepo, rel);
5256
+ const path = join8(targetRepo, rel);
4860
5257
  if (!lexists(path))
4861
5258
  return { state: "absent" };
4862
5259
  let value;
@@ -4872,8 +5269,8 @@ function readMergedJson(targetRepo, rel) {
4872
5269
  }
4873
5270
  function validate(options) {
4874
5271
  const { targetRepo, removeCredential, home } = options;
4875
- const manifestPath = join10(targetRepo, INSTALL_MANIFEST_REL);
4876
- const pinPath = join10(targetRepo, BUNDLE_PIN_REL);
5272
+ const manifestPath = join8(targetRepo, INSTALL_MANIFEST_REL);
5273
+ const pinPath = join8(targetRepo, BUNDLE_PIN_REL);
4877
5274
  if (symlinkOnPath(targetRepo, INSTALL_MANIFEST_REL) || symlinkOnPath(targetRepo, BUNDLE_PIN_REL)) {
4878
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.`);
4879
5276
  }
@@ -4917,8 +5314,8 @@ function validate(options) {
4917
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.`);
4918
5315
  }
4919
5316
  if (engagementId !== void 0) {
4920
- const expectedParent = join10(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
4921
- 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) {
4922
5319
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} does not name a folder under ~/.halfcycle/engagements. Nothing was removed.`);
4923
5320
  }
4924
5321
  }
@@ -5069,7 +5466,7 @@ function trimGitignore(targetRepo, text, rec, writeSet) {
5069
5466
  }
5070
5467
  function coveredPathExists(targetRepo, line) {
5071
5468
  const path = line.trim().replace(/^\//, "").replace(/\/$/, "");
5072
- return path !== "" && lexists(join10(targetRepo, path));
5469
+ return path !== "" && lexists(join8(targetRepo, path));
5073
5470
  }
5074
5471
  function uninstall(options) {
5075
5472
  const result = {
@@ -5127,7 +5524,7 @@ function uninstall(options) {
5127
5524
  removeRecordedFile(targetRepo, entry, writeSet, result, name);
5128
5525
  }
5129
5526
  for (const path of manifest.leftAlone) {
5130
- if (lexists(join10(targetRepo, path)))
5527
+ if (lexists(join8(targetRepo, path)))
5131
5528
  name(result.leftAlone, path, path);
5132
5529
  }
5133
5530
  switch (removeLegacyCiStanza(targetRepo)) {
@@ -5159,10 +5556,10 @@ function uninstall(options) {
5159
5556
  const pinEntry = manifest.files.find((f) => f.path === BUNDLE_PIN_REL);
5160
5557
  if (pinEntry !== void 0)
5161
5558
  removeRecordedFile(targetRepo, pinEntry, writeSet, result, name);
5162
- else if (lexists(join10(targetRepo, BUNDLE_PIN_REL)))
5559
+ else if (lexists(join8(targetRepo, BUNDLE_PIN_REL)))
5163
5560
  name(result.leftAlone, BUNDLE_PIN_REL, BUNDLE_PIN_REL);
5164
5561
  try {
5165
- rmSync2(join10(targetRepo, INSTALL_MANIFEST_REL), { force: true });
5562
+ rmSync3(join8(targetRepo, INSTALL_MANIFEST_REL), { force: true });
5166
5563
  named.add(INSTALL_MANIFEST_REL);
5167
5564
  } catch {
5168
5565
  name(result.failed, INSTALL_MANIFEST_REL, INSTALL_MANIFEST_REL);
@@ -5192,14 +5589,14 @@ function applyMergedJson(targetRepo, rel, file, lists, name, edit) {
5192
5589
  const { changed, created, adopted, keptNote } = edit(value);
5193
5590
  if (keptNote !== void 0)
5194
5591
  name(lists.kept, keptNote, rel);
5195
- const path = join10(targetRepo, rel);
5592
+ const path = join8(targetRepo, rel);
5196
5593
  const empty = Object.keys(value).length === 0;
5197
5594
  try {
5198
5595
  if (empty && created && !adopted) {
5199
- rmSync2(path);
5596
+ rmSync3(path);
5200
5597
  name(lists.removed, rel, rel);
5201
5598
  } else if (changed) {
5202
- writeFileSync7(path, serializeJson(value), "utf-8");
5599
+ writeFileSync5(path, serializeJson(value), "utf-8");
5203
5600
  name(lists.restored, rel, rel);
5204
5601
  }
5205
5602
  if (adopted && lexists(path)) {
@@ -5219,10 +5616,10 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5219
5616
  name(lists.leftAlone, `${path} (a symbolic link, or inside one \u2014 not followed)`, path);
5220
5617
  return;
5221
5618
  }
5222
- const abs = join10(targetRepo, path);
5619
+ const abs = join8(targetRepo, path);
5223
5620
  let isFile;
5224
5621
  try {
5225
- isFile = lstatSync(abs).isFile();
5622
+ isFile = lstatSync2(abs).isFile();
5226
5623
  } catch {
5227
5624
  return;
5228
5625
  }
@@ -5244,7 +5641,7 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5244
5641
  }
5245
5642
  }
5246
5643
  try {
5247
- rmSync2(abs);
5644
+ rmSync3(abs);
5248
5645
  name(lists.removed, path, path);
5249
5646
  } catch {
5250
5647
  name(lists.failed, path, path);
@@ -5253,9 +5650,9 @@ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5253
5650
  function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
5254
5651
  if (!writeSet.dirs.has(dir) || symlinkOnPath(targetRepo, dir))
5255
5652
  return;
5256
- const abs = join10(targetRepo, dir);
5653
+ const abs = join8(targetRepo, dir);
5257
5654
  try {
5258
- if (!lstatSync(abs).isDirectory() || readdirSync(abs).length > 0)
5655
+ if (!lstatSync2(abs).isDirectory() || readdirSync(abs).length > 0)
5259
5656
  return;
5260
5657
  } catch {
5261
5658
  return;
@@ -5273,7 +5670,7 @@ function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5273
5670
  name(lists.leftAlone, `${GITIGNORE_REL} (a symbolic link \u2014 not followed)`, GITIGNORE_REL);
5274
5671
  return;
5275
5672
  }
5276
- const path = join10(targetRepo, GITIGNORE_REL);
5673
+ const path = join8(targetRepo, GITIGNORE_REL);
5277
5674
  let before;
5278
5675
  try {
5279
5676
  before = readFileSync8(path, "utf-8");
@@ -5291,10 +5688,10 @@ function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5291
5688
  return;
5292
5689
  try {
5293
5690
  if (text === "" && rec.created) {
5294
- rmSync2(path);
5691
+ rmSync3(path);
5295
5692
  name(lists.removed, GITIGNORE_REL, GITIGNORE_REL);
5296
5693
  } else {
5297
- writeFileSync7(path, text, "utf-8");
5694
+ writeFileSync5(path, text, "utf-8");
5298
5695
  name(lists.restored, GITIGNORE_REL, GITIGNORE_REL);
5299
5696
  }
5300
5697
  } catch {
@@ -5318,10 +5715,10 @@ function handleCredential(options, checked) {
5318
5715
  if (!lexists(dir))
5319
5716
  return { kind: "not-held", dir };
5320
5717
  try {
5321
- if (lstatSync(dir).isSymbolicLink())
5718
+ if (lstatSync2(dir).isSymbolicLink())
5322
5719
  return { kind: "failed", dir, reason: "it is a symbolic link" };
5323
- rmSync2(engagementEnvPath(engagementId, home), { force: true });
5324
- rmSync2(notThisAccountMarkerPath(engagementId, home), { force: true });
5720
+ rmSync3(engagementEnvPath(engagementId, home), { force: true });
5721
+ rmSync3(notThisAccountMarkerPath(engagementId, home), { force: true });
5325
5722
  if (readdirSync(dir).length === 0)
5326
5723
  rmdirSync(dir);
5327
5724
  } catch (err) {
@@ -5334,16 +5731,16 @@ function filesUnder(root, rel) {
5334
5731
  const walk = (dirRel) => {
5335
5732
  let names;
5336
5733
  try {
5337
- if (!lstatSync(join10(root, dirRel)).isDirectory())
5734
+ if (!lstatSync2(join8(root, dirRel)).isDirectory())
5338
5735
  return;
5339
- names = readdirSync(join10(root, dirRel));
5736
+ names = readdirSync(join8(root, dirRel));
5340
5737
  } catch {
5341
5738
  return;
5342
5739
  }
5343
5740
  for (const entry of names.sort()) {
5344
5741
  const childRel = `${dirRel}/${entry}`;
5345
5742
  try {
5346
- if (lstatSync(join10(root, childRel)).isDirectory())
5743
+ if (lstatSync2(join8(root, childRel)).isDirectory())
5347
5744
  walk(childRel);
5348
5745
  else
5349
5746
  out.push(childRel);
@@ -5357,7 +5754,7 @@ function filesUnder(root, rel) {
5357
5754
  }
5358
5755
  function shownPath(path, home) {
5359
5756
  const base = home ?? homedir2();
5360
- const rel = relative3(base, path);
5757
+ const rel = relative4(base, path);
5361
5758
  const under = rel !== "" && !rel.startsWith("..") && !rel.startsWith(sep);
5362
5759
  return `${under ? `~/${rel.split(sep).join("/")}` : path}/`;
5363
5760
  }
@@ -5417,7 +5814,7 @@ function renderUninstallReport(result, home) {
5417
5814
  // dist/banner-facts.js
5418
5815
  import { execFileSync as execFileSync4 } from "node:child_process";
5419
5816
  import { readFileSync as readFileSync9 } from "node:fs";
5420
- 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";
5421
5818
  var BRAND_URL = "halfcycle.ai";
5422
5819
  var TAGLINE = [
5423
5820
  "Fewer cycles.",
@@ -5430,7 +5827,7 @@ var TAGLINE_NARROW = [
5430
5827
  ];
5431
5828
  function bundleVersion() {
5432
5829
  try {
5433
- 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"));
5434
5831
  return typeof pkg.version === "string" ? pkg.version : void 0;
5435
5832
  } catch {
5436
5833
  return void 0;
@@ -5580,8 +5977,11 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
5580
5977
  --yes / -y: use this machine's saved sign-in without being asked
5581
5978
  halfcycle check-drift <target-repo>
5582
5979
  halfcycle build-record <phase> [--repo <root>]
5980
+ fetch a closed phase's Build Record again and write it into docs/build-records/
5583
5981
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
5584
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/
5585
5985
  halfcycle uninstall [${REMOVE_CREDENTIAL_FLAG}]
5586
5986
  take out what the installer added to this project, keeping anything you changed
5587
5987
  ${REMOVE_CREDENTIAL_FLAG}: also remove this machine's credential for the project, which every
@@ -5594,7 +5994,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
5594
5994
  `;
5595
5995
  function isHalfcycleMonorepo(dir) {
5596
5996
  try {
5597
- const pkg = JSON.parse(readFileSync10(join12(dir, "package.json"), "utf-8"));
5997
+ const pkg = JSON.parse(readFileSync10(join10(dir, "package.json"), "utf-8"));
5598
5998
  return pkg.name === "halfcycle-monorepo";
5599
5999
  } catch {
5600
6000
  return false;
@@ -5816,6 +6216,9 @@ ${USAGE}`);
5816
6216
  process.stdout.write(`[halfcycle] Found: ${foundLine}
5817
6217
  `);
5818
6218
  process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, ${credential ? "guards armed on every edit" : "not checked \u2014 this copy is not this account\u2019s project"}
6219
+ `);
6220
+ if (result.notShared)
6221
+ process.stdout.write(`${renderNotShared(result.notShared)}
5819
6222
  `);
5820
6223
  if (credential) {
5821
6224
  process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
@@ -5898,31 +6301,24 @@ ${USAGE}`);
5898
6301
  return;
5899
6302
  }
5900
6303
  if (cmd === "build-record") {
5901
- const phaseArg = rest[0];
5902
- 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();
5903
6306
  if (phaseArg === void 0) {
5904
6307
  process.stderr.write("halfcycle: usage: halfcycle build-record <phase> [--repo <root>]\n");
5905
6308
  process.exit(1);
5906
6309
  return;
5907
6310
  }
5908
- const phaseId = phaseArg;
5909
6311
  try {
5910
- assertValidPhaseIdentity(phaseId, BUILD_RECORD_DIR);
5911
- const inputPath = join12(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
5912
- const input = JSON.parse(readFileSync10(inputPath, "utf-8"));
5913
- const result = assemblePhaseBuildRecord({
5914
- repoRoot,
5915
- phasesDir: join12(repoRoot, "docs", "phases"),
5916
- guardEvalLogDir: input.guardEvalLogDir ?? join12(repoRoot, ".workbench", "guard-eval-log"),
5917
- phaseId,
5918
- narrated: input.narrated,
5919
- touchedInvariants: input.touchedInvariants
5920
- });
5921
- process.stdout.write(`[halfcycle] Build Record written:
5922
- ${result.jsonPath}
5923
- ${result.mdPath}
5924
- `);
5925
- 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);
5926
6322
  } catch (err) {
5927
6323
  process.stderr.write(`halfcycle build-record failed: ${err instanceof Error ? err.message : String(err)}
5928
6324
  `);
@@ -6055,26 +6451,36 @@ halfcycle open-phase: a phase name has to work as a single folder name on this m
6055
6451
  process.exit(2);
6056
6452
  return;
6057
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
+ }
6058
6461
  try {
6059
6462
  const credential = readRepoCredential(repoRoot);
6060
6463
  const closed = await closePhase(credential, phaseArg, {
6061
6464
  verdict,
6062
6465
  findings: findingsDetail.length,
6063
6466
  findingsDetail
6064
- }, closeDecision, void 0, repoRoot);
6467
+ }, closeDecision, void 0, repoRoot, stated);
6065
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}.
6066
6469
  `);
6067
6470
  if (closeDecision.decision === "override") {
6068
6471
  process.stdout.write(`[halfcycle] This close was an OVERRIDE: ${closeDecision.reason}
6069
6472
  `);
6070
6473
  }
6071
- if (closed.closeRecord.recorded) {
6072
- 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.
6073
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));
6074
6482
  } else {
6075
- process.stderr.write(`halfcycle close-phase: the phase closed, but the commit it closed at was not recorded on this machine: ${closed.closeRecord.reason}
6076
- halfcycle close-phase: this phase's Build Record will say the close commit and the close date were not recorded.
6077
- `);
6483
+ process.stderr.write(notWrittenLines("close-phase", obtained, phaseArg));
6078
6484
  }
6079
6485
  process.stdout.write(`[halfcycle] Open the next phase with: halfcycle open-phase <phase> --actor "you" --evidence "\u2026"
6080
6486
  `);
@@ -6082,21 +6488,28 @@ halfcycle close-phase: this phase's Build Record will say the close commit and t
6082
6488
  process.stderr.write(`halfcycle close-phase: the phase closed, but this machine's record of it could not be cleared: ${closed.stampFailure.reason}
6083
6489
  halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard evaluations here are still filed under the phase you just closed.
6084
6490
  `);
6085
- process.exit(1);
6086
- return;
6087
6491
  }
6088
- process.exit(0);
6492
+ process.exit(recordWritten && !closed.stampFailure ? 0 : 1);
6089
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
+ }
6090
6500
  if (err instanceof ClosePhaseRefused) {
6091
6501
  process.stderr.write(`halfcycle close-phase: refused (${err.status}). ${err.message}
6092
6502
  `);
6503
+ if (err.status === 400) {
6504
+ process.stderr.write("halfcycle close-phase: Nothing was closed.\n");
6505
+ }
6093
6506
  if (err.finding) {
6094
6507
  process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${err.finding}
6095
6508
  `);
6096
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.
6097
6510
  `);
6098
6511
  } else if (err.code === "PhaseAlreadyClosed") {
6099
- 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");
6100
6513
  }
6101
6514
  process.exit(1);
6102
6515
  return;
@@ -6113,6 +6526,56 @@ Usage:
6113
6526
  ${USAGE}`);
6114
6527
  process.exit(1);
6115
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
+ }
6116
6579
  function flagValue(argv, name) {
6117
6580
  const i = argv.indexOf(name);
6118
6581
  if (i < 0 || i + 1 >= argv.length)