halfcycle 0.3.23 → 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,16 +376,184 @@ 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 crewFaceHeadSchema = z5.enum(["sq", "sqr", "rnd", "dome", "hex"]);
284
- var crewFaceAntennaSchema = z5.enum(["none", "stalk", "twin", "dish"]);
285
- var crewFaceEyesSchema = z5.enum(["dots", "bars", "visor", "cyclops"]);
286
- var crewFaceMouthSchema = z5.enum(["line", "grid", "dots", "wave"]);
287
- var crewFaceSchema = 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({
288
557
  // The outline of the head. Members who do the same kind of work share one, so a
289
558
  // team is recognisable before any name is read.
290
559
  head: crewFaceHeadSchema,
@@ -295,17 +564,17 @@ var crewFaceSchema = z5.object({
295
564
  // The mouth.
296
565
  mouth: crewFaceMouthSchema,
297
566
  // Whether this member is drawn with ears.
298
- ears: z5.boolean()
567
+ ears: z6.boolean()
299
568
  }).strict();
300
- var crewMemberSchema = z5.object({
569
+ var crewMemberSchema = z6.object({
301
570
  // The agent's name. The same twenty-one on every project, so a name is a
302
571
  // vocabulary a returning client already knows.
303
- callsign: z5.string().min(1).max(32),
572
+ callsign: z6.string().min(1).max(32),
304
573
  // Which of the three headings this member reads under.
305
574
  group: crewGroupSchema,
306
575
  // The kind of work this member takes. A task names a discipline, and only
307
576
  // agents holding it can be dispatched to it.
308
- discipline: z5.string().min(1).max(CREW_DISCIPLINE_MAX),
577
+ discipline: z6.string().min(1).max(CREW_DISCIPLINE_MAX),
309
578
  // How much thinking this member brings. There is no field naming the supplier
310
579
  // behind it, on this shape or on any other.
311
580
  tier: crewTierSchema,
@@ -470,8 +739,8 @@ var CREW_ROSTER_SIZE = CREW_ROSTER.length;
470
739
  var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
471
740
 
472
741
  // ../events/dist/device-auth.js
473
- import { z as z6 } from "zod";
474
- 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");
475
744
  var DEVICE_AUTH_STATUS = {
476
745
  /** The poll: minted, nobody has acted yet. Keep polling. 200. */
477
746
  PENDING: "pending",
@@ -497,7 +766,7 @@ var DEVICE_AUTH_STATUS = {
497
766
  ALREADY_DECIDED: "already-decided",
498
767
  /**
499
768
  * Confirm: an approval named a signed-in identity whose account has not accepted
500
- * 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.
501
770
  *
502
771
  * NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
503
772
  * says nothing about the code itself — the code is still `pending` after this
@@ -512,25 +781,25 @@ var DEVICE_POLL_REFUSALS = [
512
781
  DEVICE_AUTH_STATUS.EXPIRED,
513
782
  DEVICE_AUTH_STATUS.UNKNOWN
514
783
  ];
515
- var deviceAuthStartResponseSchema = z6.object({
516
- deviceCode: z6.string().min(1),
517
- userCode: z6.string().min(1),
518
- verificationUrl: z6.string().min(1),
519
- expiresAt: utcIso86013,
520
- 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()
521
790
  }).strict();
522
- var devicePollPendingSchema = z6.object({
523
- status: z6.literal(DEVICE_AUTH_STATUS.PENDING),
524
- pollIntervalMs: z6.number().int().positive()
791
+ var devicePollPendingSchema = z7.object({
792
+ status: z7.literal(DEVICE_AUTH_STATUS.PENDING),
793
+ pollIntervalMs: z7.number().int().positive()
525
794
  }).strict();
526
- var devicePollApprovedSchema = z6.object({
527
- status: z6.literal(DEVICE_AUTH_STATUS.APPROVED),
528
- accountId: z6.string().min(1),
529
- credential: z6.string().min(1),
530
- 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
531
800
  }).strict();
532
- var deviceAuthRefusalSchema = z6.object({
533
- status: z6.enum([
801
+ var deviceAuthRefusalSchema = z7.object({
802
+ status: z7.enum([
534
803
  DEVICE_AUTH_STATUS.DECLINED,
535
804
  DEVICE_AUTH_STATUS.EXPIRED,
536
805
  DEVICE_AUTH_STATUS.UNKNOWN,
@@ -541,25 +810,25 @@ var deviceAuthRefusalSchema = z6.object({
541
810
  DEVICE_AUTH_STATUS.ALREADY_DECIDED,
542
811
  DEVICE_AUTH_STATUS.TERMS_REQUIRED
543
812
  ]),
544
- message: z6.string().min(1)
813
+ message: z7.string().min(1)
545
814
  }).strict();
546
- var devicePollResponseSchema = z6.union([
815
+ var devicePollResponseSchema = z7.union([
547
816
  devicePollPendingSchema,
548
817
  devicePollApprovedSchema,
549
818
  deviceAuthRefusalSchema
550
819
  ]);
551
- var deviceConfirmRequestSchema = z6.object({
552
- userCode: z6.string().min(1),
553
- decision: z6.enum(["approve", "decline"]),
554
- externalAuthId: z6.string().min(1).optional(),
555
- email: z6.string().optional(),
556
- 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()
557
826
  }).strict();
558
- var deviceConfirmRecordedSchema = z6.object({
559
- status: z6.literal(DEVICE_AUTH_STATUS.RECORDED),
560
- 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()
561
830
  }).strict();
562
- var deviceConfirmResponseSchema = z6.union([
831
+ var deviceConfirmResponseSchema = z7.union([
563
832
  deviceConfirmRecordedSchema,
564
833
  deviceAuthRefusalSchema
565
834
  ]);
@@ -588,25 +857,25 @@ function loopbackVerificationUrl(verificationUrl, target) {
588
857
  }
589
858
 
590
859
  // ../events/dist/join-refusal.js
591
- import { z as z7 } from "zod";
860
+ import { z as z8 } from "zod";
592
861
  var NOT_THIS_ACCOUNT_REASON = "not-this-account";
593
862
  var JOIN_REFUSAL_STATUS = 403;
594
- var notThisAccountRefusalSchema = z7.object({
595
- statusCode: z7.literal(JOIN_REFUSAL_STATUS),
596
- error: z7.string(),
597
- message: z7.string(),
598
- 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)
599
868
  }).strict();
600
- var joinRefusalSchema = z7.union([notThisAccountRefusalSchema, wireErrorSchema]);
869
+ var joinRefusalSchema = z8.union([notThisAccountRefusalSchema, wireErrorSchema]);
601
870
  function isNotThisAccountRefusal(body) {
602
871
  return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
603
872
  }
604
873
 
605
874
  // ../events/dist/account-identity.js
606
- import { z as z8 } from "zod";
607
- var accountIdentitySchema = z8.object({
608
- accountId: z8.string().min(1),
609
- 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()
610
879
  }).strict();
611
880
  function safeParseAccountIdentity(payload) {
612
881
  return accountIdentitySchema.safeParse(payload);
@@ -673,6 +942,21 @@ function describeIdentity(identity) {
673
942
  return `(a ${typeof identity})`;
674
943
  }
675
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
+
676
960
  // dist/engagement-credential.js
677
961
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
678
962
  import { platform as platform2 } from "node:os";
@@ -683,27 +967,14 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
683
967
  import { homedir, platform } from "node:os";
684
968
  import { dirname, join } from "node:path";
685
969
  function halfcycleHome(home) {
686
- return join(home ?? homedir(), ".halfcycle");
970
+ return join(home ?? homedir(), HALFCYCLE_DIR_NAME);
687
971
  }
688
972
  function accountStorePath(home) {
689
- return join(halfcycleHome(home), "account.json");
690
- }
691
- function normaliseOrigin(serviceUrl) {
692
- return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
973
+ return join(halfcycleHome(home), ACCOUNT_STORE_FILENAME);
693
974
  }
694
975
  function readStore(home) {
695
- let raw;
696
976
  try {
697
- raw = readFileSync(accountStorePath(home), "utf-8");
698
- } catch {
699
- return { version: 1, accounts: {} };
700
- }
701
- try {
702
- const parsed = JSON.parse(raw);
703
- if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
704
- return { version: 1, accounts: {} };
705
- }
706
- return { version: 1, accounts: parsed.accounts ?? {} };
977
+ return parseAccountStoreText(readFileSync(accountStorePath(home), "utf-8"));
707
978
  } catch {
708
979
  return { version: 1, accounts: {} };
709
980
  }
@@ -749,8 +1020,9 @@ function forgetStoredCredential(serviceUrl, home) {
749
1020
  }
750
1021
 
751
1022
  // dist/engagement-credential.js
752
- var ENGAGEMENTS_DIR = "engagements";
753
- 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;
754
1026
  var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
755
1027
  function engagementStateDir(engagementId, home) {
756
1028
  return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
@@ -758,7 +1030,6 @@ function engagementStateDir(engagementId, home) {
758
1030
  function engagementEnvPath(engagementId, home) {
759
1031
  return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
760
1032
  }
761
- var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
762
1033
  function notThisAccountMarkerPath(engagementId, home) {
763
1034
  return join2(engagementStateDir(engagementId, home), NOT_THIS_ACCOUNT_MARKER_FILENAME);
764
1035
  }
@@ -785,82 +1056,8 @@ var ENGAGEMENT_ENV_KEYS = [
785
1056
  "CONTROL_TELEMETRY_URL"
786
1057
  ];
787
1058
  var PHASE_STAMP_ENV_KEY = "HALFCYCLE_PHASE";
788
- var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
789
- function shq(value) {
790
- return `'${value.replace(/'/g, `'\\''`)}'`;
791
- }
792
- function unquote(value) {
793
- const v = value.trim();
794
- if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
795
- return v.slice(1, -1).split(`'\\''`).join(`'`);
796
- }
797
- if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
798
- return v.slice(1, -1);
799
- return v;
800
- }
801
- function parseEnvText(raw) {
802
- const out = {};
803
- for (const line of raw.split("\n")) {
804
- const eq = line.indexOf("=");
805
- if (eq === -1)
806
- continue;
807
- const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
808
- if (key === "" || key.startsWith("#"))
809
- continue;
810
- out[key] = unquote(line.slice(eq + 1));
811
- }
812
- return out;
813
- }
814
- function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
815
- const desired = new Map(keys.map((k) => {
816
- const value = values[k];
817
- return [k, value === void 0 ? "" : value];
818
- }));
819
- const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
820
- const written = keys.filter((k) => desired.get(k) !== null);
821
- if (existing === null) {
822
- if (written.length === 0)
823
- return "";
824
- return `${ENV_HEADER}
825
- ` + written.map(line).join("\n") + "\n";
826
- }
827
- const seen = /* @__PURE__ */ new Set();
828
- const out = [];
829
- for (const existingLine of existing.split("\n")) {
830
- const eq = existingLine.indexOf("=");
831
- if (eq === -1) {
832
- out.push(existingLine);
833
- continue;
834
- }
835
- const lhs = existingLine.slice(0, eq);
836
- const exported = /^\s*export\s+/.exec(lhs);
837
- const prefix = exported === null ? "" : exported[0];
838
- const key = lhs.slice(prefix.length).trim();
839
- if (!desired.has(key)) {
840
- out.push(existingLine);
841
- continue;
842
- }
843
- seen.add(key);
844
- if (desired.get(key) === null)
845
- continue;
846
- out.push(`${prefix}${line(key)}`);
847
- }
848
- const missing = written.filter((k) => !seen.has(k));
849
- if (missing.length > 0) {
850
- const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
851
- `;
852
- const block = header + missing.map(line).join("\n");
853
- const trailingBlank = out.length > 0 && out[out.length - 1] === "";
854
- if (trailingBlank)
855
- out.splice(out.length - 1, 0, block);
856
- else
857
- out.push(`
858
- ${block}`);
859
- }
860
- let result = out.join("\n");
861
- if (existing.endsWith("\n") && !result.endsWith("\n"))
862
- result += "\n";
863
- return result;
1059
+ function reconcileEnvText2(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
1060
+ return reconcileEnvText(existing, values, keys);
864
1061
  }
865
1062
  function readEngagementEnv(engagementId, home) {
866
1063
  try {
@@ -878,7 +1075,7 @@ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KE
878
1075
  } catch {
879
1076
  existing = null;
880
1077
  }
881
- const reconciled = reconcileEnvText(existing, values, keys);
1078
+ const reconciled = reconcileEnvText2(existing, values, keys);
882
1079
  if (existing === null && reconciled === "")
883
1080
  return "skipped";
884
1081
  mkdirSync2(dir, { recursive: true, mode: 448 });
@@ -904,7 +1101,7 @@ function applyOwnerOnly(path, dir) {
904
1101
  function engagementResolutionShell() {
905
1102
  return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
906
1103
  # THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
907
- # $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
1104
+ # $HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
908
1105
  # written owner-only by \`npx halfcycle\`. What this repository holds is the
909
1106
  # engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
910
1107
  # the pin, then name the file. There is no jq here and node is not dependable on a
@@ -935,10 +1132,10 @@ halfcycle_env_file() {
935
1132
  HALFCYCLE_ENV_PROBLEM="no-home"
936
1133
  return 1
937
1134
  fi
938
- 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
939
1136
  HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
940
1137
  fi
941
- hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
1138
+ hc_env="$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
942
1139
  if [ ! -f "$hc_env" ]; then
943
1140
  HALFCYCLE_ENV_PROBLEM="no-credential"
944
1141
  return 1
@@ -1129,7 +1326,7 @@ function resolveVendoredBinary() {
1129
1326
  if (existsSync3(candidate))
1130
1327
  return candidate;
1131
1328
  }
1132
- 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(", ")}.`);
1133
1330
  }
1134
1331
  var WRITE_ALLOWLIST = [
1135
1332
  ".claude/commands",
@@ -1278,6 +1475,15 @@ function generateSettingsJson() {
1278
1475
  timeout: 5
1279
1476
  }
1280
1477
  ]
1478
+ },
1479
+ {
1480
+ hooks: [
1481
+ {
1482
+ type: "command",
1483
+ command: "bash ./.claude/hooks/guard-runner.sh refresh-credential",
1484
+ timeout: 10
1485
+ }
1486
+ ]
1281
1487
  }
1282
1488
  ],
1283
1489
  UserPromptSubmit: [
@@ -1347,9 +1553,9 @@ function generateGuardRunnerWrapper() {
1347
1553
  ${GENERATED_GUARD_RUNNER_HEADER} the Halfcycle installer.
1348
1554
  #
1349
1555
  # Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
1350
- # values when Claude Code fires PostToolUse / Stop / SubagentStop, then exec's the
1351
- # SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path inside this
1352
- # 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.
1353
1559
  #
1354
1560
  # The runner itself reads only process.env, so env loading lives here in the hook
1355
1561
  # wiring rather than inside the binary.
@@ -1364,6 +1570,16 @@ ${engagementResolutionShell()}
1364
1570
  # the runner say what it found \u2014 its never-configured branch is loud and non-zero
1365
1571
  # on purpose, and a wrapper that exited quietly here would hide it.
1366
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
1367
1583
  set -a
1368
1584
  . "$HALFCYCLE_ENV_FILE"
1369
1585
  set +a
@@ -2213,6 +2429,7 @@ var SIGN_IN_TIMEOUT_MESSAGE = "Sign-in timed out: the Halfcycle service stopped
2213
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.";
2214
2430
  var DEADLINE_SLACK_INTERVALS = 1;
2215
2431
  var STILL_WAITING_MS = 15e3;
2432
+ var DEFAULT_SIGN_IN_REASON = "create an engagement";
2216
2433
  function defaultWrite(text) {
2217
2434
  process.stdout.write(text);
2218
2435
  }
@@ -2402,7 +2619,7 @@ async function signIn(serviceUrl, deps = {}) {
2402
2619
  if (isNonInteractive(env)) {
2403
2620
  throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_NON_INTERACTIVE_MESSAGE);
2404
2621
  }
2405
- 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.
2406
2623
  `);
2407
2624
  const bind = await bindLoopback(env);
2408
2625
  let closed = false;
@@ -2559,6 +2776,12 @@ async function joinEngagement(baseUrl, engagementId, credential) {
2559
2776
  route: "POST /engagements/:engagementId/join"
2560
2777
  });
2561
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
+ }
2562
2785
  async function requestEngagementValues(url, requestBody, credential, shape) {
2563
2786
  const bearer = credential?.trim();
2564
2787
  let res;
@@ -2583,7 +2806,9 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
2583
2806
  const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
2584
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);
2585
2808
  }
2586
- 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;
2587
2812
  if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
2588
2813
  throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2589
2814
  }
@@ -2602,6 +2827,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
2602
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.`);
2603
2828
  }
2604
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
+ }
2605
2840
  return {
2606
2841
  engagementId: body.engagementId,
2607
2842
  sessionToken: body.sessionToken,
@@ -2940,7 +3175,8 @@ function currentEnvironment(quiet2) {
2940
3175
  // dist/confirm-identity.js
2941
3176
  var IDENTITY_TIMEOUT_MS = 5e3;
2942
3177
  var IDENTITY_DECLINED = "identity-declined";
2943
- async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
3178
+ var CREDENTIAL_REFUSED = 401;
3179
+ async function askAccount(serviceUrl, credential, fetchImpl = fetch) {
2944
3180
  const url = `${serviceUrl.replace(/\/+$/, "")}/account`;
2945
3181
  const controller = new AbortController();
2946
3182
  const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
@@ -2950,15 +3186,18 @@ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
2950
3186
  signal: controller.signal
2951
3187
  });
2952
3188
  if (!res.ok)
2953
- return void 0;
3189
+ return { identity: void 0, refused: res.status === CREDENTIAL_REFUSED };
2954
3190
  const parsed = safeParseAccountIdentity(await res.json());
2955
- return parsed.success ? parsed.data : void 0;
3191
+ return { identity: parsed.success ? parsed.data : void 0, refused: false };
2956
3192
  } catch {
2957
- return void 0;
3193
+ return { identity: void 0, refused: false };
2958
3194
  } finally {
2959
3195
  clearTimeout(timer);
2960
3196
  }
2961
3197
  }
3198
+ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
3199
+ return (await askAccount(serviceUrl, credential, fetchImpl)).identity;
3200
+ }
2962
3201
  function accountLabel(identity, storedAccountId, verbose2 = false) {
2963
3202
  const accountId = identity?.accountId ?? storedAccountId;
2964
3203
  if (identity?.email !== void 0 && identity.email.trim() !== "") {
@@ -3412,7 +3651,7 @@ function renderBuildRecordMarkdown(record2) {
3412
3651
  "",
3413
3652
  `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
3414
3653
  "",
3415
- "> 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.",
3416
3655
  "",
3417
3656
  `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
3418
3657
  `**Phase:** ${phase.id} \u2014 ${phase.name}`,
@@ -3432,7 +3671,7 @@ function renderBuildRecordMarkdown(record2) {
3432
3671
  `- **Tools:** ${record2.delivered.tools.join(", ")}`,
3433
3672
  `- **Dogfood:** ${record2.delivered.dogfood}`,
3434
3673
  "",
3435
- 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.",
3436
3675
  "",
3437
3676
  `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
3438
3677
  "",
@@ -3444,7 +3683,7 @@ function renderBuildRecordMarkdown(record2) {
3444
3683
  ""
3445
3684
  ];
3446
3685
  if (record2.acceptance.inv002ProdBypassProbe) {
3447
- 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}`, "");
3448
3687
  }
3449
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"}`, "");
3450
3689
  return lines.join("\n");
@@ -3837,15 +4076,115 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
3837
4076
  return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
3838
4077
  }
3839
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
+
3840
4171
  // dist/cli-contract.js
3841
- 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"];
3842
4173
  var CONTRACTS = [
3843
- // `install`, `check-drift` and `build-record` take positionals and no required
3844
- // flags. They are declared so the verb set has ONE home — a remedy naming a verb
3845
- // 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.
3846
4184
  { verb: "install", required: [], conditional: [] },
3847
4185
  { verb: "check-drift", required: [], conditional: [] },
3848
4186
  { verb: "build-record", required: [], conditional: [] },
4187
+ { verb: "ci", required: [], conditional: [] },
3849
4188
  {
3850
4189
  verb: "open-phase",
3851
4190
  required: [
@@ -3879,7 +4218,7 @@ var CONTRACTS = [
3879
4218
  {
3880
4219
  flag: "--verdict",
3881
4220
  takesValue: true,
3882
- 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.`
3883
4222
  },
3884
4223
  {
3885
4224
  flag: "--actor",
@@ -4038,10 +4377,9 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
4038
4377
  fact("Claude Code", probes.claudeCodeVersion()),
4039
4378
  fact("Project", project, name),
4040
4379
  fact("Status", status, installed === void 0 ? "new" : "installed"),
4041
- // `Website`, NOT `Plane` (W-3, #868). "Plane" is our internal word for the control
4042
- // plane; the developer reading this has never heard it, and raw method vocabulary
4043
- // reaching a client surface is a recorded finding class in this product. The row
4044
- // 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.)
4045
4383
  fact("Website", probes.websiteHost(env))
4046
4384
  ].filter((f) => f !== void 0);
4047
4385
  return {
@@ -4121,6 +4459,8 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
4121
4459
  halfcycle build-record <phase> [--repo <root>]
4122
4460
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
4123
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>]
4124
4464
 
4125
4465
  --quiet / -q: print no opening banner (any command)
4126
4466
  --verbose / -v: print full run detail (paths written, merged, skipped) on install
@@ -4135,7 +4475,7 @@ function isHalfcycleMonorepo(dir) {
4135
4475
  }
4136
4476
  async function main() {
4137
4477
  const [cmd, ...rest] = args;
4138
- 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);
4139
4479
  const installArm = cmd === "install" || cmd === void 0 || bareTarget;
4140
4480
  const positionals = cmd === "install" ? rest : args;
4141
4481
  const targetRepo = installArm ? positionals[0] ?? process.cwd() : process.cwd();
@@ -4176,7 +4516,9 @@ ${USAGE}`);
4176
4516
  }
4177
4517
  const pinned = readPinnedEngagement(targetRepo);
4178
4518
  const requestedId = engagementIdArg ?? pinned?.engagementId;
4179
- 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;
4180
4522
  let engagementId;
4181
4523
  let credential;
4182
4524
  let notThisAccountMessage;
@@ -4322,7 +4664,7 @@ ${USAGE}`);
4322
4664
  `);
4323
4665
  }
4324
4666
  reportClaudeCodeVersion(verbose);
4325
- 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);
4326
4668
  const written = result.writtenPaths.length;
4327
4669
  const merged = result.mergedPaths.length;
4328
4670
  process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
@@ -4593,6 +4935,69 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
4593
4935
  return;
4594
4936
  }
4595
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)}
4596
5001
  `);
4597
5002
  process.exit(1);
4598
5003
  }