halfcycle 0.3.8 → 0.3.10

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,346 +10,6 @@ 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
- // dist/engagement-credential.js
14
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
15
- import { platform as platform2 } from "node:os";
16
- import { join as join2 } from "node:path";
17
-
18
- // dist/account-credential.js
19
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
- import { homedir, platform } from "node:os";
21
- import { dirname, join } from "node:path";
22
- function halfcycleHome(home) {
23
- return join(home ?? homedir(), ".halfcycle");
24
- }
25
- function accountStorePath(home) {
26
- return join(halfcycleHome(home), "account.json");
27
- }
28
- function normaliseOrigin(serviceUrl) {
29
- return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
30
- }
31
- function readStore(home) {
32
- let raw;
33
- try {
34
- raw = readFileSync(accountStorePath(home), "utf-8");
35
- } catch {
36
- return { version: 1, accounts: {} };
37
- }
38
- try {
39
- const parsed = JSON.parse(raw);
40
- if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
41
- return { version: 1, accounts: {} };
42
- }
43
- return { version: 1, accounts: parsed.accounts ?? {} };
44
- } catch {
45
- return { version: 1, accounts: {} };
46
- }
47
- }
48
- function writeStore(store, home) {
49
- const path = accountStorePath(home);
50
- mkdirSync(dirname(path), { recursive: true, mode: 448 });
51
- writeFileSync(path, `${JSON.stringify(store, null, 2)}
52
- `, { mode: 384 });
53
- if (platform() !== "win32") {
54
- chmodSync(path, 384);
55
- try {
56
- chmodSync(dirname(path), 448);
57
- } catch {
58
- }
59
- }
60
- }
61
- function readStoredCredential(serviceUrl, home) {
62
- const entry = readStore(home).accounts[normaliseOrigin(serviceUrl)];
63
- if (!entry || typeof entry.credential !== "string" || entry.credential.trim() === "") {
64
- return void 0;
65
- }
66
- return entry;
67
- }
68
- function writeStoredCredential(serviceUrl, entry, home) {
69
- const store = readStore(home);
70
- store.accounts[normaliseOrigin(serviceUrl)] = {
71
- accountId: entry.accountId,
72
- credential: entry.credential,
73
- expiresAt: entry.expiresAt,
74
- signedInAt: entry.signedInAt ?? (/* @__PURE__ */ new Date()).toISOString()
75
- };
76
- writeStore(store, home);
77
- }
78
- function forgetStoredCredential(serviceUrl, home) {
79
- const store = readStore(home);
80
- const key = normaliseOrigin(serviceUrl);
81
- if (!(key in store.accounts))
82
- return false;
83
- delete store.accounts[key];
84
- writeStore(store, home);
85
- return true;
86
- }
87
-
88
- // dist/engagement-credential.js
89
- var ENGAGEMENTS_DIR = "engagements";
90
- var ENV_FILENAME = "env";
91
- var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
92
- function engagementStateDir(engagementId, home) {
93
- return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
94
- }
95
- function engagementEnvPath(engagementId, home) {
96
- return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
97
- }
98
- var GUARD_ENV_KEYS = [
99
- "GUARD_SERVICE_URL",
100
- "GUARD_SERVICE_TOKEN",
101
- "GUARD_ENGAGEMENT_ID"
102
- ];
103
- var ENGAGEMENT_ENV_KEYS = [
104
- "HALFCYCLE_SERVICE_URL",
105
- "HALFCYCLE_MCP_URL",
106
- "HALFCYCLE_TOKEN",
107
- "HALFCYCLE_ENGAGEMENT_ID",
108
- ...GUARD_ENV_KEYS,
109
- "CONTROL_TELEMETRY_URL"
110
- ];
111
- var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
112
- function shq(value) {
113
- return `'${value.replace(/'/g, `'\\''`)}'`;
114
- }
115
- function unquote(value) {
116
- const v = value.trim();
117
- if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
118
- return v.slice(1, -1).split(`'\\''`).join(`'`);
119
- }
120
- if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
121
- return v.slice(1, -1);
122
- return v;
123
- }
124
- function parseEnvText(raw) {
125
- const out = {};
126
- for (const line of raw.split("\n")) {
127
- const eq = line.indexOf("=");
128
- if (eq === -1)
129
- continue;
130
- const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
131
- if (key === "" || key.startsWith("#"))
132
- continue;
133
- out[key] = unquote(line.slice(eq + 1));
134
- }
135
- return out;
136
- }
137
- function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
138
- const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
139
- const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
140
- if (existing === null) {
141
- return `${ENV_HEADER}
142
- ` + keys.map(line).join("\n") + "\n";
143
- }
144
- const seen = /* @__PURE__ */ new Set();
145
- const out = existing.split("\n").map((existingLine) => {
146
- const eq = existingLine.indexOf("=");
147
- if (eq === -1)
148
- return existingLine;
149
- const lhs = existingLine.slice(0, eq);
150
- const exported = /^\s*export\s+/.exec(lhs);
151
- const prefix = exported === null ? "" : exported[0];
152
- const key = lhs.slice(prefix.length).trim();
153
- if (desired.has(key)) {
154
- seen.add(key);
155
- return `${prefix}${line(key)}`;
156
- }
157
- return existingLine;
158
- });
159
- const missing = keys.filter((k) => !seen.has(k));
160
- if (missing.length > 0) {
161
- const trailingBlank = out.length > 0 && out[out.length - 1] === "";
162
- const prefix = trailingBlank ? "" : existing.endsWith("\n") ? "" : "\n";
163
- out.push(`${prefix}${ENV_HEADER}
164
- ` + missing.map(line).join("\n"));
165
- }
166
- let result = out.join("\n");
167
- 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];
331
- }
332
- merged.hooks = mergedHooks;
333
- merged.permissions = mergePermissions(existing.permissions, generated.permissions);
334
- return merged;
335
- }
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))];
345
- }
346
- return result;
347
- }
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
13
  // ../events/dist/result.js
354
14
  import { z as z2 } from "zod";
355
15
 
@@ -544,171 +204,567 @@ var coeFindingRequestSchema = z4.object({
544
204
  addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
545
205
  mappedPatternRef: z4.string().nullable()
546
206
  }).strict();
547
- var coeFindingResponseSchema = z4.object({
548
- findingId: z4.string(),
549
- engagementId: z4.string(),
550
- phase: z4.string(),
551
- description: z4.string(),
552
- addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
553
- mappedPatternRef: z4.string().nullable(),
554
- createdAt: utcIso86012
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()
555
364
  }).strict();
556
- var phaseEntryDecisionSchema = z4.discriminatedUnion("decision", [
557
- z4.object({
558
- decision: z4.literal("entry-condition-met"),
559
- /** Who attests. */
560
- actor: z4.string(),
561
- /** What makes the entry condition hold, in the attester's own words. */
562
- evidence: z4.string()
563
- }).strict(),
564
- z4.object({
565
- decision: z4.literal("override"),
566
- /** Who is opening it anyway. */
567
- actor: z4.string(),
568
- /** Why, in the operator's own words. */
569
- reason: z4.string()
570
- }).strict()
571
- ]);
572
- var openPhaseRequestSchema = z4.object({
573
- phase: z4.string().nullable(),
574
- entry: phaseEntryDecisionSchema
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
575
370
  }).strict();
576
- var phaseEntryDecisionRecordSchema = z4.object({
577
- decisionId: z4.string(),
578
- decision: z4.enum(["entry-condition-met", "override"]),
579
- actor: z4.string(),
580
- /** The evidence (attestation) or the reason (override) — one basis per row. */
581
- justification: z4.string(),
582
- blockingFinding: z4.string().nullable(),
583
- decidedAt: utcIso86012
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)
584
384
  }).strict();
585
- var openPhaseResponseSchema = z4.object({
586
- engagement: engagementStatusResponseSchema,
587
- /** What is open now. `null` is project scope. */
588
- phase: z4.string().nullable(),
589
- /** What was open before this decision. `null` is project scope. */
590
- previousPhase: z4.string().nullable(),
591
- decision: phaseEntryDecisionRecordSchema
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()
592
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
+
428
+ // dist/engagement-credential.js
429
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
430
+ import { platform as platform2 } from "node:os";
431
+ import { join as join2 } from "node:path";
432
+
433
+ // dist/account-credential.js
434
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
435
+ import { homedir, platform } from "node:os";
436
+ import { dirname, join } from "node:path";
437
+ function halfcycleHome(home) {
438
+ return join(home ?? homedir(), ".halfcycle");
439
+ }
440
+ function accountStorePath(home) {
441
+ return join(halfcycleHome(home), "account.json");
442
+ }
443
+ function normaliseOrigin(serviceUrl) {
444
+ return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
445
+ }
446
+ function readStore(home) {
447
+ let raw;
448
+ try {
449
+ raw = readFileSync(accountStorePath(home), "utf-8");
450
+ } catch {
451
+ return { version: 1, accounts: {} };
452
+ }
453
+ try {
454
+ const parsed = JSON.parse(raw);
455
+ if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
456
+ return { version: 1, accounts: {} };
457
+ }
458
+ return { version: 1, accounts: parsed.accounts ?? {} };
459
+ } catch {
460
+ return { version: 1, accounts: {} };
461
+ }
462
+ }
463
+ function writeStore(store, home) {
464
+ const path = accountStorePath(home);
465
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
466
+ writeFileSync(path, `${JSON.stringify(store, null, 2)}
467
+ `, { mode: 384 });
468
+ if (platform() !== "win32") {
469
+ chmodSync(path, 384);
470
+ try {
471
+ chmodSync(dirname(path), 448);
472
+ } catch {
473
+ }
474
+ }
475
+ }
476
+ function readStoredCredential(serviceUrl, home) {
477
+ const entry = readStore(home).accounts[normaliseOrigin(serviceUrl)];
478
+ if (!entry || typeof entry.credential !== "string" || entry.credential.trim() === "") {
479
+ return void 0;
480
+ }
481
+ return entry;
482
+ }
483
+ function writeStoredCredential(serviceUrl, entry, home) {
484
+ const store = readStore(home);
485
+ store.accounts[normaliseOrigin(serviceUrl)] = {
486
+ accountId: entry.accountId,
487
+ credential: entry.credential,
488
+ expiresAt: entry.expiresAt,
489
+ signedInAt: entry.signedInAt ?? (/* @__PURE__ */ new Date()).toISOString()
490
+ };
491
+ writeStore(store, home);
492
+ }
493
+ function forgetStoredCredential(serviceUrl, home) {
494
+ const store = readStore(home);
495
+ const key = normaliseOrigin(serviceUrl);
496
+ if (!(key in store.accounts))
497
+ return false;
498
+ delete store.accounts[key];
499
+ writeStore(store, home);
500
+ return true;
501
+ }
502
+
503
+ // dist/engagement-credential.js
504
+ var ENGAGEMENTS_DIR = "engagements";
505
+ var ENV_FILENAME = "env";
506
+ var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
507
+ function engagementStateDir(engagementId, home) {
508
+ return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
509
+ }
510
+ function engagementEnvPath(engagementId, home) {
511
+ return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
512
+ }
513
+ var GUARD_ENV_KEYS = [
514
+ "GUARD_SERVICE_URL",
515
+ "GUARD_SERVICE_TOKEN",
516
+ "GUARD_ENGAGEMENT_ID"
517
+ ];
518
+ var GUARD_COVERAGE_REQUIRED_KEYS = GUARD_ENV_KEYS.filter((key) => key !== "GUARD_SERVICE_URL");
519
+ var ENGAGEMENT_ENV_KEYS = [
520
+ "HALFCYCLE_SERVICE_URL",
521
+ "HALFCYCLE_MCP_URL",
522
+ "HALFCYCLE_TOKEN",
523
+ "HALFCYCLE_ENGAGEMENT_ID",
524
+ ...GUARD_ENV_KEYS,
525
+ "CONTROL_TELEMETRY_URL"
526
+ ];
527
+ var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
528
+ function shq(value) {
529
+ return `'${value.replace(/'/g, `'\\''`)}'`;
530
+ }
531
+ function unquote(value) {
532
+ const v = value.trim();
533
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
534
+ return v.slice(1, -1).split(`'\\''`).join(`'`);
535
+ }
536
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
537
+ return v.slice(1, -1);
538
+ return v;
539
+ }
540
+ function parseEnvText(raw) {
541
+ const out = {};
542
+ for (const line of raw.split("\n")) {
543
+ const eq = line.indexOf("=");
544
+ if (eq === -1)
545
+ continue;
546
+ const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
547
+ if (key === "" || key.startsWith("#"))
548
+ continue;
549
+ out[key] = unquote(line.slice(eq + 1));
550
+ }
551
+ return out;
552
+ }
553
+ function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
554
+ const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
555
+ const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
556
+ if (existing === null) {
557
+ return `${ENV_HEADER}
558
+ ` + keys.map(line).join("\n") + "\n";
559
+ }
560
+ const seen = /* @__PURE__ */ new Set();
561
+ const out = existing.split("\n").map((existingLine) => {
562
+ const eq = existingLine.indexOf("=");
563
+ if (eq === -1)
564
+ return existingLine;
565
+ const lhs = existingLine.slice(0, eq);
566
+ const exported = /^\s*export\s+/.exec(lhs);
567
+ const prefix = exported === null ? "" : exported[0];
568
+ const key = lhs.slice(prefix.length).trim();
569
+ if (desired.has(key)) {
570
+ seen.add(key);
571
+ return `${prefix}${line(key)}`;
572
+ }
573
+ return existingLine;
574
+ });
575
+ const missing = keys.filter((k) => !seen.has(k));
576
+ if (missing.length > 0) {
577
+ const trailingBlank = out.length > 0 && out[out.length - 1] === "";
578
+ const prefix = trailingBlank ? "" : existing.endsWith("\n") ? "" : "\n";
579
+ out.push(`${prefix}${ENV_HEADER}
580
+ ` + missing.map(line).join("\n"));
581
+ }
582
+ let result = out.join("\n");
583
+ if (existing.endsWith("\n") && !result.endsWith("\n"))
584
+ result += "\n";
585
+ return result;
586
+ }
587
+ function readEngagementEnv(engagementId, home) {
588
+ try {
589
+ return parseEnvText(readFileSync2(engagementEnvPath(engagementId, home), "utf-8"));
590
+ } catch {
591
+ return null;
592
+ }
593
+ }
594
+ function writeEngagementEnv(engagementId, values, home) {
595
+ const path = engagementEnvPath(engagementId, home);
596
+ const dir = engagementStateDir(engagementId, home);
597
+ mkdirSync2(dir, { recursive: true, mode: 448 });
598
+ let existing;
599
+ try {
600
+ existing = readFileSync2(path, "utf-8");
601
+ } catch {
602
+ existing = null;
603
+ }
604
+ const reconciled = reconcileEnvText(existing, values);
605
+ const outcome = existing === reconciled ? "skipped" : "written";
606
+ if (outcome === "written") {
607
+ writeFileSync2(path, reconciled, { mode: 384 });
608
+ }
609
+ applyOwnerOnly(path, dir);
610
+ return outcome;
611
+ }
612
+ function applyOwnerOnly(path, dir) {
613
+ if (platform2() === "win32")
614
+ return;
615
+ try {
616
+ chmodSync2(path, 384);
617
+ } catch {
618
+ }
619
+ try {
620
+ chmodSync2(dir, 448);
621
+ } catch {
622
+ }
623
+ }
624
+ function engagementResolutionShell() {
625
+ return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
626
+ # THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
627
+ # $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
628
+ # written owner-only by \`npx halfcycle\`. What this repository holds is the
629
+ # engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
630
+ # the pin, then name the file. There is no jq here and node is not dependable on a
631
+ # desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
632
+ # file, because sed is line-oriented and the pin's line breaks are not a contract.
633
+ #
634
+ # Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT and HALFCYCLE_ENV_PROBLEM in
635
+ # the CALLER's shell and returns a status. Call it as \`if halfcycle_env_file \u2026\`,
636
+ # never as \`$(halfcycle_env_file \u2026)\` \u2014 a command substitution is a subshell and
637
+ # would discard everything it set.
638
+ halfcycle_env_file() {
639
+ HALFCYCLE_ENV_FILE=""
640
+ HALFCYCLE_ENV_ENGAGEMENT=""
641
+ HALFCYCLE_ENV_PROBLEM=""
642
+ hc_pin="$1/.halfcycle/bundle.json"
643
+ if [ ! -f "$hc_pin" ]; then
644
+ HALFCYCLE_ENV_PROBLEM="no-pin"
645
+ return 1
646
+ fi
647
+ hc_id=$(tr -d '\\n' < "$hc_pin" | sed -n 's/.*"${PIN_ENGAGEMENT_ID_FIELD}"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
648
+ if [ -z "$hc_id" ]; then
649
+ HALFCYCLE_ENV_PROBLEM="no-id"
650
+ return 1
651
+ fi
652
+ HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
653
+ if [ -z "\${HOME:-}" ]; then
654
+ HALFCYCLE_ENV_PROBLEM="no-home"
655
+ return 1
656
+ fi
657
+ hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
658
+ if [ ! -f "$hc_env" ]; then
659
+ HALFCYCLE_ENV_PROBLEM="no-credential"
660
+ return 1
661
+ fi
662
+ HALFCYCLE_ENV_FILE="$hc_env"
663
+ return 0
664
+ }
665
+ # --- end Halfcycle credential resolution ------------------------------------`;
666
+ }
593
667
 
594
- // ../events/dist/device-auth.js
595
- import { z as z5 } from "zod";
596
- 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)");
597
- var DEVICE_AUTH_STATUS = {
598
- /** The poll: minted, nobody has acted yet. Keep polling. 200. */
599
- PENDING: "pending",
600
- /** The poll: approved, and the credential is in this body. Once only. 200. */
601
- APPROVED: "approved",
602
- /** Confirm: the decision was recorded (approve or decline). 200. */
603
- RECORDED: "recorded",
604
- /** The human refused at the confirmation page. 400 on the poll. */
605
- DECLINED: "declined",
606
- /** The code's absolute expiry has passed. 400. */
607
- EXPIRED: "expired",
608
- /** Never minted, or already spent. 400. */
609
- UNKNOWN: "unknown",
610
- /** Confirm: neither `approve` nor `decline` was named. Nothing was bound. 400. */
611
- INVALID_DECISION: "invalid-decision",
612
- /** Confirm: an approval arrived with no signed-in identity. 400. */
613
- IDENTITY_REQUIRED: "identity-required",
614
- /** This deployment has no `SIGNIN_BASE_URL` / `DEVICE_AUTH_CONFIRM_SECRET`. 503. */
615
- NOT_CONFIGURED: "not-configured",
616
- /** Confirm: the shared confirmation secret was missing or wrong. 401. */
617
- UNAUTHORIZED: "unauthorized",
618
- /** Confirm: the code was already approved, declined or claimed. 409. */
619
- ALREADY_DECIDED: "already-decided",
620
- /**
621
- * Confirm: an approval named a signed-in identity whose account has not accepted
622
- * the terms of service and privacy policy currently in force (T-18, #588). 400.
623
- *
624
- * NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
625
- * says nothing about the code itself — the code is still `pending` after this
626
- * response, exactly as it was before the request, so the SAME userCode can be
627
- * confirmed again once the caller has recorded acceptance. `deviceConfirmRequestSchema`'s
628
- * `acceptTerms` is how a second confirm says it did.
629
- */
630
- TERMS_REQUIRED: "terms-required"
631
- };
632
- var DEVICE_POLL_REFUSALS = [
633
- DEVICE_AUTH_STATUS.DECLINED,
634
- DEVICE_AUTH_STATUS.EXPIRED,
635
- DEVICE_AUTH_STATUS.UNKNOWN
636
- ];
637
- var deviceAuthStartResponseSchema = z5.object({
638
- deviceCode: z5.string().min(1),
639
- userCode: z5.string().min(1),
640
- verificationUrl: z5.string().min(1),
641
- expiresAt: utcIso86013,
642
- pollIntervalMs: z5.number().int().positive()
643
- }).strict();
644
- var devicePollPendingSchema = z5.object({
645
- status: z5.literal(DEVICE_AUTH_STATUS.PENDING),
646
- pollIntervalMs: z5.number().int().positive()
647
- }).strict();
648
- var devicePollApprovedSchema = z5.object({
649
- status: z5.literal(DEVICE_AUTH_STATUS.APPROVED),
650
- accountId: z5.string().min(1),
651
- credential: z5.string().min(1),
652
- expiresAt: utcIso86013
653
- }).strict();
654
- var deviceAuthRefusalSchema = z5.object({
655
- status: z5.enum([
656
- DEVICE_AUTH_STATUS.DECLINED,
657
- DEVICE_AUTH_STATUS.EXPIRED,
658
- DEVICE_AUTH_STATUS.UNKNOWN,
659
- DEVICE_AUTH_STATUS.INVALID_DECISION,
660
- DEVICE_AUTH_STATUS.IDENTITY_REQUIRED,
661
- DEVICE_AUTH_STATUS.NOT_CONFIGURED,
662
- DEVICE_AUTH_STATUS.UNAUTHORIZED,
663
- DEVICE_AUTH_STATUS.ALREADY_DECIDED,
664
- DEVICE_AUTH_STATUS.TERMS_REQUIRED
665
- ]),
666
- message: z5.string().min(1)
667
- }).strict();
668
- var devicePollResponseSchema = z5.union([
669
- devicePollPendingSchema,
670
- devicePollApprovedSchema,
671
- deviceAuthRefusalSchema
672
- ]);
673
- var deviceConfirmRequestSchema = z5.object({
674
- userCode: z5.string().min(1),
675
- decision: z5.enum(["approve", "decline"]),
676
- externalAuthId: z5.string().min(1).optional(),
677
- email: z5.string().optional(),
678
- acceptTerms: z5.boolean().optional()
679
- }).strict();
680
- var deviceConfirmRecordedSchema = z5.object({
681
- status: z5.literal(DEVICE_AUTH_STATUS.RECORDED),
682
- accountId: z5.string().min(1).optional()
683
- }).strict();
684
- var deviceConfirmResponseSchema = z5.union([
685
- deviceConfirmRecordedSchema,
686
- deviceAuthRefusalSchema
687
- ]);
688
- var LOOPBACK_REDIRECT_HOST = "127.0.0.1";
689
- var LOOPBACK_PORT_MIN = 1024;
690
- var LOOPBACK_PORT_MAX = 65535;
691
- var LOOPBACK_CALLBACK_PATH = "/halfcycle-cli-callback";
692
- var LOOPBACK_QUERY_PORT = "loopback_port";
693
- var LOOPBACK_QUERY_STATE = "loopback_state";
694
- var LOOPBACK_QUERY_RESULT = "result";
695
- var LOOPBACK_RESULT = {
696
- APPROVED: "approved",
697
- DECLINED: "declined"
698
- };
699
- function loopbackVerificationUrl(verificationUrl, target) {
700
- let url;
668
+ // dist/identity.js
669
+ import { execFileSync } from "node:child_process";
670
+ import { existsSync, readFileSync as readFileSync3 } from "node:fs";
671
+ import { join as join3 } from "node:path";
672
+ import { randomUUID } from "node:crypto";
673
+ 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.';
674
+ function git(targetRepoRoot, args2) {
701
675
  try {
702
- url = new URL(verificationUrl);
676
+ const out = execFileSync("git", ["-C", targetRepoRoot, ...args2], {
677
+ encoding: "utf-8",
678
+ stdio: ["ignore", "pipe", "ignore"]
679
+ });
680
+ const trimmed = out.trim();
681
+ return trimmed.length > 0 ? trimmed : null;
703
682
  } catch {
704
- return verificationUrl;
683
+ return null;
705
684
  }
706
- url.searchParams.set(LOOPBACK_QUERY_PORT, String(target.port));
707
- url.searchParams.set(LOOPBACK_QUERY_STATE, target.state);
708
- return url.toString();
685
+ }
686
+ function readRootCommit(targetRepoRoot) {
687
+ const roots = git(targetRepoRoot, ["rev-list", "--max-parents=0", "HEAD"]);
688
+ if (!roots)
689
+ return null;
690
+ const lines = roots.split("\n").filter((l) => l.length > 0);
691
+ return lines.length > 0 ? lines[lines.length - 1] : null;
692
+ }
693
+ function readRemote(targetRepoRoot) {
694
+ return git(targetRepoRoot, ["remote", "get-url", "origin"]);
695
+ }
696
+ function mintOrReadIdentity(targetRepoRoot) {
697
+ const path = join3(targetRepoRoot, ".halfcycle", "project.json");
698
+ if (existsSync(path)) {
699
+ const onDisk = JSON.parse(readFileSync3(path, "utf-8"));
700
+ const identity2 = {
701
+ projectId: onDisk.projectId ?? "",
702
+ rootCommit: onDisk.rootCommit ?? null,
703
+ remote: onDisk.remote ?? null,
704
+ note: PROJECT_IDENTITY_NOTE
705
+ };
706
+ return { identity: identity2, minted: false };
707
+ }
708
+ const identity = {
709
+ projectId: randomUUID(),
710
+ rootCommit: readRootCommit(targetRepoRoot),
711
+ remote: readRemote(targetRepoRoot),
712
+ note: PROJECT_IDENTITY_NOTE
713
+ };
714
+ return { identity, minted: true };
715
+ }
716
+
717
+ // dist/mcp-endpoint.js
718
+ var MCP_ENDPOINT_PATH = "/mcp";
719
+ function mcpEndpointUrl(origin) {
720
+ return `${origin.trim().replace(/\/+$/, "")}${MCP_ENDPOINT_PATH}`;
721
+ }
722
+
723
+ // dist/merge-settings.js
724
+ function isHalfcycleEntry(entry, halfcycleCommands) {
725
+ return entry.hooks.some((h) => halfcycleCommands.has(h.command));
726
+ }
727
+ function mergeSettings(existing, generated) {
728
+ const merged = { ...existing };
729
+ if (generated.$schema !== void 0) {
730
+ merged.$schema = generated.$schema;
731
+ }
732
+ const generatedHooks = generated.hooks ?? {};
733
+ const halfcycleCommands = /* @__PURE__ */ new Set();
734
+ for (const entries of Object.values(generatedHooks)) {
735
+ for (const entry of entries) {
736
+ for (const h of entry.hooks)
737
+ halfcycleCommands.add(h.command);
738
+ }
739
+ }
740
+ const existingHooks = existing.hooks ?? {};
741
+ const mergedHooks = {};
742
+ const events = /* @__PURE__ */ new Set([...Object.keys(existingHooks), ...Object.keys(generatedHooks)]);
743
+ for (const event of events) {
744
+ const developerEntries = (existingHooks[event] ?? []).filter((entry) => !isHalfcycleEntry(entry, halfcycleCommands));
745
+ const halfcycleEntries = generatedHooks[event] ?? [];
746
+ mergedHooks[event] = [...developerEntries, ...halfcycleEntries];
747
+ }
748
+ merged.hooks = mergedHooks;
749
+ merged.permissions = mergePermissions(existing.permissions, generated.permissions);
750
+ return merged;
751
+ }
752
+ function mergePermissions(existing, generated) {
753
+ if (existing === void 0 && generated === void 0)
754
+ return void 0;
755
+ const result = { ...existing ?? {} };
756
+ const generatedDeny = generated?.deny ?? [];
757
+ if (generatedDeny.length > 0) {
758
+ const theirs = existing?.deny ?? [];
759
+ const seen = new Set(theirs);
760
+ result.deny = [...theirs, ...generatedDeny.filter((p) => !seen.has(p))];
761
+ }
762
+ return result;
709
763
  }
710
764
 
711
765
  // dist/scan.js
766
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
767
+ import { join as join4 } from "node:path";
712
768
  var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
713
769
  var HALFCYCLE_STATE_NOTE = [
714
770
  "This file is a local cache written ONCE when Halfcycle was installed. It is not a",
@@ -999,7 +1055,7 @@ ${engagementResolutionShell()}
999
1055
  set +a
1000
1056
  fi
1001
1057
  hc_missing=""
1002
- for hc_key in ${GUARD_ENV_KEYS.join(" ")}; do
1058
+ for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
1003
1059
  eval "hc_value=\\\${$hc_key:-}"
1004
1060
  if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
1005
1061
  done
@@ -1092,7 +1148,10 @@ function generateCiStanza() {
1092
1148
  # node-version: '20'
1093
1149
  # - name: Halfcycle guard CI check
1094
1150
  # env:
1095
- # GUARD_SERVICE_URL: \${{ secrets.GUARD_SERVICE_URL }}
1151
+ # # TWO SECRETS, AND THEY ARE THE TWO THAT ARE YOURS: the engagement's
1152
+ # # token and its id. Both were printed by the installer that generated
1153
+ # # this file. There is no address to configure \u2014 the guard service this
1154
+ # # job talks to ships inside the binary below.
1096
1155
  # GUARD_SERVICE_TOKEN: \${{ secrets.GUARD_SERVICE_TOKEN }}
1097
1156
  # GUARD_ENGAGEMENT_ID: \${{ secrets.GUARD_ENGAGEMENT_ID }}
1098
1157
  # run: node ./.halfcycle/bin/bin.bundle.mjs ci
@@ -1104,24 +1163,21 @@ function generateCiStanza() {
1104
1163
  `;
1105
1164
  }
1106
1165
  var ENV_EXAMPLE_NAMES = [
1107
- "GUARD_SERVICE_URL",
1166
+ // The engagement's own secret, under the name the guard runner reads. A CI job
1167
+ // has no per-user credential store, so this is one of the two it must supply.
1108
1168
  "GUARD_SERVICE_TOKEN",
1169
+ // Which engagement this repository is. Per-engagement by construction; the same
1170
+ // CI job supplies it beside the token.
1109
1171
  "GUARD_ENGAGEMENT_ID",
1110
- "HALFCYCLE_SERVICE_URL",
1111
- "HALFCYCLE_MCP_URL",
1112
- "HALFCYCLE_TOKEN",
1113
- // T-10 — declared because it is now WRITTEN into the credential store, and
1114
- // `install-guard-config.test.ts`'s INV-004 check requires every written name to
1115
- // be named here too (`HALFCYCLE_ENGAGEMENT_ID` is the one deliberate exception,
1116
- // recorded in the spec). Optional at runtime — the runner's own telemetry
1117
- // emission is fail-open when it is absent — but a name that is written and never
1118
- // declared is exactly what this generated section exists to prevent.
1119
- "CONTROL_TELEMETRY_URL"
1172
+ // The same credential under the name every other Halfcycle surface reads. Set in
1173
+ // the environment it takes precedence over the stored credential, which is the
1174
+ // escape hatch for running as a specific identity.
1175
+ "HALFCYCLE_TOKEN"
1120
1176
  ];
1121
1177
  function generateEnvExampleSection() {
1122
1178
  return `
1123
1179
  # ---------------------------------------------------------------------------
1124
- # Halfcycle runner (generated by the Halfcycle installer \u2014 names only, no values)
1180
+ # Halfcycle (generated by the Halfcycle installer \u2014 names only, no values)
1125
1181
  # ---------------------------------------------------------------------------
1126
1182
  # YOU DO NOT NEED TO SET ANY OF THESE ON YOUR OWN MACHINE, and there is nowhere in
1127
1183
  # this repository to put them. \`npx halfcycle\` writes this engagement's credential
@@ -1129,33 +1185,18 @@ function generateEnvExampleSection() {
1129
1185
  # MCP headers helper read it from there. Nothing Halfcycle writes a secret into
1130
1186
  # this tree, so there is no file here to leak, ignore or clean up.
1131
1187
  #
1132
- # These names are declared because they ARE read from the environment in one
1133
- # place: a CI job, which has no browser and no per-user store and supplies them as
1134
- # workflow secrets (see .halfcycle/ci-stanza.yml). HALFCYCLE_TOKEN set in the
1135
- # environment also takes precedence over the stored credential everywhere, which is
1136
- # the escape hatch for running as a specific identity.
1137
- GUARD_SERVICE_URL=<argType:runtime>
1188
+ # These three are declared because they ARE read from the environment in one place:
1189
+ # a CI job, which has no browser and no per-user store and supplies them as workflow
1190
+ # secrets (see .halfcycle/ci-stanza.yml). All three are yours \u2014 a credential and the
1191
+ # id of your engagement.
1192
+ #
1193
+ # THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
1194
+ # product talks to has one address, the same for every user, and it ships in the
1195
+ # tools you already have: the installer, the guard hook and the CI binary each know
1196
+ # where to go. If something tells you to set a Halfcycle URL, it is out of date.
1138
1197
  GUARD_SERVICE_TOKEN=<argType:runtime>
1139
1198
  GUARD_ENGAGEMENT_ID=<argType:runtime>
1140
- # TWO ORIGINS, TWO SERVICES (T-22). HALFCYCLE_SERVICE_URL is the Halfcycle
1141
- # CONTROL plane \u2014 it serves engagement creation and the board's first-visit
1142
- # code. HALFCYCLE_MCP_URL is the method-delivery service, which serves /mcp and
1143
- # nothing else here. The platform reports the second on create, so it is written
1144
- # for you and .mcp.json carries the composed address.
1145
- #
1146
- # NEITHER IS SOMETHING YOU NEED TO SET (T-27). The installer talks to Halfcycle's
1147
- # own control plane by default; HALFCYCLE_SERVICE_URL OVERRIDES that address and
1148
- # exists for a self-hosted or development plane. It is not a prerequisite for
1149
- # \`npx halfcycle\`, and nothing in the product asks you to find its value.
1150
- HALFCYCLE_SERVICE_URL=<argType:runtime>
1151
- HALFCYCLE_MCP_URL=<argType:runtime>
1152
1199
  HALFCYCLE_TOKEN=<argType:runtime>
1153
- # CONTROL_TELEMETRY_URL (T-10) \u2014 the platform reports this on create/join, same as
1154
- # HALFCYCLE_MCP_URL. OPTIONAL: absent, guard-eval telemetry is silently disabled
1155
- # and the guard loop itself is unaffected \u2014 this name is declared only because it
1156
- # is one this installer now writes to the machine-level store, same as every name
1157
- # above it.
1158
- CONTROL_TELEMETRY_URL=<argType:runtime>
1159
1200
  `;
1160
1201
  }
1161
1202
  var MCP_REGISTRATION_REL = ".mcp.json";
@@ -1332,6 +1373,16 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, w
1332
1373
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1333
1374
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
1334
1375
  }
1376
+ function writeCrewRoster(targetRepoRoot, report) {
1377
+ const doc = {
1378
+ $comment: "GENERATED by the Halfcycle installer \u2014 do not edit by hand. Re-run the installer to pick up a roster change.",
1379
+ crew: CREW_ROSTER
1380
+ };
1381
+ const rendered = `${JSON.stringify(doc, null, 2)}
1382
+ `;
1383
+ const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
1384
+ record(report, writeOwned(crewPath, targetRepoRoot, rendered), ".halfcycle/crew.json");
1385
+ }
1335
1386
  function readBundlePin(targetRepoRoot) {
1336
1387
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1337
1388
  if (!existsSync3(pinPath))
@@ -1563,6 +1614,7 @@ async function install(options) {
1563
1614
  break;
1564
1615
  }
1565
1616
  writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
1617
+ writeCrewRoster(targetRepo, report);
1566
1618
  const scanResult = runBootstrapScan(targetRepo);
1567
1619
  return {
1568
1620
  version: manifest.version,
@@ -1584,7 +1636,7 @@ function resolveControlOrigin(env = process.env) {
1584
1636
  return { origin: DEFAULT_CONTROL_ORIGIN, source: "default" };
1585
1637
  }
1586
1638
  function controlOriginNote(resolved) {
1587
- return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane; set HALFCYCLE_SERVICE_URL to use another)`;
1639
+ return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane)`;
1588
1640
  }
1589
1641
 
1590
1642
  // dist/create-engagement.js
@@ -1644,7 +1696,7 @@ function planeRefusal(detail) {
1644
1696
  error: typeof candidate.error === "string" ? candidate.error : void 0
1645
1697
  };
1646
1698
  }
1647
- var CONTROL_ORIGIN_HINT = `With nothing configured this CLI talks to ${DEFAULT_CONTROL_ORIGIN}; HALFCYCLE_SERVICE_URL overrides that, and is only for a self-hosted or development plane. Whichever is in use, 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 do not configure it.`;
1699
+ 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.`;
1648
1700
  async function createEngagement(baseUrl, name, credential) {
1649
1701
  return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
1650
1702
  }
@@ -3006,7 +3058,7 @@ ${USAGE}`);
3006
3058
  [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.
3007
3059
  `);
3008
3060
  }
3009
- 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.
3061
+ process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_COVERAGE_REQUIRED_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.
3010
3062
  `);
3011
3063
  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.
3012
3064
  `);