halfcycle 0.3.23 → 0.3.25

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
@@ -1202,19 +1413,36 @@ function generateCiStanza() {
1202
1413
  # .halfcycle/bin/, so it works in a Python or Go repository as well as a Node
1203
1414
  # one \u2014 the only prerequisite is Node 20 to run the bundled binary.
1204
1415
  #
1416
+ # THERE IS NOTHING TO STORE. No token in your repository's secrets, no project id
1417
+ # to look up, and no address to set. The \`permissions\` block below is the whole
1418
+ # configuration: it lets the job ask GitHub for a short-lived signed token naming
1419
+ # the repository it is running in, and Halfcycle trusts the name GitHub signs
1420
+ # rather than anything the job says about itself. That token is traded for a
1421
+ # credential that lives for minutes, and nothing is kept at either end.
1422
+ #
1423
+ # BOTH PERMISSION LINES, NOT JUST THE SECOND. Declaring any permission
1424
+ # replaces the defaults rather than adding to them, so a block naming only
1425
+ # \`id-token\` takes read access away from the checkout step and a private
1426
+ # repository stops checking out before the guard is reached. If your workflow
1427
+ # already has a \`permissions\` block, add \`id-token: write\` to it and leave the
1428
+ # rest alone.
1429
+ #
1430
+ # ONE THING TO DO FIRST, ONCE, ON YOUR OWN MACHINE. In this project, run
1431
+ #
1432
+ # npx halfcycle ci bind <owner>/<repo>
1433
+ #
1434
+ # naming this repository as GitHub spells it (for example acme/widgets). That
1435
+ # tells Halfcycle this project's CI runs from that repository, and it is the only
1436
+ # thing that makes a run mean anything: without it, Halfcycle has a signed
1437
+ # statement of which repository the job is in and no idea whose project that is.
1438
+ # The check says exactly that, and names the command, if you skip it.
1439
+ #
1205
1440
  # THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
1206
1441
  # product talks to has one address, the same for every user, and it ships in the
1207
1442
  # tools you already have: the installer, the guard hook and the binary this job
1208
1443
  # runs each know where to go. If something tells you to set a Halfcycle URL, it is
1209
1444
  # out of date.
1210
1445
  #
1211
- # AND THERE IS NOTHING TO SET ON YOUR OWN MACHINE EITHER. \`npx halfcycle\` wrote
1212
- # this engagement's credential to $HOME/.halfcycle, owner-only, outside every
1213
- # checkout, and the guard hook reads it from there. A CI job is the one place that
1214
- # store cannot be reached \u2014 no browser, no per-user home \u2014 which is why the two
1215
- # names below exist at all, and why they are the only two: a credential, and the id
1216
- # of your own engagement.
1217
- #
1218
1446
  # WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
1219
1447
  # commits pushed straight to the default branch, which is the shape most
1220
1448
  # Halfcycle engagements settle on. \`fetch-depth\` is what makes that true: the
@@ -1225,6 +1453,9 @@ function generateCiStanza() {
1225
1453
  #
1226
1454
  # halfcycle-guard-ci:
1227
1455
  # runs-on: ubuntu-latest
1456
+ # permissions:
1457
+ # contents: read
1458
+ # id-token: write
1228
1459
  # steps:
1229
1460
  # - uses: actions/checkout@v4
1230
1461
  # with:
@@ -1236,20 +1467,15 @@ function generateCiStanza() {
1236
1467
  # with:
1237
1468
  # node-version: '20'
1238
1469
  # - name: Halfcycle guard CI check
1239
- # env:
1240
- # # TWO SECRETS, AND THEY ARE THE TWO THAT ARE YOURS: the engagement's
1241
- # # token and its id. Both were printed by the installer that generated
1242
- # # this file. There is no address to configure \u2014 the guard service this
1243
- # # job talks to ships inside the binary below.
1244
- # GUARD_SERVICE_TOKEN: \${{ secrets.GUARD_SERVICE_TOKEN }}
1245
- # GUARD_ENGAGEMENT_ID: \${{ secrets.GUARD_ENGAGEMENT_ID }}
1470
+ # # No env block, on purpose: this step holds no secret. The permissions
1471
+ # # above are what authenticate it.
1246
1472
  # run: node ./.halfcycle/bin/bin.bundle.mjs ci
1247
1473
  #
1248
1474
  # The job prints the diff base it used and how many files it evaluated, on every
1249
1475
  # run. If that line says 0 files on a commit that changed something, the base is
1250
- # wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block OF YOUR COPY, beside the two
1251
- # secrets, to name it explicitly (on a GitHub push event, \${{ github.event.before }}
1252
- # is the right value).
1476
+ # wrong \u2014 set HALFCYCLE_DIFF_BASE in an env block OF YOUR COPY of the check step,
1477
+ # to name it explicitly (on a GitHub push event, \${{ github.event.before }} is the
1478
+ # right value).
1253
1479
  #
1254
1480
  # EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
1255
1481
  # changes to it would be replaced the next time you run \`npx halfcycle\`. It is
@@ -1743,6 +1969,12 @@ async function createEngagement(baseUrl, name, credential, derivedName) {
1743
1969
  { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" }
1744
1970
  );
1745
1971
  }
1972
+ function withDeclaredTelemetryKey(raw) {
1973
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
1974
+ return null;
1975
+ const obj = raw;
1976
+ return "controlTelemetryUrl" in obj ? obj : { ...obj, controlTelemetryUrl: "" };
1977
+ }
1746
1978
  async function requestEngagementValues(url, requestBody, credential, shape) {
1747
1979
  const bearer = credential?.trim();
1748
1980
  let res;
@@ -1767,7 +1999,9 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
1767
1999
  const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
1768
2000
  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
2001
  }
1770
- const body = await res.json().catch(() => null);
2002
+ const candidate = withDeclaredTelemetryKey(await res.json().catch(() => null));
2003
+ const declared = engagementCredentialResponseSchema.safeParse(candidate);
2004
+ const body = declared.success ? declared.data : candidate;
1771
2005
  if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
1772
2006
  throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1773
2007
  }
@@ -1786,6 +2020,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
1786
2020
  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
2021
  }
1788
2022
  const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
2023
+ if (!declared.success) {
2024
+ const fields = declared.error.issues.map((issue) => {
2025
+ const unrecognised = issue.keys;
2026
+ if (unrecognised && unrecognised.length > 0) {
2027
+ return `unexpected: ${unrecognised.map(String).join(", ")}`;
2028
+ }
2029
+ return issue.path.length > 0 ? issue.path.map(String).join(".") : "(the body itself)";
2030
+ }).join(", ");
2031
+ 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.`);
2032
+ }
1789
2033
  return {
1790
2034
  engagementId: body.engagementId,
1791
2035
  sessionToken: body.sessionToken,
@@ -1831,7 +2075,7 @@ import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFile
1831
2075
  import { dirname as dirname3, join as join6, posix } from "node:path";
1832
2076
  var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
1833
2077
  var DECLARED_DIR_PREFIX = "docs";
1834
- var RESERVED_MIRROR_PATH = "docs/method";
2078
+ var RESERVED_METHOD_PATH = "docs/method";
1835
2079
  var STEP_ID_GRAMMAR = /\b(?:l[0-5](?:-l[0-5])?|xl)\.[a-z0-9-]+/i;
1836
2080
  var STEP_ANNOTATION = /<!--\s*step:/i;
1837
2081
  var ANCHOR_DECLARATION = /<!--\s*anchor:/i;
@@ -1858,8 +2102,8 @@ function assertWritable(relPath) {
1858
2102
  if (path.split("/").includes("..")) {
1859
2103
  throw new Error(`[halfcycle setup] Refusing to write "${relPath}": a path may not climb out of the repository.`);
1860
2104
  }
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).`);
2105
+ if (isUnderIgnoringCase(foldWin32Canonicalisation(path), RESERVED_METHOD_PATH)) {
2106
+ 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
2107
  }
1864
2108
  const declared = DECLARED_ROOT_FILES.includes(path) || isUnder(path, DECLARED_DIR_PREFIX);
1865
2109
  if (!declared) {
@@ -1869,7 +2113,7 @@ function assertWritable(relPath) {
1869
2113
  function assertContentCarriesNoMethodStructure(relPath, content) {
1870
2114
  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
2115
  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).`);
2116
+ 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
2117
  }
1874
2118
  }
1875
2119
  function assertUsableHeading(relPath, heading) {
@@ -2419,7 +2663,7 @@ function renderBuildRecordMarkdown(record2) {
2419
2663
  "",
2420
2664
  `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
2421
2665
  "",
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.",
2666
+ "> 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
2667
  "",
2424
2668
  `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
2425
2669
  `**Phase:** ${phase.id} \u2014 ${phase.name}`,
@@ -2439,7 +2683,7 @@ function renderBuildRecordMarkdown(record2) {
2439
2683
  `- **Tools:** ${record2.delivered.tools.join(", ")}`,
2440
2684
  `- **Dogfood:** ${record2.delivered.dogfood}`,
2441
2685
  "",
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.",
2686
+ 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
2687
  "",
2444
2688
  `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
2445
2689
  "",
@@ -2451,7 +2695,7 @@ function renderBuildRecordMarkdown(record2) {
2451
2695
  ""
2452
2696
  ];
2453
2697
  if (record2.acceptance.inv002ProdBypassProbe) {
2454
- lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
2698
+ lines.push(`**Production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
2455
2699
  }
2456
2700
  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
2701
  return lines.join("\n");