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/index.js CHANGED
@@ -23,6 +23,89 @@ var matcherSchema = z.discriminatedUnion("kind", [
23
23
  llmMatcherSchema
24
24
  ]);
25
25
 
26
+ // ../core/dist/credential-format.js
27
+ var HALFCYCLE_DIR_NAME = ".halfcycle";
28
+ var ENGAGEMENTS_DIR_NAME = "engagements";
29
+ var ENGAGEMENT_ENV_FILENAME = "env";
30
+ var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
31
+ var ENGAGEMENT_ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
32
+ function shq(value) {
33
+ return `'${value.replace(/'/g, `'\\''`)}'`;
34
+ }
35
+ function unquote(value) {
36
+ const v = value.trim();
37
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
38
+ return v.slice(1, -1).split(`'\\''`).join(`'`);
39
+ }
40
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
41
+ return v.slice(1, -1);
42
+ return v;
43
+ }
44
+ function parseEnvText(raw) {
45
+ const out = {};
46
+ for (const line of raw.split("\n")) {
47
+ const eq = line.indexOf("=");
48
+ if (eq === -1)
49
+ continue;
50
+ const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
51
+ if (key === "" || key.startsWith("#"))
52
+ continue;
53
+ out[key] = unquote(line.slice(eq + 1));
54
+ }
55
+ return out;
56
+ }
57
+ function reconcileEnvText(existing, values, keys) {
58
+ const desired = new Map(keys.map((k) => {
59
+ const value = values[k];
60
+ return [k, value === void 0 ? "" : value];
61
+ }));
62
+ const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
63
+ const written = keys.filter((k) => desired.get(k) !== null);
64
+ if (existing === null) {
65
+ if (written.length === 0)
66
+ return "";
67
+ return `${ENGAGEMENT_ENV_HEADER}
68
+ ` + written.map(line).join("\n") + "\n";
69
+ }
70
+ const seen = /* @__PURE__ */ new Set();
71
+ const out = [];
72
+ for (const existingLine of existing.split("\n")) {
73
+ const eq = existingLine.indexOf("=");
74
+ if (eq === -1) {
75
+ out.push(existingLine);
76
+ continue;
77
+ }
78
+ const lhs = existingLine.slice(0, eq);
79
+ const exported = /^\s*export\s+/.exec(lhs);
80
+ const prefix = exported === null ? "" : exported[0];
81
+ const key = lhs.slice(prefix.length).trim();
82
+ if (!desired.has(key)) {
83
+ out.push(existingLine);
84
+ continue;
85
+ }
86
+ seen.add(key);
87
+ if (desired.get(key) === null)
88
+ continue;
89
+ out.push(`${prefix}${line(key)}`);
90
+ }
91
+ const missing = written.filter((k) => !seen.has(k));
92
+ if (missing.length > 0) {
93
+ const header = existing.includes(ENGAGEMENT_ENV_HEADER) ? "" : `${ENGAGEMENT_ENV_HEADER}
94
+ `;
95
+ const block = header + missing.map(line).join("\n");
96
+ const trailingBlank = out.length > 0 && out[out.length - 1] === "";
97
+ if (trailingBlank)
98
+ out.splice(out.length - 1, 0, block);
99
+ else
100
+ out.push(`
101
+ ${block}`);
102
+ }
103
+ let result = out.join("\n");
104
+ if (existing.endsWith("\n") && !result.endsWith("\n"))
105
+ result += "\n";
106
+ return result;
107
+ }
108
+
26
109
  // ../events/dist/result.js
27
110
  var firedGuardSchema = z2.object({
28
111
  guardId: z2.string(),
@@ -83,7 +166,7 @@ var wireErrorSchema = z2.object({
83
166
 
84
167
  // ../events/dist/telemetry.js
85
168
  import { z as z3 } from "zod";
86
- 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)");
169
+ 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");
87
170
  var guardEvalOutcomeSchema = z3.enum([
88
171
  "evaluated",
89
172
  "infra-error",
@@ -137,50 +220,364 @@ var guardEvalRunSchema = z3.object({
137
220
  }
138
221
  });
139
222
 
140
- // ../events/dist/crew.js
223
+ // ../events/dist/state.js
141
224
  import { z as z4 } from "zod";
142
- var crewGroupSchema = z4.enum(["builder", "reviewer", "always-on"]);
143
- var crewTierSchema = z4.enum(["deep", "standard", "fast"]);
225
+ 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");
226
+ var STATE_RECORD_FORMAT = "halfcycle-state-record/v1";
227
+ var stepIdSchema = z4.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
228
+ var methodVersionSchema = z4.string().regex(/^\d+\.\d+\.\d+$/);
229
+ var anchorPathRegex = /^(?!\/)(?!.*#)(?!method\/)(?!(.*\/)?docs\/method\/).+$/;
230
+ var recordKindSchema = z4.enum(["artefact", "step-run", "gate", "intervention"]);
231
+ var methodLayerSchema = z4.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
232
+ var evidenceKindSchema = z4.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
233
+ var declarableEvidenceKindSchema = z4.enum(["anchor", "diff", "repro", "question-answer"]);
234
+ var dispositionSchema = z4.enum([
235
+ "prevented",
236
+ "corrected-in-spec",
237
+ "corrected-in-scope",
238
+ "blocked-in-code",
239
+ "flagged",
240
+ "auto-fixed",
241
+ "surfaced",
242
+ "filed-forward"
243
+ ]);
244
+ var mechanismSchema = z4.enum(["guard", "verdict", "served-step", "method-step"]);
245
+ var artefactStateSchema = z4.enum(["created", "updated"]);
246
+ var QUALIFIED_OUTCOMES = ["declined", "parked"];
247
+ var qualifiedOutcomeSchema = z4.enum(QUALIFIED_OUTCOMES);
248
+ function isQualifiedOutcome(outcome) {
249
+ return QUALIFIED_OUTCOMES.includes(outcome);
250
+ }
251
+ var runOutcomeSchema = z4.enum([
252
+ "completed",
253
+ "attempted-failed",
254
+ ...QUALIFIED_OUTCOMES
255
+ ]);
256
+ var gateKindSchema = z4.enum(["approval", "mechanical"]);
257
+ var gateVerdictSchema = z4.enum(["pass", "fail", ...QUALIFIED_OUTCOMES]);
258
+ function outcomeReasonIssue(outcome, reason) {
259
+ if (isQualifiedOutcome(outcome) && reason === void 0) {
260
+ return { message: `outcomeReason is required when the outcome is '${outcome}'` };
261
+ }
262
+ if (!isQualifiedOutcome(outcome) && reason !== void 0) {
263
+ return {
264
+ message: `outcomeReason is only present on a qualified outcome (${QUALIFIED_OUTCOMES.join(" | ")})`
265
+ };
266
+ }
267
+ return null;
268
+ }
269
+ var anchorEvidenceSchema = z4.object({
270
+ kind: z4.literal("anchor"),
271
+ path: z4.string().regex(anchorPathRegex),
272
+ // Optional 1-based line number within the file at `path`. Present when the
273
+ // writer knows the exact line; omitted — never `0`, never `null` — when it
274
+ // does not, because a line number is not always obtainable. Absence is a
275
+ // valid, permanent state here, not a gap meant to be filled in later.
276
+ line: z4.number().optional()
277
+ }).strict();
278
+ var diffEvidenceSchema = z4.object({
279
+ kind: z4.literal("diff"),
280
+ ref: z4.string()
281
+ }).strict();
282
+ var reproEvidenceSchema = z4.object({
283
+ kind: z4.literal("repro"),
284
+ steps: z4.string().max(2e3)
285
+ }).strict();
286
+ var questionAnswerEvidenceSchema = z4.object({
287
+ kind: z4.literal("question-answer"),
288
+ question: z4.string().max(2e3),
289
+ before: z4.string().max(2e3),
290
+ after: z4.string().max(2e3)
291
+ }).strict();
292
+ var noOpEvidenceSchema = z4.object({
293
+ kind: z4.literal("no-op"),
294
+ inspected: z4.string().max(2e3),
295
+ unchangedBecause: z4.string().max(2e3)
296
+ }).strict();
297
+ var evidenceSchema = z4.discriminatedUnion("kind", [
298
+ anchorEvidenceSchema,
299
+ diffEvidenceSchema,
300
+ reproEvidenceSchema,
301
+ questionAnswerEvidenceSchema,
302
+ noOpEvidenceSchema
303
+ ]);
304
+ var stateRecordEnvelopeSchema = z4.object({
305
+ format: z4.literal(STATE_RECORD_FORMAT),
306
+ recordKind: recordKindSchema,
307
+ recordId: z4.string().uuid(),
308
+ methodVersion: methodVersionSchema,
309
+ engagementId: z4.string(),
310
+ stepId: stepIdSchema,
311
+ // Immutable: names when the thing happened, never when a later disposition
312
+ // moved.
313
+ occurredAt: utcIso86012,
314
+ // Equal to `occurredAt` on first write; advances on each re-emit OF A FINDING.
315
+ // Carried on the shared envelope so a "when did this last change" query is
316
+ // kind-agnostic. Kept rather than generalised to `updatedAt` because
317
+ // `disposition` is the only field any re-emit may change (§5.3).
318
+ dispositionAt: utcIso86012,
319
+ evidence: evidenceSchema,
320
+ // Which phase of the project this record belongs to, if any. Present on
321
+ // records written during phase-scoped work; absent on records that run once
322
+ // for the whole project rather than per phase. Absence is meaningful on its
323
+ // own — it is never replaced with a placeholder value, and it is read as
324
+ // "satisfies any phase", not as "unknown".
325
+ phase: z4.string().optional()
326
+ });
327
+ var artefactRefSchema = z4.object({
328
+ name: z4.string(),
329
+ parent: z4.string()
330
+ }).strict();
331
+ var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
332
+ recordKind: z4.literal("artefact"),
333
+ artefactRef: artefactRefSchema,
334
+ // Some layer wrote it, by construction — an empty array is not a legitimate
335
+ // state, so the schema says so.
336
+ writtenByLayers: z4.array(methodLayerSchema).min(1),
337
+ state: artefactStateSchema
338
+ }).strict();
339
+ var stepRunRecordSchema = stateRecordEnvelopeSchema.extend({
340
+ recordKind: z4.literal("step-run"),
341
+ runOutcome: runOutcomeSchema,
342
+ failureReason: z4.string().optional(),
343
+ outcomeReason: z4.string().max(2e3).optional()
344
+ }).strict().superRefine((rec, ctx) => {
345
+ if (rec.runOutcome === "attempted-failed" && rec.failureReason === void 0) {
346
+ ctx.addIssue({
347
+ code: z4.ZodIssueCode.custom,
348
+ path: ["failureReason"],
349
+ message: "failureReason is required when runOutcome is 'attempted-failed'"
350
+ });
351
+ }
352
+ if (rec.runOutcome !== "attempted-failed" && rec.failureReason !== void 0) {
353
+ ctx.addIssue({
354
+ code: z4.ZodIssueCode.custom,
355
+ path: ["failureReason"],
356
+ message: "failureReason is only present when runOutcome is 'attempted-failed'"
357
+ });
358
+ }
359
+ const issue = outcomeReasonIssue(rec.runOutcome, rec.outcomeReason);
360
+ if (issue) {
361
+ ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
362
+ }
363
+ });
364
+ var gateRecordSchema = stateRecordEnvelopeSchema.extend({
365
+ recordKind: z4.literal("gate"),
366
+ gateKind: gateKindSchema,
367
+ verdict: gateVerdictSchema,
368
+ actor: z4.string(),
369
+ outcomeReason: z4.string().max(2e3).optional()
370
+ }).strict().superRefine((rec, ctx) => {
371
+ const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
372
+ if (issue) {
373
+ ctx.addIssue({ code: z4.ZodIssueCode.custom, path: ["outcomeReason"], ...issue });
374
+ }
375
+ });
376
+ var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
377
+ recordKind: z4.literal("intervention"),
378
+ layer: methodLayerSchema,
379
+ mechanism: mechanismSchema,
380
+ severity: severitySchema,
381
+ disposition: dispositionSchema,
382
+ summary: z4.string().max(2e3)
383
+ }).strict();
384
+ var stateRecordSchema = z4.discriminatedUnion("recordKind", [
385
+ artefactRecordSchema,
386
+ stepRunRecordSchema,
387
+ gateRecordSchema,
388
+ interventionRecordSchema
389
+ ]);
390
+
391
+ // ../events/dist/crew.js
392
+ import { z as z5 } from "zod";
393
+ var crewGroupSchema = z5.enum(["builder", "reviewer", "always-on"]);
394
+ var crewTierSchema = z5.enum(["deep", "standard", "fast"]);
144
395
  var CREW_DISCIPLINE_MAX = 64;
145
- var crewMemberSchema = z4.object({
396
+ var crewFaceHeadSchema = z5.enum(["sq", "sqr", "rnd", "dome", "hex"]);
397
+ var crewFaceAntennaSchema = z5.enum(["none", "stalk", "twin", "dish"]);
398
+ var crewFaceEyesSchema = z5.enum(["dots", "bars", "visor", "cyclops"]);
399
+ var crewFaceMouthSchema = z5.enum(["line", "grid", "dots", "wave"]);
400
+ var crewFaceSchema = z5.object({
401
+ // The outline of the head. Members who do the same kind of work share one, so a
402
+ // team is recognisable before any name is read.
403
+ head: crewFaceHeadSchema,
404
+ // What sits on top of the head, if anything.
405
+ antenna: crewFaceAntennaSchema,
406
+ // The eyes.
407
+ eyes: crewFaceEyesSchema,
408
+ // The mouth.
409
+ mouth: crewFaceMouthSchema,
410
+ // Whether this member is drawn with ears.
411
+ ears: z5.boolean()
412
+ }).strict();
413
+ var crewMemberSchema = z5.object({
146
414
  // The agent's name. The same twenty-one on every project, so a name is a
147
415
  // vocabulary a returning client already knows.
148
- callsign: z4.string().min(1).max(32),
416
+ callsign: z5.string().min(1).max(32),
149
417
  // Which of the three headings this member reads under.
150
418
  group: crewGroupSchema,
151
419
  // The kind of work this member takes. A task names a discipline, and only
152
420
  // agents holding it can be dispatched to it.
153
- discipline: z4.string().min(1).max(CREW_DISCIPLINE_MAX),
421
+ discipline: z5.string().min(1).max(CREW_DISCIPLINE_MAX),
154
422
  // How much thinking this member brings. There is no field naming the supplier
155
423
  // behind it, on this shape or on any other.
156
- tier: crewTierSchema
424
+ tier: crewTierSchema,
425
+ // How this member's face is drawn. The same face on every project, so a
426
+ // returning client recognises a member before reading the name.
427
+ face: crewFaceSchema
157
428
  }).strict();
158
429
  var CREW_ROSTER = [
159
430
  // ── builders: code ──────────────────────────────────────────────────────────
160
- { callsign: "ADA", group: "builder", discipline: "implementation", tier: "deep" },
161
- { callsign: "OTTO", group: "builder", discipline: "implementation", tier: "deep" },
162
- { callsign: "MILO", group: "builder", discipline: "implementation", tier: "standard" },
163
- { callsign: "NOVA", group: "builder", discipline: "implementation", tier: "standard" },
164
- { callsign: "KAI", group: "builder", discipline: "implementation", tier: "standard" },
165
- { callsign: "HUGO", group: "builder", discipline: "infrastructure", tier: "standard" },
166
- { callsign: "WREN", group: "builder", discipline: "refactor", tier: "fast" },
167
- { callsign: "PIP", group: "builder", discipline: "refactor", tier: "fast" },
431
+ {
432
+ callsign: "ADA",
433
+ group: "builder",
434
+ discipline: "implementation",
435
+ tier: "deep",
436
+ face: { head: "sq", antenna: "stalk", eyes: "dots", mouth: "line", ears: false }
437
+ },
438
+ {
439
+ callsign: "OTTO",
440
+ group: "builder",
441
+ discipline: "implementation",
442
+ tier: "deep",
443
+ face: { head: "sq", antenna: "twin", eyes: "bars", mouth: "grid", ears: true }
444
+ },
445
+ {
446
+ callsign: "MILO",
447
+ group: "builder",
448
+ discipline: "implementation",
449
+ tier: "standard",
450
+ face: { head: "sqr", antenna: "stalk", eyes: "dots", mouth: "dots", ears: false }
451
+ },
452
+ {
453
+ callsign: "NOVA",
454
+ group: "builder",
455
+ discipline: "implementation",
456
+ tier: "standard",
457
+ face: { head: "sq", antenna: "dish", eyes: "cyclops", mouth: "line", ears: false }
458
+ },
459
+ {
460
+ callsign: "KAI",
461
+ group: "builder",
462
+ discipline: "implementation",
463
+ tier: "standard",
464
+ face: { head: "sqr", antenna: "none", eyes: "bars", mouth: "line", ears: true }
465
+ },
466
+ {
467
+ callsign: "HUGO",
468
+ group: "builder",
469
+ discipline: "infrastructure",
470
+ tier: "standard",
471
+ face: { head: "sq", antenna: "twin", eyes: "dots", mouth: "grid", ears: true }
472
+ },
473
+ {
474
+ callsign: "WREN",
475
+ group: "builder",
476
+ discipline: "refactor",
477
+ tier: "fast",
478
+ face: { head: "sqr", antenna: "stalk", eyes: "bars", mouth: "wave", ears: false }
479
+ },
480
+ {
481
+ callsign: "PIP",
482
+ group: "builder",
483
+ discipline: "refactor",
484
+ tier: "fast",
485
+ face: { head: "sqr", antenna: "none", eyes: "dots", mouth: "dots", ears: false }
486
+ },
168
487
  // ── builders: specs and docs ────────────────────────────────────────────────
169
- { callsign: "IRIS", group: "builder", discipline: "feature spec", tier: "deep" },
170
- { callsign: "JUNO", group: "builder", discipline: "feature spec", tier: "standard" },
171
- { callsign: "LEX", group: "builder", discipline: "context & docs", tier: "fast" },
488
+ {
489
+ callsign: "IRIS",
490
+ group: "builder",
491
+ discipline: "feature spec",
492
+ tier: "deep",
493
+ face: { head: "dome", antenna: "stalk", eyes: "visor", mouth: "line", ears: false }
494
+ },
495
+ {
496
+ callsign: "JUNO",
497
+ group: "builder",
498
+ discipline: "feature spec",
499
+ tier: "standard",
500
+ face: { head: "dome", antenna: "none", eyes: "dots", mouth: "wave", ears: false }
501
+ },
502
+ {
503
+ callsign: "LEX",
504
+ group: "builder",
505
+ discipline: "context & docs",
506
+ tier: "fast",
507
+ face: { head: "dome", antenna: "twin", eyes: "bars", mouth: "dots", ears: false }
508
+ },
172
509
  // ── reviewers ───────────────────────────────────────────────────────────────
173
- { callsign: "VERA", group: "reviewer", discipline: "spec consistency", tier: "deep" },
174
- { callsign: "ODIN", group: "reviewer", discipline: "cross-spec audit", tier: "deep" },
175
- { callsign: "CASS", group: "reviewer", discipline: "code review", tier: "deep" },
176
- { callsign: "THEO", group: "reviewer", discipline: "code review", tier: "standard" },
177
- { callsign: "ECHO", group: "reviewer", discipline: "test quality", tier: "standard" },
178
- { callsign: "MAVIS", group: "reviewer", discipline: "suites & coverage", tier: "standard" },
179
- { callsign: "ZARA", group: "reviewer", discipline: "smoke & walk aid", tier: "standard" },
180
- { callsign: "NORA", group: "reviewer", discipline: "evidence & provenance", tier: "fast" },
510
+ {
511
+ callsign: "VERA",
512
+ group: "reviewer",
513
+ discipline: "spec consistency",
514
+ tier: "deep",
515
+ face: { head: "rnd", antenna: "stalk", eyes: "visor", mouth: "line", ears: false }
516
+ },
517
+ {
518
+ callsign: "ODIN",
519
+ group: "reviewer",
520
+ discipline: "cross-spec audit",
521
+ tier: "deep",
522
+ face: { head: "rnd", antenna: "dish", eyes: "cyclops", mouth: "line", ears: false }
523
+ },
524
+ {
525
+ callsign: "CASS",
526
+ group: "reviewer",
527
+ discipline: "code review",
528
+ tier: "deep",
529
+ face: { head: "rnd", antenna: "none", eyes: "visor", mouth: "grid", ears: true }
530
+ },
531
+ {
532
+ callsign: "THEO",
533
+ group: "reviewer",
534
+ discipline: "code review",
535
+ tier: "standard",
536
+ face: { head: "rnd", antenna: "twin", eyes: "bars", mouth: "line", ears: false }
537
+ },
538
+ {
539
+ callsign: "ECHO",
540
+ group: "reviewer",
541
+ discipline: "test quality",
542
+ tier: "standard",
543
+ face: { head: "rnd", antenna: "stalk", eyes: "dots", mouth: "wave", ears: false }
544
+ },
545
+ {
546
+ callsign: "MAVIS",
547
+ group: "reviewer",
548
+ discipline: "suites & coverage",
549
+ tier: "standard",
550
+ face: { head: "rnd", antenna: "none", eyes: "visor", mouth: "dots", ears: false }
551
+ },
552
+ {
553
+ callsign: "ZARA",
554
+ group: "reviewer",
555
+ discipline: "smoke & walk aid",
556
+ tier: "standard",
557
+ face: { head: "rnd", antenna: "dish", eyes: "bars", mouth: "wave", ears: false }
558
+ },
559
+ {
560
+ callsign: "NORA",
561
+ group: "reviewer",
562
+ discipline: "evidence & provenance",
563
+ tier: "fast",
564
+ face: { head: "rnd", antenna: "twin", eyes: "dots", mouth: "line", ears: false }
565
+ },
181
566
  // ── always on: continuous, takes no task ────────────────────────────────────
182
- { callsign: "ARGUS", group: "always-on", discipline: "guard \xB7 every diff", tier: "standard" },
183
- { callsign: "ATLAS", group: "always-on", discipline: "coordinator", tier: "deep" }
567
+ {
568
+ callsign: "ARGUS",
569
+ group: "always-on",
570
+ discipline: "guard \xB7 every diff",
571
+ tier: "standard",
572
+ face: { head: "hex", antenna: "dish", eyes: "visor", mouth: "grid", ears: true }
573
+ },
574
+ {
575
+ callsign: "ATLAS",
576
+ group: "always-on",
577
+ discipline: "coordinator",
578
+ tier: "deep",
579
+ face: { head: "hex", antenna: "twin", eyes: "cyclops", mouth: "line", ears: true }
580
+ }
184
581
  ];
185
582
  var CREW_ROSTER_SIZE = CREW_ROSTER.length;
186
583
  var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
@@ -246,6 +643,21 @@ function describeIdentity(identity) {
246
643
  return `(a ${typeof identity})`;
247
644
  }
248
645
 
646
+ // ../events/dist/credential-wire.js
647
+ import { z as z6 } from "zod";
648
+ var engagementCredentialResponseSchema = z6.object({
649
+ engagementId: z6.string(),
650
+ sessionToken: z6.string(),
651
+ mcpUrl: z6.string(),
652
+ guardUrl: z6.string(),
653
+ controlTelemetryUrl: z6.string()
654
+ }).strict();
655
+ var ciTokenExchangeResponseSchema = z6.object({
656
+ token: z6.string(),
657
+ expiresAt: utcIso86012,
658
+ engagementId: z6.string()
659
+ }).strict();
660
+
249
661
  // dist/engagement-credential.js
250
662
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
251
663
  import { platform as platform2 } from "node:os";
@@ -255,12 +667,13 @@ import { join as join2 } from "node:path";
255
667
  import { homedir, platform } from "node:os";
256
668
  import { dirname, join } from "node:path";
257
669
  function halfcycleHome(home) {
258
- return join(home ?? homedir(), ".halfcycle");
670
+ return join(home ?? homedir(), HALFCYCLE_DIR_NAME);
259
671
  }
260
672
 
261
673
  // dist/engagement-credential.js
262
- var ENGAGEMENTS_DIR = "engagements";
263
- var ENV_FILENAME = "env";
674
+ var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
675
+ var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
676
+ var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
264
677
  var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
265
678
  function engagementStateDir(engagementId, home) {
266
679
  return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
@@ -268,7 +681,6 @@ function engagementStateDir(engagementId, home) {
268
681
  function engagementEnvPath(engagementId, home) {
269
682
  return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
270
683
  }
271
- var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
272
684
  var GUARD_ENV_KEYS = [
273
685
  "GUARD_SERVICE_URL",
274
686
  "GUARD_SERVICE_TOKEN",
@@ -283,82 +695,8 @@ var ENGAGEMENT_ENV_KEYS = [
283
695
  ...GUARD_ENV_KEYS,
284
696
  "CONTROL_TELEMETRY_URL"
285
697
  ];
286
- var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
287
- function shq(value) {
288
- return `'${value.replace(/'/g, `'\\''`)}'`;
289
- }
290
- function unquote(value) {
291
- const v = value.trim();
292
- if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
293
- return v.slice(1, -1).split(`'\\''`).join(`'`);
294
- }
295
- if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
296
- return v.slice(1, -1);
297
- return v;
298
- }
299
- function parseEnvText(raw) {
300
- const out = {};
301
- for (const line of raw.split("\n")) {
302
- const eq = line.indexOf("=");
303
- if (eq === -1)
304
- continue;
305
- const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
306
- if (key === "" || key.startsWith("#"))
307
- continue;
308
- out[key] = unquote(line.slice(eq + 1));
309
- }
310
- return out;
311
- }
312
- function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
313
- const desired = new Map(keys.map((k) => {
314
- const value = values[k];
315
- return [k, value === void 0 ? "" : value];
316
- }));
317
- const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
318
- const written = keys.filter((k) => desired.get(k) !== null);
319
- if (existing === null) {
320
- if (written.length === 0)
321
- return "";
322
- return `${ENV_HEADER}
323
- ` + written.map(line).join("\n") + "\n";
324
- }
325
- const seen = /* @__PURE__ */ new Set();
326
- const out = [];
327
- for (const existingLine of existing.split("\n")) {
328
- const eq = existingLine.indexOf("=");
329
- if (eq === -1) {
330
- out.push(existingLine);
331
- continue;
332
- }
333
- const lhs = existingLine.slice(0, eq);
334
- const exported = /^\s*export\s+/.exec(lhs);
335
- const prefix = exported === null ? "" : exported[0];
336
- const key = lhs.slice(prefix.length).trim();
337
- if (!desired.has(key)) {
338
- out.push(existingLine);
339
- continue;
340
- }
341
- seen.add(key);
342
- if (desired.get(key) === null)
343
- continue;
344
- out.push(`${prefix}${line(key)}`);
345
- }
346
- const missing = written.filter((k) => !seen.has(k));
347
- if (missing.length > 0) {
348
- const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
349
- `;
350
- const block = header + missing.map(line).join("\n");
351
- const trailingBlank = out.length > 0 && out[out.length - 1] === "";
352
- if (trailingBlank)
353
- out.splice(out.length - 1, 0, block);
354
- else
355
- out.push(`
356
- ${block}`);
357
- }
358
- let result = out.join("\n");
359
- if (existing.endsWith("\n") && !result.endsWith("\n"))
360
- result += "\n";
361
- return result;
698
+ function reconcileEnvText2(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
699
+ return reconcileEnvText(existing, values, keys);
362
700
  }
363
701
  function readEngagementEnv(engagementId, home) {
364
702
  try {
@@ -376,7 +714,7 @@ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KE
376
714
  } catch {
377
715
  existing = null;
378
716
  }
379
- const reconciled = reconcileEnvText(existing, values, keys);
717
+ const reconciled = reconcileEnvText2(existing, values, keys);
380
718
  if (existing === null && reconciled === "")
381
719
  return "skipped";
382
720
  mkdirSync(dir, { recursive: true, mode: 448 });
@@ -402,7 +740,7 @@ function applyOwnerOnly(path, dir) {
402
740
  function engagementResolutionShell() {
403
741
  return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
404
742
  # THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
405
- # $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
743
+ # $HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
406
744
  # written owner-only by \`npx halfcycle\`. What this repository holds is the
407
745
  # engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
408
746
  # the pin, then name the file. There is no jq here and node is not dependable on a
@@ -433,10 +771,10 @@ halfcycle_env_file() {
433
771
  HALFCYCLE_ENV_PROBLEM="no-home"
434
772
  return 1
435
773
  fi
436
- if [ -f "$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
774
+ if [ -f "$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
437
775
  HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
438
776
  fi
439
- hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
777
+ hc_env="$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
440
778
  if [ ! -f "$hc_env" ]; then
441
779
  HALFCYCLE_ENV_PROBLEM="no-credential"
442
780
  return 1
@@ -635,7 +973,7 @@ function resolveVendoredBinary() {
635
973
  if (existsSync3(candidate))
636
974
  return candidate;
637
975
  }
638
- 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(", ")}.`);
976
+ 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(", ")}.`);
639
977
  }
640
978
  var WRITE_ALLOWLIST = [
641
979
  ".claude/commands",
@@ -696,11 +1034,15 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
696
1034
  writeFileSync3(targetAbsPath, content, "utf-8");
697
1035
  return "written";
698
1036
  }
1037
+ var INSTALLER_OWNED_DIR = ".halfcycle/";
699
1038
  function writeOwned(targetAbsPath, targetRepoRoot, content) {
700
1039
  const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
701
1040
  if (!isAllowlisted(rel)) {
702
1041
  throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
703
1042
  }
1043
+ if (!rel.startsWith(INSTALLER_OWNED_DIR)) {
1044
+ 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.`);
1045
+ }
704
1046
  if (existsSync3(targetAbsPath) && readFileSync4(targetAbsPath, "utf-8") === content) {
705
1047
  return "skipped";
706
1048
  }
@@ -708,6 +1050,39 @@ function writeOwned(targetAbsPath, targetRepoRoot, content) {
708
1050
  writeFileSync3(targetAbsPath, content, "utf-8");
709
1051
  return "written";
710
1052
  }
1053
+ var GENERATED_HEADER_LINE_INDEX = 1;
1054
+ function writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker) {
1055
+ const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
1056
+ if (!isAllowlisted(rel)) {
1057
+ throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
1058
+ }
1059
+ let replacedExisting = false;
1060
+ if (existsSync3(targetAbsPath)) {
1061
+ const current = readFileSync4(targetAbsPath, "utf-8");
1062
+ if (current === content)
1063
+ return { outcome: "skipped", replacedExisting: false };
1064
+ const headerLine = current.split("\n")[GENERATED_HEADER_LINE_INDEX] ?? "";
1065
+ if (!headerLine.startsWith(generatedMarker))
1066
+ return { outcome: "collided", replacedExisting: false };
1067
+ replacedExisting = true;
1068
+ }
1069
+ mkdirSync3(dirname2(targetAbsPath), { recursive: true });
1070
+ writeFileSync3(targetAbsPath, content, "utf-8");
1071
+ return { outcome: "written", replacedExisting };
1072
+ }
1073
+ function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, generatedMarker, rel) {
1074
+ const { outcome, replacedExisting } = writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker);
1075
+ record(report, outcome, rel);
1076
+ if (replacedExisting)
1077
+ report.replacedPaths.push(rel);
1078
+ }
1079
+ function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1080
+ const existedBefore = existsSync3(targetAbsPath);
1081
+ const outcome = writeOwned(targetAbsPath, targetRepoRoot, content);
1082
+ record(report, outcome, rel);
1083
+ if (outcome === "written" && existedBefore)
1084
+ report.replacedPaths.push(rel);
1085
+ }
711
1086
  function record(report, outcome, rel) {
712
1087
  const bucket = {
713
1088
  written: report.writtenPaths,
@@ -747,6 +1122,15 @@ function generateSettingsJson() {
747
1122
  timeout: 5
748
1123
  }
749
1124
  ]
1125
+ },
1126
+ {
1127
+ hooks: [
1128
+ {
1129
+ type: "command",
1130
+ command: "bash ./.claude/hooks/guard-runner.sh refresh-credential",
1131
+ timeout: 10
1132
+ }
1133
+ ]
750
1134
  }
751
1135
  ],
752
1136
  UserPromptSubmit: [
@@ -772,6 +1156,17 @@ function generateSettingsJson() {
772
1156
  ]
773
1157
  }
774
1158
  ],
1159
+ Stop: [
1160
+ {
1161
+ hooks: [
1162
+ {
1163
+ type: "command",
1164
+ command: "bash ./.claude/hooks/guard-runner.sh hook stop",
1165
+ timeout: 60
1166
+ }
1167
+ ]
1168
+ }
1169
+ ],
775
1170
  SubagentStop: [
776
1171
  {
777
1172
  hooks: [
@@ -782,20 +1177,32 @@ function generateSettingsJson() {
782
1177
  }
783
1178
  ]
784
1179
  }
1180
+ ],
1181
+ SessionEnd: [
1182
+ {
1183
+ hooks: [
1184
+ {
1185
+ type: "command",
1186
+ command: "bash ./.claude/hooks/session-end-marker.sh",
1187
+ timeout: 5
1188
+ }
1189
+ ]
1190
+ }
785
1191
  ]
786
1192
  }
787
1193
  };
788
1194
  return JSON.stringify(settings, null, 2) + "\n";
789
1195
  }
790
1196
  var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
1197
+ var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
791
1198
  function generateGuardRunnerWrapper() {
792
1199
  return `#!/usr/bin/env bash
793
- # Governance hook wrapper \u2014 generated by the Halfcycle installer.
1200
+ ${GENERATED_GUARD_RUNNER_HEADER} the Halfcycle installer.
794
1201
  #
795
1202
  # Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
796
- # values when Claude Code fires PostToolUse / SubagentStop, then exec's the
797
- # SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path inside this
798
- # project, so the hook resolves in any checkout of it.
1203
+ # values when Claude Code fires SessionStart / PostToolUse / Stop / SubagentStop,
1204
+ # then exec's the SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path
1205
+ # inside this project, so the hook resolves in any checkout of it.
799
1206
  #
800
1207
  # The runner itself reads only process.env, so env loading lives here in the hook
801
1208
  # wiring rather than inside the binary.
@@ -810,6 +1217,16 @@ ${engagementResolutionShell()}
810
1217
  # the runner say what it found \u2014 its never-configured branch is loud and non-zero
811
1218
  # on purpose, and a wrapper that exited quietly here would hide it.
812
1219
  if halfcycle_env_file "$PROJECT_DIR"; then
1220
+ # EXPORTED, NOT MERELY SET \u2014 one word, and without it a whole feature is off.
1221
+ # \`set -a\` exports what is assigned AFTER it, and the resolution above assigned
1222
+ # this variable BEFORE it, so the exec'd binary would never see the name of the
1223
+ # file these credentials came from. It needs that name: when the service says
1224
+ # this engagement's credential has timed out, the binary renews it by rewriting
1225
+ # that file. With nothing naming the file it renews nothing \u2014 and it says
1226
+ # nothing and exits 0 while doing so, because there is no session in which that
1227
+ # silence is wrong on its own. So a wrapper missing this line looks perfectly
1228
+ # healthy at every session start and renews nothing, forever.
1229
+ export HALFCYCLE_ENV_FILE
813
1230
  set -a
814
1231
  . "$HALFCYCLE_ENV_FILE"
815
1232
  set +a
@@ -825,15 +1242,28 @@ fi
825
1242
  exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
826
1243
  `;
827
1244
  }
1245
+ var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
1246
+ 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)"`;
828
1247
  function generateSessionStartMarker() {
829
1248
  return `#!/usr/bin/env bash
830
- # Session-start marker \u2014 generated by the Halfcycle installer.
831
- # Writes the current git HEAD SHA to a temp file so SubagentStop can
832
- # diff against session-start rather than just HEAD at stop time.
1249
+ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
1250
+ # Records the git HEAD sha this session starts from, in a file named after this
1251
+ # session, so the stop-time check can compare what the session did against where
1252
+ # it began instead of only against the latest commit. The session id comes in on
1253
+ # stdin with the hook input; without one, the older session-less name is written
1254
+ # and the stop-time check compares against the latest commit as it used to.
1255
+ INPUT="$(cat 2>/dev/null || true)"
833
1256
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
834
1257
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
835
- HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
836
- MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
1258
+ HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
1259
+ ${SESSION_ID_FROM_HOOK_INPUT_SH}
1260
+ MARKER_DIR="\${TMPDIR:-/tmp}"
1261
+ MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
1262
+ if [ -n "$SESSION_ID" ]; then
1263
+ MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref"
1264
+ else
1265
+ MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
1266
+ fi
837
1267
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
838
1268
 
839
1269
  # ---------------------------------------------------------------------------
@@ -896,9 +1326,41 @@ ${engagementResolutionShell()}
896
1326
  exit 0
897
1327
  `;
898
1328
  }
1329
+ var GENERATED_SESSION_END_HEADER = "# Session-end marker cleanup \u2014 generated by";
1330
+ function generateSessionEndMarker() {
1331
+ return `#!/usr/bin/env bash
1332
+ ${GENERATED_SESSION_END_HEADER} the Halfcycle installer.
1333
+ # Removes this session's marker file (and the evaluation-state file beside it)
1334
+ # written at session start. Deletion happens HERE and not at stop time: the stop
1335
+ # hook runs at the end of every turn, and deleting the marker there would throw
1336
+ # away the session's starting point for every later turn.
1337
+ INPUT="$(cat 2>/dev/null || true)"
1338
+
1339
+ REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
1340
+ if [ -z "$REPO_ROOT" ]; then exit 0; fi
1341
+ HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
1342
+
1343
+ # The session id arrives on stdin as part of the hook payload. Empty when absent.
1344
+ ${SESSION_ID_FROM_HOOK_INPUT_SH}
1345
+
1346
+ MARKER_DIR="\${TMPDIR:-/tmp}"
1347
+ MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
1348
+
1349
+ # Exact names only \u2014 never a wildcard. A pattern would match another session
1350
+ # running in this same repository and delete the point it started from.
1351
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.ref" 2>/dev/null || true
1352
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.eval" 2>/dev/null || true
1353
+ if [ -n "$SESSION_ID" ]; then
1354
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref" 2>/dev/null || true
1355
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.eval" 2>/dev/null || true
1356
+ fi
1357
+ exit 0
1358
+ `;
1359
+ }
1360
+ var GENERATED_USER_PROMPT_REMINDER_HEADER = "# UserPromptSubmit house-rule reminder \u2014 generated by";
899
1361
  function generateUserPromptReminderHook() {
900
1362
  return `#!/usr/bin/env bash
901
- # UserPromptSubmit house-rule reminder \u2014 generated by the Halfcycle installer.
1363
+ ${GENERATED_USER_PROMPT_REMINDER_HEADER} the Halfcycle installer.
902
1364
  #
903
1365
  # Injects ONE plain-text sentence into context on every prompt (F-2(c),
904
1366
  # W8-T-05b) \u2014 the per-turn twin of the house rule W8-T-05a put in the client's
@@ -996,17 +1458,31 @@ function generateCiStanza() {
996
1458
  #
997
1459
  # The job prints the diff base it used and how many files it evaluated, on every
998
1460
  # run. If that line says 0 files on a commit that changed something, the base is
999
- # wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block above to name it explicitly
1000
- # (on a GitHub push event, \${{ github.event.before }} is the right value).
1461
+ # wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block OF YOUR COPY, beside the two
1462
+ # secrets, to name it explicitly (on a GitHub push event, \${{ github.event.before }}
1463
+ # is the right value).
1464
+ #
1465
+ # EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
1466
+ # changes to it would be replaced the next time you run \`npx halfcycle\`. It is
1467
+ # also inert where it sits: no CI system reads this path. Copy the job above into
1468
+ # your own workflow and change it there.
1001
1469
  `;
1002
1470
  }
1003
1471
  var MCP_REGISTRATION_REL = ".mcp.json";
1004
1472
  var MCP_SERVER_KEY = "halfcycle";
1005
1473
  var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
1006
1474
  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}"'`;
1475
+ var GENERATED_MCP_HEADERS_HEADER = "# Halfcycle MCP connection headers \u2014 generated by";
1476
+ var OWNED_GENERATED_HEADERS = {
1477
+ ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
1478
+ ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
1479
+ ".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
1480
+ ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER,
1481
+ ".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
1482
+ };
1007
1483
  function generateMcpHeadersHelper() {
1008
1484
  return `#!/bin/sh
1009
- # Halfcycle MCP connection headers \u2014 generated by the Halfcycle installer.
1485
+ ${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
1010
1486
  #
1011
1487
  # Claude Code runs this at MCP connection time, from the session's cwd, and merges
1012
1488
  # its stdout into the connection headers for the "halfcycle" server in .mcp.json.
@@ -1191,7 +1667,7 @@ function writeCrewRoster(targetRepoRoot, report) {
1191
1667
  const rendered = `${JSON.stringify(doc, null, 2)}
1192
1668
  `;
1193
1669
  const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
1194
- record(report, writeOwned(crewPath, targetRepoRoot, rendered), ".halfcycle/crew.json");
1670
+ recordOwned(report, crewPath, targetRepoRoot, rendered, ".halfcycle/crew.json");
1195
1671
  }
1196
1672
  function readBundlePin(targetRepoRoot) {
1197
1673
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
@@ -1305,7 +1781,8 @@ async function install(options) {
1305
1781
  writtenPaths: [],
1306
1782
  skippedPaths: [],
1307
1783
  mergedPaths: [],
1308
- collidedPaths: []
1784
+ collidedPaths: [],
1785
+ replacedPaths: []
1309
1786
  };
1310
1787
  copyManifestCommands(manifest, targetRepo, report);
1311
1788
  const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
@@ -1318,7 +1795,7 @@ async function install(options) {
1318
1795
  }
1319
1796
  const vendoredBinSrc = resolveVendoredBinary();
1320
1797
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
1321
- record(report, writeOwned(vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
1798
+ recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"), ".halfcycle/bin/bin.bundle.mjs");
1322
1799
  const settingsPath = join5(targetRepo, ".claude", "settings.json");
1323
1800
  const settingsPreexisted = existsSync3(settingsPath);
1324
1801
  const generatedSettings = JSON.parse(generateSettingsJson());
@@ -1330,17 +1807,19 @@ async function install(options) {
1330
1807
  (settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
1331
1808
  for (const [name, content] of [
1332
1809
  ["guard-runner.sh", generateGuardRunnerWrapper()],
1333
- ["session-start-marker.sh", generateSessionStartMarker()],
1334
- ["user-prompt-reminder.sh", generateUserPromptReminderHook()]
1810
+ ["session-end-marker.sh", generateSessionEndMarker()],
1811
+ ["user-prompt-reminder.sh", generateUserPromptReminderHook()],
1812
+ ["session-start-marker.sh", generateSessionStartMarker()]
1335
1813
  ]) {
1814
+ const rel = `.claude/hooks/${name}`;
1336
1815
  const hookPath = join5(targetRepo, ".claude", "hooks", name);
1337
- record(report, writeCollisionSafe(hookPath, targetRepo, content), `.claude/hooks/${name}`);
1816
+ recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
1338
1817
  }
1339
1818
  const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
1340
- record(report, writeCollisionSafe(ciStanzaPath, targetRepo, generateCiStanza()), ".halfcycle/ci-stanza.yml");
1819
+ recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
1341
1820
  if (credential) {
1342
1821
  const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
1343
- record(report, writeCollisionSafe(helperPath, targetRepo, generateMcpHeadersHelper()), MCP_HEADERS_HELPER_REL);
1822
+ recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
1344
1823
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
1345
1824
  const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
1346
1825
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
@@ -1394,6 +1873,7 @@ async function install(options) {
1394
1873
  skippedPaths: report.skippedPaths,
1395
1874
  mergedPaths: report.mergedPaths,
1396
1875
  collidedPaths: report.collidedPaths,
1876
+ replacedPaths: report.replacedPaths,
1397
1877
  bootstrapScanRan: scanResult.ran,
1398
1878
  legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null
1399
1879
  };
@@ -1474,6 +1954,12 @@ async function createEngagement(baseUrl, name, credential, derivedName) {
1474
1954
  { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" }
1475
1955
  );
1476
1956
  }
1957
+ function withDeclaredTelemetryKey(raw) {
1958
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
1959
+ return null;
1960
+ const obj = raw;
1961
+ return "controlTelemetryUrl" in obj ? obj : { ...obj, controlTelemetryUrl: "" };
1962
+ }
1477
1963
  async function requestEngagementValues(url, requestBody, credential, shape) {
1478
1964
  const bearer = credential?.trim();
1479
1965
  let res;
@@ -1498,7 +1984,9 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
1498
1984
  const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
1499
1985
  throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1500
1986
  }
1501
- const body = await res.json().catch(() => null);
1987
+ const candidate = withDeclaredTelemetryKey(await res.json().catch(() => null));
1988
+ const declared = engagementCredentialResponseSchema.safeParse(candidate);
1989
+ const body = declared.success ? declared.data : candidate;
1502
1990
  if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
1503
1991
  throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1504
1992
  }
@@ -1517,6 +2005,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
1517
2005
  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.`);
1518
2006
  }
1519
2007
  const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
2008
+ if (!declared.success) {
2009
+ const fields = declared.error.issues.map((issue) => {
2010
+ const unrecognised = issue.keys;
2011
+ if (unrecognised && unrecognised.length > 0) {
2012
+ return `unexpected: ${unrecognised.map(String).join(", ")}`;
2013
+ }
2014
+ return issue.path.length > 0 ? issue.path.map(String).join(".") : "(the body itself)";
2015
+ }).join(", ");
2016
+ 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.`);
2017
+ }
1520
2018
  return {
1521
2019
  engagementId: body.engagementId,
1522
2020
  sessionToken: body.sessionToken,
@@ -1562,7 +2060,7 @@ import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFile
1562
2060
  import { dirname as dirname3, join as join6, posix } from "node:path";
1563
2061
  var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
1564
2062
  var DECLARED_DIR_PREFIX = "docs";
1565
- var RESERVED_MIRROR_PATH = "docs/method";
2063
+ var RESERVED_METHOD_PATH = "docs/method";
1566
2064
  var STEP_ID_GRAMMAR = /\b(?:l[0-5](?:-l[0-5])?|xl)\.[a-z0-9-]+/i;
1567
2065
  var STEP_ANNOTATION = /<!--\s*step:/i;
1568
2066
  var ANCHOR_DECLARATION = /<!--\s*anchor:/i;
@@ -1589,8 +2087,8 @@ function assertWritable(relPath) {
1589
2087
  if (path.split("/").includes("..")) {
1590
2088
  throw new Error(`[halfcycle setup] Refusing to write "${relPath}": a path may not climb out of the repository.`);
1591
2089
  }
1592
- if (isUnderIgnoringCase(foldWin32Canonicalisation(path), RESERVED_MIRROR_PATH)) {
1593
- throw new Error(`[halfcycle setup] Refusing to write "${relPath}": ${RESERVED_MIRROR_PATH}/ is reserved and is never created in a project repository. The method is served per step and never installed (INV-016).`);
2090
+ if (isUnderIgnoringCase(foldWin32Canonicalisation(path), RESERVED_METHOD_PATH)) {
2091
+ throw new Error(`[halfcycle setup] Refusing to write "${relPath}": ${RESERVED_METHOD_PATH}/ is reserved and is never created in a project repository. The method is served per step and never installed.`);
1594
2092
  }
1595
2093
  const declared = DECLARED_ROOT_FILES.includes(path) || isUnder(path, DECLARED_DIR_PREFIX);
1596
2094
  if (!declared) {
@@ -1600,7 +2098,7 @@ function assertWritable(relPath) {
1600
2098
  function assertContentCarriesNoMethodStructure(relPath, content) {
1601
2099
  const carried = STEP_ID_GRAMMAR.test(content) && "a method step identifier" || STEP_ANNOTATION.test(content) && "a method step annotation" || ANCHOR_DECLARATION.test(content) && "a method anchor declaration, so this is corpus text";
1602
2100
  if (carried) {
1603
- throw new Error(`[halfcycle setup] Refusing to write "${relPath}": the content carries ${carried}. What a step directs is executed, never transcribed into the repository (INV-016).`);
2101
+ throw new Error(`[halfcycle setup] Refusing to write "${relPath}": the content carries ${carried}. What a step directs is executed, never transcribed into the repository.`);
1604
2102
  }
1605
2103
  }
1606
2104
  function assertUsableHeading(relPath, heading) {
@@ -2150,7 +2648,7 @@ function renderBuildRecordMarkdown(record2) {
2150
2648
  "",
2151
2649
  `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
2152
2650
  "",
2153
- "> 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.",
2651
+ "> 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.",
2154
2652
  "",
2155
2653
  `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
2156
2654
  `**Phase:** ${phase.id} \u2014 ${phase.name}`,
@@ -2170,7 +2668,7 @@ function renderBuildRecordMarkdown(record2) {
2170
2668
  `- **Tools:** ${record2.delivered.tools.join(", ")}`,
2171
2669
  `- **Dogfood:** ${record2.delivered.dogfood}`,
2172
2670
  "",
2173
- 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.",
2671
+ 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.",
2174
2672
  "",
2175
2673
  `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
2176
2674
  "",
@@ -2182,7 +2680,7 @@ function renderBuildRecordMarkdown(record2) {
2182
2680
  ""
2183
2681
  ];
2184
2682
  if (record2.acceptance.inv002ProdBypassProbe) {
2185
- lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
2683
+ lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
2186
2684
  }
2187
2685
  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"}`, "");
2188
2686
  return lines.join("\n");