halfcycle 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,6 +3,173 @@ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as rea
3
3
  import { dirname as dirname2, join as join5, relative } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
+ // ../events/dist/result.js
7
+ import { z as z2 } from "zod";
8
+
9
+ // ../core/dist/guard-record.js
10
+ import { z } from "zod";
11
+ var severitySchema = z.enum(["info", "warn", "block"]);
12
+ var channelSchema = z.enum(["stable", "candidate", "rented"]);
13
+ var matcherKindSchema = z.enum(["grep", "ast", "integrationTest", "llm"]);
14
+ var matcherConfigSchema = z.record(z.string(), z.unknown());
15
+ var grepMatcherSchema = z.object({ kind: z.literal("grep"), config: matcherConfigSchema }).strict();
16
+ var astMatcherSchema = z.object({ kind: z.literal("ast"), config: matcherConfigSchema }).strict();
17
+ var integrationTestMatcherSchema = z.object({ kind: z.literal("integrationTest"), config: matcherConfigSchema }).strict();
18
+ var llmMatcherSchema = z.object({ kind: z.literal("llm"), config: matcherConfigSchema }).strict();
19
+ var matcherSchema = z.discriminatedUnion("kind", [
20
+ grepMatcherSchema,
21
+ astMatcherSchema,
22
+ integrationTestMatcherSchema,
23
+ llmMatcherSchema
24
+ ]);
25
+
26
+ // ../events/dist/result.js
27
+ var firedGuardSchema = z2.object({
28
+ guardId: z2.string(),
29
+ patternRef: z2.string(),
30
+ severity: severitySchema,
31
+ explanation: z2.string(),
32
+ /**
33
+ * The repo-relative path of the file this firing was decided on. The matcher
34
+ * has the file live at the moment it decides to fire; this is the field that
35
+ * carries the answer out. It is the CLIENT's own coordinate — the wire type
36
+ * still has no field for a matcher body, a channel, a version, a scope or a
37
+ * guard's provenance, and physically cannot carry one.
38
+ *
39
+ * THE PRESENCE RULE, WHICH LIVES HERE AND NOT IN A CONSUMER'S PROSE: every
40
+ * firing produced from this contract version onward carries `path`, so on the
41
+ * WIRE, absence means exactly one thing — a response from a guard service
42
+ * older than this contract. It is declared optional because a client's guard
43
+ * runner is built from its own checkout and calls a service that may be older
44
+ * than it; a required field would make that pairing unparseable.
45
+ *
46
+ * The rule does NOT transfer to a store that persists a firing, where an
47
+ * absent path has a second legitimate meaning: a row written before the
48
+ * column existed. A persisted NULL is therefore not a writer bug.
49
+ *
50
+ * NO SHAPE CONSTRAINT. This value is whatever the changed tree called the
51
+ * file. A consumer that maps it onto a coordinate with its own path rule must
52
+ * safe-parse it and decide what to do with a refusal, rather than assume the
53
+ * two shapes agree.
54
+ */
55
+ path: z2.string().optional()
56
+ }).strict();
57
+ var resultEnvelopeSchema = z2.object({
58
+ guardsFired: z2.array(firedGuardSchema),
59
+ severity: severitySchema.nullable(),
60
+ blocking: z2.boolean(),
61
+ explanation: z2.string()
62
+ }).strict();
63
+ var wireErrorSchema = z2.object({
64
+ statusCode: z2.number(),
65
+ error: z2.string(),
66
+ message: z2.string()
67
+ }).strict();
68
+
69
+ // ../events/dist/telemetry.js
70
+ import { z as z3 } from "zod";
71
+ var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
72
+ var guardEvalOutcomeSchema = z3.enum([
73
+ "evaluated",
74
+ "infra-error",
75
+ "contract-error",
76
+ "unconfigured"
77
+ ]);
78
+ var guardEvalRunSchema = z3.object({
79
+ // `runId`/`outcome` are the E2 run-observability additions, declared
80
+ // field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
81
+ // migration. Every new record populated by the migrated writer (T-07)
82
+ // carries both; a legacy line predating the amendment carries neither and
83
+ // must still parse (the mixed-era guard-eval log the Build-Record reader
84
+ // assembles from, and the runner emit not yet migrated). So the presence
85
+ // refinement below is skipped for a legacy line (no `outcome`), and the
86
+ // per-writer guarantee lives with the writer + reader-normalisation (T-07),
87
+ // where the schema may then be tightened to required. runId is uuid-checked
88
+ // WHEN present; outcome is enum-checked when present.
89
+ runId: z3.string().uuid().optional(),
90
+ engagementId: z3.string().optional(),
91
+ runType: z3.enum(["hook", "ci"]),
92
+ occurredAt: utcIso8601,
93
+ outcome: guardEvalOutcomeSchema.optional(),
94
+ failureReason: z3.string().optional(),
95
+ phase: z3.string().optional(),
96
+ scope: z3.record(z3.string(), z3.unknown()),
97
+ cost: z3.record(z3.string(), z3.unknown()).optional(),
98
+ result: resultEnvelopeSchema.optional()
99
+ }).strict().superRefine((run, ctx) => {
100
+ if (run.outcome === void 0)
101
+ return;
102
+ if (run.outcome === "evaluated" && run.result === void 0) {
103
+ ctx.addIssue({
104
+ code: z3.ZodIssueCode.custom,
105
+ path: ["result"],
106
+ message: "result is required when outcome is 'evaluated'"
107
+ });
108
+ }
109
+ if (run.outcome !== "evaluated" && run.result !== void 0) {
110
+ ctx.addIssue({
111
+ code: z3.ZodIssueCode.custom,
112
+ path: ["result"],
113
+ message: "result is only present when outcome is 'evaluated'"
114
+ });
115
+ }
116
+ if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
117
+ ctx.addIssue({
118
+ code: z3.ZodIssueCode.custom,
119
+ path: ["engagementId"],
120
+ message: "engagementId is required unless outcome is 'unconfigured'"
121
+ });
122
+ }
123
+ });
124
+
125
+ // ../events/dist/crew.js
126
+ import { z as z4 } from "zod";
127
+ var crewGroupSchema = z4.enum(["builder", "reviewer", "always-on"]);
128
+ var crewTierSchema = z4.enum(["deep", "standard", "fast"]);
129
+ var CREW_DISCIPLINE_MAX = 64;
130
+ var crewMemberSchema = z4.object({
131
+ // The agent's name. The same twenty-one on every project, so a name is a
132
+ // vocabulary a returning client already knows.
133
+ callsign: z4.string().min(1).max(32),
134
+ // Which of the three headings this member reads under.
135
+ group: crewGroupSchema,
136
+ // The kind of work this member takes. A task names a discipline, and only
137
+ // agents holding it can be dispatched to it.
138
+ discipline: z4.string().min(1).max(CREW_DISCIPLINE_MAX),
139
+ // How much thinking this member brings. There is no field naming the supplier
140
+ // behind it, on this shape or on any other.
141
+ tier: crewTierSchema
142
+ }).strict();
143
+ var CREW_ROSTER = [
144
+ // ── builders: code ──────────────────────────────────────────────────────────
145
+ { callsign: "ADA", group: "builder", discipline: "implementation", tier: "deep" },
146
+ { callsign: "OTTO", group: "builder", discipline: "implementation", tier: "deep" },
147
+ { callsign: "MILO", group: "builder", discipline: "implementation", tier: "standard" },
148
+ { callsign: "NOVA", group: "builder", discipline: "implementation", tier: "standard" },
149
+ { callsign: "KAI", group: "builder", discipline: "implementation", tier: "standard" },
150
+ { callsign: "HUGO", group: "builder", discipline: "infrastructure", tier: "standard" },
151
+ { callsign: "WREN", group: "builder", discipline: "refactor", tier: "fast" },
152
+ { callsign: "PIP", group: "builder", discipline: "refactor", tier: "fast" },
153
+ // ── builders: specs and docs ────────────────────────────────────────────────
154
+ { callsign: "IRIS", group: "builder", discipline: "feature spec", tier: "deep" },
155
+ { callsign: "JUNO", group: "builder", discipline: "feature spec", tier: "standard" },
156
+ { callsign: "LEX", group: "builder", discipline: "context & docs", tier: "fast" },
157
+ // ── reviewers ───────────────────────────────────────────────────────────────
158
+ { callsign: "VERA", group: "reviewer", discipline: "spec consistency", tier: "deep" },
159
+ { callsign: "ODIN", group: "reviewer", discipline: "cross-spec audit", tier: "deep" },
160
+ { callsign: "CASS", group: "reviewer", discipline: "code review", tier: "deep" },
161
+ { callsign: "THEO", group: "reviewer", discipline: "code review", tier: "standard" },
162
+ { callsign: "ECHO", group: "reviewer", discipline: "test quality", tier: "standard" },
163
+ { callsign: "MAVIS", group: "reviewer", discipline: "suites & coverage", tier: "standard" },
164
+ { callsign: "ZARA", group: "reviewer", discipline: "smoke & walk aid", tier: "standard" },
165
+ { callsign: "NORA", group: "reviewer", discipline: "evidence & provenance", tier: "fast" },
166
+ // ── always on: continuous, takes no task ────────────────────────────────────
167
+ { callsign: "ARGUS", group: "always-on", discipline: "guard \xB7 every diff", tier: "standard" },
168
+ { callsign: "ATLAS", group: "always-on", discipline: "coordinator", tier: "deep" }
169
+ ];
170
+ var CREW_ROSTER_SIZE = CREW_ROSTER.length;
171
+ var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
172
+
6
173
  // dist/engagement-credential.js
7
174
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
175
  import { platform as platform2 } from "node:os";
@@ -279,103 +446,6 @@ function mergePermissions(existing, generated) {
279
446
  // dist/scan.js
280
447
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
281
448
  import { join as join4 } from "node:path";
282
-
283
- // ../events/dist/result.js
284
- import { z as z2 } from "zod";
285
-
286
- // ../core/dist/guard-record.js
287
- import { z } from "zod";
288
- var severitySchema = z.enum(["info", "warn", "block"]);
289
- var channelSchema = z.enum(["stable", "candidate", "rented"]);
290
- var matcherKindSchema = z.enum(["grep", "ast", "integrationTest", "llm"]);
291
- var matcherConfigSchema = z.record(z.string(), z.unknown());
292
- var grepMatcherSchema = z.object({ kind: z.literal("grep"), config: matcherConfigSchema }).strict();
293
- var astMatcherSchema = z.object({ kind: z.literal("ast"), config: matcherConfigSchema }).strict();
294
- var integrationTestMatcherSchema = z.object({ kind: z.literal("integrationTest"), config: matcherConfigSchema }).strict();
295
- var llmMatcherSchema = z.object({ kind: z.literal("llm"), config: matcherConfigSchema }).strict();
296
- var matcherSchema = z.discriminatedUnion("kind", [
297
- grepMatcherSchema,
298
- astMatcherSchema,
299
- integrationTestMatcherSchema,
300
- llmMatcherSchema
301
- ]);
302
-
303
- // ../events/dist/result.js
304
- var firedGuardSchema = z2.object({
305
- guardId: z2.string(),
306
- patternRef: z2.string(),
307
- severity: severitySchema,
308
- explanation: z2.string()
309
- }).strict();
310
- var resultEnvelopeSchema = z2.object({
311
- guardsFired: z2.array(firedGuardSchema),
312
- severity: severitySchema.nullable(),
313
- blocking: z2.boolean(),
314
- explanation: z2.string()
315
- }).strict();
316
- var wireErrorSchema = z2.object({
317
- statusCode: z2.number(),
318
- error: z2.string(),
319
- message: z2.string()
320
- }).strict();
321
-
322
- // ../events/dist/telemetry.js
323
- import { z as z3 } from "zod";
324
- var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
325
- var guardEvalOutcomeSchema = z3.enum([
326
- "evaluated",
327
- "infra-error",
328
- "contract-error",
329
- "unconfigured"
330
- ]);
331
- var guardEvalRunSchema = z3.object({
332
- // `runId`/`outcome` are the E2 run-observability additions, declared
333
- // field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
334
- // migration. Every new record populated by the migrated writer (T-07)
335
- // carries both; a legacy line predating the amendment carries neither and
336
- // must still parse (the mixed-era guard-eval log the Build-Record reader
337
- // assembles from, and the runner emit not yet migrated). So the presence
338
- // refinement below is skipped for a legacy line (no `outcome`), and the
339
- // per-writer guarantee lives with the writer + reader-normalisation (T-07),
340
- // where the schema may then be tightened to required. runId is uuid-checked
341
- // WHEN present; outcome is enum-checked when present.
342
- runId: z3.string().uuid().optional(),
343
- engagementId: z3.string().optional(),
344
- runType: z3.enum(["hook", "ci"]),
345
- occurredAt: utcIso8601,
346
- outcome: guardEvalOutcomeSchema.optional(),
347
- failureReason: z3.string().optional(),
348
- phase: z3.string().optional(),
349
- scope: z3.record(z3.string(), z3.unknown()),
350
- cost: z3.record(z3.string(), z3.unknown()).optional(),
351
- result: resultEnvelopeSchema.optional()
352
- }).strict().superRefine((run, ctx) => {
353
- if (run.outcome === void 0)
354
- return;
355
- if (run.outcome === "evaluated" && run.result === void 0) {
356
- ctx.addIssue({
357
- code: z3.ZodIssueCode.custom,
358
- path: ["result"],
359
- message: "result is required when outcome is 'evaluated'"
360
- });
361
- }
362
- if (run.outcome !== "evaluated" && run.result !== void 0) {
363
- ctx.addIssue({
364
- code: z3.ZodIssueCode.custom,
365
- path: ["result"],
366
- message: "result is only present when outcome is 'evaluated'"
367
- });
368
- }
369
- if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
370
- ctx.addIssue({
371
- code: z3.ZodIssueCode.custom,
372
- path: ["engagementId"],
373
- message: "engagementId is required unless outcome is 'unconfigured'"
374
- });
375
- }
376
- });
377
-
378
- // dist/scan.js
379
449
  var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
380
450
  var HALFCYCLE_STATE_NOTE = [
381
451
  "This file is a local cache written ONCE when Halfcycle was installed. It is not a",
@@ -526,6 +596,18 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
526
596
  writeFileSync3(targetAbsPath, content, "utf-8");
527
597
  return "written";
528
598
  }
599
+ function writeOwned(targetAbsPath, targetRepoRoot, content) {
600
+ const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
601
+ if (!isAllowlisted(rel)) {
602
+ throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
603
+ }
604
+ if (existsSync3(targetAbsPath) && readFileSync4(targetAbsPath, "utf-8") === content) {
605
+ return "skipped";
606
+ }
607
+ mkdirSync3(dirname2(targetAbsPath), { recursive: true });
608
+ writeFileSync3(targetAbsPath, content, "utf-8");
609
+ return "written";
610
+ }
529
611
  function record(report, outcome, rel) {
530
612
  const bucket = {
531
613
  written: report.writtenPaths,
@@ -646,6 +728,42 @@ if [ -z "$REPO_ROOT" ]; then exit 0; fi
646
728
  HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
647
729
  MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
648
730
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
731
+
732
+ # ---------------------------------------------------------------------------
733
+ # STANDING GUARD-COVERAGE STATEMENT (T-27).
734
+ #
735
+ # A repository with no credential on this machine is UNGUARDED: the PostToolUse
736
+ # hook will find nothing to evaluate with, and it says so once per edit, after
737
+ # the edit. This says it once, up front, before anything is written \u2014 which is
738
+ # the one thing a session-start hook can do that nothing else can.
739
+ #
740
+ # Everything below runs in a SUBSHELL: it sources a credential store, and a
741
+ # session-start hook must not leak an engagement's variables into whatever the
742
+ # editor runs next. Any failure inside it is swallowed; this script's exit
743
+ # status is 0 either way, and the marker above has already been written.
744
+ # ---------------------------------------------------------------------------
745
+ ${engagementResolutionShell()}
746
+
747
+ (
748
+ if halfcycle_env_file "$REPO_ROOT"; then
749
+ set -a
750
+ . "$HALFCYCLE_ENV_FILE"
751
+ set +a
752
+ fi
753
+ hc_missing=""
754
+ for hc_key in ${GUARD_ENV_KEYS.join(" ")}; do
755
+ eval "hc_value=\\\${$hc_key:-}"
756
+ if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
757
+ done
758
+ if [ -n "$hc_missing" ]; then
759
+ echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
760
+ echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
761
+ echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
762
+ echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
763
+ echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
764
+ echo "[halfcycle] and needs nothing configured first."
765
+ fi
766
+ ) 2>/dev/null || true
649
767
  exit 0
650
768
  `;
651
769
  }
@@ -659,6 +777,13 @@ function generateUserPromptReminderHook() {
659
777
  # one home: RULE_SENTENCE_PLAIN in the method repo's own position-house-rule
660
778
  # test governs it \u2014 reword there and here together, in the same commit.
661
779
  #
780
+ # SILENT WHEN THE TOOL IT NAMES IS NOT THERE. The sentence tells the session to
781
+ # call halfcycle_resolve, which arrives over the Halfcycle MCP server registered
782
+ # in .mcp.json. If this repository carries no such registration, the instruction
783
+ # cannot be followed, and a per-prompt instruction that cannot be followed is
784
+ # worse than silence. The standing "no coverage" statement belongs in the
785
+ # session-start banner, which says it once; this hook just stops talking.
786
+ #
662
787
  # FAILS OPEN. This hook's stdout is always plain text, never JSON, so it
663
788
  # cannot form a {"decision":"block",...} body (UserPromptSubmit's only way to
664
789
  # hold the turn). But a write failure here (a closed stdout, say) must still
@@ -666,6 +791,26 @@ function generateUserPromptReminderHook() {
666
791
  # than propagate.
667
792
  trap 'exit 0' ERR
668
793
  set -e
794
+
795
+ # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
796
+ # session's cwd \u2014 a session started in a subdirectory must find the same root
797
+ # .mcp.json the editor loaded, which is the bug mcp-headers.sh already paid for.
798
+ HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
799
+ HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
800
+ HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
801
+
802
+ # Presence of the FILE is not enough \u2014 a developer's own .mcp.json with no
803
+ # Halfcycle server in it registers no halfcycle_resolve. \`grep\` is guarded with
804
+ # \`|| true\` because a non-match is a status, not an error, and \`set -e\` would
805
+ # otherwise route it through the trap (same outcome here, but by accident).
806
+ HC_REGISTERED=""
807
+ if [ -f "$HC_REGISTRATION" ]; then
808
+ HC_REGISTERED=$(tr -d '\\n' < "$HC_REGISTRATION" | grep -c '"${MCP_SERVER_KEY}"[[:space:]]*:' || true)
809
+ fi
810
+ if [ -z "$HC_REGISTERED" ] || [ "$HC_REGISTERED" = "0" ]; then
811
+ exit 0
812
+ fi
813
+
669
814
  printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
670
815
  exit 0
671
816
  `;
@@ -714,6 +859,13 @@ var ENV_EXAMPLE_NAMES = [
714
859
  "GUARD_SERVICE_URL",
715
860
  "GUARD_SERVICE_TOKEN",
716
861
  "GUARD_ENGAGEMENT_ID",
862
+ // Declared for ONE reason only: it is written into the credential store, and the
863
+ // INV-004 check below requires every written name to be named here. It is NOT
864
+ // read from a client's environment — the generated CI stanza sets the three
865
+ // GUARD_* names and not this one, and `packages/runner` never reads it at all.
866
+ // It is how an install REMEMBERS the plane it was created against, which matters
867
+ // only to our own development and CI planes (operator amendment, 2026-08-30);
868
+ // nothing tells a client to set it, and the generated prose says so.
717
869
  "HALFCYCLE_SERVICE_URL",
718
870
  "HALFCYCLE_MCP_URL",
719
871
  "HALFCYCLE_TOKEN",
@@ -747,8 +899,14 @@ GUARD_ENGAGEMENT_ID=<argType:runtime>
747
899
  # TWO ORIGINS, TWO SERVICES (T-22). HALFCYCLE_SERVICE_URL is the Halfcycle
748
900
  # CONTROL plane \u2014 it serves engagement creation and the board's first-visit
749
901
  # code. HALFCYCLE_MCP_URL is the method-delivery service, which serves /mcp and
750
- # nothing else here. You configure the first; the platform reports the second on
751
- # create, so it is written for you and .mcp.json carries the composed address.
902
+ # nothing else here. The platform reports the second on create, so it is written
903
+ # for you and .mcp.json carries the composed address.
904
+ #
905
+ # NEITHER IS A SETTING. Both names are here because both are WRITTEN by the
906
+ # installer into this engagement's credential, and every name it writes is declared
907
+ # here \u2014 that is the rule this section exists for. Halfcycle's address ships in the
908
+ # CLI and is the same one for everybody, so there is nothing to look up and nothing
909
+ # to put in this file. Leave both lines exactly as they are.
752
910
  HALFCYCLE_SERVICE_URL=<argType:runtime>
753
911
  HALFCYCLE_MCP_URL=<argType:runtime>
754
912
  HALFCYCLE_TOKEN=<argType:runtime>
@@ -761,6 +919,7 @@ CONTROL_TELEMETRY_URL=<argType:runtime>
761
919
  `;
762
920
  }
763
921
  var MCP_REGISTRATION_REL = ".mcp.json";
922
+ var MCP_SERVER_KEY = "halfcycle";
764
923
  var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
765
924
  var MCP_HEADERS_HELPER_COMMAND = `/bin/sh -c 'd=$(pwd); while [ ! -f "$d/${MCP_HEADERS_HELPER_REL}" ] && [ "$d" != / ]; do d=$(dirname "$d"); done; [ -f "$d/${MCP_HEADERS_HELPER_REL}" ] || { echo "halfcycle: ${MCP_HEADERS_HELPER_REL} not found at or above $(pwd); re-run npx halfcycle" >&2; exit 1; }; exec /bin/sh "$d/${MCP_HEADERS_HELPER_REL}"'`;
766
925
  function generateMcpHeadersHelper() {
@@ -873,7 +1032,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
873
1032
  if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
874
1033
  base.mcpServers = {};
875
1034
  }
876
- base.mcpServers["halfcycle"] = {
1035
+ base.mcpServers[MCP_SERVER_KEY] = {
877
1036
  type: "http",
878
1037
  url: mcpEndpointUrl(mcpOrigin),
879
1038
  headersHelper: MCP_HEADERS_HELPER_COMMAND
@@ -933,6 +1092,16 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, w
933
1092
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
934
1093
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
935
1094
  }
1095
+ function writeCrewRoster(targetRepoRoot, report) {
1096
+ const doc = {
1097
+ $comment: "GENERATED by the Halfcycle installer \u2014 do not edit by hand. Re-run the installer to pick up a roster change.",
1098
+ crew: CREW_ROSTER
1099
+ };
1100
+ const rendered = `${JSON.stringify(doc, null, 2)}
1101
+ `;
1102
+ const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
1103
+ record(report, writeOwned(crewPath, targetRepoRoot, rendered), ".halfcycle/crew.json");
1104
+ }
936
1105
  function readBundlePin(targetRepoRoot) {
937
1106
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
938
1107
  if (!existsSync3(pinPath))
@@ -1058,7 +1227,7 @@ async function install(options) {
1058
1227
  }
1059
1228
  const vendoredBinSrc = resolveVendoredBinary();
1060
1229
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
1061
- record(report, writeCollisionSafe(vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
1230
+ record(report, writeOwned(vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
1062
1231
  const settingsPath = join5(targetRepo, ".claude", "settings.json");
1063
1232
  const settingsPreexisted = existsSync3(settingsPath);
1064
1233
  const generatedSettings = JSON.parse(generateSettingsJson());
@@ -1127,6 +1296,7 @@ async function install(options) {
1127
1296
  break;
1128
1297
  }
1129
1298
  writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
1299
+ writeCrewRoster(targetRepo, report);
1130
1300
  const scanResult = runBootstrapScan(targetRepo);
1131
1301
  return {
1132
1302
  version: manifest.version,
@@ -1139,6 +1309,9 @@ async function install(options) {
1139
1309
  };
1140
1310
  }
1141
1311
 
1312
+ // dist/control-origin.js
1313
+ var DEFAULT_CONTROL_ORIGIN = "https://control.halfcycle.ai";
1314
+
1142
1315
  // dist/create-engagement.js
1143
1316
  var CreateEngagementRefused = class extends Error {
1144
1317
  status;
@@ -1196,7 +1369,7 @@ function planeRefusal(detail) {
1196
1369
  error: typeof candidate.error === "string" ? candidate.error : void 0
1197
1370
  };
1198
1371
  }
1199
- var CONTROL_ORIGIN_HINT = "HALFCYCLE_SERVICE_URL must be the Halfcycle CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you do not configure it.";
1372
+ 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.`;
1200
1373
  async function createEngagement(baseUrl, name, credential) {
1201
1374
  return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
1202
1375
  }