halfcycle 0.3.22 → 0.3.24

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
@@ -30,6 +30,107 @@ var matcherSchema = z.discriminatedUnion("kind", [
30
30
  llmMatcherSchema
31
31
  ]);
32
32
 
33
+ // ../core/dist/credential-format.js
34
+ var HALFCYCLE_DIR_NAME = ".halfcycle";
35
+ var ENGAGEMENTS_DIR_NAME = "engagements";
36
+ var ENGAGEMENT_ENV_FILENAME = "env";
37
+ var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
38
+ var ACCOUNT_STORE_FILENAME = "account.json";
39
+ var ENGAGEMENT_ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
40
+ function shq(value) {
41
+ return `'${value.replace(/'/g, `'\\''`)}'`;
42
+ }
43
+ function unquote(value) {
44
+ const v = value.trim();
45
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
46
+ return v.slice(1, -1).split(`'\\''`).join(`'`);
47
+ }
48
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
49
+ return v.slice(1, -1);
50
+ return v;
51
+ }
52
+ function parseEnvText(raw) {
53
+ const out = {};
54
+ for (const line of raw.split("\n")) {
55
+ const eq = line.indexOf("=");
56
+ if (eq === -1)
57
+ continue;
58
+ const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
59
+ if (key === "" || key.startsWith("#"))
60
+ continue;
61
+ out[key] = unquote(line.slice(eq + 1));
62
+ }
63
+ return out;
64
+ }
65
+ function reconcileEnvText(existing, values, keys) {
66
+ const desired = new Map(keys.map((k) => {
67
+ const value = values[k];
68
+ return [k, value === void 0 ? "" : value];
69
+ }));
70
+ const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
71
+ const written = keys.filter((k) => desired.get(k) !== null);
72
+ if (existing === null) {
73
+ if (written.length === 0)
74
+ return "";
75
+ return `${ENGAGEMENT_ENV_HEADER}
76
+ ` + written.map(line).join("\n") + "\n";
77
+ }
78
+ const seen = /* @__PURE__ */ new Set();
79
+ const out = [];
80
+ for (const existingLine of existing.split("\n")) {
81
+ const eq = existingLine.indexOf("=");
82
+ if (eq === -1) {
83
+ out.push(existingLine);
84
+ continue;
85
+ }
86
+ const lhs = existingLine.slice(0, eq);
87
+ const exported = /^\s*export\s+/.exec(lhs);
88
+ const prefix = exported === null ? "" : exported[0];
89
+ const key = lhs.slice(prefix.length).trim();
90
+ if (!desired.has(key)) {
91
+ out.push(existingLine);
92
+ continue;
93
+ }
94
+ seen.add(key);
95
+ if (desired.get(key) === null)
96
+ continue;
97
+ out.push(`${prefix}${line(key)}`);
98
+ }
99
+ const missing = written.filter((k) => !seen.has(k));
100
+ if (missing.length > 0) {
101
+ const header = existing.includes(ENGAGEMENT_ENV_HEADER) ? "" : `${ENGAGEMENT_ENV_HEADER}
102
+ `;
103
+ const block = header + missing.map(line).join("\n");
104
+ const trailingBlank = out.length > 0 && out[out.length - 1] === "";
105
+ if (trailingBlank)
106
+ out.splice(out.length - 1, 0, block);
107
+ else
108
+ out.push(`
109
+ ${block}`);
110
+ }
111
+ let result = out.join("\n");
112
+ if (existing.endsWith("\n") && !result.endsWith("\n"))
113
+ result += "\n";
114
+ return result;
115
+ }
116
+ function normaliseOrigin(serviceUrl) {
117
+ return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
118
+ }
119
+ function parseAccountStoreText(raw) {
120
+ try {
121
+ const parsed = JSON.parse(raw);
122
+ if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
123
+ return { version: 1, accounts: {} };
124
+ }
125
+ return {
126
+ version: 1,
127
+ accounts: parsed.accounts ?? {}
128
+ };
129
+ } catch {
130
+ return { version: 1, accounts: {} };
131
+ }
132
+ }
133
+
33
134
  // ../events/dist/result.js
34
135
  var firedGuardSchema = z2.object({
35
136
  guardId: z2.string(),
@@ -90,7 +191,7 @@ var wireErrorSchema = z2.object({
90
191
 
91
192
  // ../events/dist/telemetry.js
92
193
  import { z as z3 } from "zod";
93
- 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 (INV-015)");
194
+ 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");
94
195
  var guardEvalOutcomeSchema = z3.enum([
95
196
  "evaluated",
96
197
  "infra-error",
@@ -146,7 +247,7 @@ var guardEvalRunSchema = z3.object({
146
247
 
147
248
  // ../events/dist/engagement-lifecycle.js
148
249
  import { z as z4 } from "zod";
149
- 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 (INV-015)");
250
+ 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");
150
251
  var engagementTypeSchema = z4.enum(["client"]);
151
252
  var lifecycleStatusSchema = z4.enum(["active", "delivering", "closed"]);
152
253
  var createEngagementRequestSchema = z4.object({
@@ -275,57 +376,371 @@ var openPhaseResponseSchema = z4.object({
275
376
  decision: phaseEntryDecisionRecordSchema
276
377
  }).strict();
277
378
 
278
- // ../events/dist/crew.js
379
+ // ../events/dist/state.js
279
380
  import { z as z5 } from "zod";
280
- var crewGroupSchema = z5.enum(["builder", "reviewer", "always-on"]);
281
- var crewTierSchema = z5.enum(["deep", "standard", "fast"]);
381
+ 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");
382
+ var STATE_RECORD_FORMAT = "halfcycle-state-record/v1";
383
+ var stepIdSchema = z5.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
384
+ var methodVersionSchema = z5.string().regex(/^\d+\.\d+\.\d+$/);
385
+ var anchorPathRegex = /^(?!\/)(?!.*#)(?!method\/)(?!(.*\/)?docs\/method\/).+$/;
386
+ var recordKindSchema = z5.enum(["artefact", "step-run", "gate", "intervention"]);
387
+ var methodLayerSchema = z5.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
388
+ var evidenceKindSchema = z5.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
389
+ var declarableEvidenceKindSchema = z5.enum(["anchor", "diff", "repro", "question-answer"]);
390
+ var dispositionSchema = z5.enum([
391
+ "prevented",
392
+ "corrected-in-spec",
393
+ "corrected-in-scope",
394
+ "blocked-in-code",
395
+ "flagged",
396
+ "auto-fixed",
397
+ "surfaced",
398
+ "filed-forward"
399
+ ]);
400
+ var mechanismSchema = z5.enum(["guard", "verdict", "served-step", "method-step"]);
401
+ var artefactStateSchema = z5.enum(["created", "updated"]);
402
+ var QUALIFIED_OUTCOMES = ["declined", "parked"];
403
+ var qualifiedOutcomeSchema = z5.enum(QUALIFIED_OUTCOMES);
404
+ function isQualifiedOutcome(outcome) {
405
+ return QUALIFIED_OUTCOMES.includes(outcome);
406
+ }
407
+ var runOutcomeSchema = z5.enum([
408
+ "completed",
409
+ "attempted-failed",
410
+ ...QUALIFIED_OUTCOMES
411
+ ]);
412
+ var gateKindSchema = z5.enum(["approval", "mechanical"]);
413
+ var gateVerdictSchema = z5.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
414
+ function outcomeReasonIssue(outcome, reason) {
415
+ if (isQualifiedOutcome(outcome) && reason === void 0) {
416
+ return { message: `outcomeReason is required when the outcome is '${outcome}'` };
417
+ }
418
+ if (!isQualifiedOutcome(outcome) && reason !== void 0) {
419
+ return {
420
+ message: `outcomeReason is only present on a qualified outcome (${QUALIFIED_OUTCOMES.join(" | ")})`
421
+ };
422
+ }
423
+ return null;
424
+ }
425
+ var anchorEvidenceSchema = z5.object({
426
+ kind: z5.literal("anchor"),
427
+ path: z5.string().regex(anchorPathRegex),
428
+ // Optional 1-based line number within the file at `path`. Present when the
429
+ // writer knows the exact line; omitted — never `0`, never `null` — when it
430
+ // does not, because a line number is not always obtainable. Absence is a
431
+ // valid, permanent state here, not a gap meant to be filled in later.
432
+ line: z5.number().optional()
433
+ }).strict();
434
+ var diffEvidenceSchema = z5.object({
435
+ kind: z5.literal("diff"),
436
+ ref: z5.string()
437
+ }).strict();
438
+ var reproEvidenceSchema = z5.object({
439
+ kind: z5.literal("repro"),
440
+ steps: z5.string().max(2e3)
441
+ }).strict();
442
+ var questionAnswerEvidenceSchema = z5.object({
443
+ kind: z5.literal("question-answer"),
444
+ question: z5.string().max(2e3),
445
+ before: z5.string().max(2e3),
446
+ after: z5.string().max(2e3)
447
+ }).strict();
448
+ var noOpEvidenceSchema = z5.object({
449
+ kind: z5.literal("no-op"),
450
+ inspected: z5.string().max(2e3),
451
+ unchangedBecause: z5.string().max(2e3)
452
+ }).strict();
453
+ var evidenceSchema = z5.discriminatedUnion("kind", [
454
+ anchorEvidenceSchema,
455
+ diffEvidenceSchema,
456
+ reproEvidenceSchema,
457
+ questionAnswerEvidenceSchema,
458
+ noOpEvidenceSchema
459
+ ]);
460
+ var stateRecordEnvelopeSchema = z5.object({
461
+ format: z5.literal(STATE_RECORD_FORMAT),
462
+ recordKind: recordKindSchema,
463
+ recordId: z5.string().uuid(),
464
+ methodVersion: methodVersionSchema,
465
+ engagementId: z5.string(),
466
+ stepId: stepIdSchema,
467
+ // Immutable: names when the thing happened, never when a later disposition
468
+ // moved.
469
+ occurredAt: utcIso86013,
470
+ // Equal to `occurredAt` on first write; advances on each re-emit OF A FINDING.
471
+ // Carried on the shared envelope so a "when did this last change" query is
472
+ // kind-agnostic. Kept rather than generalised to `updatedAt` because
473
+ // `disposition` is the only field any re-emit may change (§5.3).
474
+ dispositionAt: utcIso86013,
475
+ evidence: evidenceSchema,
476
+ // Which phase of the project this record belongs to, if any. Present on
477
+ // records written during phase-scoped work; absent on records that run once
478
+ // for the whole project rather than per phase. Absence is meaningful on its
479
+ // own — it is never replaced with a placeholder value, and it is read as
480
+ // "satisfies any phase", not as "unknown".
481
+ phase: z5.string().optional()
482
+ });
483
+ var artefactRefSchema = z5.object({
484
+ name: z5.string(),
485
+ parent: z5.string()
486
+ }).strict();
487
+ var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
488
+ recordKind: z5.literal("artefact"),
489
+ artefactRef: artefactRefSchema,
490
+ // Some layer wrote it, by construction — an empty array is not a legitimate
491
+ // state, so the schema says so.
492
+ writtenByLayers: z5.array(methodLayerSchema).min(1),
493
+ state: artefactStateSchema
494
+ }).strict();
495
+ var stepRunRecordSchema = stateRecordEnvelopeSchema.extend({
496
+ recordKind: z5.literal("step-run"),
497
+ runOutcome: runOutcomeSchema,
498
+ failureReason: z5.string().optional(),
499
+ outcomeReason: z5.string().max(2e3).optional()
500
+ }).strict().superRefine((rec, ctx) => {
501
+ if (rec.runOutcome === "attempted-failed" && rec.failureReason === void 0) {
502
+ ctx.addIssue({
503
+ code: z5.ZodIssueCode.custom,
504
+ path: ["failureReason"],
505
+ message: "failureReason is required when runOutcome is 'attempted-failed'"
506
+ });
507
+ }
508
+ if (rec.runOutcome !== "attempted-failed" && rec.failureReason !== void 0) {
509
+ ctx.addIssue({
510
+ code: z5.ZodIssueCode.custom,
511
+ path: ["failureReason"],
512
+ message: "failureReason is only present when runOutcome is 'attempted-failed'"
513
+ });
514
+ }
515
+ const issue = outcomeReasonIssue(rec.runOutcome, rec.outcomeReason);
516
+ if (issue) {
517
+ ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
518
+ }
519
+ });
520
+ var gateRecordSchema = stateRecordEnvelopeSchema.extend({
521
+ recordKind: z5.literal("gate"),
522
+ gateKind: gateKindSchema,
523
+ verdict: gateVerdictSchema,
524
+ actor: z5.string(),
525
+ outcomeReason: z5.string().max(2e3).optional()
526
+ }).strict().superRefine((rec, ctx) => {
527
+ const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
528
+ if (issue) {
529
+ ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
530
+ }
531
+ });
532
+ var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
533
+ recordKind: z5.literal("intervention"),
534
+ layer: methodLayerSchema,
535
+ mechanism: mechanismSchema,
536
+ severity: severitySchema,
537
+ disposition: dispositionSchema,
538
+ summary: z5.string().max(2e3)
539
+ }).strict();
540
+ var stateRecordSchema = z5.discriminatedUnion("recordKind", [
541
+ artefactRecordSchema,
542
+ stepRunRecordSchema,
543
+ gateRecordSchema,
544
+ interventionRecordSchema
545
+ ]);
546
+
547
+ // ../events/dist/crew.js
548
+ import { z as z6 } from "zod";
549
+ var crewGroupSchema = z6.enum(["builder", "reviewer", "always-on"]);
550
+ var crewTierSchema = z6.enum(["deep", "standard", "fast"]);
282
551
  var CREW_DISCIPLINE_MAX = 64;
283
- var crewMemberSchema = z5.object({
552
+ var crewFaceHeadSchema = z6.enum(["sq", "sqr", "rnd", "dome", "hex"]);
553
+ var crewFaceAntennaSchema = z6.enum(["none", "stalk", "twin", "dish"]);
554
+ var crewFaceEyesSchema = z6.enum(["dots", "bars", "visor", "cyclops"]);
555
+ var crewFaceMouthSchema = z6.enum(["line", "grid", "dots", "wave"]);
556
+ var crewFaceSchema = z6.object({
557
+ // The outline of the head. Members who do the same kind of work share one, so a
558
+ // team is recognisable before any name is read.
559
+ head: crewFaceHeadSchema,
560
+ // What sits on top of the head, if anything.
561
+ antenna: crewFaceAntennaSchema,
562
+ // The eyes.
563
+ eyes: crewFaceEyesSchema,
564
+ // The mouth.
565
+ mouth: crewFaceMouthSchema,
566
+ // Whether this member is drawn with ears.
567
+ ears: z6.boolean()
568
+ }).strict();
569
+ var crewMemberSchema = z6.object({
284
570
  // The agent's name. The same twenty-one on every project, so a name is a
285
571
  // vocabulary a returning client already knows.
286
- callsign: z5.string().min(1).max(32),
572
+ callsign: z6.string().min(1).max(32),
287
573
  // Which of the three headings this member reads under.
288
574
  group: crewGroupSchema,
289
575
  // The kind of work this member takes. A task names a discipline, and only
290
576
  // agents holding it can be dispatched to it.
291
- discipline: z5.string().min(1).max(CREW_DISCIPLINE_MAX),
577
+ discipline: z6.string().min(1).max(CREW_DISCIPLINE_MAX),
292
578
  // How much thinking this member brings. There is no field naming the supplier
293
579
  // behind it, on this shape or on any other.
294
- tier: crewTierSchema
580
+ tier: crewTierSchema,
581
+ // How this member's face is drawn. The same face on every project, so a
582
+ // returning client recognises a member before reading the name.
583
+ face: crewFaceSchema
295
584
  }).strict();
296
585
  var CREW_ROSTER = [
297
586
  // ── builders: code ──────────────────────────────────────────────────────────
298
- { callsign: "ADA", group: "builder", discipline: "implementation", tier: "deep" },
299
- { callsign: "OTTO", group: "builder", discipline: "implementation", tier: "deep" },
300
- { callsign: "MILO", group: "builder", discipline: "implementation", tier: "standard" },
301
- { callsign: "NOVA", group: "builder", discipline: "implementation", tier: "standard" },
302
- { callsign: "KAI", group: "builder", discipline: "implementation", tier: "standard" },
303
- { callsign: "HUGO", group: "builder", discipline: "infrastructure", tier: "standard" },
304
- { callsign: "WREN", group: "builder", discipline: "refactor", tier: "fast" },
305
- { callsign: "PIP", group: "builder", discipline: "refactor", tier: "fast" },
587
+ {
588
+ callsign: "ADA",
589
+ group: "builder",
590
+ discipline: "implementation",
591
+ tier: "deep",
592
+ face: { head: "sq", antenna: "stalk", eyes: "dots", mouth: "line", ears: false }
593
+ },
594
+ {
595
+ callsign: "OTTO",
596
+ group: "builder",
597
+ discipline: "implementation",
598
+ tier: "deep",
599
+ face: { head: "sq", antenna: "twin", eyes: "bars", mouth: "grid", ears: true }
600
+ },
601
+ {
602
+ callsign: "MILO",
603
+ group: "builder",
604
+ discipline: "implementation",
605
+ tier: "standard",
606
+ face: { head: "sqr", antenna: "stalk", eyes: "dots", mouth: "dots", ears: false }
607
+ },
608
+ {
609
+ callsign: "NOVA",
610
+ group: "builder",
611
+ discipline: "implementation",
612
+ tier: "standard",
613
+ face: { head: "sq", antenna: "dish", eyes: "cyclops", mouth: "line", ears: false }
614
+ },
615
+ {
616
+ callsign: "KAI",
617
+ group: "builder",
618
+ discipline: "implementation",
619
+ tier: "standard",
620
+ face: { head: "sqr", antenna: "none", eyes: "bars", mouth: "line", ears: true }
621
+ },
622
+ {
623
+ callsign: "HUGO",
624
+ group: "builder",
625
+ discipline: "infrastructure",
626
+ tier: "standard",
627
+ face: { head: "sq", antenna: "twin", eyes: "dots", mouth: "grid", ears: true }
628
+ },
629
+ {
630
+ callsign: "WREN",
631
+ group: "builder",
632
+ discipline: "refactor",
633
+ tier: "fast",
634
+ face: { head: "sqr", antenna: "stalk", eyes: "bars", mouth: "wave", ears: false }
635
+ },
636
+ {
637
+ callsign: "PIP",
638
+ group: "builder",
639
+ discipline: "refactor",
640
+ tier: "fast",
641
+ face: { head: "sqr", antenna: "none", eyes: "dots", mouth: "dots", ears: false }
642
+ },
306
643
  // ── builders: specs and docs ────────────────────────────────────────────────
307
- { callsign: "IRIS", group: "builder", discipline: "feature spec", tier: "deep" },
308
- { callsign: "JUNO", group: "builder", discipline: "feature spec", tier: "standard" },
309
- { callsign: "LEX", group: "builder", discipline: "context & docs", tier: "fast" },
644
+ {
645
+ callsign: "IRIS",
646
+ group: "builder",
647
+ discipline: "feature spec",
648
+ tier: "deep",
649
+ face: { head: "dome", antenna: "stalk", eyes: "visor", mouth: "line", ears: false }
650
+ },
651
+ {
652
+ callsign: "JUNO",
653
+ group: "builder",
654
+ discipline: "feature spec",
655
+ tier: "standard",
656
+ face: { head: "dome", antenna: "none", eyes: "dots", mouth: "wave", ears: false }
657
+ },
658
+ {
659
+ callsign: "LEX",
660
+ group: "builder",
661
+ discipline: "context & docs",
662
+ tier: "fast",
663
+ face: { head: "dome", antenna: "twin", eyes: "bars", mouth: "dots", ears: false }
664
+ },
310
665
  // ── reviewers ───────────────────────────────────────────────────────────────
311
- { callsign: "VERA", group: "reviewer", discipline: "spec consistency", tier: "deep" },
312
- { callsign: "ODIN", group: "reviewer", discipline: "cross-spec audit", tier: "deep" },
313
- { callsign: "CASS", group: "reviewer", discipline: "code review", tier: "deep" },
314
- { callsign: "THEO", group: "reviewer", discipline: "code review", tier: "standard" },
315
- { callsign: "ECHO", group: "reviewer", discipline: "test quality", tier: "standard" },
316
- { callsign: "MAVIS", group: "reviewer", discipline: "suites & coverage", tier: "standard" },
317
- { callsign: "ZARA", group: "reviewer", discipline: "smoke & walk aid", tier: "standard" },
318
- { callsign: "NORA", group: "reviewer", discipline: "evidence & provenance", tier: "fast" },
666
+ {
667
+ callsign: "VERA",
668
+ group: "reviewer",
669
+ discipline: "spec consistency",
670
+ tier: "deep",
671
+ face: { head: "rnd", antenna: "stalk", eyes: "visor", mouth: "line", ears: false }
672
+ },
673
+ {
674
+ callsign: "ODIN",
675
+ group: "reviewer",
676
+ discipline: "cross-spec audit",
677
+ tier: "deep",
678
+ face: { head: "rnd", antenna: "dish", eyes: "cyclops", mouth: "line", ears: false }
679
+ },
680
+ {
681
+ callsign: "CASS",
682
+ group: "reviewer",
683
+ discipline: "code review",
684
+ tier: "deep",
685
+ face: { head: "rnd", antenna: "none", eyes: "visor", mouth: "grid", ears: true }
686
+ },
687
+ {
688
+ callsign: "THEO",
689
+ group: "reviewer",
690
+ discipline: "code review",
691
+ tier: "standard",
692
+ face: { head: "rnd", antenna: "twin", eyes: "bars", mouth: "line", ears: false }
693
+ },
694
+ {
695
+ callsign: "ECHO",
696
+ group: "reviewer",
697
+ discipline: "test quality",
698
+ tier: "standard",
699
+ face: { head: "rnd", antenna: "stalk", eyes: "dots", mouth: "wave", ears: false }
700
+ },
701
+ {
702
+ callsign: "MAVIS",
703
+ group: "reviewer",
704
+ discipline: "suites & coverage",
705
+ tier: "standard",
706
+ face: { head: "rnd", antenna: "none", eyes: "visor", mouth: "dots", ears: false }
707
+ },
708
+ {
709
+ callsign: "ZARA",
710
+ group: "reviewer",
711
+ discipline: "smoke & walk aid",
712
+ tier: "standard",
713
+ face: { head: "rnd", antenna: "dish", eyes: "bars", mouth: "wave", ears: false }
714
+ },
715
+ {
716
+ callsign: "NORA",
717
+ group: "reviewer",
718
+ discipline: "evidence & provenance",
719
+ tier: "fast",
720
+ face: { head: "rnd", antenna: "twin", eyes: "dots", mouth: "line", ears: false }
721
+ },
319
722
  // ── always on: continuous, takes no task ────────────────────────────────────
320
- { callsign: "ARGUS", group: "always-on", discipline: "guard \xB7 every diff", tier: "standard" },
321
- { callsign: "ATLAS", group: "always-on", discipline: "coordinator", tier: "deep" }
723
+ {
724
+ callsign: "ARGUS",
725
+ group: "always-on",
726
+ discipline: "guard \xB7 every diff",
727
+ tier: "standard",
728
+ face: { head: "hex", antenna: "dish", eyes: "visor", mouth: "grid", ears: true }
729
+ },
730
+ {
731
+ callsign: "ATLAS",
732
+ group: "always-on",
733
+ discipline: "coordinator",
734
+ tier: "deep",
735
+ face: { head: "hex", antenna: "twin", eyes: "cyclops", mouth: "line", ears: true }
736
+ }
322
737
  ];
323
738
  var CREW_ROSTER_SIZE = CREW_ROSTER.length;
324
739
  var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
325
740
 
326
741
  // ../events/dist/device-auth.js
327
- import { z as z6 } from "zod";
328
- var utcIso86013 = z6.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
742
+ import { z as z7 } from "zod";
743
+ 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");
329
744
  var DEVICE_AUTH_STATUS = {
330
745
  /** The poll: minted, nobody has acted yet. Keep polling. 200. */
331
746
  PENDING: "pending",
@@ -351,7 +766,7 @@ var DEVICE_AUTH_STATUS = {
351
766
  ALREADY_DECIDED: "already-decided",
352
767
  /**
353
768
  * Confirm: an approval named a signed-in identity whose account has not accepted
354
- * the terms of service and privacy policy currently in force (T-18, #588). 400.
769
+ * the terms of service and privacy policy currently in force. 400.
355
770
  *
356
771
  * NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
357
772
  * says nothing about the code itself — the code is still `pending` after this
@@ -366,25 +781,25 @@ var DEVICE_POLL_REFUSALS = [
366
781
  DEVICE_AUTH_STATUS.EXPIRED,
367
782
  DEVICE_AUTH_STATUS.UNKNOWN
368
783
  ];
369
- var deviceAuthStartResponseSchema = z6.object({
370
- deviceCode: z6.string().min(1),
371
- userCode: z6.string().min(1),
372
- verificationUrl: z6.string().min(1),
373
- expiresAt: utcIso86013,
374
- pollIntervalMs: z6.number().int().positive()
784
+ var deviceAuthStartResponseSchema = z7.object({
785
+ deviceCode: z7.string().min(1),
786
+ userCode: z7.string().min(1),
787
+ verificationUrl: z7.string().min(1),
788
+ expiresAt: utcIso86014,
789
+ pollIntervalMs: z7.number().int().positive()
375
790
  }).strict();
376
- var devicePollPendingSchema = z6.object({
377
- status: z6.literal(DEVICE_AUTH_STATUS.PENDING),
378
- pollIntervalMs: z6.number().int().positive()
791
+ var devicePollPendingSchema = z7.object({
792
+ status: z7.literal(DEVICE_AUTH_STATUS.PENDING),
793
+ pollIntervalMs: z7.number().int().positive()
379
794
  }).strict();
380
- var devicePollApprovedSchema = z6.object({
381
- status: z6.literal(DEVICE_AUTH_STATUS.APPROVED),
382
- accountId: z6.string().min(1),
383
- credential: z6.string().min(1),
384
- expiresAt: utcIso86013
795
+ var devicePollApprovedSchema = z7.object({
796
+ status: z7.literal(DEVICE_AUTH_STATUS.APPROVED),
797
+ accountId: z7.string().min(1),
798
+ credential: z7.string().min(1),
799
+ expiresAt: utcIso86014
385
800
  }).strict();
386
- var deviceAuthRefusalSchema = z6.object({
387
- status: z6.enum([
801
+ var deviceAuthRefusalSchema = z7.object({
802
+ status: z7.enum([
388
803
  DEVICE_AUTH_STATUS.DECLINED,
389
804
  DEVICE_AUTH_STATUS.EXPIRED,
390
805
  DEVICE_AUTH_STATUS.UNKNOWN,
@@ -395,25 +810,25 @@ var deviceAuthRefusalSchema = z6.object({
395
810
  DEVICE_AUTH_STATUS.ALREADY_DECIDED,
396
811
  DEVICE_AUTH_STATUS.TERMS_REQUIRED
397
812
  ]),
398
- message: z6.string().min(1)
813
+ message: z7.string().min(1)
399
814
  }).strict();
400
- var devicePollResponseSchema = z6.union([
815
+ var devicePollResponseSchema = z7.union([
401
816
  devicePollPendingSchema,
402
817
  devicePollApprovedSchema,
403
818
  deviceAuthRefusalSchema
404
819
  ]);
405
- var deviceConfirmRequestSchema = z6.object({
406
- userCode: z6.string().min(1),
407
- decision: z6.enum(["approve", "decline"]),
408
- externalAuthId: z6.string().min(1).optional(),
409
- email: z6.string().optional(),
410
- acceptTerms: z6.boolean().optional()
820
+ var deviceConfirmRequestSchema = z7.object({
821
+ userCode: z7.string().min(1),
822
+ decision: z7.enum(["approve", "decline"]),
823
+ externalAuthId: z7.string().min(1).optional(),
824
+ email: z7.string().optional(),
825
+ acceptTerms: z7.boolean().optional()
411
826
  }).strict();
412
- var deviceConfirmRecordedSchema = z6.object({
413
- status: z6.literal(DEVICE_AUTH_STATUS.RECORDED),
414
- accountId: z6.string().min(1).optional()
827
+ var deviceConfirmRecordedSchema = z7.object({
828
+ status: z7.literal(DEVICE_AUTH_STATUS.RECORDED),
829
+ accountId: z7.string().min(1).optional()
415
830
  }).strict();
416
- var deviceConfirmResponseSchema = z6.union([
831
+ var deviceConfirmResponseSchema = z7.union([
417
832
  deviceConfirmRecordedSchema,
418
833
  deviceAuthRefusalSchema
419
834
  ]);
@@ -442,25 +857,25 @@ function loopbackVerificationUrl(verificationUrl, target) {
442
857
  }
443
858
 
444
859
  // ../events/dist/join-refusal.js
445
- import { z as z7 } from "zod";
860
+ import { z as z8 } from "zod";
446
861
  var NOT_THIS_ACCOUNT_REASON = "not-this-account";
447
862
  var JOIN_REFUSAL_STATUS = 403;
448
- var notThisAccountRefusalSchema = z7.object({
449
- statusCode: z7.literal(JOIN_REFUSAL_STATUS),
450
- error: z7.string(),
451
- message: z7.string(),
452
- reason: z7.literal(NOT_THIS_ACCOUNT_REASON)
863
+ var notThisAccountRefusalSchema = z8.object({
864
+ statusCode: z8.literal(JOIN_REFUSAL_STATUS),
865
+ error: z8.string(),
866
+ message: z8.string(),
867
+ reason: z8.literal(NOT_THIS_ACCOUNT_REASON)
453
868
  }).strict();
454
- var joinRefusalSchema = z7.union([notThisAccountRefusalSchema, wireErrorSchema]);
869
+ var joinRefusalSchema = z8.union([notThisAccountRefusalSchema, wireErrorSchema]);
455
870
  function isNotThisAccountRefusal(body) {
456
871
  return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
457
872
  }
458
873
 
459
874
  // ../events/dist/account-identity.js
460
- import { z as z8 } from "zod";
461
- var accountIdentitySchema = z8.object({
462
- accountId: z8.string().min(1),
463
- email: z8.string().min(1).optional()
875
+ import { z as z9 } from "zod";
876
+ var accountIdentitySchema = z9.object({
877
+ accountId: z9.string().min(1),
878
+ email: z9.string().min(1).optional()
464
879
  }).strict();
465
880
  function safeParseAccountIdentity(payload) {
466
881
  return accountIdentitySchema.safeParse(payload);
@@ -527,6 +942,21 @@ function describeIdentity(identity) {
527
942
  return `(a ${typeof identity})`;
528
943
  }
529
944
 
945
+ // ../events/dist/credential-wire.js
946
+ import { z as z10 } from "zod";
947
+ var engagementCredentialResponseSchema = z10.object({
948
+ engagementId: z10.string(),
949
+ sessionToken: z10.string(),
950
+ mcpUrl: z10.string(),
951
+ guardUrl: z10.string(),
952
+ controlTelemetryUrl: z10.string()
953
+ }).strict();
954
+ var ciTokenExchangeResponseSchema = z10.object({
955
+ token: z10.string(),
956
+ expiresAt: utcIso86013,
957
+ engagementId: z10.string()
958
+ }).strict();
959
+
530
960
  // dist/engagement-credential.js
531
961
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
532
962
  import { platform as platform2 } from "node:os";
@@ -537,27 +967,14 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
537
967
  import { homedir, platform } from "node:os";
538
968
  import { dirname, join } from "node:path";
539
969
  function halfcycleHome(home) {
540
- return join(home ?? homedir(), ".halfcycle");
970
+ return join(home ?? homedir(), HALFCYCLE_DIR_NAME);
541
971
  }
542
972
  function accountStorePath(home) {
543
- return join(halfcycleHome(home), "account.json");
544
- }
545
- function normaliseOrigin(serviceUrl) {
546
- return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
973
+ return join(halfcycleHome(home), ACCOUNT_STORE_FILENAME);
547
974
  }
548
975
  function readStore(home) {
549
- let raw;
550
976
  try {
551
- raw = readFileSync(accountStorePath(home), "utf-8");
552
- } catch {
553
- return { version: 1, accounts: {} };
554
- }
555
- try {
556
- const parsed = JSON.parse(raw);
557
- if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
558
- return { version: 1, accounts: {} };
559
- }
560
- return { version: 1, accounts: parsed.accounts ?? {} };
977
+ return parseAccountStoreText(readFileSync(accountStorePath(home), "utf-8"));
561
978
  } catch {
562
979
  return { version: 1, accounts: {} };
563
980
  }
@@ -603,8 +1020,9 @@ function forgetStoredCredential(serviceUrl, home) {
603
1020
  }
604
1021
 
605
1022
  // dist/engagement-credential.js
606
- var ENGAGEMENTS_DIR = "engagements";
607
- var ENV_FILENAME = "env";
1023
+ var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
1024
+ var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
1025
+ var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
608
1026
  var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
609
1027
  function engagementStateDir(engagementId, home) {
610
1028
  return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
@@ -612,7 +1030,6 @@ function engagementStateDir(engagementId, home) {
612
1030
  function engagementEnvPath(engagementId, home) {
613
1031
  return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
614
1032
  }
615
- var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
616
1033
  function notThisAccountMarkerPath(engagementId, home) {
617
1034
  return join2(engagementStateDir(engagementId, home), NOT_THIS_ACCOUNT_MARKER_FILENAME);
618
1035
  }
@@ -639,82 +1056,8 @@ var ENGAGEMENT_ENV_KEYS = [
639
1056
  "CONTROL_TELEMETRY_URL"
640
1057
  ];
641
1058
  var PHASE_STAMP_ENV_KEY = "HALFCYCLE_PHASE";
642
- var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
643
- function shq(value) {
644
- return `'${value.replace(/'/g, `'\\''`)}'`;
645
- }
646
- function unquote(value) {
647
- const v = value.trim();
648
- if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
649
- return v.slice(1, -1).split(`'\\''`).join(`'`);
650
- }
651
- if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
652
- return v.slice(1, -1);
653
- return v;
654
- }
655
- function parseEnvText(raw) {
656
- const out = {};
657
- for (const line of raw.split("\n")) {
658
- const eq = line.indexOf("=");
659
- if (eq === -1)
660
- continue;
661
- const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
662
- if (key === "" || key.startsWith("#"))
663
- continue;
664
- out[key] = unquote(line.slice(eq + 1));
665
- }
666
- return out;
667
- }
668
- function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
669
- const desired = new Map(keys.map((k) => {
670
- const value = values[k];
671
- return [k, value === void 0 ? "" : value];
672
- }));
673
- const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
674
- const written = keys.filter((k) => desired.get(k) !== null);
675
- if (existing === null) {
676
- if (written.length === 0)
677
- return "";
678
- return `${ENV_HEADER}
679
- ` + written.map(line).join("\n") + "\n";
680
- }
681
- const seen = /* @__PURE__ */ new Set();
682
- const out = [];
683
- for (const existingLine of existing.split("\n")) {
684
- const eq = existingLine.indexOf("=");
685
- if (eq === -1) {
686
- out.push(existingLine);
687
- continue;
688
- }
689
- const lhs = existingLine.slice(0, eq);
690
- const exported = /^\s*export\s+/.exec(lhs);
691
- const prefix = exported === null ? "" : exported[0];
692
- const key = lhs.slice(prefix.length).trim();
693
- if (!desired.has(key)) {
694
- out.push(existingLine);
695
- continue;
696
- }
697
- seen.add(key);
698
- if (desired.get(key) === null)
699
- continue;
700
- out.push(`${prefix}${line(key)}`);
701
- }
702
- const missing = written.filter((k) => !seen.has(k));
703
- if (missing.length > 0) {
704
- const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
705
- `;
706
- const block = header + missing.map(line).join("\n");
707
- const trailingBlank = out.length > 0 && out[out.length - 1] === "";
708
- if (trailingBlank)
709
- out.splice(out.length - 1, 0, block);
710
- else
711
- out.push(`
712
- ${block}`);
713
- }
714
- let result = out.join("\n");
715
- if (existing.endsWith("\n") && !result.endsWith("\n"))
716
- result += "\n";
717
- return result;
1059
+ function reconcileEnvText2(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
1060
+ return reconcileEnvText(existing, values, keys);
718
1061
  }
719
1062
  function readEngagementEnv(engagementId, home) {
720
1063
  try {
@@ -732,7 +1075,7 @@ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KE
732
1075
  } catch {
733
1076
  existing = null;
734
1077
  }
735
- const reconciled = reconcileEnvText(existing, values, keys);
1078
+ const reconciled = reconcileEnvText2(existing, values, keys);
736
1079
  if (existing === null && reconciled === "")
737
1080
  return "skipped";
738
1081
  mkdirSync2(dir, { recursive: true, mode: 448 });
@@ -758,7 +1101,7 @@ function applyOwnerOnly(path, dir) {
758
1101
  function engagementResolutionShell() {
759
1102
  return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
760
1103
  # THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
761
- # $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
1104
+ # $HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
762
1105
  # written owner-only by \`npx halfcycle\`. What this repository holds is the
763
1106
  # engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
764
1107
  # the pin, then name the file. There is no jq here and node is not dependable on a
@@ -789,10 +1132,10 @@ halfcycle_env_file() {
789
1132
  HALFCYCLE_ENV_PROBLEM="no-home"
790
1133
  return 1
791
1134
  fi
792
- if [ -f "$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
1135
+ if [ -f "$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
793
1136
  HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
794
1137
  fi
795
- hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
1138
+ hc_env="$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
796
1139
  if [ ! -f "$hc_env" ]; then
797
1140
  HALFCYCLE_ENV_PROBLEM="no-credential"
798
1141
  return 1
@@ -983,7 +1326,7 @@ function resolveVendoredBinary() {
983
1326
  if (existsSync3(candidate))
984
1327
  return candidate;
985
1328
  }
986
- throw new Error(`[bundle install] Cannot find the self-contained guard binary bin.bundle.mjs. Run the runner's "bundle" script (pnpm --filter @halfcycle/runner run build) so the vendored artefact exists before installing. Looked in: ${candidates.join(", ")}.`);
1329
+ throw new Error(`[bundle install] Cannot find the self-contained guard binary bin.bundle.mjs. This copy of the halfcycle package is incomplete \u2014 reinstall it, or rebuild it if you are working on it. Looked in: ${candidates.join(", ")}.`);
987
1330
  }
988
1331
  var WRITE_ALLOWLIST = [
989
1332
  ".claude/commands",
@@ -1044,11 +1387,15 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
1044
1387
  writeFileSync4(targetAbsPath, content, "utf-8");
1045
1388
  return "written";
1046
1389
  }
1390
+ var INSTALLER_OWNED_DIR = ".halfcycle/";
1047
1391
  function writeOwned(targetAbsPath, targetRepoRoot, content) {
1048
1392
  const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
1049
1393
  if (!isAllowlisted(rel)) {
1050
1394
  throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
1051
1395
  }
1396
+ if (!rel.startsWith(INSTALLER_OWNED_DIR)) {
1397
+ throw new Error(`[bundle install] Path-ownership violation: attempted to claim "${rel}" by path. Only ${INSTALLER_OWNED_DIR} is owned outright by this installer; everywhere else a differing file may be a developer's, so ownership must be proved from the file's own contents (writeOwnedGenerated) rather than assumed from where it sits.`);
1398
+ }
1052
1399
  if (existsSync3(targetAbsPath) && readFileSync5(targetAbsPath, "utf-8") === content) {
1053
1400
  return "skipped";
1054
1401
  }
@@ -1056,6 +1403,39 @@ function writeOwned(targetAbsPath, targetRepoRoot, content) {
1056
1403
  writeFileSync4(targetAbsPath, content, "utf-8");
1057
1404
  return "written";
1058
1405
  }
1406
+ var GENERATED_HEADER_LINE_INDEX = 1;
1407
+ function writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker) {
1408
+ const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
1409
+ if (!isAllowlisted(rel)) {
1410
+ throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
1411
+ }
1412
+ let replacedExisting = false;
1413
+ if (existsSync3(targetAbsPath)) {
1414
+ const current = readFileSync5(targetAbsPath, "utf-8");
1415
+ if (current === content)
1416
+ return { outcome: "skipped", replacedExisting: false };
1417
+ const headerLine = current.split("\n")[GENERATED_HEADER_LINE_INDEX] ?? "";
1418
+ if (!headerLine.startsWith(generatedMarker))
1419
+ return { outcome: "collided", replacedExisting: false };
1420
+ replacedExisting = true;
1421
+ }
1422
+ mkdirSync4(dirname2(targetAbsPath), { recursive: true });
1423
+ writeFileSync4(targetAbsPath, content, "utf-8");
1424
+ return { outcome: "written", replacedExisting };
1425
+ }
1426
+ function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, generatedMarker, rel) {
1427
+ const { outcome, replacedExisting } = writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker);
1428
+ record(report, outcome, rel);
1429
+ if (replacedExisting)
1430
+ report.replacedPaths.push(rel);
1431
+ }
1432
+ function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1433
+ const existedBefore = existsSync3(targetAbsPath);
1434
+ const outcome = writeOwned(targetAbsPath, targetRepoRoot, content);
1435
+ record(report, outcome, rel);
1436
+ if (outcome === "written" && existedBefore)
1437
+ report.replacedPaths.push(rel);
1438
+ }
1059
1439
  function record(report, outcome, rel) {
1060
1440
  const bucket = {
1061
1441
  written: report.writtenPaths,
@@ -1095,6 +1475,15 @@ function generateSettingsJson() {
1095
1475
  timeout: 5
1096
1476
  }
1097
1477
  ]
1478
+ },
1479
+ {
1480
+ hooks: [
1481
+ {
1482
+ type: "command",
1483
+ command: "bash ./.claude/hooks/guard-runner.sh refresh-credential",
1484
+ timeout: 10
1485
+ }
1486
+ ]
1098
1487
  }
1099
1488
  ],
1100
1489
  UserPromptSubmit: [
@@ -1120,6 +1509,17 @@ function generateSettingsJson() {
1120
1509
  ]
1121
1510
  }
1122
1511
  ],
1512
+ Stop: [
1513
+ {
1514
+ hooks: [
1515
+ {
1516
+ type: "command",
1517
+ command: "bash ./.claude/hooks/guard-runner.sh hook stop",
1518
+ timeout: 60
1519
+ }
1520
+ ]
1521
+ }
1522
+ ],
1123
1523
  SubagentStop: [
1124
1524
  {
1125
1525
  hooks: [
@@ -1130,20 +1530,32 @@ function generateSettingsJson() {
1130
1530
  }
1131
1531
  ]
1132
1532
  }
1533
+ ],
1534
+ SessionEnd: [
1535
+ {
1536
+ hooks: [
1537
+ {
1538
+ type: "command",
1539
+ command: "bash ./.claude/hooks/session-end-marker.sh",
1540
+ timeout: 5
1541
+ }
1542
+ ]
1543
+ }
1133
1544
  ]
1134
1545
  }
1135
1546
  };
1136
1547
  return JSON.stringify(settings, null, 2) + "\n";
1137
1548
  }
1138
1549
  var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
1550
+ var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
1139
1551
  function generateGuardRunnerWrapper() {
1140
1552
  return `#!/usr/bin/env bash
1141
- # Governance hook wrapper \u2014 generated by the Halfcycle installer.
1553
+ ${GENERATED_GUARD_RUNNER_HEADER} the Halfcycle installer.
1142
1554
  #
1143
1555
  # Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
1144
- # values when Claude Code fires PostToolUse / SubagentStop, then exec's the
1145
- # SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path inside this
1146
- # project, so the hook resolves in any checkout of it.
1556
+ # values when Claude Code fires SessionStart / PostToolUse / Stop / SubagentStop,
1557
+ # then exec's the SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path
1558
+ # inside this project, so the hook resolves in any checkout of it.
1147
1559
  #
1148
1560
  # The runner itself reads only process.env, so env loading lives here in the hook
1149
1561
  # wiring rather than inside the binary.
@@ -1158,6 +1570,16 @@ ${engagementResolutionShell()}
1158
1570
  # the runner say what it found \u2014 its never-configured branch is loud and non-zero
1159
1571
  # on purpose, and a wrapper that exited quietly here would hide it.
1160
1572
  if halfcycle_env_file "$PROJECT_DIR"; then
1573
+ # EXPORTED, NOT MERELY SET \u2014 one word, and without it a whole feature is off.
1574
+ # \`set -a\` exports what is assigned AFTER it, and the resolution above assigned
1575
+ # this variable BEFORE it, so the exec'd binary would never see the name of the
1576
+ # file these credentials came from. It needs that name: when the service says
1577
+ # this engagement's credential has timed out, the binary renews it by rewriting
1578
+ # that file. With nothing naming the file it renews nothing \u2014 and it says
1579
+ # nothing and exits 0 while doing so, because there is no session in which that
1580
+ # silence is wrong on its own. So a wrapper missing this line looks perfectly
1581
+ # healthy at every session start and renews nothing, forever.
1582
+ export HALFCYCLE_ENV_FILE
1161
1583
  set -a
1162
1584
  . "$HALFCYCLE_ENV_FILE"
1163
1585
  set +a
@@ -1173,15 +1595,28 @@ fi
1173
1595
  exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
1174
1596
  `;
1175
1597
  }
1598
+ var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
1599
+ var SESSION_ID_FROM_HOOK_INPUT_SH = `SESSION_ID="$(printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(typeof j.session_id==="string"?j.session_id:"")}catch{process.stdout.write("")}})' 2>/dev/null || true)"`;
1176
1600
  function generateSessionStartMarker() {
1177
1601
  return `#!/usr/bin/env bash
1178
- # Session-start marker \u2014 generated by the Halfcycle installer.
1179
- # Writes the current git HEAD SHA to a temp file so SubagentStop can
1180
- # diff against session-start rather than just HEAD at stop time.
1602
+ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
1603
+ # Records the git HEAD sha this session starts from, in a file named after this
1604
+ # session, so the stop-time check can compare what the session did against where
1605
+ # it began instead of only against the latest commit. The session id comes in on
1606
+ # stdin with the hook input; without one, the older session-less name is written
1607
+ # and the stop-time check compares against the latest commit as it used to.
1608
+ INPUT="$(cat 2>/dev/null || true)"
1181
1609
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
1182
1610
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
1183
- HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
1184
- MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
1611
+ HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
1612
+ ${SESSION_ID_FROM_HOOK_INPUT_SH}
1613
+ MARKER_DIR="\${TMPDIR:-/tmp}"
1614
+ MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
1615
+ if [ -n "$SESSION_ID" ]; then
1616
+ MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref"
1617
+ else
1618
+ MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
1619
+ fi
1185
1620
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
1186
1621
 
1187
1622
  # ---------------------------------------------------------------------------
@@ -1244,9 +1679,41 @@ ${engagementResolutionShell()}
1244
1679
  exit 0
1245
1680
  `;
1246
1681
  }
1682
+ var GENERATED_SESSION_END_HEADER = "# Session-end marker cleanup \u2014 generated by";
1683
+ function generateSessionEndMarker() {
1684
+ return `#!/usr/bin/env bash
1685
+ ${GENERATED_SESSION_END_HEADER} the Halfcycle installer.
1686
+ # Removes this session's marker file (and the evaluation-state file beside it)
1687
+ # written at session start. Deletion happens HERE and not at stop time: the stop
1688
+ # hook runs at the end of every turn, and deleting the marker there would throw
1689
+ # away the session's starting point for every later turn.
1690
+ INPUT="$(cat 2>/dev/null || true)"
1691
+
1692
+ REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
1693
+ if [ -z "$REPO_ROOT" ]; then exit 0; fi
1694
+ HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
1695
+
1696
+ # The session id arrives on stdin as part of the hook payload. Empty when absent.
1697
+ ${SESSION_ID_FROM_HOOK_INPUT_SH}
1698
+
1699
+ MARKER_DIR="\${TMPDIR:-/tmp}"
1700
+ MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
1701
+
1702
+ # Exact names only \u2014 never a wildcard. A pattern would match another session
1703
+ # running in this same repository and delete the point it started from.
1704
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.ref" 2>/dev/null || true
1705
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.eval" 2>/dev/null || true
1706
+ if [ -n "$SESSION_ID" ]; then
1707
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref" 2>/dev/null || true
1708
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.eval" 2>/dev/null || true
1709
+ fi
1710
+ exit 0
1711
+ `;
1712
+ }
1713
+ var GENERATED_USER_PROMPT_REMINDER_HEADER = "# UserPromptSubmit house-rule reminder \u2014 generated by";
1247
1714
  function generateUserPromptReminderHook() {
1248
1715
  return `#!/usr/bin/env bash
1249
- # UserPromptSubmit house-rule reminder \u2014 generated by the Halfcycle installer.
1716
+ ${GENERATED_USER_PROMPT_REMINDER_HEADER} the Halfcycle installer.
1250
1717
  #
1251
1718
  # Injects ONE plain-text sentence into context on every prompt (F-2(c),
1252
1719
  # W8-T-05b) \u2014 the per-turn twin of the house rule W8-T-05a put in the client's
@@ -1344,17 +1811,31 @@ function generateCiStanza() {
1344
1811
  #
1345
1812
  # The job prints the diff base it used and how many files it evaluated, on every
1346
1813
  # run. If that line says 0 files on a commit that changed something, the base is
1347
- # wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block above to name it explicitly
1348
- # (on a GitHub push event, \${{ github.event.before }} is the right value).
1814
+ # wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block OF YOUR COPY, beside the two
1815
+ # secrets, to name it explicitly (on a GitHub push event, \${{ github.event.before }}
1816
+ # is the right value).
1817
+ #
1818
+ # EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
1819
+ # changes to it would be replaced the next time you run \`npx halfcycle\`. It is
1820
+ # also inert where it sits: no CI system reads this path. Copy the job above into
1821
+ # your own workflow and change it there.
1349
1822
  `;
1350
1823
  }
1351
1824
  var MCP_REGISTRATION_REL = ".mcp.json";
1352
1825
  var MCP_SERVER_KEY = "halfcycle";
1353
1826
  var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
1354
1827
  var MCP_HEADERS_HELPER_COMMAND = `/bin/sh -c 'd=$(pwd); while [ ! -f "$d/${MCP_HEADERS_HELPER_REL}" ] && [ "$d" != / ]; do d=$(dirname "$d"); done; [ -f "$d/${MCP_HEADERS_HELPER_REL}" ] || { echo "halfcycle: ${MCP_HEADERS_HELPER_REL} not found at or above $(pwd); re-run npx halfcycle" >&2; exit 1; }; exec /bin/sh "$d/${MCP_HEADERS_HELPER_REL}"'`;
1828
+ var GENERATED_MCP_HEADERS_HEADER = "# Halfcycle MCP connection headers \u2014 generated by";
1829
+ var OWNED_GENERATED_HEADERS = {
1830
+ ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
1831
+ ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
1832
+ ".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
1833
+ ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER,
1834
+ ".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
1835
+ };
1355
1836
  function generateMcpHeadersHelper() {
1356
1837
  return `#!/bin/sh
1357
- # Halfcycle MCP connection headers \u2014 generated by the Halfcycle installer.
1838
+ ${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
1358
1839
  #
1359
1840
  # Claude Code runs this at MCP connection time, from the session's cwd, and merges
1360
1841
  # its stdout into the connection headers for the "halfcycle" server in .mcp.json.
@@ -1539,7 +2020,7 @@ function writeCrewRoster(targetRepoRoot, report) {
1539
2020
  const rendered = `${JSON.stringify(doc, null, 2)}
1540
2021
  `;
1541
2022
  const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
1542
- record(report, writeOwned(crewPath, targetRepoRoot, rendered), ".halfcycle/crew.json");
2023
+ recordOwned(report, crewPath, targetRepoRoot, rendered, ".halfcycle/crew.json");
1543
2024
  }
1544
2025
  function readBundlePin(targetRepoRoot) {
1545
2026
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
@@ -1690,7 +2171,8 @@ async function install(options) {
1690
2171
  writtenPaths: [],
1691
2172
  skippedPaths: [],
1692
2173
  mergedPaths: [],
1693
- collidedPaths: []
2174
+ collidedPaths: [],
2175
+ replacedPaths: []
1694
2176
  };
1695
2177
  copyManifestCommands(manifest, targetRepo, report);
1696
2178
  const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
@@ -1703,7 +2185,7 @@ async function install(options) {
1703
2185
  }
1704
2186
  const vendoredBinSrc = resolveVendoredBinary();
1705
2187
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
1706
- record(report, writeOwned(vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
2188
+ recordOwned(report, vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8"), ".halfcycle/bin/bin.bundle.mjs");
1707
2189
  const settingsPath = join5(targetRepo, ".claude", "settings.json");
1708
2190
  const settingsPreexisted = existsSync3(settingsPath);
1709
2191
  const generatedSettings = JSON.parse(generateSettingsJson());
@@ -1715,17 +2197,19 @@ async function install(options) {
1715
2197
  (settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
1716
2198
  for (const [name, content] of [
1717
2199
  ["guard-runner.sh", generateGuardRunnerWrapper()],
1718
- ["session-start-marker.sh", generateSessionStartMarker()],
1719
- ["user-prompt-reminder.sh", generateUserPromptReminderHook()]
2200
+ ["session-end-marker.sh", generateSessionEndMarker()],
2201
+ ["user-prompt-reminder.sh", generateUserPromptReminderHook()],
2202
+ ["session-start-marker.sh", generateSessionStartMarker()]
1720
2203
  ]) {
2204
+ const rel = `.claude/hooks/${name}`;
1721
2205
  const hookPath = join5(targetRepo, ".claude", "hooks", name);
1722
- record(report, writeCollisionSafe(hookPath, targetRepo, content), `.claude/hooks/${name}`);
2206
+ recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
1723
2207
  }
1724
2208
  const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
1725
- record(report, writeCollisionSafe(ciStanzaPath, targetRepo, generateCiStanza()), ".halfcycle/ci-stanza.yml");
2209
+ recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
1726
2210
  if (credential) {
1727
2211
  const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
1728
- record(report, writeCollisionSafe(helperPath, targetRepo, generateMcpHeadersHelper()), MCP_HEADERS_HELPER_REL);
2212
+ recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
1729
2213
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
1730
2214
  const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
1731
2215
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
@@ -1779,6 +2263,7 @@ async function install(options) {
1779
2263
  skippedPaths: report.skippedPaths,
1780
2264
  mergedPaths: report.mergedPaths,
1781
2265
  collidedPaths: report.collidedPaths,
2266
+ replacedPaths: report.replacedPaths,
1782
2267
  bootstrapScanRan: scanResult.ran,
1783
2268
  legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null
1784
2269
  };
@@ -1944,6 +2429,7 @@ var SIGN_IN_TIMEOUT_MESSAGE = "Sign-in timed out: the Halfcycle service stopped
1944
2429
  var SIGN_IN_NON_INTERACTIVE_MESSAGE = "Halfcycle needs a signed-in account and this looks like a non-interactive run (CI is set), so there is no browser to sign in with. Set HALFCYCLE_TOKEN in the environment to a Halfcycle credential and run this again. Nothing was created.";
1945
2430
  var DEADLINE_SLACK_INTERVALS = 1;
1946
2431
  var STILL_WAITING_MS = 15e3;
2432
+ var DEFAULT_SIGN_IN_REASON = "create an engagement";
1947
2433
  function defaultWrite(text) {
1948
2434
  process.stdout.write(text);
1949
2435
  }
@@ -2133,7 +2619,7 @@ async function signIn(serviceUrl, deps = {}) {
2133
2619
  if (isNonInteractive(env)) {
2134
2620
  throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_NON_INTERACTIVE_MESSAGE);
2135
2621
  }
2136
- write(`[halfcycle] Halfcycle needs an account before it can create an engagement \u2014 signing you in through your browser.
2622
+ write(`[halfcycle] Halfcycle needs an account before it can ${deps.reason ?? DEFAULT_SIGN_IN_REASON} \u2014 signing you in through your browser.
2137
2623
  `);
2138
2624
  const bind = await bindLoopback(env);
2139
2625
  let closed = false;
@@ -2290,6 +2776,12 @@ async function joinEngagement(baseUrl, engagementId, credential) {
2290
2776
  route: "POST /engagements/:engagementId/join"
2291
2777
  });
2292
2778
  }
2779
+ function withDeclaredTelemetryKey(raw) {
2780
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
2781
+ return null;
2782
+ const obj = raw;
2783
+ return "controlTelemetryUrl" in obj ? obj : { ...obj, controlTelemetryUrl: "" };
2784
+ }
2293
2785
  async function requestEngagementValues(url, requestBody, credential, shape) {
2294
2786
  const bearer = credential?.trim();
2295
2787
  let res;
@@ -2314,7 +2806,9 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
2314
2806
  const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
2315
2807
  throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2316
2808
  }
2317
- const body = await res.json().catch(() => null);
2809
+ const candidate = withDeclaredTelemetryKey(await res.json().catch(() => null));
2810
+ const declared = engagementCredentialResponseSchema.safeParse(candidate);
2811
+ const body = declared.success ? declared.data : candidate;
2318
2812
  if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
2319
2813
  throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2320
2814
  }
@@ -2333,6 +2827,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
2333
2827
  throw new Error(`[bundle install] The Halfcycle service at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that service is older than this installer, or ${url} is not a Halfcycle service.`);
2334
2828
  }
2335
2829
  const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
2830
+ if (!declared.success) {
2831
+ const fields = declared.error.issues.map((issue) => {
2832
+ const unrecognised = issue.keys;
2833
+ if (unrecognised && unrecognised.length > 0) {
2834
+ return `unexpected: ${unrecognised.map(String).join(", ")}`;
2835
+ }
2836
+ return issue.path.length > 0 ? issue.path.map(String).join(".") : "(the body itself)";
2837
+ }).join(", ");
2838
+ throw new Error(`[bundle install] ${shape.action} response from ${url} is not the credential response this CLI is built to read (${fields}). ${shape.nothingHappened}. Either that service is a different version from this installer, or ${url} is not a Halfcycle service.`);
2839
+ }
2336
2840
  return {
2337
2841
  engagementId: body.engagementId,
2338
2842
  sessionToken: body.sessionToken,
@@ -2671,7 +3175,8 @@ function currentEnvironment(quiet2) {
2671
3175
  // dist/confirm-identity.js
2672
3176
  var IDENTITY_TIMEOUT_MS = 5e3;
2673
3177
  var IDENTITY_DECLINED = "identity-declined";
2674
- async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
3178
+ var CREDENTIAL_REFUSED = 401;
3179
+ async function askAccount(serviceUrl, credential, fetchImpl = fetch) {
2675
3180
  const url = `${serviceUrl.replace(/\/+$/, "")}/account`;
2676
3181
  const controller = new AbortController();
2677
3182
  const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
@@ -2681,15 +3186,18 @@ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
2681
3186
  signal: controller.signal
2682
3187
  });
2683
3188
  if (!res.ok)
2684
- return void 0;
3189
+ return { identity: void 0, refused: res.status === CREDENTIAL_REFUSED };
2685
3190
  const parsed = safeParseAccountIdentity(await res.json());
2686
- return parsed.success ? parsed.data : void 0;
3191
+ return { identity: parsed.success ? parsed.data : void 0, refused: false };
2687
3192
  } catch {
2688
- return void 0;
3193
+ return { identity: void 0, refused: false };
2689
3194
  } finally {
2690
3195
  clearTimeout(timer);
2691
3196
  }
2692
3197
  }
3198
+ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
3199
+ return (await askAccount(serviceUrl, credential, fetchImpl)).identity;
3200
+ }
2693
3201
  function accountLabel(identity, storedAccountId, verbose2 = false) {
2694
3202
  const accountId = identity?.accountId ?? storedAccountId;
2695
3203
  if (identity?.email !== void 0 && identity.email.trim() !== "") {
@@ -3143,7 +3651,7 @@ function renderBuildRecordMarkdown(record2) {
3143
3651
  "",
3144
3652
  `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
3145
3653
  "",
3146
- "> This Build Record was assembled automatically at phase close by the method bundle (`packages/bundle`). Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
3654
+ "> 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.",
3147
3655
  "",
3148
3656
  `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
3149
3657
  `**Phase:** ${phase.id} \u2014 ${phase.name}`,
@@ -3163,7 +3671,7 @@ function renderBuildRecordMarkdown(record2) {
3163
3671
  `- **Tools:** ${record2.delivered.tools.join(", ")}`,
3164
3672
  `- **Dogfood:** ${record2.delivered.dogfood}`,
3165
3673
  "",
3166
- guardsList ? `Guards evaluated (INV-007-safe results): ${guardsList}. *(Guard \`channel\`/\`version\` are guard-asset metadata, excluded from the v1 record \u2014 INV-007/INV-001.)*` : "No guards fired during this phase.",
3674
+ 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.",
3167
3675
  "",
3168
3676
  `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
3169
3677
  "",
@@ -3175,7 +3683,7 @@ function renderBuildRecordMarkdown(record2) {
3175
3683
  ""
3176
3684
  ];
3177
3685
  if (record2.acceptance.inv002ProdBypassProbe) {
3178
- lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
3686
+ lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
3179
3687
  }
3180
3688
  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"}`, "");
3181
3689
  return lines.join("\n");
@@ -3568,15 +4076,115 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
3568
4076
  return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
3569
4077
  }
3570
4078
 
4079
+ // dist/ci-bind.js
4080
+ function parseCiRepositoryArg(arg) {
4081
+ const parts = arg.split("/");
4082
+ if (parts.length !== 2)
4083
+ return null;
4084
+ const [owner, repo] = parts;
4085
+ if (owner === void 0 || owner.trim() === "")
4086
+ return null;
4087
+ if (repo === void 0 || repo.trim() === "")
4088
+ return null;
4089
+ return { owner, repo };
4090
+ }
4091
+ var CiBindRefused = class extends Error {
4092
+ status;
4093
+ constructor(status, message) {
4094
+ super(message);
4095
+ this.status = status;
4096
+ this.name = "CiBindRefused";
4097
+ }
4098
+ /** Is this the ONE arm signing in again can fix — see this file's header. */
4099
+ get authRefused() {
4100
+ return this.status === 401;
4101
+ }
4102
+ /**
4103
+ * Is this "ours to fix, try again", not "yours to fix"? The question is not
4104
+ * mechanical (did a handler run) — it is what a developer reading the message
4105
+ * does next. `502`/`504` are the reverse proxy in front of the published origin
4106
+ * answering for a control plane that is down or slow, with an HTML body this
4107
+ * file's own `requestBinding` cannot parse, so the message would otherwise
4108
+ * degrade to a bare "the URL returned 502". `503` is `resolveAccount`'s own
4109
+ * fail-closed refusal when it could not reach the token store — the caller's
4110
+ * credential was never actually checked, and its posture is emphatic this is NOT
4111
+ * a "no". **`500` belongs beside them, not with the 4xx arms.** It is an
4112
+ * unhandled throw: nothing decided the request's merits, and the bind may even
4113
+ * have half-happened if the throw landed after a commit. On this route the
4114
+ * body is the same degraded "the URL returned 500" a 502/504 produces — an
4115
+ * internal path echoed back with no advice — so `refused (500)` would send a
4116
+ * developer to re-check ownership, the repository name and whether it is bound
4117
+ * elsewhere: every 4xx question, none of them this status's actual cause.
4118
+ *
4119
+ * So this is every `5xx`, not an enumerated set of three — the boundary is
4120
+ * "did any handler decide yes or no", and a 4xx is the only family that ever did.
4121
+ */
4122
+ get unavailable() {
4123
+ return this.status >= 500 && this.status < 600;
4124
+ }
4125
+ };
4126
+ async function requestBinding(method, serviceUrl, engagementId, repository, credential, noun) {
4127
+ const url = `${serviceUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/ci-bindings/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`;
4128
+ let res;
4129
+ try {
4130
+ res = await fetch(url, { method, headers: { authorization: `Bearer ${credential}` } });
4131
+ } catch (err) {
4132
+ throw new Error(`[halfcycle] Could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}. Nothing was ${noun}.`);
4133
+ }
4134
+ if (res.status === 204)
4135
+ return;
4136
+ const body = await res.json().catch(() => null);
4137
+ const message = typeof body?.message === "string" ? body.message : `${url} returned ${res.status}.`;
4138
+ throw new CiBindRefused(res.status, message);
4139
+ }
4140
+ async function setCiBinding(action, serviceUrl, engagementId, repository, deps = {}) {
4141
+ const noun = action === "bind" ? "bound" : "unbound";
4142
+ const signInDeps = {
4143
+ ...deps,
4144
+ reason: deps.reason ?? (action === "bind" ? "trust this repository for this engagement's CI" : "stop trusting this repository for this engagement's CI")
4145
+ };
4146
+ let credential = await obtainCredential(serviceUrl, signInDeps);
4147
+ for (; ; ) {
4148
+ try {
4149
+ await requestBinding(action === "bind" ? "PUT" : "DELETE", serviceUrl, engagementId, repository, credential.credential, noun);
4150
+ return;
4151
+ } catch (err) {
4152
+ if (!(err instanceof CiBindRefused) || !err.authRefused)
4153
+ throw err;
4154
+ const replacement = await replaceRefusedCredential(serviceUrl, credential, signInDeps);
4155
+ if (replacement === null) {
4156
+ throw new SignInRefused(`ci-${action}-${err.status}`, `${err.message} ${refusedCredentialRemedy(credential.source)} Nothing was ${noun}.`);
4157
+ }
4158
+ process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
4159
+ `);
4160
+ credential = replacement;
4161
+ }
4162
+ }
4163
+ }
4164
+ function bindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
4165
+ return setCiBinding("bind", serviceUrl, engagementId, repository, deps);
4166
+ }
4167
+ function unbindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
4168
+ return setCiBinding("unbind", serviceUrl, engagementId, repository, deps);
4169
+ }
4170
+
3571
4171
  // dist/cli-contract.js
3572
- var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase"];
4172
+ var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "ci"];
3573
4173
  var CONTRACTS = [
3574
- // `install`, `check-drift` and `build-record` take positionals and no required
3575
- // flags. They are declared so the verb set has ONE home — a remedy naming a verb
3576
- // this CLI does not have is the same defect as one missing a flag.
4174
+ // `install`, `check-drift`, `build-record` and `ci` take positionals and no
4175
+ // required flags. They are declared so the verb set has ONE home — a remedy
4176
+ // naming a verb this CLI does not have is the same defect as one missing a flag.
4177
+ //
4178
+ // `ci`'s two forms (`ci bind <owner>/<repo>`, `ci unbind <owner>/<repo>`) take a
4179
+ // sub-action and a repository as POSITIONALS, not flags — this table answers only
4180
+ // *which flags must an invocation carry*, and neither form has one (T-11,
4181
+ // `ci-oidc-token-exchange`). The route it calls declares no wire shape either
4182
+ // (path segments in, `204` out — T-05), so there is no schema for an argument
4183
+ // here to disagree with.
3577
4184
  { verb: "install", required: [], conditional: [] },
3578
4185
  { verb: "check-drift", required: [], conditional: [] },
3579
4186
  { verb: "build-record", required: [], conditional: [] },
4187
+ { verb: "ci", required: [], conditional: [] },
3580
4188
  {
3581
4189
  verb: "open-phase",
3582
4190
  required: [
@@ -3610,7 +4218,7 @@ var CONTRACTS = [
3610
4218
  {
3611
4219
  flag: "--verdict",
3612
4220
  takesValue: true,
3613
- missingMessage: `halfcycle close-phase: --verdict must be "clean" or "defects" \u2014 it is the acceptance walk's own verdict (INV-009) and nothing derives it for you.`
4221
+ missingMessage: `halfcycle close-phase: --verdict must be "clean" or "defects" \u2014 it is the acceptance walk's own verdict and nothing derives it for you.`
3614
4222
  },
3615
4223
  {
3616
4224
  flag: "--actor",
@@ -3769,10 +4377,9 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
3769
4377
  fact("Claude Code", probes.claudeCodeVersion()),
3770
4378
  fact("Project", project, name),
3771
4379
  fact("Status", status, installed === void 0 ? "new" : "installed"),
3772
- // `Website`, NOT `Plane` (W-3, #868). "Plane" is our internal word for the control
3773
- // plane; the developer reading this has never heard it, and raw method vocabulary
3774
- // reaching a client surface is a recorded finding class in this product. The row
3775
- // answers *which Halfcycle is this*, and an address is the answer they recognise.
4380
+ // `Website`, NOT `Plane` — see this function's docblock. (No issue number here:
4381
+ // a comment beside a property survives into the published entry, unlike a
4382
+ // leading docblock, which esbuild drops.)
3776
4383
  fact("Website", probes.websiteHost(env))
3777
4384
  ].filter((f) => f !== void 0);
3778
4385
  return {
@@ -3852,6 +4459,8 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
3852
4459
  halfcycle build-record <phase> [--repo <root>]
3853
4460
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
3854
4461
  halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
4462
+ halfcycle ci bind <owner>/<repo> [--repo <root>]
4463
+ halfcycle ci unbind <owner>/<repo> [--repo <root>]
3855
4464
 
3856
4465
  --quiet / -q: print no opening banner (any command)
3857
4466
  --verbose / -v: print full run detail (paths written, merged, skipped) on install
@@ -3866,7 +4475,7 @@ function isHalfcycleMonorepo(dir) {
3866
4475
  }
3867
4476
  async function main() {
3868
4477
  const [cmd, ...rest] = args;
3869
- const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase)$/.test(cmd);
4478
+ const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase|ci)$/.test(cmd);
3870
4479
  const installArm = cmd === "install" || cmd === void 0 || bareTarget;
3871
4480
  const positionals = cmd === "install" ? rest : args;
3872
4481
  const targetRepo = installArm ? positionals[0] ?? process.cwd() : process.cwd();
@@ -3907,7 +4516,9 @@ ${USAGE}`);
3907
4516
  }
3908
4517
  const pinned = readPinnedEngagement(targetRepo);
3909
4518
  const requestedId = engagementIdArg ?? pinned?.engagementId;
3910
- const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
4519
+ const held = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
4520
+ const heldAnswer = held !== void 0 ? await askAccount(held.serviceUrl, held.token) : void 0;
4521
+ const reusable = heldAnswer?.refused === true ? void 0 : held;
3911
4522
  let engagementId;
3912
4523
  let credential;
3913
4524
  let notThisAccountMessage;
@@ -4007,6 +4618,12 @@ ${USAGE}`);
4007
4618
  }
4008
4619
  if (result.collidedPaths.length > 0) {
4009
4620
  process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${result.collidedPaths.join(", ")}
4621
+ `);
4622
+ }
4623
+ if (result.replacedPaths.length > 0) {
4624
+ process.stdout.write(`[halfcycle] Updated (files Halfcycle wrote earlier, now replaced): ${result.replacedPaths.join(", ")}
4625
+ `);
4626
+ process.stdout.write(`[halfcycle] If you had changed one of those: a change you had COMMITTED is still in your git history ("git log -p -- <path>"). A change you had not committed is gone \u2014 this command overwrote the file.
4010
4627
  `);
4011
4628
  }
4012
4629
  if (result.legacyCredentialKept !== null) {
@@ -4047,7 +4664,7 @@ ${USAGE}`);
4047
4664
  `);
4048
4665
  }
4049
4666
  reportClaudeCodeVersion(verbose);
4050
- const identity = actingIdentity ?? (credential ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
4667
+ const identity = actingIdentity ?? heldAnswer?.identity ?? (credential && heldAnswer === void 0 ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
4051
4668
  const written = result.writtenPaths.length;
4052
4669
  const merged = result.mergedPaths.length;
4053
4670
  process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
@@ -4318,6 +4935,69 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
4318
4935
  return;
4319
4936
  }
4320
4937
  process.stderr.write(`halfcycle close-phase failed: ${err instanceof Error ? err.message : String(err)}
4938
+ `);
4939
+ process.exit(1);
4940
+ }
4941
+ return;
4942
+ }
4943
+ if (cmd === "ci") {
4944
+ const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
4945
+ const positionals2 = rest.filter((token, i) => !token.startsWith("--") && !(i > 0 && rest[i - 1] === "--repo"));
4946
+ const action = positionals2[0];
4947
+ const repositoryArg = positionals2[1];
4948
+ if (action !== "bind" && action !== "unbind") {
4949
+ process.stderr.write("halfcycle ci: name bind or unbind.\n halfcycle ci bind <owner>/<repo> [--repo <root>]\n halfcycle ci unbind <owner>/<repo> [--repo <root>]\n");
4950
+ process.exit(2);
4951
+ return;
4952
+ }
4953
+ if (repositoryArg === void 0) {
4954
+ process.stderr.write(`halfcycle ci ${action}: name the repository, as owner/repo.
4955
+ `);
4956
+ process.exit(2);
4957
+ return;
4958
+ }
4959
+ const repository = parseCiRepositoryArg(repositoryArg);
4960
+ if (repository === null) {
4961
+ process.stderr.write(`halfcycle ci ${action}: "${repositoryArg}" is not a repository \u2014 give it as owner/repo, exactly as GitHub spells it, for example acme/widgets.
4962
+ `);
4963
+ process.exit(2);
4964
+ return;
4965
+ }
4966
+ const pin = readBundlePin(repoRoot);
4967
+ if (pin === null || pin.engagementId === "") {
4968
+ process.stderr.write(`halfcycle ci ${action}: ${join11(repoRoot, ".halfcycle", "bundle.json")} names no Halfcycle engagement. Run \`npx halfcycle\` here first.
4969
+ `);
4970
+ process.exit(1);
4971
+ return;
4972
+ }
4973
+ try {
4974
+ const controlOrigin = resolveControlOrigin(process.env);
4975
+ const label = `${repository.owner}/${repository.repo}`;
4976
+ if (action === "bind") {
4977
+ await bindCiRepository(controlOrigin.origin, pin.engagementId, repository);
4978
+ process.stdout.write(`[halfcycle] ${label} now trusts this engagement's CI \u2014 a workflow there granting both \`contents: read\` and \`id-token: write\` can authenticate with no stored secret. Both lines: declaring any permission replaces the defaults, so naming only the identity token stops a private repository checking out.
4979
+ `);
4980
+ } else {
4981
+ await unbindCiRepository(controlOrigin.origin, pin.engagementId, repository);
4982
+ process.stdout.write(`[halfcycle] ${label} no longer trusts this engagement's CI.
4983
+ `);
4984
+ }
4985
+ process.exit(0);
4986
+ } catch (err) {
4987
+ if (err instanceof SignInRefused) {
4988
+ process.stderr.write(`halfcycle ci ${action}: ${err.message}
4989
+ `);
4990
+ process.exit(1);
4991
+ return;
4992
+ }
4993
+ if (err instanceof CiBindRefused) {
4994
+ const label = err.unavailable ? `temporarily unavailable (${err.status})` : `refused (${err.status})`;
4995
+ process.stderr.write(`halfcycle ci ${action}: ${label}. ${err.message}
4996
+ `);
4997
+ process.exit(1);
4998
+ return;
4999
+ }
5000
+ process.stderr.write(`halfcycle ci ${action} failed: ${err instanceof Error ? err.message : String(err)}
4321
5001
  `);
4322
5002
  process.exit(1);
4323
5003
  }