halfcycle 0.3.22 → 0.3.23

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halfcycle",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "description": "Halfcycle Method bundle — resolution-stub slash commands, remote method-delivery registration, and governance-hook wiring for a Halfcycle engagement repo. It ships NO worker role files: a project's roles are authored from that project's own recorded decisions at orchestration kickoff (FX-2, W3-F-27).",
5
5
  "commands": [
6
6
  {
package/dist/bin.js CHANGED
@@ -280,6 +280,23 @@ import { z as z5 } from "zod";
280
280
  var crewGroupSchema = z5.enum(["builder", "reviewer", "always-on"]);
281
281
  var crewTierSchema = z5.enum(["deep", "standard", "fast"]);
282
282
  var CREW_DISCIPLINE_MAX = 64;
283
+ var crewFaceHeadSchema = z5.enum(["sq", "sqr", "rnd", "dome", "hex"]);
284
+ var crewFaceAntennaSchema = z5.enum(["none", "stalk", "twin", "dish"]);
285
+ var crewFaceEyesSchema = z5.enum(["dots", "bars", "visor", "cyclops"]);
286
+ var crewFaceMouthSchema = z5.enum(["line", "grid", "dots", "wave"]);
287
+ var crewFaceSchema = z5.object({
288
+ // The outline of the head. Members who do the same kind of work share one, so a
289
+ // team is recognisable before any name is read.
290
+ head: crewFaceHeadSchema,
291
+ // What sits on top of the head, if anything.
292
+ antenna: crewFaceAntennaSchema,
293
+ // The eyes.
294
+ eyes: crewFaceEyesSchema,
295
+ // The mouth.
296
+ mouth: crewFaceMouthSchema,
297
+ // Whether this member is drawn with ears.
298
+ ears: z5.boolean()
299
+ }).strict();
283
300
  var crewMemberSchema = z5.object({
284
301
  // The agent's name. The same twenty-one on every project, so a name is a
285
302
  // vocabulary a returning client already knows.
@@ -291,34 +308,163 @@ var crewMemberSchema = z5.object({
291
308
  discipline: z5.string().min(1).max(CREW_DISCIPLINE_MAX),
292
309
  // How much thinking this member brings. There is no field naming the supplier
293
310
  // behind it, on this shape or on any other.
294
- tier: crewTierSchema
311
+ tier: crewTierSchema,
312
+ // How this member's face is drawn. The same face on every project, so a
313
+ // returning client recognises a member before reading the name.
314
+ face: crewFaceSchema
295
315
  }).strict();
296
316
  var CREW_ROSTER = [
297
317
  // ── builders: code ──────────────────────────────────────────────────────────
298
- { callsign: "ADA", group: "builder", discipline: "implementation", tier: "deep" },
299
- { callsign: "OTTO", group: "builder", discipline: "implementation", tier: "deep" },
300
- { callsign: "MILO", group: "builder", discipline: "implementation", tier: "standard" },
301
- { callsign: "NOVA", group: "builder", discipline: "implementation", tier: "standard" },
302
- { callsign: "KAI", group: "builder", discipline: "implementation", tier: "standard" },
303
- { callsign: "HUGO", group: "builder", discipline: "infrastructure", tier: "standard" },
304
- { callsign: "WREN", group: "builder", discipline: "refactor", tier: "fast" },
305
- { callsign: "PIP", group: "builder", discipline: "refactor", tier: "fast" },
318
+ {
319
+ callsign: "ADA",
320
+ group: "builder",
321
+ discipline: "implementation",
322
+ tier: "deep",
323
+ face: { head: "sq", antenna: "stalk", eyes: "dots", mouth: "line", ears: false }
324
+ },
325
+ {
326
+ callsign: "OTTO",
327
+ group: "builder",
328
+ discipline: "implementation",
329
+ tier: "deep",
330
+ face: { head: "sq", antenna: "twin", eyes: "bars", mouth: "grid", ears: true }
331
+ },
332
+ {
333
+ callsign: "MILO",
334
+ group: "builder",
335
+ discipline: "implementation",
336
+ tier: "standard",
337
+ face: { head: "sqr", antenna: "stalk", eyes: "dots", mouth: "dots", ears: false }
338
+ },
339
+ {
340
+ callsign: "NOVA",
341
+ group: "builder",
342
+ discipline: "implementation",
343
+ tier: "standard",
344
+ face: { head: "sq", antenna: "dish", eyes: "cyclops", mouth: "line", ears: false }
345
+ },
346
+ {
347
+ callsign: "KAI",
348
+ group: "builder",
349
+ discipline: "implementation",
350
+ tier: "standard",
351
+ face: { head: "sqr", antenna: "none", eyes: "bars", mouth: "line", ears: true }
352
+ },
353
+ {
354
+ callsign: "HUGO",
355
+ group: "builder",
356
+ discipline: "infrastructure",
357
+ tier: "standard",
358
+ face: { head: "sq", antenna: "twin", eyes: "dots", mouth: "grid", ears: true }
359
+ },
360
+ {
361
+ callsign: "WREN",
362
+ group: "builder",
363
+ discipline: "refactor",
364
+ tier: "fast",
365
+ face: { head: "sqr", antenna: "stalk", eyes: "bars", mouth: "wave", ears: false }
366
+ },
367
+ {
368
+ callsign: "PIP",
369
+ group: "builder",
370
+ discipline: "refactor",
371
+ tier: "fast",
372
+ face: { head: "sqr", antenna: "none", eyes: "dots", mouth: "dots", ears: false }
373
+ },
306
374
  // ── builders: specs and docs ────────────────────────────────────────────────
307
- { callsign: "IRIS", group: "builder", discipline: "feature spec", tier: "deep" },
308
- { callsign: "JUNO", group: "builder", discipline: "feature spec", tier: "standard" },
309
- { callsign: "LEX", group: "builder", discipline: "context & docs", tier: "fast" },
375
+ {
376
+ callsign: "IRIS",
377
+ group: "builder",
378
+ discipline: "feature spec",
379
+ tier: "deep",
380
+ face: { head: "dome", antenna: "stalk", eyes: "visor", mouth: "line", ears: false }
381
+ },
382
+ {
383
+ callsign: "JUNO",
384
+ group: "builder",
385
+ discipline: "feature spec",
386
+ tier: "standard",
387
+ face: { head: "dome", antenna: "none", eyes: "dots", mouth: "wave", ears: false }
388
+ },
389
+ {
390
+ callsign: "LEX",
391
+ group: "builder",
392
+ discipline: "context & docs",
393
+ tier: "fast",
394
+ face: { head: "dome", antenna: "twin", eyes: "bars", mouth: "dots", ears: false }
395
+ },
310
396
  // ── reviewers ───────────────────────────────────────────────────────────────
311
- { callsign: "VERA", group: "reviewer", discipline: "spec consistency", tier: "deep" },
312
- { callsign: "ODIN", group: "reviewer", discipline: "cross-spec audit", tier: "deep" },
313
- { callsign: "CASS", group: "reviewer", discipline: "code review", tier: "deep" },
314
- { callsign: "THEO", group: "reviewer", discipline: "code review", tier: "standard" },
315
- { callsign: "ECHO", group: "reviewer", discipline: "test quality", tier: "standard" },
316
- { callsign: "MAVIS", group: "reviewer", discipline: "suites & coverage", tier: "standard" },
317
- { callsign: "ZARA", group: "reviewer", discipline: "smoke & walk aid", tier: "standard" },
318
- { callsign: "NORA", group: "reviewer", discipline: "evidence & provenance", tier: "fast" },
397
+ {
398
+ callsign: "VERA",
399
+ group: "reviewer",
400
+ discipline: "spec consistency",
401
+ tier: "deep",
402
+ face: { head: "rnd", antenna: "stalk", eyes: "visor", mouth: "line", ears: false }
403
+ },
404
+ {
405
+ callsign: "ODIN",
406
+ group: "reviewer",
407
+ discipline: "cross-spec audit",
408
+ tier: "deep",
409
+ face: { head: "rnd", antenna: "dish", eyes: "cyclops", mouth: "line", ears: false }
410
+ },
411
+ {
412
+ callsign: "CASS",
413
+ group: "reviewer",
414
+ discipline: "code review",
415
+ tier: "deep",
416
+ face: { head: "rnd", antenna: "none", eyes: "visor", mouth: "grid", ears: true }
417
+ },
418
+ {
419
+ callsign: "THEO",
420
+ group: "reviewer",
421
+ discipline: "code review",
422
+ tier: "standard",
423
+ face: { head: "rnd", antenna: "twin", eyes: "bars", mouth: "line", ears: false }
424
+ },
425
+ {
426
+ callsign: "ECHO",
427
+ group: "reviewer",
428
+ discipline: "test quality",
429
+ tier: "standard",
430
+ face: { head: "rnd", antenna: "stalk", eyes: "dots", mouth: "wave", ears: false }
431
+ },
432
+ {
433
+ callsign: "MAVIS",
434
+ group: "reviewer",
435
+ discipline: "suites & coverage",
436
+ tier: "standard",
437
+ face: { head: "rnd", antenna: "none", eyes: "visor", mouth: "dots", ears: false }
438
+ },
439
+ {
440
+ callsign: "ZARA",
441
+ group: "reviewer",
442
+ discipline: "smoke & walk aid",
443
+ tier: "standard",
444
+ face: { head: "rnd", antenna: "dish", eyes: "bars", mouth: "wave", ears: false }
445
+ },
446
+ {
447
+ callsign: "NORA",
448
+ group: "reviewer",
449
+ discipline: "evidence & provenance",
450
+ tier: "fast",
451
+ face: { head: "rnd", antenna: "twin", eyes: "dots", mouth: "line", ears: false }
452
+ },
319
453
  // ── always on: continuous, takes no task ────────────────────────────────────
320
- { callsign: "ARGUS", group: "always-on", discipline: "guard \xB7 every diff", tier: "standard" },
321
- { callsign: "ATLAS", group: "always-on", discipline: "coordinator", tier: "deep" }
454
+ {
455
+ callsign: "ARGUS",
456
+ group: "always-on",
457
+ discipline: "guard \xB7 every diff",
458
+ tier: "standard",
459
+ face: { head: "hex", antenna: "dish", eyes: "visor", mouth: "grid", ears: true }
460
+ },
461
+ {
462
+ callsign: "ATLAS",
463
+ group: "always-on",
464
+ discipline: "coordinator",
465
+ tier: "deep",
466
+ face: { head: "hex", antenna: "twin", eyes: "cyclops", mouth: "line", ears: true }
467
+ }
322
468
  ];
323
469
  var CREW_ROSTER_SIZE = CREW_ROSTER.length;
324
470
  var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
@@ -1044,11 +1190,15 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
1044
1190
  writeFileSync4(targetAbsPath, content, "utf-8");
1045
1191
  return "written";
1046
1192
  }
1193
+ var INSTALLER_OWNED_DIR = ".halfcycle/";
1047
1194
  function writeOwned(targetAbsPath, targetRepoRoot, content) {
1048
1195
  const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
1049
1196
  if (!isAllowlisted(rel)) {
1050
1197
  throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
1051
1198
  }
1199
+ if (!rel.startsWith(INSTALLER_OWNED_DIR)) {
1200
+ throw new Error(`[bundle install] Path-ownership violation: attempted to claim "${rel}" by path. Only ${INSTALLER_OWNED_DIR} is owned outright by this installer; everywhere else a differing file may be a developer's, so ownership must be proved from the file's own contents (writeOwnedGenerated) rather than assumed from where it sits.`);
1201
+ }
1052
1202
  if (existsSync3(targetAbsPath) && readFileSync5(targetAbsPath, "utf-8") === content) {
1053
1203
  return "skipped";
1054
1204
  }
@@ -1056,6 +1206,39 @@ function writeOwned(targetAbsPath, targetRepoRoot, content) {
1056
1206
  writeFileSync4(targetAbsPath, content, "utf-8");
1057
1207
  return "written";
1058
1208
  }
1209
+ var GENERATED_HEADER_LINE_INDEX = 1;
1210
+ function writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker) {
1211
+ const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
1212
+ if (!isAllowlisted(rel)) {
1213
+ throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
1214
+ }
1215
+ let replacedExisting = false;
1216
+ if (existsSync3(targetAbsPath)) {
1217
+ const current = readFileSync5(targetAbsPath, "utf-8");
1218
+ if (current === content)
1219
+ return { outcome: "skipped", replacedExisting: false };
1220
+ const headerLine = current.split("\n")[GENERATED_HEADER_LINE_INDEX] ?? "";
1221
+ if (!headerLine.startsWith(generatedMarker))
1222
+ return { outcome: "collided", replacedExisting: false };
1223
+ replacedExisting = true;
1224
+ }
1225
+ mkdirSync4(dirname2(targetAbsPath), { recursive: true });
1226
+ writeFileSync4(targetAbsPath, content, "utf-8");
1227
+ return { outcome: "written", replacedExisting };
1228
+ }
1229
+ function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, generatedMarker, rel) {
1230
+ const { outcome, replacedExisting } = writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker);
1231
+ record(report, outcome, rel);
1232
+ if (replacedExisting)
1233
+ report.replacedPaths.push(rel);
1234
+ }
1235
+ function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1236
+ const existedBefore = existsSync3(targetAbsPath);
1237
+ const outcome = writeOwned(targetAbsPath, targetRepoRoot, content);
1238
+ record(report, outcome, rel);
1239
+ if (outcome === "written" && existedBefore)
1240
+ report.replacedPaths.push(rel);
1241
+ }
1059
1242
  function record(report, outcome, rel) {
1060
1243
  const bucket = {
1061
1244
  written: report.writtenPaths,
@@ -1120,6 +1303,17 @@ function generateSettingsJson() {
1120
1303
  ]
1121
1304
  }
1122
1305
  ],
1306
+ Stop: [
1307
+ {
1308
+ hooks: [
1309
+ {
1310
+ type: "command",
1311
+ command: "bash ./.claude/hooks/guard-runner.sh hook stop",
1312
+ timeout: 60
1313
+ }
1314
+ ]
1315
+ }
1316
+ ],
1123
1317
  SubagentStop: [
1124
1318
  {
1125
1319
  hooks: [
@@ -1130,18 +1324,30 @@ function generateSettingsJson() {
1130
1324
  }
1131
1325
  ]
1132
1326
  }
1327
+ ],
1328
+ SessionEnd: [
1329
+ {
1330
+ hooks: [
1331
+ {
1332
+ type: "command",
1333
+ command: "bash ./.claude/hooks/session-end-marker.sh",
1334
+ timeout: 5
1335
+ }
1336
+ ]
1337
+ }
1133
1338
  ]
1134
1339
  }
1135
1340
  };
1136
1341
  return JSON.stringify(settings, null, 2) + "\n";
1137
1342
  }
1138
1343
  var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
1344
+ var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
1139
1345
  function generateGuardRunnerWrapper() {
1140
1346
  return `#!/usr/bin/env bash
1141
- # Governance hook wrapper \u2014 generated by the Halfcycle installer.
1347
+ ${GENERATED_GUARD_RUNNER_HEADER} the Halfcycle installer.
1142
1348
  #
1143
1349
  # Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
1144
- # values when Claude Code fires PostToolUse / SubagentStop, then exec's the
1350
+ # values when Claude Code fires PostToolUse / Stop / SubagentStop, then exec's the
1145
1351
  # SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path inside this
1146
1352
  # project, so the hook resolves in any checkout of it.
1147
1353
  #
@@ -1173,15 +1379,28 @@ fi
1173
1379
  exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
1174
1380
  `;
1175
1381
  }
1382
+ var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
1383
+ var SESSION_ID_FROM_HOOK_INPUT_SH = `SESSION_ID="$(printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(typeof j.session_id==="string"?j.session_id:"")}catch{process.stdout.write("")}})' 2>/dev/null || true)"`;
1176
1384
  function generateSessionStartMarker() {
1177
1385
  return `#!/usr/bin/env bash
1178
- # Session-start marker \u2014 generated by the Halfcycle installer.
1179
- # Writes the current git HEAD SHA to a temp file so SubagentStop can
1180
- # diff against session-start rather than just HEAD at stop time.
1386
+ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
1387
+ # Records the git HEAD sha this session starts from, in a file named after this
1388
+ # session, so the stop-time check can compare what the session did against where
1389
+ # it began instead of only against the latest commit. The session id comes in on
1390
+ # stdin with the hook input; without one, the older session-less name is written
1391
+ # and the stop-time check compares against the latest commit as it used to.
1392
+ INPUT="$(cat 2>/dev/null || true)"
1181
1393
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
1182
1394
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
1183
- HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
1184
- MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
1395
+ HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
1396
+ ${SESSION_ID_FROM_HOOK_INPUT_SH}
1397
+ MARKER_DIR="\${TMPDIR:-/tmp}"
1398
+ MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
1399
+ if [ -n "$SESSION_ID" ]; then
1400
+ MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref"
1401
+ else
1402
+ MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
1403
+ fi
1185
1404
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
1186
1405
 
1187
1406
  # ---------------------------------------------------------------------------
@@ -1244,9 +1463,41 @@ ${engagementResolutionShell()}
1244
1463
  exit 0
1245
1464
  `;
1246
1465
  }
1466
+ var GENERATED_SESSION_END_HEADER = "# Session-end marker cleanup \u2014 generated by";
1467
+ function generateSessionEndMarker() {
1468
+ return `#!/usr/bin/env bash
1469
+ ${GENERATED_SESSION_END_HEADER} the Halfcycle installer.
1470
+ # Removes this session's marker file (and the evaluation-state file beside it)
1471
+ # written at session start. Deletion happens HERE and not at stop time: the stop
1472
+ # hook runs at the end of every turn, and deleting the marker there would throw
1473
+ # away the session's starting point for every later turn.
1474
+ INPUT="$(cat 2>/dev/null || true)"
1475
+
1476
+ REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
1477
+ if [ -z "$REPO_ROOT" ]; then exit 0; fi
1478
+ HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
1479
+
1480
+ # The session id arrives on stdin as part of the hook payload. Empty when absent.
1481
+ ${SESSION_ID_FROM_HOOK_INPUT_SH}
1482
+
1483
+ MARKER_DIR="\${TMPDIR:-/tmp}"
1484
+ MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
1485
+
1486
+ # Exact names only \u2014 never a wildcard. A pattern would match another session
1487
+ # running in this same repository and delete the point it started from.
1488
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.ref" 2>/dev/null || true
1489
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.eval" 2>/dev/null || true
1490
+ if [ -n "$SESSION_ID" ]; then
1491
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref" 2>/dev/null || true
1492
+ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.eval" 2>/dev/null || true
1493
+ fi
1494
+ exit 0
1495
+ `;
1496
+ }
1497
+ var GENERATED_USER_PROMPT_REMINDER_HEADER = "# UserPromptSubmit house-rule reminder \u2014 generated by";
1247
1498
  function generateUserPromptReminderHook() {
1248
1499
  return `#!/usr/bin/env bash
1249
- # UserPromptSubmit house-rule reminder \u2014 generated by the Halfcycle installer.
1500
+ ${GENERATED_USER_PROMPT_REMINDER_HEADER} the Halfcycle installer.
1250
1501
  #
1251
1502
  # Injects ONE plain-text sentence into context on every prompt (F-2(c),
1252
1503
  # W8-T-05b) \u2014 the per-turn twin of the house rule W8-T-05a put in the client's
@@ -1344,17 +1595,31 @@ function generateCiStanza() {
1344
1595
  #
1345
1596
  # The job prints the diff base it used and how many files it evaluated, on every
1346
1597
  # run. If that line says 0 files on a commit that changed something, the base is
1347
- # wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block above to name it explicitly
1348
- # (on a GitHub push event, \${{ github.event.before }} is the right value).
1598
+ # wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block OF YOUR COPY, beside the two
1599
+ # secrets, to name it explicitly (on a GitHub push event, \${{ github.event.before }}
1600
+ # is the right value).
1601
+ #
1602
+ # EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
1603
+ # changes to it would be replaced the next time you run \`npx halfcycle\`. It is
1604
+ # also inert where it sits: no CI system reads this path. Copy the job above into
1605
+ # your own workflow and change it there.
1349
1606
  `;
1350
1607
  }
1351
1608
  var MCP_REGISTRATION_REL = ".mcp.json";
1352
1609
  var MCP_SERVER_KEY = "halfcycle";
1353
1610
  var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
1354
1611
  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}"'`;
1612
+ var GENERATED_MCP_HEADERS_HEADER = "# Halfcycle MCP connection headers \u2014 generated by";
1613
+ var OWNED_GENERATED_HEADERS = {
1614
+ ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
1615
+ ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
1616
+ ".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
1617
+ ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER,
1618
+ ".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
1619
+ };
1355
1620
  function generateMcpHeadersHelper() {
1356
1621
  return `#!/bin/sh
1357
- # Halfcycle MCP connection headers \u2014 generated by the Halfcycle installer.
1622
+ ${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
1358
1623
  #
1359
1624
  # Claude Code runs this at MCP connection time, from the session's cwd, and merges
1360
1625
  # its stdout into the connection headers for the "halfcycle" server in .mcp.json.
@@ -1539,7 +1804,7 @@ function writeCrewRoster(targetRepoRoot, report) {
1539
1804
  const rendered = `${JSON.stringify(doc, null, 2)}
1540
1805
  `;
1541
1806
  const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
1542
- record(report, writeOwned(crewPath, targetRepoRoot, rendered), ".halfcycle/crew.json");
1807
+ recordOwned(report, crewPath, targetRepoRoot, rendered, ".halfcycle/crew.json");
1543
1808
  }
1544
1809
  function readBundlePin(targetRepoRoot) {
1545
1810
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
@@ -1690,7 +1955,8 @@ async function install(options) {
1690
1955
  writtenPaths: [],
1691
1956
  skippedPaths: [],
1692
1957
  mergedPaths: [],
1693
- collidedPaths: []
1958
+ collidedPaths: [],
1959
+ replacedPaths: []
1694
1960
  };
1695
1961
  copyManifestCommands(manifest, targetRepo, report);
1696
1962
  const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
@@ -1703,7 +1969,7 @@ async function install(options) {
1703
1969
  }
1704
1970
  const vendoredBinSrc = resolveVendoredBinary();
1705
1971
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
1706
- record(report, writeOwned(vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
1972
+ recordOwned(report, vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8"), ".halfcycle/bin/bin.bundle.mjs");
1707
1973
  const settingsPath = join5(targetRepo, ".claude", "settings.json");
1708
1974
  const settingsPreexisted = existsSync3(settingsPath);
1709
1975
  const generatedSettings = JSON.parse(generateSettingsJson());
@@ -1715,17 +1981,19 @@ async function install(options) {
1715
1981
  (settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
1716
1982
  for (const [name, content] of [
1717
1983
  ["guard-runner.sh", generateGuardRunnerWrapper()],
1718
- ["session-start-marker.sh", generateSessionStartMarker()],
1719
- ["user-prompt-reminder.sh", generateUserPromptReminderHook()]
1984
+ ["session-end-marker.sh", generateSessionEndMarker()],
1985
+ ["user-prompt-reminder.sh", generateUserPromptReminderHook()],
1986
+ ["session-start-marker.sh", generateSessionStartMarker()]
1720
1987
  ]) {
1988
+ const rel = `.claude/hooks/${name}`;
1721
1989
  const hookPath = join5(targetRepo, ".claude", "hooks", name);
1722
- record(report, writeCollisionSafe(hookPath, targetRepo, content), `.claude/hooks/${name}`);
1990
+ recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
1723
1991
  }
1724
1992
  const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
1725
- record(report, writeCollisionSafe(ciStanzaPath, targetRepo, generateCiStanza()), ".halfcycle/ci-stanza.yml");
1993
+ recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
1726
1994
  if (credential) {
1727
1995
  const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
1728
- record(report, writeCollisionSafe(helperPath, targetRepo, generateMcpHeadersHelper()), MCP_HEADERS_HELPER_REL);
1996
+ recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
1729
1997
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
1730
1998
  const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
1731
1999
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
@@ -1779,6 +2047,7 @@ async function install(options) {
1779
2047
  skippedPaths: report.skippedPaths,
1780
2048
  mergedPaths: report.mergedPaths,
1781
2049
  collidedPaths: report.collidedPaths,
2050
+ replacedPaths: report.replacedPaths,
1782
2051
  bootstrapScanRan: scanResult.ran,
1783
2052
  legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null
1784
2053
  };
@@ -4007,6 +4276,12 @@ ${USAGE}`);
4007
4276
  }
4008
4277
  if (result.collidedPaths.length > 0) {
4009
4278
  process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${result.collidedPaths.join(", ")}
4279
+ `);
4280
+ }
4281
+ if (result.replacedPaths.length > 0) {
4282
+ process.stdout.write(`[halfcycle] Updated (files Halfcycle wrote earlier, now replaced): ${result.replacedPaths.join(", ")}
4283
+ `);
4284
+ process.stdout.write(`[halfcycle] If you had changed one of those: a change you had COMMITTED is still in your git history ("git log -p -- <path>"). A change you had not committed is gone \u2014 this command overwrote the file.
4010
4285
  `);
4011
4286
  }
4012
4287
  if (result.legacyCredentialKept !== null) {