halfcycle 0.3.8 → 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,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
 
@@ -538,177 +198,572 @@ var acceptPhaseRequestSchema = z4.object({
538
198
  acceptanceOutcome: acceptanceOutcomeSchema,
539
199
  close: phaseCloseDecisionSchema.optional()
540
200
  }).strict();
541
- var coeFindingRequestSchema = z4.object({
542
- phase: z4.string(),
543
- description: z4.string(),
544
- addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
545
- mappedPatternRef: z4.string().nullable()
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()
546
364
  }).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
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
555
370
  }).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
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)
575
384
  }).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
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()
584
396
  }).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
397
+ var deviceConfirmRecordedSchema = z6.object({
398
+ status: z6.literal(DEVICE_AUTH_STATUS.RECORDED),
399
+ accountId: z6.string().min(1).optional()
592
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 ENGAGEMENT_ENV_KEYS = [
519
+ "HALFCYCLE_SERVICE_URL",
520
+ "HALFCYCLE_MCP_URL",
521
+ "HALFCYCLE_TOKEN",
522
+ "HALFCYCLE_ENGAGEMENT_ID",
523
+ ...GUARD_ENV_KEYS,
524
+ "CONTROL_TELEMETRY_URL"
525
+ ];
526
+ var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
527
+ function shq(value) {
528
+ return `'${value.replace(/'/g, `'\\''`)}'`;
529
+ }
530
+ function unquote(value) {
531
+ const v = value.trim();
532
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
533
+ return v.slice(1, -1).split(`'\\''`).join(`'`);
534
+ }
535
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
536
+ return v.slice(1, -1);
537
+ return v;
538
+ }
539
+ function parseEnvText(raw) {
540
+ const out = {};
541
+ for (const line of raw.split("\n")) {
542
+ const eq = line.indexOf("=");
543
+ if (eq === -1)
544
+ continue;
545
+ const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
546
+ if (key === "" || key.startsWith("#"))
547
+ continue;
548
+ out[key] = unquote(line.slice(eq + 1));
549
+ }
550
+ return out;
551
+ }
552
+ function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
553
+ const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
554
+ const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
555
+ if (existing === null) {
556
+ return `${ENV_HEADER}
557
+ ` + keys.map(line).join("\n") + "\n";
558
+ }
559
+ const seen = /* @__PURE__ */ new Set();
560
+ const out = existing.split("\n").map((existingLine) => {
561
+ const eq = existingLine.indexOf("=");
562
+ if (eq === -1)
563
+ return existingLine;
564
+ const lhs = existingLine.slice(0, eq);
565
+ const exported = /^\s*export\s+/.exec(lhs);
566
+ const prefix = exported === null ? "" : exported[0];
567
+ const key = lhs.slice(prefix.length).trim();
568
+ if (desired.has(key)) {
569
+ seen.add(key);
570
+ return `${prefix}${line(key)}`;
571
+ }
572
+ return existingLine;
573
+ });
574
+ const missing = keys.filter((k) => !seen.has(k));
575
+ if (missing.length > 0) {
576
+ const trailingBlank = out.length > 0 && out[out.length - 1] === "";
577
+ const prefix = trailingBlank ? "" : existing.endsWith("\n") ? "" : "\n";
578
+ out.push(`${prefix}${ENV_HEADER}
579
+ ` + missing.map(line).join("\n"));
580
+ }
581
+ let result = out.join("\n");
582
+ if (existing.endsWith("\n") && !result.endsWith("\n"))
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;
591
+ }
592
+ }
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;
602
+ }
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;
610
+ }
611
+ function applyOwnerOnly(path, dir) {
612
+ if (platform2() === "win32")
613
+ return;
614
+ try {
615
+ chmodSync2(path, 384);
616
+ } catch {
617
+ }
618
+ try {
619
+ chmodSync2(dir, 448);
620
+ } catch {
621
+ }
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
+ }
593
666
 
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;
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) {
701
674
  try {
702
- 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;
703
681
  } catch {
704
- return verificationUrl;
682
+ return null;
705
683
  }
706
- url.searchParams.set(LOOPBACK_QUERY_PORT, String(target.port));
707
- url.searchParams.set(LOOPBACK_QUERY_STATE, target.state);
708
- 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;
709
762
  }
710
763
 
711
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";
712
767
  var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
713
768
  var HALFCYCLE_STATE_NOTE = [
714
769
  "This file is a local cache written ONCE when Halfcycle was installed. It is not a",
@@ -1107,6 +1162,13 @@ var ENV_EXAMPLE_NAMES = [
1107
1162
  "GUARD_SERVICE_URL",
1108
1163
  "GUARD_SERVICE_TOKEN",
1109
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.
1110
1172
  "HALFCYCLE_SERVICE_URL",
1111
1173
  "HALFCYCLE_MCP_URL",
1112
1174
  "HALFCYCLE_TOKEN",
@@ -1143,10 +1205,11 @@ GUARD_ENGAGEMENT_ID=<argType:runtime>
1143
1205
  # nothing else here. The platform reports the second on create, so it is written
1144
1206
  # for you and .mcp.json carries the composed address.
1145
1207
  #
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.
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.
1150
1213
  HALFCYCLE_SERVICE_URL=<argType:runtime>
1151
1214
  HALFCYCLE_MCP_URL=<argType:runtime>
1152
1215
  HALFCYCLE_TOKEN=<argType:runtime>
@@ -1332,6 +1395,16 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, w
1332
1395
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1333
1396
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
1334
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
+ }
1335
1408
  function readBundlePin(targetRepoRoot) {
1336
1409
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1337
1410
  if (!existsSync3(pinPath))
@@ -1563,6 +1636,7 @@ async function install(options) {
1563
1636
  break;
1564
1637
  }
1565
1638
  writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
1639
+ writeCrewRoster(targetRepo, report);
1566
1640
  const scanResult = runBootstrapScan(targetRepo);
1567
1641
  return {
1568
1642
  version: manifest.version,
@@ -1584,7 +1658,7 @@ function resolveControlOrigin(env = process.env) {
1584
1658
  return { origin: DEFAULT_CONTROL_ORIGIN, source: "default" };
1585
1659
  }
1586
1660
  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)`;
1661
+ return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane)`;
1588
1662
  }
1589
1663
 
1590
1664
  // dist/create-engagement.js
@@ -1644,7 +1718,7 @@ function planeRefusal(detail) {
1644
1718
  error: typeof candidate.error === "string" ? candidate.error : void 0
1645
1719
  };
1646
1720
  }
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.`;
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.`;
1648
1722
  async function createEngagement(baseUrl, name, credential) {
1649
1723
  return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
1650
1724
  }