halfcycle 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -10,6 +10,421 @@ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as rea
10
10
  import { dirname as dirname2, join as join5, relative } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
 
13
+ // ../events/dist/result.js
14
+ import { z as z2 } from "zod";
15
+
16
+ // ../core/dist/guard-record.js
17
+ import { z } from "zod";
18
+ var severitySchema = z.enum(["info", "warn", "block"]);
19
+ var channelSchema = z.enum(["stable", "candidate", "rented"]);
20
+ var matcherKindSchema = z.enum(["grep", "ast", "integrationTest", "llm"]);
21
+ var matcherConfigSchema = z.record(z.string(), z.unknown());
22
+ var grepMatcherSchema = z.object({ kind: z.literal("grep"), config: matcherConfigSchema }).strict();
23
+ var astMatcherSchema = z.object({ kind: z.literal("ast"), config: matcherConfigSchema }).strict();
24
+ var integrationTestMatcherSchema = z.object({ kind: z.literal("integrationTest"), config: matcherConfigSchema }).strict();
25
+ var llmMatcherSchema = z.object({ kind: z.literal("llm"), config: matcherConfigSchema }).strict();
26
+ var matcherSchema = z.discriminatedUnion("kind", [
27
+ grepMatcherSchema,
28
+ astMatcherSchema,
29
+ integrationTestMatcherSchema,
30
+ llmMatcherSchema
31
+ ]);
32
+
33
+ // ../events/dist/result.js
34
+ var firedGuardSchema = z2.object({
35
+ guardId: z2.string(),
36
+ patternRef: z2.string(),
37
+ severity: severitySchema,
38
+ explanation: z2.string(),
39
+ /**
40
+ * The repo-relative path of the file this firing was decided on. The matcher
41
+ * has the file live at the moment it decides to fire; this is the field that
42
+ * carries the answer out. It is the CLIENT's own coordinate — the wire type
43
+ * still has no field for a matcher body, a channel, a version, a scope or a
44
+ * guard's provenance, and physically cannot carry one.
45
+ *
46
+ * THE PRESENCE RULE, WHICH LIVES HERE AND NOT IN A CONSUMER'S PROSE: every
47
+ * firing produced from this contract version onward carries `path`, so on the
48
+ * WIRE, absence means exactly one thing — a response from a guard service
49
+ * older than this contract. It is declared optional because a client's guard
50
+ * runner is built from its own checkout and calls a service that may be older
51
+ * than it; a required field would make that pairing unparseable.
52
+ *
53
+ * The rule does NOT transfer to a store that persists a firing, where an
54
+ * absent path has a second legitimate meaning: a row written before the
55
+ * column existed. A persisted NULL is therefore not a writer bug.
56
+ *
57
+ * NO SHAPE CONSTRAINT. This value is whatever the changed tree called the
58
+ * file. A consumer that maps it onto a coordinate with its own path rule must
59
+ * safe-parse it and decide what to do with a refusal, rather than assume the
60
+ * two shapes agree.
61
+ */
62
+ path: z2.string().optional()
63
+ }).strict();
64
+ var resultEnvelopeSchema = z2.object({
65
+ guardsFired: z2.array(firedGuardSchema),
66
+ severity: severitySchema.nullable(),
67
+ blocking: z2.boolean(),
68
+ explanation: z2.string()
69
+ }).strict();
70
+ var wireErrorSchema = z2.object({
71
+ statusCode: z2.number(),
72
+ error: z2.string(),
73
+ message: z2.string()
74
+ }).strict();
75
+
76
+ // ../events/dist/telemetry.js
77
+ import { z as z3 } from "zod";
78
+ 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)");
79
+ var guardEvalOutcomeSchema = z3.enum([
80
+ "evaluated",
81
+ "infra-error",
82
+ "contract-error",
83
+ "unconfigured"
84
+ ]);
85
+ var guardEvalRunSchema = z3.object({
86
+ // `runId`/`outcome` are the E2 run-observability additions, declared
87
+ // field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
88
+ // migration. Every new record populated by the migrated writer (T-07)
89
+ // carries both; a legacy line predating the amendment carries neither and
90
+ // must still parse (the mixed-era guard-eval log the Build-Record reader
91
+ // assembles from, and the runner emit not yet migrated). So the presence
92
+ // refinement below is skipped for a legacy line (no `outcome`), and the
93
+ // per-writer guarantee lives with the writer + reader-normalisation (T-07),
94
+ // where the schema may then be tightened to required. runId is uuid-checked
95
+ // WHEN present; outcome is enum-checked when present.
96
+ runId: z3.string().uuid().optional(),
97
+ engagementId: z3.string().optional(),
98
+ runType: z3.enum(["hook", "ci"]),
99
+ occurredAt: utcIso8601,
100
+ outcome: guardEvalOutcomeSchema.optional(),
101
+ failureReason: z3.string().optional(),
102
+ phase: z3.string().optional(),
103
+ scope: z3.record(z3.string(), z3.unknown()),
104
+ cost: z3.record(z3.string(), z3.unknown()).optional(),
105
+ result: resultEnvelopeSchema.optional()
106
+ }).strict().superRefine((run, ctx) => {
107
+ if (run.outcome === void 0)
108
+ return;
109
+ if (run.outcome === "evaluated" && run.result === void 0) {
110
+ ctx.addIssue({
111
+ code: z3.ZodIssueCode.custom,
112
+ path: ["result"],
113
+ message: "result is required when outcome is 'evaluated'"
114
+ });
115
+ }
116
+ if (run.outcome !== "evaluated" && run.result !== void 0) {
117
+ ctx.addIssue({
118
+ code: z3.ZodIssueCode.custom,
119
+ path: ["result"],
120
+ message: "result is only present when outcome is 'evaluated'"
121
+ });
122
+ }
123
+ if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
124
+ ctx.addIssue({
125
+ code: z3.ZodIssueCode.custom,
126
+ path: ["engagementId"],
127
+ message: "engagementId is required unless outcome is 'unconfigured'"
128
+ });
129
+ }
130
+ });
131
+
132
+ // ../events/dist/engagement-lifecycle.js
133
+ import { z as z4 } from "zod";
134
+ var utcIso86012 = z4.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
135
+ var engagementTypeSchema = z4.enum(["client"]);
136
+ var lifecycleStatusSchema = z4.enum(["active", "delivering", "closed"]);
137
+ var createEngagementRequestSchema = z4.object({
138
+ name: z4.string(),
139
+ region: z4.string(),
140
+ engagementType: engagementTypeSchema
141
+ }).strict();
142
+ var createEngagementResponseSchema = z4.object({
143
+ engagementId: z4.string(),
144
+ name: z4.string(),
145
+ region: z4.string(),
146
+ status: lifecycleStatusSchema,
147
+ createdAt: utcIso86012,
148
+ sessionToken: z4.string()
149
+ }).strict();
150
+ var engagementStatusResponseSchema = z4.object({
151
+ engagementId: z4.string(),
152
+ name: z4.string(),
153
+ region: z4.string(),
154
+ status: lifecycleStatusSchema,
155
+ installLinked: z4.boolean(),
156
+ createdAt: utcIso86012,
157
+ updatedAt: utcIso86012
158
+ }).strict();
159
+ var linkInstallationRequestSchema = z4.object({
160
+ installationId: z4.string(),
161
+ engagementId: z4.string()
162
+ }).strict();
163
+ var startBuildRequestSchema = z4.object({
164
+ engagementId: z4.string()
165
+ }).strict();
166
+ var startBuildResponseSchema = z4.object({
167
+ engagementId: z4.string(),
168
+ status: lifecycleStatusSchema,
169
+ enqueuedAt: utcIso86012
170
+ }).strict();
171
+ var acceptanceOutcomeSchema = z4.object({
172
+ verdict: z4.enum(["clean", "defects"]),
173
+ findings: z4.number().int().nonnegative(),
174
+ findingsDetail: z4.array(z4.object({ summary: z4.string() }).strict())
175
+ }).strict();
176
+ var PHASE_CLOSE_REASON_MAX = 1900;
177
+ var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
178
+ z4.object({
179
+ decision: z4.literal("done-when-met"),
180
+ /** Who is closing the phase. */
181
+ actor: z4.string()
182
+ }).strict(),
183
+ z4.object({
184
+ decision: z4.literal("override"),
185
+ /** Who is closing it anyway. */
186
+ actor: z4.string(),
187
+ /**
188
+ * Why, in their own words. Bounded: the reason is kept on the phase's
189
+ * permanent record of its own close, and that record refuses anything
190
+ * longer — so an over-long reason is refused here, before the close is
191
+ * attempted, rather than failing the close itself.
192
+ */
193
+ reason: z4.string().max(PHASE_CLOSE_REASON_MAX)
194
+ }).strict()
195
+ ]);
196
+ var acceptPhaseRequestSchema = z4.object({
197
+ phase: z4.string(),
198
+ acceptanceOutcome: acceptanceOutcomeSchema,
199
+ close: phaseCloseDecisionSchema.optional()
200
+ }).strict();
201
+ var coeFindingRequestSchema = z4.object({
202
+ phase: z4.string(),
203
+ description: z4.string(),
204
+ addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
205
+ mappedPatternRef: z4.string().nullable()
206
+ }).strict();
207
+ var coeFindingResponseSchema = z4.object({
208
+ findingId: z4.string(),
209
+ engagementId: z4.string(),
210
+ phase: z4.string(),
211
+ description: z4.string(),
212
+ addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
213
+ mappedPatternRef: z4.string().nullable(),
214
+ createdAt: utcIso86012
215
+ }).strict();
216
+ var phaseEntryDecisionSchema = z4.discriminatedUnion("decision", [
217
+ z4.object({
218
+ decision: z4.literal("entry-condition-met"),
219
+ /** Who attests. */
220
+ actor: z4.string(),
221
+ /** What makes the entry condition hold, in the attester's own words. */
222
+ evidence: z4.string()
223
+ }).strict(),
224
+ z4.object({
225
+ decision: z4.literal("override"),
226
+ /** Who is opening it anyway. */
227
+ actor: z4.string(),
228
+ /** Why, in the operator's own words. */
229
+ reason: z4.string()
230
+ }).strict()
231
+ ]);
232
+ var PHASE_ENTRY_DECISION_KINDS = [
233
+ "entry-condition-met",
234
+ "override"
235
+ ];
236
+ var PHASE_CLOSE_DECISION_KINDS = ["phase-closed", "phase-close-override"];
237
+ var phaseDecisionKindSchema = z4.enum([
238
+ ...PHASE_ENTRY_DECISION_KINDS,
239
+ ...PHASE_CLOSE_DECISION_KINDS
240
+ ]);
241
+ var openPhaseRequestSchema = z4.object({
242
+ phase: z4.string().nullable(),
243
+ entry: phaseEntryDecisionSchema
244
+ }).strict();
245
+ var phaseEntryDecisionRecordSchema = z4.object({
246
+ decisionId: z4.string(),
247
+ decision: z4.enum(PHASE_ENTRY_DECISION_KINDS),
248
+ actor: z4.string(),
249
+ /** The evidence (attestation) or the reason (override) — one basis per row. */
250
+ justification: z4.string(),
251
+ blockingFinding: z4.string().nullable(),
252
+ decidedAt: utcIso86012
253
+ }).strict();
254
+ var openPhaseResponseSchema = z4.object({
255
+ engagement: engagementStatusResponseSchema,
256
+ /** What is open now. `null` is project scope. */
257
+ phase: z4.string().nullable(),
258
+ /** What was open before this decision. `null` is project scope. */
259
+ previousPhase: z4.string().nullable(),
260
+ decision: phaseEntryDecisionRecordSchema
261
+ }).strict();
262
+
263
+ // ../events/dist/crew.js
264
+ import { z as z5 } from "zod";
265
+ var crewGroupSchema = z5.enum(["builder", "reviewer", "always-on"]);
266
+ var crewTierSchema = z5.enum(["deep", "standard", "fast"]);
267
+ var CREW_DISCIPLINE_MAX = 64;
268
+ var crewMemberSchema = z5.object({
269
+ // The agent's name. The same twenty-one on every project, so a name is a
270
+ // vocabulary a returning client already knows.
271
+ callsign: z5.string().min(1).max(32),
272
+ // Which of the three headings this member reads under.
273
+ group: crewGroupSchema,
274
+ // The kind of work this member takes. A task names a discipline, and only
275
+ // agents holding it can be dispatched to it.
276
+ discipline: z5.string().min(1).max(CREW_DISCIPLINE_MAX),
277
+ // How much thinking this member brings. There is no field naming the supplier
278
+ // behind it, on this shape or on any other.
279
+ tier: crewTierSchema
280
+ }).strict();
281
+ var CREW_ROSTER = [
282
+ // ── builders: code ──────────────────────────────────────────────────────────
283
+ { callsign: "ADA", group: "builder", discipline: "implementation", tier: "deep" },
284
+ { callsign: "OTTO", group: "builder", discipline: "implementation", tier: "deep" },
285
+ { callsign: "MILO", group: "builder", discipline: "implementation", tier: "standard" },
286
+ { callsign: "NOVA", group: "builder", discipline: "implementation", tier: "standard" },
287
+ { callsign: "KAI", group: "builder", discipline: "implementation", tier: "standard" },
288
+ { callsign: "HUGO", group: "builder", discipline: "infrastructure", tier: "standard" },
289
+ { callsign: "WREN", group: "builder", discipline: "refactor", tier: "fast" },
290
+ { callsign: "PIP", group: "builder", discipline: "refactor", tier: "fast" },
291
+ // ── builders: specs and docs ────────────────────────────────────────────────
292
+ { callsign: "IRIS", group: "builder", discipline: "feature spec", tier: "deep" },
293
+ { callsign: "JUNO", group: "builder", discipline: "feature spec", tier: "standard" },
294
+ { callsign: "LEX", group: "builder", discipline: "context & docs", tier: "fast" },
295
+ // ── reviewers ───────────────────────────────────────────────────────────────
296
+ { callsign: "VERA", group: "reviewer", discipline: "spec consistency", tier: "deep" },
297
+ { callsign: "ODIN", group: "reviewer", discipline: "cross-spec audit", tier: "deep" },
298
+ { callsign: "CASS", group: "reviewer", discipline: "code review", tier: "deep" },
299
+ { callsign: "THEO", group: "reviewer", discipline: "code review", tier: "standard" },
300
+ { callsign: "ECHO", group: "reviewer", discipline: "test quality", tier: "standard" },
301
+ { callsign: "MAVIS", group: "reviewer", discipline: "suites & coverage", tier: "standard" },
302
+ { callsign: "ZARA", group: "reviewer", discipline: "smoke & walk aid", tier: "standard" },
303
+ { callsign: "NORA", group: "reviewer", discipline: "evidence & provenance", tier: "fast" },
304
+ // ── always on: continuous, takes no task ────────────────────────────────────
305
+ { callsign: "ARGUS", group: "always-on", discipline: "guard \xB7 every diff", tier: "standard" },
306
+ { callsign: "ATLAS", group: "always-on", discipline: "coordinator", tier: "deep" }
307
+ ];
308
+ var CREW_ROSTER_SIZE = CREW_ROSTER.length;
309
+ var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
310
+
311
+ // ../events/dist/device-auth.js
312
+ import { z as z6 } from "zod";
313
+ var utcIso86013 = z6.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
314
+ var DEVICE_AUTH_STATUS = {
315
+ /** The poll: minted, nobody has acted yet. Keep polling. 200. */
316
+ PENDING: "pending",
317
+ /** The poll: approved, and the credential is in this body. Once only. 200. */
318
+ APPROVED: "approved",
319
+ /** Confirm: the decision was recorded (approve or decline). 200. */
320
+ RECORDED: "recorded",
321
+ /** The human refused at the confirmation page. 400 on the poll. */
322
+ DECLINED: "declined",
323
+ /** The code's absolute expiry has passed. 400. */
324
+ EXPIRED: "expired",
325
+ /** Never minted, or already spent. 400. */
326
+ UNKNOWN: "unknown",
327
+ /** Confirm: neither `approve` nor `decline` was named. Nothing was bound. 400. */
328
+ INVALID_DECISION: "invalid-decision",
329
+ /** Confirm: an approval arrived with no signed-in identity. 400. */
330
+ IDENTITY_REQUIRED: "identity-required",
331
+ /** This deployment has no `SIGNIN_BASE_URL` / `DEVICE_AUTH_CONFIRM_SECRET`. 503. */
332
+ NOT_CONFIGURED: "not-configured",
333
+ /** Confirm: the shared confirmation secret was missing or wrong. 401. */
334
+ UNAUTHORIZED: "unauthorized",
335
+ /** Confirm: the code was already approved, declined or claimed. 409. */
336
+ ALREADY_DECIDED: "already-decided",
337
+ /**
338
+ * Confirm: an approval named a signed-in identity whose account has not accepted
339
+ * the terms of service and privacy policy currently in force (T-18, #588). 400.
340
+ *
341
+ * NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
342
+ * says nothing about the code itself — the code is still `pending` after this
343
+ * response, exactly as it was before the request, so the SAME userCode can be
344
+ * confirmed again once the caller has recorded acceptance. `deviceConfirmRequestSchema`'s
345
+ * `acceptTerms` is how a second confirm says it did.
346
+ */
347
+ TERMS_REQUIRED: "terms-required"
348
+ };
349
+ var DEVICE_POLL_REFUSALS = [
350
+ DEVICE_AUTH_STATUS.DECLINED,
351
+ DEVICE_AUTH_STATUS.EXPIRED,
352
+ DEVICE_AUTH_STATUS.UNKNOWN
353
+ ];
354
+ var deviceAuthStartResponseSchema = z6.object({
355
+ deviceCode: z6.string().min(1),
356
+ userCode: z6.string().min(1),
357
+ verificationUrl: z6.string().min(1),
358
+ expiresAt: utcIso86013,
359
+ pollIntervalMs: z6.number().int().positive()
360
+ }).strict();
361
+ var devicePollPendingSchema = z6.object({
362
+ status: z6.literal(DEVICE_AUTH_STATUS.PENDING),
363
+ pollIntervalMs: z6.number().int().positive()
364
+ }).strict();
365
+ var devicePollApprovedSchema = z6.object({
366
+ status: z6.literal(DEVICE_AUTH_STATUS.APPROVED),
367
+ accountId: z6.string().min(1),
368
+ credential: z6.string().min(1),
369
+ expiresAt: utcIso86013
370
+ }).strict();
371
+ var deviceAuthRefusalSchema = z6.object({
372
+ status: z6.enum([
373
+ DEVICE_AUTH_STATUS.DECLINED,
374
+ DEVICE_AUTH_STATUS.EXPIRED,
375
+ DEVICE_AUTH_STATUS.UNKNOWN,
376
+ DEVICE_AUTH_STATUS.INVALID_DECISION,
377
+ DEVICE_AUTH_STATUS.IDENTITY_REQUIRED,
378
+ DEVICE_AUTH_STATUS.NOT_CONFIGURED,
379
+ DEVICE_AUTH_STATUS.UNAUTHORIZED,
380
+ DEVICE_AUTH_STATUS.ALREADY_DECIDED,
381
+ DEVICE_AUTH_STATUS.TERMS_REQUIRED
382
+ ]),
383
+ message: z6.string().min(1)
384
+ }).strict();
385
+ var devicePollResponseSchema = z6.union([
386
+ devicePollPendingSchema,
387
+ devicePollApprovedSchema,
388
+ deviceAuthRefusalSchema
389
+ ]);
390
+ var deviceConfirmRequestSchema = z6.object({
391
+ userCode: z6.string().min(1),
392
+ decision: z6.enum(["approve", "decline"]),
393
+ externalAuthId: z6.string().min(1).optional(),
394
+ email: z6.string().optional(),
395
+ acceptTerms: z6.boolean().optional()
396
+ }).strict();
397
+ var deviceConfirmRecordedSchema = z6.object({
398
+ status: z6.literal(DEVICE_AUTH_STATUS.RECORDED),
399
+ accountId: z6.string().min(1).optional()
400
+ }).strict();
401
+ var deviceConfirmResponseSchema = z6.union([
402
+ deviceConfirmRecordedSchema,
403
+ deviceAuthRefusalSchema
404
+ ]);
405
+ var LOOPBACK_REDIRECT_HOST = "127.0.0.1";
406
+ var LOOPBACK_PORT_MIN = 1024;
407
+ var LOOPBACK_PORT_MAX = 65535;
408
+ var LOOPBACK_CALLBACK_PATH = "/halfcycle-cli-callback";
409
+ var LOOPBACK_QUERY_PORT = "loopback_port";
410
+ var LOOPBACK_QUERY_STATE = "loopback_state";
411
+ var LOOPBACK_QUERY_RESULT = "result";
412
+ var LOOPBACK_RESULT = {
413
+ APPROVED: "approved",
414
+ DECLINED: "declined"
415
+ };
416
+ function loopbackVerificationUrl(verificationUrl, target) {
417
+ let url;
418
+ try {
419
+ url = new URL(verificationUrl);
420
+ } catch {
421
+ return verificationUrl;
422
+ }
423
+ url.searchParams.set(LOOPBACK_QUERY_PORT, String(target.port));
424
+ url.searchParams.set(LOOPBACK_QUERY_STATE, target.state);
425
+ return url.toString();
426
+ }
427
+
13
428
  // dist/engagement-credential.js
14
429
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
15
430
  import { platform as platform2 } from "node:os";
@@ -165,507 +580,190 @@ function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
165
580
  }
166
581
  let result = out.join("\n");
167
582
  if (existing.endsWith("\n") && !result.endsWith("\n"))
168
- result += "\n";
169
- return result;
170
- }
171
- function readEngagementEnv(engagementId, home) {
172
- try {
173
- return parseEnvText(readFileSync2(engagementEnvPath(engagementId, home), "utf-8"));
174
- } catch {
175
- return null;
176
- }
177
- }
178
- function writeEngagementEnv(engagementId, values, home) {
179
- const path = engagementEnvPath(engagementId, home);
180
- const dir = engagementStateDir(engagementId, home);
181
- mkdirSync2(dir, { recursive: true, mode: 448 });
182
- let existing;
183
- try {
184
- existing = readFileSync2(path, "utf-8");
185
- } catch {
186
- existing = null;
187
- }
188
- const reconciled = reconcileEnvText(existing, values);
189
- const outcome = existing === reconciled ? "skipped" : "written";
190
- if (outcome === "written") {
191
- writeFileSync2(path, reconciled, { mode: 384 });
192
- }
193
- applyOwnerOnly(path, dir);
194
- return outcome;
195
- }
196
- function applyOwnerOnly(path, dir) {
197
- if (platform2() === "win32")
198
- return;
199
- try {
200
- chmodSync2(path, 384);
201
- } catch {
202
- }
203
- try {
204
- chmodSync2(dir, 448);
205
- } catch {
206
- }
207
- }
208
- function engagementResolutionShell() {
209
- return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
210
- # THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
211
- # $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
212
- # written owner-only by \`npx halfcycle\`. What this repository holds is the
213
- # engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
214
- # the pin, then name the file. There is no jq here and node is not dependable on a
215
- # desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
216
- # file, because sed is line-oriented and the pin's line breaks are not a contract.
217
- #
218
- # Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT and HALFCYCLE_ENV_PROBLEM in
219
- # the CALLER's shell and returns a status. Call it as \`if halfcycle_env_file \u2026\`,
220
- # never as \`$(halfcycle_env_file \u2026)\` \u2014 a command substitution is a subshell and
221
- # would discard everything it set.
222
- halfcycle_env_file() {
223
- HALFCYCLE_ENV_FILE=""
224
- HALFCYCLE_ENV_ENGAGEMENT=""
225
- HALFCYCLE_ENV_PROBLEM=""
226
- hc_pin="$1/.halfcycle/bundle.json"
227
- if [ ! -f "$hc_pin" ]; then
228
- HALFCYCLE_ENV_PROBLEM="no-pin"
229
- return 1
230
- fi
231
- hc_id=$(tr -d '\\n' < "$hc_pin" | sed -n 's/.*"${PIN_ENGAGEMENT_ID_FIELD}"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
232
- if [ -z "$hc_id" ]; then
233
- HALFCYCLE_ENV_PROBLEM="no-id"
234
- return 1
235
- fi
236
- HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
237
- if [ -z "\${HOME:-}" ]; then
238
- HALFCYCLE_ENV_PROBLEM="no-home"
239
- return 1
240
- fi
241
- hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
242
- if [ ! -f "$hc_env" ]; then
243
- HALFCYCLE_ENV_PROBLEM="no-credential"
244
- return 1
245
- fi
246
- HALFCYCLE_ENV_FILE="$hc_env"
247
- return 0
248
- }
249
- # --- end Halfcycle credential resolution ------------------------------------`;
250
- }
251
-
252
- // dist/identity.js
253
- import { execFileSync } from "node:child_process";
254
- import { existsSync, readFileSync as readFileSync3 } from "node:fs";
255
- import { join as join3 } from "node:path";
256
- import { randomUUID } from "node:crypto";
257
- var PROJECT_IDENTITY_NOTE = 'projectId is NOT the engagement id \u2014 it identifies this repository/worktree to Halfcycle, not an engagement. The engagement id lives in .halfcycle/bundle.json, in its "engagementId" field.';
258
- function git(targetRepoRoot, args2) {
259
- try {
260
- const out = execFileSync("git", ["-C", targetRepoRoot, ...args2], {
261
- encoding: "utf-8",
262
- stdio: ["ignore", "pipe", "ignore"]
263
- });
264
- const trimmed = out.trim();
265
- return trimmed.length > 0 ? trimmed : null;
266
- } catch {
267
- return null;
268
- }
269
- }
270
- function readRootCommit(targetRepoRoot) {
271
- const roots = git(targetRepoRoot, ["rev-list", "--max-parents=0", "HEAD"]);
272
- if (!roots)
273
- return null;
274
- const lines = roots.split("\n").filter((l) => l.length > 0);
275
- return lines.length > 0 ? lines[lines.length - 1] : null;
276
- }
277
- function readRemote(targetRepoRoot) {
278
- return git(targetRepoRoot, ["remote", "get-url", "origin"]);
279
- }
280
- function mintOrReadIdentity(targetRepoRoot) {
281
- const path = join3(targetRepoRoot, ".halfcycle", "project.json");
282
- if (existsSync(path)) {
283
- const onDisk = JSON.parse(readFileSync3(path, "utf-8"));
284
- const identity2 = {
285
- projectId: onDisk.projectId ?? "",
286
- rootCommit: onDisk.rootCommit ?? null,
287
- remote: onDisk.remote ?? null,
288
- note: PROJECT_IDENTITY_NOTE
289
- };
290
- return { identity: identity2, minted: false };
291
- }
292
- const identity = {
293
- projectId: randomUUID(),
294
- rootCommit: readRootCommit(targetRepoRoot),
295
- remote: readRemote(targetRepoRoot),
296
- note: PROJECT_IDENTITY_NOTE
297
- };
298
- return { identity, minted: true };
299
- }
300
-
301
- // dist/mcp-endpoint.js
302
- var MCP_ENDPOINT_PATH = "/mcp";
303
- function mcpEndpointUrl(origin) {
304
- return `${origin.trim().replace(/\/+$/, "")}${MCP_ENDPOINT_PATH}`;
305
- }
306
-
307
- // dist/merge-settings.js
308
- function isHalfcycleEntry(entry, halfcycleCommands) {
309
- return entry.hooks.some((h) => halfcycleCommands.has(h.command));
310
- }
311
- function mergeSettings(existing, generated) {
312
- const merged = { ...existing };
313
- if (generated.$schema !== void 0) {
314
- merged.$schema = generated.$schema;
315
- }
316
- const generatedHooks = generated.hooks ?? {};
317
- const halfcycleCommands = /* @__PURE__ */ new Set();
318
- for (const entries of Object.values(generatedHooks)) {
319
- for (const entry of entries) {
320
- for (const h of entry.hooks)
321
- halfcycleCommands.add(h.command);
322
- }
323
- }
324
- const existingHooks = existing.hooks ?? {};
325
- const mergedHooks = {};
326
- const events = /* @__PURE__ */ new Set([...Object.keys(existingHooks), ...Object.keys(generatedHooks)]);
327
- for (const event of events) {
328
- const developerEntries = (existingHooks[event] ?? []).filter((entry) => !isHalfcycleEntry(entry, halfcycleCommands));
329
- const halfcycleEntries = generatedHooks[event] ?? [];
330
- mergedHooks[event] = [...developerEntries, ...halfcycleEntries];
583
+ result += "\n";
584
+ return result;
585
+ }
586
+ function readEngagementEnv(engagementId, home) {
587
+ try {
588
+ return parseEnvText(readFileSync2(engagementEnvPath(engagementId, home), "utf-8"));
589
+ } catch {
590
+ return null;
331
591
  }
332
- merged.hooks = mergedHooks;
333
- merged.permissions = mergePermissions(existing.permissions, generated.permissions);
334
- return merged;
335
592
  }
336
- function mergePermissions(existing, generated) {
337
- if (existing === void 0 && generated === void 0)
338
- return void 0;
339
- const result = { ...existing ?? {} };
340
- const generatedDeny = generated?.deny ?? [];
341
- if (generatedDeny.length > 0) {
342
- const theirs = existing?.deny ?? [];
343
- const seen = new Set(theirs);
344
- result.deny = [...theirs, ...generatedDeny.filter((p) => !seen.has(p))];
593
+ function writeEngagementEnv(engagementId, values, home) {
594
+ const path = engagementEnvPath(engagementId, home);
595
+ const dir = engagementStateDir(engagementId, home);
596
+ mkdirSync2(dir, { recursive: true, mode: 448 });
597
+ let existing;
598
+ try {
599
+ existing = readFileSync2(path, "utf-8");
600
+ } catch {
601
+ existing = null;
345
602
  }
346
- return result;
603
+ const reconciled = reconcileEnvText(existing, values);
604
+ const outcome = existing === reconciled ? "skipped" : "written";
605
+ if (outcome === "written") {
606
+ writeFileSync2(path, reconciled, { mode: 384 });
607
+ }
608
+ applyOwnerOnly(path, dir);
609
+ return outcome;
347
610
  }
348
-
349
- // dist/scan.js
350
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
351
- import { join as join4 } from "node:path";
352
-
353
- // ../events/dist/result.js
354
- import { z as z2 } from "zod";
355
-
356
- // ../core/dist/guard-record.js
357
- import { z } from "zod";
358
- var severitySchema = z.enum(["info", "warn", "block"]);
359
- var channelSchema = z.enum(["stable", "candidate", "rented"]);
360
- var matcherKindSchema = z.enum(["grep", "ast", "integrationTest", "llm"]);
361
- var matcherConfigSchema = z.record(z.string(), z.unknown());
362
- var grepMatcherSchema = z.object({ kind: z.literal("grep"), config: matcherConfigSchema }).strict();
363
- var astMatcherSchema = z.object({ kind: z.literal("ast"), config: matcherConfigSchema }).strict();
364
- var integrationTestMatcherSchema = z.object({ kind: z.literal("integrationTest"), config: matcherConfigSchema }).strict();
365
- var llmMatcherSchema = z.object({ kind: z.literal("llm"), config: matcherConfigSchema }).strict();
366
- var matcherSchema = z.discriminatedUnion("kind", [
367
- grepMatcherSchema,
368
- astMatcherSchema,
369
- integrationTestMatcherSchema,
370
- llmMatcherSchema
371
- ]);
372
-
373
- // ../events/dist/result.js
374
- var firedGuardSchema = z2.object({
375
- guardId: z2.string(),
376
- patternRef: z2.string(),
377
- severity: severitySchema,
378
- explanation: z2.string()
379
- }).strict();
380
- var resultEnvelopeSchema = z2.object({
381
- guardsFired: z2.array(firedGuardSchema),
382
- severity: severitySchema.nullable(),
383
- blocking: z2.boolean(),
384
- explanation: z2.string()
385
- }).strict();
386
- var wireErrorSchema = z2.object({
387
- statusCode: z2.number(),
388
- error: z2.string(),
389
- message: z2.string()
390
- }).strict();
391
-
392
- // ../events/dist/telemetry.js
393
- import { z as z3 } from "zod";
394
- 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)");
395
- var guardEvalOutcomeSchema = z3.enum([
396
- "evaluated",
397
- "infra-error",
398
- "contract-error",
399
- "unconfigured"
400
- ]);
401
- var guardEvalRunSchema = z3.object({
402
- // `runId`/`outcome` are the E2 run-observability additions, declared
403
- // field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
404
- // migration. Every new record populated by the migrated writer (T-07)
405
- // carries both; a legacy line predating the amendment carries neither and
406
- // must still parse (the mixed-era guard-eval log the Build-Record reader
407
- // assembles from, and the runner emit not yet migrated). So the presence
408
- // refinement below is skipped for a legacy line (no `outcome`), and the
409
- // per-writer guarantee lives with the writer + reader-normalisation (T-07),
410
- // where the schema may then be tightened to required. runId is uuid-checked
411
- // WHEN present; outcome is enum-checked when present.
412
- runId: z3.string().uuid().optional(),
413
- engagementId: z3.string().optional(),
414
- runType: z3.enum(["hook", "ci"]),
415
- occurredAt: utcIso8601,
416
- outcome: guardEvalOutcomeSchema.optional(),
417
- failureReason: z3.string().optional(),
418
- phase: z3.string().optional(),
419
- scope: z3.record(z3.string(), z3.unknown()),
420
- cost: z3.record(z3.string(), z3.unknown()).optional(),
421
- result: resultEnvelopeSchema.optional()
422
- }).strict().superRefine((run, ctx) => {
423
- if (run.outcome === void 0)
611
+ function applyOwnerOnly(path, dir) {
612
+ if (platform2() === "win32")
424
613
  return;
425
- if (run.outcome === "evaluated" && run.result === void 0) {
426
- ctx.addIssue({
427
- code: z3.ZodIssueCode.custom,
428
- path: ["result"],
429
- message: "result is required when outcome is 'evaluated'"
430
- });
431
- }
432
- if (run.outcome !== "evaluated" && run.result !== void 0) {
433
- ctx.addIssue({
434
- code: z3.ZodIssueCode.custom,
435
- path: ["result"],
436
- message: "result is only present when outcome is 'evaluated'"
437
- });
614
+ try {
615
+ chmodSync2(path, 384);
616
+ } catch {
438
617
  }
439
- if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
440
- ctx.addIssue({
441
- code: z3.ZodIssueCode.custom,
442
- path: ["engagementId"],
443
- message: "engagementId is required unless outcome is 'unconfigured'"
444
- });
618
+ try {
619
+ chmodSync2(dir, 448);
620
+ } catch {
445
621
  }
446
- });
447
-
448
- // ../events/dist/engagement-lifecycle.js
449
- import { z as z4 } from "zod";
450
- var utcIso86012 = z4.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
451
- var engagementTypeSchema = z4.enum(["client"]);
452
- var lifecycleStatusSchema = z4.enum(["active", "delivering", "closed"]);
453
- var createEngagementRequestSchema = z4.object({
454
- name: z4.string(),
455
- region: z4.string(),
456
- engagementType: engagementTypeSchema
457
- }).strict();
458
- var createEngagementResponseSchema = z4.object({
459
- engagementId: z4.string(),
460
- name: z4.string(),
461
- region: z4.string(),
462
- status: lifecycleStatusSchema,
463
- createdAt: utcIso86012,
464
- sessionToken: z4.string()
465
- }).strict();
466
- var engagementStatusResponseSchema = z4.object({
467
- engagementId: z4.string(),
468
- name: z4.string(),
469
- region: z4.string(),
470
- status: lifecycleStatusSchema,
471
- installLinked: z4.boolean(),
472
- createdAt: utcIso86012,
473
- updatedAt: utcIso86012
474
- }).strict();
475
- var linkInstallationRequestSchema = z4.object({
476
- installationId: z4.string(),
477
- engagementId: z4.string()
478
- }).strict();
479
- var startBuildRequestSchema = z4.object({
480
- engagementId: z4.string()
481
- }).strict();
482
- var startBuildResponseSchema = z4.object({
483
- engagementId: z4.string(),
484
- status: lifecycleStatusSchema,
485
- enqueuedAt: utcIso86012
486
- }).strict();
487
- var acceptanceOutcomeSchema = z4.object({
488
- verdict: z4.enum(["clean", "defects"]),
489
- findings: z4.number().int().nonnegative(),
490
- findingsDetail: z4.array(z4.object({ summary: z4.string() }).strict())
491
- }).strict();
492
- var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
493
- z4.object({
494
- decision: z4.literal("done-when-met"),
495
- /** Who is closing the phase. */
496
- actor: z4.string()
497
- }).strict(),
498
- z4.object({
499
- decision: z4.literal("override"),
500
- /** Who is closing it anyway. */
501
- actor: z4.string(),
502
- /** Why, in their own words. */
503
- reason: z4.string()
504
- }).strict()
505
- ]);
506
- var acceptPhaseRequestSchema = z4.object({
507
- phase: z4.string(),
508
- acceptanceOutcome: acceptanceOutcomeSchema,
509
- close: phaseCloseDecisionSchema.optional()
510
- }).strict();
511
- var coeFindingRequestSchema = z4.object({
512
- phase: z4.string(),
513
- description: z4.string(),
514
- addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
515
- mappedPatternRef: z4.string().nullable()
516
- }).strict();
517
- var coeFindingResponseSchema = z4.object({
518
- findingId: z4.string(),
519
- engagementId: z4.string(),
520
- phase: z4.string(),
521
- description: z4.string(),
522
- addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
523
- mappedPatternRef: z4.string().nullable(),
524
- createdAt: utcIso86012
525
- }).strict();
526
- var phaseEntryDecisionSchema = z4.discriminatedUnion("decision", [
527
- z4.object({
528
- decision: z4.literal("entry-condition-met"),
529
- /** Who attests. */
530
- actor: z4.string(),
531
- /** What makes the entry condition hold, in the attester's own words. */
532
- evidence: z4.string()
533
- }).strict(),
534
- z4.object({
535
- decision: z4.literal("override"),
536
- /** Who is opening it anyway. */
537
- actor: z4.string(),
538
- /** Why, in the operator's own words. */
539
- reason: z4.string()
540
- }).strict()
541
- ]);
542
- var openPhaseRequestSchema = z4.object({
543
- phase: z4.string().nullable(),
544
- entry: phaseEntryDecisionSchema
545
- }).strict();
546
- var phaseEntryDecisionRecordSchema = z4.object({
547
- decisionId: z4.string(),
548
- decision: z4.enum(["entry-condition-met", "override"]),
549
- actor: z4.string(),
550
- /** The evidence (attestation) or the reason (override) — one basis per row. */
551
- justification: z4.string(),
552
- blockingFinding: z4.string().nullable(),
553
- decidedAt: utcIso86012
554
- }).strict();
555
- var openPhaseResponseSchema = z4.object({
556
- engagement: engagementStatusResponseSchema,
557
- /** What is open now. `null` is project scope. */
558
- phase: z4.string().nullable(),
559
- /** What was open before this decision. `null` is project scope. */
560
- previousPhase: z4.string().nullable(),
561
- decision: phaseEntryDecisionRecordSchema
562
- }).strict();
622
+ }
623
+ function engagementResolutionShell() {
624
+ return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
625
+ # THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
626
+ # $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
627
+ # written owner-only by \`npx halfcycle\`. What this repository holds is the
628
+ # engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
629
+ # the pin, then name the file. There is no jq here and node is not dependable on a
630
+ # desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
631
+ # file, because sed is line-oriented and the pin's line breaks are not a contract.
632
+ #
633
+ # Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT and HALFCYCLE_ENV_PROBLEM in
634
+ # the CALLER's shell and returns a status. Call it as \`if halfcycle_env_file \u2026\`,
635
+ # never as \`$(halfcycle_env_file \u2026)\` \u2014 a command substitution is a subshell and
636
+ # would discard everything it set.
637
+ halfcycle_env_file() {
638
+ HALFCYCLE_ENV_FILE=""
639
+ HALFCYCLE_ENV_ENGAGEMENT=""
640
+ HALFCYCLE_ENV_PROBLEM=""
641
+ hc_pin="$1/.halfcycle/bundle.json"
642
+ if [ ! -f "$hc_pin" ]; then
643
+ HALFCYCLE_ENV_PROBLEM="no-pin"
644
+ return 1
645
+ fi
646
+ hc_id=$(tr -d '\\n' < "$hc_pin" | sed -n 's/.*"${PIN_ENGAGEMENT_ID_FIELD}"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
647
+ if [ -z "$hc_id" ]; then
648
+ HALFCYCLE_ENV_PROBLEM="no-id"
649
+ return 1
650
+ fi
651
+ HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
652
+ if [ -z "\${HOME:-}" ]; then
653
+ HALFCYCLE_ENV_PROBLEM="no-home"
654
+ return 1
655
+ fi
656
+ hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
657
+ if [ ! -f "$hc_env" ]; then
658
+ HALFCYCLE_ENV_PROBLEM="no-credential"
659
+ return 1
660
+ fi
661
+ HALFCYCLE_ENV_FILE="$hc_env"
662
+ return 0
663
+ }
664
+ # --- end Halfcycle credential resolution ------------------------------------`;
665
+ }
563
666
 
564
- // ../events/dist/device-auth.js
565
- import { z as z5 } from "zod";
566
- var utcIso86013 = z5.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
567
- var DEVICE_AUTH_STATUS = {
568
- /** The poll: minted, nobody has acted yet. Keep polling. 200. */
569
- PENDING: "pending",
570
- /** The poll: approved, and the credential is in this body. Once only. 200. */
571
- APPROVED: "approved",
572
- /** Confirm: the decision was recorded (approve or decline). 200. */
573
- RECORDED: "recorded",
574
- /** The human refused at the confirmation page. 400 on the poll. */
575
- DECLINED: "declined",
576
- /** The code's absolute expiry has passed. 400. */
577
- EXPIRED: "expired",
578
- /** Never minted, or already spent. 400. */
579
- UNKNOWN: "unknown",
580
- /** Confirm: neither `approve` nor `decline` was named. Nothing was bound. 400. */
581
- INVALID_DECISION: "invalid-decision",
582
- /** Confirm: an approval arrived with no signed-in identity. 400. */
583
- IDENTITY_REQUIRED: "identity-required",
584
- /** This deployment has no `SIGNIN_BASE_URL` / `DEVICE_AUTH_CONFIRM_SECRET`. 503. */
585
- NOT_CONFIGURED: "not-configured",
586
- /** Confirm: the shared confirmation secret was missing or wrong. 401. */
587
- UNAUTHORIZED: "unauthorized",
588
- /** Confirm: the code was already approved, declined or claimed. 409. */
589
- ALREADY_DECIDED: "already-decided"
590
- };
591
- var DEVICE_POLL_REFUSALS = [
592
- DEVICE_AUTH_STATUS.DECLINED,
593
- DEVICE_AUTH_STATUS.EXPIRED,
594
- DEVICE_AUTH_STATUS.UNKNOWN
595
- ];
596
- var deviceAuthStartResponseSchema = z5.object({
597
- deviceCode: z5.string().min(1),
598
- userCode: z5.string().min(1),
599
- verificationUrl: z5.string().min(1),
600
- expiresAt: utcIso86013,
601
- pollIntervalMs: z5.number().int().positive()
602
- }).strict();
603
- var devicePollPendingSchema = z5.object({
604
- status: z5.literal(DEVICE_AUTH_STATUS.PENDING),
605
- pollIntervalMs: z5.number().int().positive()
606
- }).strict();
607
- var devicePollApprovedSchema = z5.object({
608
- status: z5.literal(DEVICE_AUTH_STATUS.APPROVED),
609
- accountId: z5.string().min(1),
610
- credential: z5.string().min(1),
611
- expiresAt: utcIso86013
612
- }).strict();
613
- var deviceAuthRefusalSchema = z5.object({
614
- status: z5.enum([
615
- DEVICE_AUTH_STATUS.DECLINED,
616
- DEVICE_AUTH_STATUS.EXPIRED,
617
- DEVICE_AUTH_STATUS.UNKNOWN,
618
- DEVICE_AUTH_STATUS.INVALID_DECISION,
619
- DEVICE_AUTH_STATUS.IDENTITY_REQUIRED,
620
- DEVICE_AUTH_STATUS.NOT_CONFIGURED,
621
- DEVICE_AUTH_STATUS.UNAUTHORIZED,
622
- DEVICE_AUTH_STATUS.ALREADY_DECIDED
623
- ]),
624
- message: z5.string().min(1)
625
- }).strict();
626
- var devicePollResponseSchema = z5.union([
627
- devicePollPendingSchema,
628
- devicePollApprovedSchema,
629
- deviceAuthRefusalSchema
630
- ]);
631
- var deviceConfirmRequestSchema = z5.object({
632
- userCode: z5.string().min(1),
633
- decision: z5.enum(["approve", "decline"]),
634
- externalAuthId: z5.string().min(1).optional(),
635
- email: z5.string().optional()
636
- }).strict();
637
- var deviceConfirmRecordedSchema = z5.object({
638
- status: z5.literal(DEVICE_AUTH_STATUS.RECORDED),
639
- accountId: z5.string().min(1).optional()
640
- }).strict();
641
- var deviceConfirmResponseSchema = z5.union([
642
- deviceConfirmRecordedSchema,
643
- deviceAuthRefusalSchema
644
- ]);
645
- var LOOPBACK_REDIRECT_HOST = "127.0.0.1";
646
- var LOOPBACK_PORT_MIN = 1024;
647
- var LOOPBACK_PORT_MAX = 65535;
648
- var LOOPBACK_CALLBACK_PATH = "/halfcycle-cli-callback";
649
- var LOOPBACK_QUERY_PORT = "loopback_port";
650
- var LOOPBACK_QUERY_STATE = "loopback_state";
651
- var LOOPBACK_QUERY_RESULT = "result";
652
- var LOOPBACK_RESULT = {
653
- APPROVED: "approved",
654
- DECLINED: "declined"
655
- };
656
- function loopbackVerificationUrl(verificationUrl, target) {
657
- let url;
667
+ // dist/identity.js
668
+ import { execFileSync } from "node:child_process";
669
+ import { existsSync, readFileSync as readFileSync3 } from "node:fs";
670
+ import { join as join3 } from "node:path";
671
+ import { randomUUID } from "node:crypto";
672
+ var PROJECT_IDENTITY_NOTE = 'projectId is NOT the engagement id \u2014 it identifies this repository/worktree to Halfcycle, not an engagement. The engagement id lives in .halfcycle/bundle.json, in its "engagementId" field.';
673
+ function git(targetRepoRoot, args2) {
658
674
  try {
659
- url = new URL(verificationUrl);
675
+ const out = execFileSync("git", ["-C", targetRepoRoot, ...args2], {
676
+ encoding: "utf-8",
677
+ stdio: ["ignore", "pipe", "ignore"]
678
+ });
679
+ const trimmed = out.trim();
680
+ return trimmed.length > 0 ? trimmed : null;
660
681
  } catch {
661
- return verificationUrl;
682
+ return null;
662
683
  }
663
- url.searchParams.set(LOOPBACK_QUERY_PORT, String(target.port));
664
- url.searchParams.set(LOOPBACK_QUERY_STATE, target.state);
665
- return url.toString();
684
+ }
685
+ function readRootCommit(targetRepoRoot) {
686
+ const roots = git(targetRepoRoot, ["rev-list", "--max-parents=0", "HEAD"]);
687
+ if (!roots)
688
+ return null;
689
+ const lines = roots.split("\n").filter((l) => l.length > 0);
690
+ return lines.length > 0 ? lines[lines.length - 1] : null;
691
+ }
692
+ function readRemote(targetRepoRoot) {
693
+ return git(targetRepoRoot, ["remote", "get-url", "origin"]);
694
+ }
695
+ function mintOrReadIdentity(targetRepoRoot) {
696
+ const path = join3(targetRepoRoot, ".halfcycle", "project.json");
697
+ if (existsSync(path)) {
698
+ const onDisk = JSON.parse(readFileSync3(path, "utf-8"));
699
+ const identity2 = {
700
+ projectId: onDisk.projectId ?? "",
701
+ rootCommit: onDisk.rootCommit ?? null,
702
+ remote: onDisk.remote ?? null,
703
+ note: PROJECT_IDENTITY_NOTE
704
+ };
705
+ return { identity: identity2, minted: false };
706
+ }
707
+ const identity = {
708
+ projectId: randomUUID(),
709
+ rootCommit: readRootCommit(targetRepoRoot),
710
+ remote: readRemote(targetRepoRoot),
711
+ note: PROJECT_IDENTITY_NOTE
712
+ };
713
+ return { identity, minted: true };
714
+ }
715
+
716
+ // dist/mcp-endpoint.js
717
+ var MCP_ENDPOINT_PATH = "/mcp";
718
+ function mcpEndpointUrl(origin) {
719
+ return `${origin.trim().replace(/\/+$/, "")}${MCP_ENDPOINT_PATH}`;
720
+ }
721
+
722
+ // dist/merge-settings.js
723
+ function isHalfcycleEntry(entry, halfcycleCommands) {
724
+ return entry.hooks.some((h) => halfcycleCommands.has(h.command));
725
+ }
726
+ function mergeSettings(existing, generated) {
727
+ const merged = { ...existing };
728
+ if (generated.$schema !== void 0) {
729
+ merged.$schema = generated.$schema;
730
+ }
731
+ const generatedHooks = generated.hooks ?? {};
732
+ const halfcycleCommands = /* @__PURE__ */ new Set();
733
+ for (const entries of Object.values(generatedHooks)) {
734
+ for (const entry of entries) {
735
+ for (const h of entry.hooks)
736
+ halfcycleCommands.add(h.command);
737
+ }
738
+ }
739
+ const existingHooks = existing.hooks ?? {};
740
+ const mergedHooks = {};
741
+ const events = /* @__PURE__ */ new Set([...Object.keys(existingHooks), ...Object.keys(generatedHooks)]);
742
+ for (const event of events) {
743
+ const developerEntries = (existingHooks[event] ?? []).filter((entry) => !isHalfcycleEntry(entry, halfcycleCommands));
744
+ const halfcycleEntries = generatedHooks[event] ?? [];
745
+ mergedHooks[event] = [...developerEntries, ...halfcycleEntries];
746
+ }
747
+ merged.hooks = mergedHooks;
748
+ merged.permissions = mergePermissions(existing.permissions, generated.permissions);
749
+ return merged;
750
+ }
751
+ function mergePermissions(existing, generated) {
752
+ if (existing === void 0 && generated === void 0)
753
+ return void 0;
754
+ const result = { ...existing ?? {} };
755
+ const generatedDeny = generated?.deny ?? [];
756
+ if (generatedDeny.length > 0) {
757
+ const theirs = existing?.deny ?? [];
758
+ const seen = new Set(theirs);
759
+ result.deny = [...theirs, ...generatedDeny.filter((p) => !seen.has(p))];
760
+ }
761
+ return result;
666
762
  }
667
763
 
668
764
  // dist/scan.js
765
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
766
+ import { join as join4 } from "node:path";
669
767
  var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
670
768
  var HALFCYCLE_STATE_NOTE = [
671
769
  "This file is a local cache written ONCE when Halfcycle was installed. It is not a",
@@ -801,6 +899,18 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
801
899
  writeFileSync4(targetAbsPath, content, "utf-8");
802
900
  return "written";
803
901
  }
902
+ function writeOwned(targetAbsPath, targetRepoRoot, content) {
903
+ const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
904
+ if (!isAllowlisted(rel)) {
905
+ throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
906
+ }
907
+ if (existsSync3(targetAbsPath) && readFileSync5(targetAbsPath, "utf-8") === content) {
908
+ return "skipped";
909
+ }
910
+ mkdirSync4(dirname2(targetAbsPath), { recursive: true });
911
+ writeFileSync4(targetAbsPath, content, "utf-8");
912
+ return "written";
913
+ }
804
914
  function record(report, outcome, rel) {
805
915
  const bucket = {
806
916
  written: report.writtenPaths,
@@ -921,6 +1031,42 @@ if [ -z "$REPO_ROOT" ]; then exit 0; fi
921
1031
  HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
922
1032
  MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
923
1033
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
1034
+
1035
+ # ---------------------------------------------------------------------------
1036
+ # STANDING GUARD-COVERAGE STATEMENT (T-27).
1037
+ #
1038
+ # A repository with no credential on this machine is UNGUARDED: the PostToolUse
1039
+ # hook will find nothing to evaluate with, and it says so once per edit, after
1040
+ # the edit. This says it once, up front, before anything is written \u2014 which is
1041
+ # the one thing a session-start hook can do that nothing else can.
1042
+ #
1043
+ # Everything below runs in a SUBSHELL: it sources a credential store, and a
1044
+ # session-start hook must not leak an engagement's variables into whatever the
1045
+ # editor runs next. Any failure inside it is swallowed; this script's exit
1046
+ # status is 0 either way, and the marker above has already been written.
1047
+ # ---------------------------------------------------------------------------
1048
+ ${engagementResolutionShell()}
1049
+
1050
+ (
1051
+ if halfcycle_env_file "$REPO_ROOT"; then
1052
+ set -a
1053
+ . "$HALFCYCLE_ENV_FILE"
1054
+ set +a
1055
+ fi
1056
+ hc_missing=""
1057
+ for hc_key in ${GUARD_ENV_KEYS.join(" ")}; do
1058
+ eval "hc_value=\\\${$hc_key:-}"
1059
+ if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
1060
+ done
1061
+ if [ -n "$hc_missing" ]; then
1062
+ echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
1063
+ echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
1064
+ echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
1065
+ echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
1066
+ echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
1067
+ echo "[halfcycle] and needs nothing configured first."
1068
+ fi
1069
+ ) 2>/dev/null || true
924
1070
  exit 0
925
1071
  `;
926
1072
  }
@@ -934,6 +1080,13 @@ function generateUserPromptReminderHook() {
934
1080
  # one home: RULE_SENTENCE_PLAIN in the method repo's own position-house-rule
935
1081
  # test governs it \u2014 reword there and here together, in the same commit.
936
1082
  #
1083
+ # SILENT WHEN THE TOOL IT NAMES IS NOT THERE. The sentence tells the session to
1084
+ # call halfcycle_resolve, which arrives over the Halfcycle MCP server registered
1085
+ # in .mcp.json. If this repository carries no such registration, the instruction
1086
+ # cannot be followed, and a per-prompt instruction that cannot be followed is
1087
+ # worse than silence. The standing "no coverage" statement belongs in the
1088
+ # session-start banner, which says it once; this hook just stops talking.
1089
+ #
937
1090
  # FAILS OPEN. This hook's stdout is always plain text, never JSON, so it
938
1091
  # cannot form a {"decision":"block",...} body (UserPromptSubmit's only way to
939
1092
  # hold the turn). But a write failure here (a closed stdout, say) must still
@@ -941,6 +1094,26 @@ function generateUserPromptReminderHook() {
941
1094
  # than propagate.
942
1095
  trap 'exit 0' ERR
943
1096
  set -e
1097
+
1098
+ # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
1099
+ # session's cwd \u2014 a session started in a subdirectory must find the same root
1100
+ # .mcp.json the editor loaded, which is the bug mcp-headers.sh already paid for.
1101
+ HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
1102
+ HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
1103
+ HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
1104
+
1105
+ # Presence of the FILE is not enough \u2014 a developer's own .mcp.json with no
1106
+ # Halfcycle server in it registers no halfcycle_resolve. \`grep\` is guarded with
1107
+ # \`|| true\` because a non-match is a status, not an error, and \`set -e\` would
1108
+ # otherwise route it through the trap (same outcome here, but by accident).
1109
+ HC_REGISTERED=""
1110
+ if [ -f "$HC_REGISTRATION" ]; then
1111
+ HC_REGISTERED=$(tr -d '\\n' < "$HC_REGISTRATION" | grep -c '"${MCP_SERVER_KEY}"[[:space:]]*:' || true)
1112
+ fi
1113
+ if [ -z "$HC_REGISTERED" ] || [ "$HC_REGISTERED" = "0" ]; then
1114
+ exit 0
1115
+ fi
1116
+
944
1117
  printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
945
1118
  exit 0
946
1119
  `;
@@ -989,6 +1162,13 @@ var ENV_EXAMPLE_NAMES = [
989
1162
  "GUARD_SERVICE_URL",
990
1163
  "GUARD_SERVICE_TOKEN",
991
1164
  "GUARD_ENGAGEMENT_ID",
1165
+ // Declared for ONE reason only: it is written into the credential store, and the
1166
+ // INV-004 check below requires every written name to be named here. It is NOT
1167
+ // read from a client's environment — the generated CI stanza sets the three
1168
+ // GUARD_* names and not this one, and `packages/runner` never reads it at all.
1169
+ // It is how an install REMEMBERS the plane it was created against, which matters
1170
+ // only to our own development and CI planes (operator amendment, 2026-08-30);
1171
+ // nothing tells a client to set it, and the generated prose says so.
992
1172
  "HALFCYCLE_SERVICE_URL",
993
1173
  "HALFCYCLE_MCP_URL",
994
1174
  "HALFCYCLE_TOKEN",
@@ -1022,8 +1202,14 @@ GUARD_ENGAGEMENT_ID=<argType:runtime>
1022
1202
  # TWO ORIGINS, TWO SERVICES (T-22). HALFCYCLE_SERVICE_URL is the Halfcycle
1023
1203
  # CONTROL plane \u2014 it serves engagement creation and the board's first-visit
1024
1204
  # code. HALFCYCLE_MCP_URL is the method-delivery service, which serves /mcp and
1025
- # nothing else here. You configure the first; the platform reports the second on
1026
- # create, so it is written for you and .mcp.json carries the composed address.
1205
+ # nothing else here. The platform reports the second on create, so it is written
1206
+ # for you and .mcp.json carries the composed address.
1207
+ #
1208
+ # NEITHER IS A SETTING. Both names are here because both are WRITTEN by the
1209
+ # installer into this engagement's credential, and every name it writes is declared
1210
+ # here \u2014 that is the rule this section exists for. Halfcycle's address ships in the
1211
+ # CLI and is the same one for everybody, so there is nothing to look up and nothing
1212
+ # to put in this file. Leave both lines exactly as they are.
1027
1213
  HALFCYCLE_SERVICE_URL=<argType:runtime>
1028
1214
  HALFCYCLE_MCP_URL=<argType:runtime>
1029
1215
  HALFCYCLE_TOKEN=<argType:runtime>
@@ -1036,6 +1222,7 @@ CONTROL_TELEMETRY_URL=<argType:runtime>
1036
1222
  `;
1037
1223
  }
1038
1224
  var MCP_REGISTRATION_REL = ".mcp.json";
1225
+ var MCP_SERVER_KEY = "halfcycle";
1039
1226
  var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
1040
1227
  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}"'`;
1041
1228
  function generateMcpHeadersHelper() {
@@ -1148,7 +1335,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
1148
1335
  if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
1149
1336
  base.mcpServers = {};
1150
1337
  }
1151
- base.mcpServers["halfcycle"] = {
1338
+ base.mcpServers[MCP_SERVER_KEY] = {
1152
1339
  type: "http",
1153
1340
  url: mcpEndpointUrl(mcpOrigin),
1154
1341
  headersHelper: MCP_HEADERS_HELPER_COMMAND
@@ -1208,6 +1395,16 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, w
1208
1395
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1209
1396
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
1210
1397
  }
1398
+ function writeCrewRoster(targetRepoRoot, report) {
1399
+ const doc = {
1400
+ $comment: "GENERATED by the Halfcycle installer \u2014 do not edit by hand. Re-run the installer to pick up a roster change.",
1401
+ crew: CREW_ROSTER
1402
+ };
1403
+ const rendered = `${JSON.stringify(doc, null, 2)}
1404
+ `;
1405
+ const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
1406
+ record(report, writeOwned(crewPath, targetRepoRoot, rendered), ".halfcycle/crew.json");
1407
+ }
1211
1408
  function readBundlePin(targetRepoRoot) {
1212
1409
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1213
1410
  if (!existsSync3(pinPath))
@@ -1370,7 +1567,7 @@ async function install(options) {
1370
1567
  }
1371
1568
  const vendoredBinSrc = resolveVendoredBinary();
1372
1569
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
1373
- record(report, writeCollisionSafe(vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
1570
+ record(report, writeOwned(vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
1374
1571
  const settingsPath = join5(targetRepo, ".claude", "settings.json");
1375
1572
  const settingsPreexisted = existsSync3(settingsPath);
1376
1573
  const generatedSettings = JSON.parse(generateSettingsJson());
@@ -1439,6 +1636,7 @@ async function install(options) {
1439
1636
  break;
1440
1637
  }
1441
1638
  writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
1639
+ writeCrewRoster(targetRepo, report);
1442
1640
  const scanResult = runBootstrapScan(targetRepo);
1443
1641
  return {
1444
1642
  version: manifest.version,
@@ -1451,6 +1649,18 @@ async function install(options) {
1451
1649
  };
1452
1650
  }
1453
1651
 
1652
+ // dist/control-origin.js
1653
+ var DEFAULT_CONTROL_ORIGIN = "https://control.halfcycle.ai";
1654
+ function resolveControlOrigin(env = process.env) {
1655
+ const configured = env["HALFCYCLE_SERVICE_URL"]?.trim();
1656
+ if (configured)
1657
+ return { origin: configured.replace(/\/+$/, ""), source: "environment" };
1658
+ return { origin: DEFAULT_CONTROL_ORIGIN, source: "default" };
1659
+ }
1660
+ function controlOriginNote(resolved) {
1661
+ return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane)`;
1662
+ }
1663
+
1454
1664
  // dist/create-engagement.js
1455
1665
  var CreateEngagementRefused = class extends Error {
1456
1666
  status;
@@ -1508,7 +1718,7 @@ function planeRefusal(detail) {
1508
1718
  error: typeof candidate.error === "string" ? candidate.error : void 0
1509
1719
  };
1510
1720
  }
1511
- var CONTROL_ORIGIN_HINT = "HALFCYCLE_SERVICE_URL must be the Halfcycle CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you do not configure it.";
1721
+ var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
1512
1722
  async function createEngagement(baseUrl, name, credential) {
1513
1723
  return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
1514
1724
  }
@@ -2500,7 +2710,7 @@ async function closePhase(credential, phase, outcome, close) {
2500
2710
  }
2501
2711
  const parsedClose = phaseCloseDecisionSchema.safeParse(close);
2502
2712
  if (!parsedClose.success) {
2503
- throw new Error(`[halfcycle] That is not a phase-close decision this platform accepts: ${parsedClose.error.issues.map((i) => i.message).join("; ")}.`);
2713
+ throw new Error(`[halfcycle] That is not a phase-close decision this platform accepts: ${parsedClose.error.issues.map((i) => `${i.path.join(".") || "(decision)"}: ${i.message}`).join("; ")}.`);
2504
2714
  }
2505
2715
  const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/accept`;
2506
2716
  let res;
@@ -2774,45 +2984,43 @@ ${USAGE}`);
2774
2984
  return;
2775
2985
  }
2776
2986
  try {
2777
- const serviceUrl = process.env.HALFCYCLE_SERVICE_URL?.trim();
2778
- const pinned = engagementIdArg === void 0 ? readPinnedEngagement(targetRepo) : null;
2779
- let engagementId = engagementIdArg ?? pinned?.engagementId ?? "engagement-001";
2987
+ const controlOrigin = resolveControlOrigin(process.env);
2988
+ const serviceUrl = controlOrigin.origin;
2989
+ process.stdout.write(`[halfcycle] Halfcycle plane: ${controlOriginNote(controlOrigin)}
2990
+ `);
2991
+ const pinned = readPinnedEngagement(targetRepo);
2992
+ const requestedId = engagementIdArg ?? pinned?.engagementId;
2993
+ const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
2994
+ let engagementId;
2780
2995
  let credential;
2781
- if (pinned) {
2782
- credential = pinned.credential;
2996
+ if (reusable !== void 0 && pinned !== null) {
2997
+ engagementId = pinned.engagementId;
2998
+ credential = reusable;
2783
2999
  process.stdout.write(`[halfcycle] Re-using the engagement already pinned in this repo: ${pinned.engagementId}
2784
3000
  `);
2785
- if (credential === void 0 && serviceUrl) {
2786
- const joined = await joinPinnedEngagement(serviceUrl, pinned.engagementId);
2787
- credential = {
2788
- serviceUrl,
2789
- token: joined.sessionToken,
2790
- mcpUrl: joined.mcpUrl,
2791
- guardUrl: joined.guardUrl,
2792
- // T-10 — optional; absent on a plane that has not configured
2793
- // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
2794
- // an absent value, never `undefined` verbatim.
2795
- controlTelemetryUrl: joined.controlTelemetryUrl
2796
- };
2797
- process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
2798
- `);
2799
- } else if (credential === void 0) {
2800
- process.stdout.write(`[halfcycle] This machine holds no credential for it, and HALFCYCLE_SERVICE_URL is not
2801
- [halfcycle] set \u2014 so there is no plane to ask for one. Nothing will be minted, and the
2802
- [halfcycle] board link and MCP calls will not work until it is. Looked in
2803
- [halfcycle] ${engagementEnvPath(pinned.engagementId)}.
2804
- [halfcycle] Set that variable and re-run to get a credential of your own for this
2805
- [halfcycle] engagement. To start a NEW engagement instead, remove
2806
- [halfcycle] .halfcycle/bundle.json first.
2807
- `);
2808
- } else if (pinned.fromLegacyEnvLocal) {
3001
+ if (pinned.fromLegacyEnvLocal) {
2809
3002
  process.stdout.write(`[halfcycle] Its credential is in this repository's .env.local \u2014 an install from before
2810
3003
  [halfcycle] credentials moved out of the tree. It is being copied to
2811
3004
  [halfcycle] ${engagementEnvPath(pinned.engagementId)} and taken back out of .env.local.
2812
3005
  [halfcycle] Anything else that file holds is untouched.
2813
3006
  `);
2814
3007
  }
2815
- } else if (serviceUrl && engagementIdArg === void 0) {
3008
+ } else if (requestedId !== void 0) {
3009
+ const joined = await joinPinnedEngagement(serviceUrl, requestedId);
3010
+ engagementId = joined.engagementId;
3011
+ credential = {
3012
+ serviceUrl,
3013
+ token: joined.sessionToken,
3014
+ mcpUrl: joined.mcpUrl,
3015
+ guardUrl: joined.guardUrl,
3016
+ // T-10 — optional; absent on a plane that has not configured
3017
+ // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
3018
+ // an absent value, never `undefined` verbatim.
3019
+ controlTelemetryUrl: joined.controlTelemetryUrl
3020
+ };
3021
+ process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
3022
+ `);
3023
+ } else {
2816
3024
  const created = await createOwnedEngagement(serviceUrl);
2817
3025
  engagementId = created.engagementId;
2818
3026
  credential = {
@@ -2863,44 +3071,32 @@ ${USAGE}`);
2863
3071
  [halfcycle] ${missing.length === 1 ? "it" : "them"}) and re-run, and the credential moves out of the tree.
2864
3072
  `);
2865
3073
  }
2866
- if (credential) {
2867
- const probe = await probeMcpOrigin(credential.mcpUrl);
2868
- if (probe.reached) {
2869
- process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
2870
- `);
2871
- } else {
2872
- process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
2873
- [halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not your HALFCYCLE_SERVICE_URL (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
2874
- `);
2875
- }
2876
- }
2877
- if (credential) {
2878
- process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_ENV_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
3074
+ const probe = await probeMcpOrigin(credential.mcpUrl);
3075
+ if (probe.reached) {
3076
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
2879
3077
  `);
2880
3078
  } else {
2881
- const remedy = pinned ? `Set HALFCYCLE_SERVICE_URL to the Halfcycle CONTROL origin and re-run \u2014 this repo is pinned to engagement ${pinned.engagementId}, and with a plane to ask, the installer mints you a credential of your own for it, at ${engagementEnvPath(pinned.engagementId)}. To start a NEW engagement here instead, remove .halfcycle/bundle.json and re-run.` : engagementIdArg !== void 0 ? `This install named engagement ${engagementIdArg} explicitly, which skips the create that mints a credential. This repo is now pinned to it, so re-run \`npx halfcycle\` with no id and the installer will mint you a credential of your own for it. To start a NEW engagement instead, remove .halfcycle/bundle.json and re-run.` : `Set HALFCYCLE_SERVICE_URL to the Halfcycle CONTROL origin and re-run this installer.`;
2882
- process.stdout.write(`[halfcycle] Guard evaluation: OFF \u2014 this install had no credential, so no guard will run in this repository and edits are unguarded. ${remedy}
3079
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
3080
+ [halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not the CONTROL origin (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
2883
3081
  `);
2884
3082
  }
2885
- if (credential) {
2886
- process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through .halfcycle/mcp-headers.sh, so there is no token in this tree to commit and nothing for you to export.
3083
+ process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_ENV_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
2887
3084
  `);
2888
- process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the helper is a shell command, and Claude Code will not run it until you accept. Needs Claude Code ${MIN_HEADERS_HELPER_VERSION} or newer (${MIN_HEADER_ROTATION_VERSION}+ to re-authenticate a rotated token without restarting).
3085
+ process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through .halfcycle/mcp-headers.sh, so there is no token in this tree to commit and nothing for you to export.
2889
3086
  `);
2890
- process.stdout.write(`[halfcycle] If Halfcycle tools appear but every call returns 401, the helper could not read HALFCYCLE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
3087
+ process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the helper is a shell command, and Claude Code will not run it until you accept. Needs Claude Code ${MIN_HEADERS_HELPER_VERSION} or newer (${MIN_HEADER_ROTATION_VERSION}+ to re-authenticate a rotated token without restarting).
2891
3088
  `);
2892
- reportClaudeCodeVersion();
2893
- }
2894
- if (credential) {
2895
- try {
2896
- const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
2897
- process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
3089
+ process.stdout.write(`[halfcycle] If Halfcycle tools appear but every call returns 401, the helper could not read HALFCYCLE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
2898
3090
  `);
2899
- process.stdout.write(`[halfcycle] First-visit code \u2014 single use, expires ${minted.expiresAt}; paste it on that page, and re-run this installer for a fresh one: ${minted.enterCode}
3091
+ reportClaudeCodeVersion();
3092
+ try {
3093
+ const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
3094
+ process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
2900
3095
  `);
2901
- } catch {
2902
- process.stdout.write("[halfcycle] Your board: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
2903
- }
3096
+ process.stdout.write(`[halfcycle] First-visit code \u2014 single use, expires ${minted.expiresAt}; paste it on that page, and re-run this installer for a fresh one: ${minted.enterCode}
3097
+ `);
3098
+ } catch {
3099
+ process.stdout.write("[halfcycle] Your board: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
2904
3100
  }
2905
3101
  process.exit(0);
2906
3102
  } catch (err) {