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/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,16 +220,184 @@ 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 crewFaceHeadSchema = z4.enum(["sq", "sqr", "rnd", "dome", "hex"]);
146
- var crewFaceAntennaSchema = z4.enum(["none", "stalk", "twin", "dish"]);
147
- var crewFaceEyesSchema = z4.enum(["dots", "bars", "visor", "cyclops"]);
148
- var crewFaceMouthSchema = z4.enum(["line", "grid", "dots", "wave"]);
149
- var crewFaceSchema = 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({
150
401
  // The outline of the head. Members who do the same kind of work share one, so a
151
402
  // team is recognisable before any name is read.
152
403
  head: crewFaceHeadSchema,
@@ -157,17 +408,17 @@ var crewFaceSchema = z4.object({
157
408
  // The mouth.
158
409
  mouth: crewFaceMouthSchema,
159
410
  // Whether this member is drawn with ears.
160
- ears: z4.boolean()
411
+ ears: z5.boolean()
161
412
  }).strict();
162
- var crewMemberSchema = z4.object({
413
+ var crewMemberSchema = z5.object({
163
414
  // The agent's name. The same twenty-one on every project, so a name is a
164
415
  // vocabulary a returning client already knows.
165
- callsign: z4.string().min(1).max(32),
416
+ callsign: z5.string().min(1).max(32),
166
417
  // Which of the three headings this member reads under.
167
418
  group: crewGroupSchema,
168
419
  // The kind of work this member takes. A task names a discipline, and only
169
420
  // agents holding it can be dispatched to it.
170
- discipline: z4.string().min(1).max(CREW_DISCIPLINE_MAX),
421
+ discipline: z5.string().min(1).max(CREW_DISCIPLINE_MAX),
171
422
  // How much thinking this member brings. There is no field naming the supplier
172
423
  // behind it, on this shape or on any other.
173
424
  tier: crewTierSchema,
@@ -392,6 +643,21 @@ function describeIdentity(identity) {
392
643
  return `(a ${typeof identity})`;
393
644
  }
394
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
+
395
661
  // dist/engagement-credential.js
396
662
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
397
663
  import { platform as platform2 } from "node:os";
@@ -401,12 +667,13 @@ import { join as join2 } from "node:path";
401
667
  import { homedir, platform } from "node:os";
402
668
  import { dirname, join } from "node:path";
403
669
  function halfcycleHome(home) {
404
- return join(home ?? homedir(), ".halfcycle");
670
+ return join(home ?? homedir(), HALFCYCLE_DIR_NAME);
405
671
  }
406
672
 
407
673
  // dist/engagement-credential.js
408
- var ENGAGEMENTS_DIR = "engagements";
409
- 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;
410
677
  var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
411
678
  function engagementStateDir(engagementId, home) {
412
679
  return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
@@ -414,7 +681,6 @@ function engagementStateDir(engagementId, home) {
414
681
  function engagementEnvPath(engagementId, home) {
415
682
  return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
416
683
  }
417
- var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
418
684
  var GUARD_ENV_KEYS = [
419
685
  "GUARD_SERVICE_URL",
420
686
  "GUARD_SERVICE_TOKEN",
@@ -429,82 +695,8 @@ var ENGAGEMENT_ENV_KEYS = [
429
695
  ...GUARD_ENV_KEYS,
430
696
  "CONTROL_TELEMETRY_URL"
431
697
  ];
432
- var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
433
- function shq(value) {
434
- return `'${value.replace(/'/g, `'\\''`)}'`;
435
- }
436
- function unquote(value) {
437
- const v = value.trim();
438
- if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
439
- return v.slice(1, -1).split(`'\\''`).join(`'`);
440
- }
441
- if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
442
- return v.slice(1, -1);
443
- return v;
444
- }
445
- function parseEnvText(raw) {
446
- const out = {};
447
- for (const line of raw.split("\n")) {
448
- const eq = line.indexOf("=");
449
- if (eq === -1)
450
- continue;
451
- const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
452
- if (key === "" || key.startsWith("#"))
453
- continue;
454
- out[key] = unquote(line.slice(eq + 1));
455
- }
456
- return out;
457
- }
458
- function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
459
- const desired = new Map(keys.map((k) => {
460
- const value = values[k];
461
- return [k, value === void 0 ? "" : value];
462
- }));
463
- const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
464
- const written = keys.filter((k) => desired.get(k) !== null);
465
- if (existing === null) {
466
- if (written.length === 0)
467
- return "";
468
- return `${ENV_HEADER}
469
- ` + written.map(line).join("\n") + "\n";
470
- }
471
- const seen = /* @__PURE__ */ new Set();
472
- const out = [];
473
- for (const existingLine of existing.split("\n")) {
474
- const eq = existingLine.indexOf("=");
475
- if (eq === -1) {
476
- out.push(existingLine);
477
- continue;
478
- }
479
- const lhs = existingLine.slice(0, eq);
480
- const exported = /^\s*export\s+/.exec(lhs);
481
- const prefix = exported === null ? "" : exported[0];
482
- const key = lhs.slice(prefix.length).trim();
483
- if (!desired.has(key)) {
484
- out.push(existingLine);
485
- continue;
486
- }
487
- seen.add(key);
488
- if (desired.get(key) === null)
489
- continue;
490
- out.push(`${prefix}${line(key)}`);
491
- }
492
- const missing = written.filter((k) => !seen.has(k));
493
- if (missing.length > 0) {
494
- const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
495
- `;
496
- const block = header + missing.map(line).join("\n");
497
- const trailingBlank = out.length > 0 && out[out.length - 1] === "";
498
- if (trailingBlank)
499
- out.splice(out.length - 1, 0, block);
500
- else
501
- out.push(`
502
- ${block}`);
503
- }
504
- let result = out.join("\n");
505
- if (existing.endsWith("\n") && !result.endsWith("\n"))
506
- result += "\n";
507
- return result;
698
+ function reconcileEnvText2(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
699
+ return reconcileEnvText(existing, values, keys);
508
700
  }
509
701
  function readEngagementEnv(engagementId, home) {
510
702
  try {
@@ -522,7 +714,7 @@ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KE
522
714
  } catch {
523
715
  existing = null;
524
716
  }
525
- const reconciled = reconcileEnvText(existing, values, keys);
717
+ const reconciled = reconcileEnvText2(existing, values, keys);
526
718
  if (existing === null && reconciled === "")
527
719
  return "skipped";
528
720
  mkdirSync(dir, { recursive: true, mode: 448 });
@@ -548,7 +740,7 @@ function applyOwnerOnly(path, dir) {
548
740
  function engagementResolutionShell() {
549
741
  return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
550
742
  # THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
551
- # $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
743
+ # $HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
552
744
  # written owner-only by \`npx halfcycle\`. What this repository holds is the
553
745
  # engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
554
746
  # the pin, then name the file. There is no jq here and node is not dependable on a
@@ -579,10 +771,10 @@ halfcycle_env_file() {
579
771
  HALFCYCLE_ENV_PROBLEM="no-home"
580
772
  return 1
581
773
  fi
582
- 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
583
775
  HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
584
776
  fi
585
- hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
777
+ hc_env="$HOME/${HALFCYCLE_DIR}/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
586
778
  if [ ! -f "$hc_env" ]; then
587
779
  HALFCYCLE_ENV_PROBLEM="no-credential"
588
780
  return 1
@@ -781,7 +973,7 @@ function resolveVendoredBinary() {
781
973
  if (existsSync3(candidate))
782
974
  return candidate;
783
975
  }
784
- 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(", ")}.`);
785
977
  }
786
978
  var WRITE_ALLOWLIST = [
787
979
  ".claude/commands",
@@ -930,6 +1122,15 @@ function generateSettingsJson() {
930
1122
  timeout: 5
931
1123
  }
932
1124
  ]
1125
+ },
1126
+ {
1127
+ hooks: [
1128
+ {
1129
+ type: "command",
1130
+ command: "bash ./.claude/hooks/guard-runner.sh refresh-credential",
1131
+ timeout: 10
1132
+ }
1133
+ ]
933
1134
  }
934
1135
  ],
935
1136
  UserPromptSubmit: [
@@ -999,9 +1200,9 @@ function generateGuardRunnerWrapper() {
999
1200
  ${GENERATED_GUARD_RUNNER_HEADER} the Halfcycle installer.
1000
1201
  #
1001
1202
  # Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
1002
- # values when Claude Code fires PostToolUse / Stop / SubagentStop, then exec's the
1003
- # SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path inside this
1004
- # 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.
1005
1206
  #
1006
1207
  # The runner itself reads only process.env, so env loading lives here in the hook
1007
1208
  # wiring rather than inside the binary.
@@ -1016,6 +1217,16 @@ ${engagementResolutionShell()}
1016
1217
  # the runner say what it found \u2014 its never-configured branch is loud and non-zero
1017
1218
  # on purpose, and a wrapper that exited quietly here would hide it.
1018
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
1019
1230
  set -a
1020
1231
  . "$HALFCYCLE_ENV_FILE"
1021
1232
  set +a
@@ -1743,6 +1954,12 @@ async function createEngagement(baseUrl, name, credential, derivedName) {
1743
1954
  { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" }
1744
1955
  );
1745
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
+ }
1746
1963
  async function requestEngagementValues(url, requestBody, credential, shape) {
1747
1964
  const bearer = credential?.trim();
1748
1965
  let res;
@@ -1767,7 +1984,9 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
1767
1984
  const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
1768
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);
1769
1986
  }
1770
- 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;
1771
1990
  if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
1772
1991
  throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1773
1992
  }
@@ -1786,6 +2005,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
1786
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.`);
1787
2006
  }
1788
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
+ }
1789
2018
  return {
1790
2019
  engagementId: body.engagementId,
1791
2020
  sessionToken: body.sessionToken,
@@ -1831,7 +2060,7 @@ import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFile
1831
2060
  import { dirname as dirname3, join as join6, posix } from "node:path";
1832
2061
  var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
1833
2062
  var DECLARED_DIR_PREFIX = "docs";
1834
- var RESERVED_MIRROR_PATH = "docs/method";
2063
+ var RESERVED_METHOD_PATH = "docs/method";
1835
2064
  var STEP_ID_GRAMMAR = /\b(?:l[0-5](?:-l[0-5])?|xl)\.[a-z0-9-]+/i;
1836
2065
  var STEP_ANNOTATION = /<!--\s*step:/i;
1837
2066
  var ANCHOR_DECLARATION = /<!--\s*anchor:/i;
@@ -1858,8 +2087,8 @@ function assertWritable(relPath) {
1858
2087
  if (path.split("/").includes("..")) {
1859
2088
  throw new Error(`[halfcycle setup] Refusing to write "${relPath}": a path may not climb out of the repository.`);
1860
2089
  }
1861
- if (isUnderIgnoringCase(foldWin32Canonicalisation(path), RESERVED_MIRROR_PATH)) {
1862
- 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.`);
1863
2092
  }
1864
2093
  const declared = DECLARED_ROOT_FILES.includes(path) || isUnder(path, DECLARED_DIR_PREFIX);
1865
2094
  if (!declared) {
@@ -1869,7 +2098,7 @@ function assertWritable(relPath) {
1869
2098
  function assertContentCarriesNoMethodStructure(relPath, content) {
1870
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";
1871
2100
  if (carried) {
1872
- 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.`);
1873
2102
  }
1874
2103
  }
1875
2104
  function assertUsableHeading(relPath, heading) {
@@ -2419,7 +2648,7 @@ function renderBuildRecordMarkdown(record2) {
2419
2648
  "",
2420
2649
  `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
2421
2650
  "",
2422
- "> 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.",
2423
2652
  "",
2424
2653
  `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
2425
2654
  `**Phase:** ${phase.id} \u2014 ${phase.name}`,
@@ -2439,7 +2668,7 @@ function renderBuildRecordMarkdown(record2) {
2439
2668
  `- **Tools:** ${record2.delivered.tools.join(", ")}`,
2440
2669
  `- **Dogfood:** ${record2.delivered.dogfood}`,
2441
2670
  "",
2442
- 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.",
2443
2672
  "",
2444
2673
  `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
2445
2674
  "",
@@ -2451,7 +2680,7 @@ function renderBuildRecordMarkdown(record2) {
2451
2680
  ""
2452
2681
  ];
2453
2682
  if (record2.acceptance.inv002ProdBypassProbe) {
2454
- 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}`, "");
2455
2684
  }
2456
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"}`, "");
2457
2686
  return lines.join("\n");