halfcycle 0.4.14 → 0.4.15

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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/bin.bundle.mjs +2525 -541
  3. package/bin/pre-tool-use-filter.sh +41 -5
  4. package/commands/agent-teams-orchestration.md +1 -1
  5. package/commands/consistency-check.md +1 -1
  6. package/commands/context-update.md +1 -1
  7. package/commands/halfcycle-design.md +1 -1
  8. package/commands/halfcycle-setup.md +1 -1
  9. package/commands/stack-assembly-worker.md +1 -1
  10. package/commands/task-execution.md +1 -1
  11. package/commands/task-graph.md +1 -1
  12. package/commands/write-spec.md +1 -1
  13. package/dist/bin.js +0 -0
  14. package/dist/build-record/render-v2.d.ts.map +1 -1
  15. package/dist/claude-plan.d.ts +24 -5
  16. package/dist/claude-plan.d.ts.map +1 -1
  17. package/dist/cli-contract.d.ts +1 -1
  18. package/dist/cli-contract.d.ts.map +1 -1
  19. package/dist/cli.js +2242 -565
  20. package/dist/cli.js.map +3 -3
  21. package/dist/close-phase.d.ts.map +1 -1
  22. package/dist/doctor.d.ts +34 -0
  23. package/dist/doctor.d.ts.map +1 -0
  24. package/dist/folder-refusals.d.ts +27 -0
  25. package/dist/folder-refusals.d.ts.map +1 -0
  26. package/dist/identity.d.ts +31 -1
  27. package/dist/identity.d.ts.map +1 -1
  28. package/dist/index.js +1276 -386
  29. package/dist/index.js.map +3 -3
  30. package/dist/install-plan.d.ts +112 -0
  31. package/dist/install-plan.d.ts.map +1 -0
  32. package/dist/install.d.ts +535 -1
  33. package/dist/install.d.ts.map +1 -1
  34. package/dist/plan.d.ts +16 -3
  35. package/dist/plan.d.ts.map +1 -1
  36. package/dist/pre-create-checks.d.ts +28 -0
  37. package/dist/pre-create-checks.d.ts.map +1 -1
  38. package/dist/preview.d.ts +40 -0
  39. package/dist/preview.d.ts.map +1 -0
  40. package/dist/restore.d.ts +58 -0
  41. package/dist/restore.d.ts.map +1 -0
  42. package/dist/review.d.ts.map +1 -1
  43. package/dist/scan.d.ts +11 -0
  44. package/dist/scan.d.ts.map +1 -1
  45. package/dist/setup/discovery.d.ts.map +1 -1
  46. package/dist/setup/rows.d.ts +10 -6
  47. package/dist/setup/rows.d.ts.map +1 -1
  48. package/package.json +12 -12
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // dist/install.js
2
2
  import { execFileSync as execFileSync2 } from "node:child_process";
3
3
  import { createHash as createHash2 } from "node:crypto";
4
- import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "node:fs";
4
+ import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "node:fs";
5
5
  import { homedir as homedir2 } from "node:os";
6
- import { dirname as dirname3, isAbsolute as isAbsolute2, join as join5, posix, relative, resolve } from "node:path";
6
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join6, posix as posix2, relative, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  // ../core/dist/guard-record.js
@@ -122,9 +122,10 @@ var SESSION_START_MARKER_REL = ".claude/hooks/session-start-marker.sh";
122
122
  var GUARD_RUNNER_REL = ".claude/hooks/guard-runner.sh";
123
123
  var USER_PROMPT_REMINDER_REL = ".claude/hooks/user-prompt-reminder.sh";
124
124
  var USER_PROMPT_SCOPE_REL = ".claude/hooks/user-prompt-scope.sh";
125
+ var USER_PROMPT_FINDINGS_REL = ".claude/hooks/user-prompt-findings.sh";
125
126
  var SESSION_END_MARKER_REL = ".claude/hooks/session-end-marker.sh";
126
127
  var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
127
- var PRE_TOOL_USE_MATCHER = "^(Bash|Read|Grep|Write|Edit|MultiEdit|NotebookEdit|Agent)$";
128
+ var PRE_TOOL_USE_MATCHER = "^(Bash|Read|Grep|Write|Edit|MultiEdit|NotebookEdit|Agent|PowerShell)$";
128
129
  function hookCommand(script, verb) {
129
130
  const invocation = `bash "$CLAUDE_PROJECT_DIR"/${script}`;
130
131
  return verb === void 0 ? invocation : `${invocation} ${verb}`;
@@ -158,6 +159,13 @@ var HALFCYCLE_HOOK_WIRING = [
158
159
  command: hookCommand(USER_PROMPT_SCOPE_REL),
159
160
  script: USER_PROMPT_SCOPE_REL
160
161
  },
162
+ {
163
+ event: "UserPromptSubmit",
164
+ matcher: null,
165
+ timeout: 10,
166
+ command: hookCommand(USER_PROMPT_FINDINGS_REL),
167
+ script: USER_PROMPT_FINDINGS_REL
168
+ },
161
169
  {
162
170
  event: "PreToolUse",
163
171
  matcher: PRE_TOOL_USE_MATCHER,
@@ -1243,8 +1251,9 @@ var SCOPE_PART_STATUSES = ["approved", "waiting"];
1243
1251
  var scopePartStatusSchema = z10.enum(SCOPE_PART_STATUSES);
1244
1252
  var SCOPE_ACTS = ["approve", "send-back"];
1245
1253
  var scopeActSchema = z10.enum(SCOPE_ACTS);
1246
- var SCOPE_GIVEN_IN = ["console", "chat"];
1254
+ var SCOPE_GIVEN_IN = ["console", "chat", "autonomy"];
1247
1255
  var scopeGivenInSchema = z10.enum(SCOPE_GIVEN_IN);
1256
+ var APPROVED_IN_ADVANCE_SENTENCE = "approved in advance by your autonomy setting";
1248
1257
  var scopeNoteSchema = descriptionText(SCOPE_NOTE_MAX);
1249
1258
  var scopeNoteWireSchema = z10.string().max(SCOPE_NOTE_MAX);
1250
1259
  var SCOPE_PART_VERDICTS = ["ok", "change"];
@@ -1275,7 +1284,8 @@ var phasePlanRecordedSchema = z10.object({
1275
1284
  typedApprovalOffered: z10.boolean(),
1276
1285
  reportedAt: utcIso8601,
1277
1286
  carriedFrom: z10.array(carriedFromEntrySchema).min(1).max(PHASE_PLAN_CHANGES_MAX).optional(),
1278
- movedUp: planMovedUpSchema.optional()
1287
+ movedUp: planMovedUpSchema.optional(),
1288
+ givenIn: scopeGivenInSchema.optional()
1279
1289
  }).strict();
1280
1290
  var phasePlanRefusedSchema = z10.object({
1281
1291
  outcome: z10.literal("refused"),
@@ -2282,6 +2292,15 @@ var REVIEW_PART_CHANGES = Object.freeze([...PHASE_PLAN_CHANGE_KINDS, "not-in-pla
2282
2292
  var reviewPartChangeSchema = z17.enum(REVIEW_PART_CHANGES);
2283
2293
  var REVIEW_DECISIONS = Object.freeze(["accepted", "carried", "dropped"]);
2284
2294
  var reviewDecisionSchema = z17.enum(REVIEW_DECISIONS);
2295
+ var reviewGivenInSchema = scopeGivenInSchema.extract(["autonomy"]);
2296
+ var DECIDED_BY_AUTONOMY_SENTENCE = "decided by your autonomy setting";
2297
+ function checkReviewGivenIn(v, ctx) {
2298
+ if (v.givenIn === void 0)
2299
+ return;
2300
+ if (v.decision !== "accepted" && v.decision !== "carried") {
2301
+ ctx.addIssue({ code: "custom", path: ["givenIn"], message: "is present only on a part accepted or carried" });
2302
+ }
2303
+ }
2285
2304
  function actOf(decision) {
2286
2305
  if (decision === "accepted")
2287
2306
  return "accept";
@@ -2347,6 +2366,25 @@ var reviewRefusedSchema = z17.object({
2347
2366
  function bareReviewOutcome(outcome) {
2348
2367
  return z17.object({ outcome: z17.literal(outcome) }).strict();
2349
2368
  }
2369
+ var reviewClosedByAutonomySchema = z17.object({
2370
+ outcome: z17.literal("closed-by-autonomy"),
2371
+ accepted: z17.number().int().min(0).max(REVIEW_PARTS_MAX),
2372
+ carried: z17.number().int().min(0).max(REVIEW_PARTS_MAX)
2373
+ }).strict().refine((v) => v.accepted + v.carried <= REVIEW_PARTS_MAX, {
2374
+ message: "cannot exceed the parts a round holds",
2375
+ path: ["carried"]
2376
+ });
2377
+ var AUTONOMY_WAIT_REASONS = ["unchecked-claims", "built-without-approval"];
2378
+ var autonomyWaitReasonSchema = z17.enum(AUTONOMY_WAIT_REASONS);
2379
+ var reviewAutonomyWaitsSchema = z17.object({
2380
+ outcome: z17.literal("autonomy-waits"),
2381
+ reason: autonomyWaitReasonSchema,
2382
+ count: z17.number().int().min(1),
2383
+ parts: z17.array(z17.object({ part: systemPartIdSchema, name: systemPartSchema.shape.name }).strict()).min(1).max(REVIEW_PARTS_MAX)
2384
+ }).strict().refine((v) => v.reason !== "built-without-approval" || v.count === v.parts.length, {
2385
+ message: "counts the parts it names",
2386
+ path: ["count"]
2387
+ });
2350
2388
  var openReviewResultSchema = z17.discriminatedUnion("outcome", [
2351
2389
  reviewOpenedSchema,
2352
2390
  reviewTasksOpenSchema,
@@ -2355,7 +2393,9 @@ var openReviewResultSchema = z17.discriminatedUnion("outcome", [
2355
2393
  reviewOutstandingSchema,
2356
2394
  reviewCleanOverFindingsSchema,
2357
2395
  reviewRefusedSchema,
2358
- bareReviewOutcome("unavailable")
2396
+ bareReviewOutcome("unavailable"),
2397
+ reviewClosedByAutonomySchema,
2398
+ reviewAutonomyWaitsSchema
2359
2399
  ]);
2360
2400
  var reviewPhaseNotOpenSchema = z17.object({
2361
2401
  statusCode: z17.literal(409),
@@ -2469,6 +2509,7 @@ var reviewViewPartSchema = z17.object({
2469
2509
  decidable: z17.boolean(),
2470
2510
  waiting: z17.boolean(),
2471
2511
  decision: reviewDecisionSchema.optional(),
2512
+ givenIn: reviewGivenInSchema.optional(),
2472
2513
  reason: reviewNoteSchema.optional(),
2473
2514
  carriedTo: reviewCarriedToSchema.optional(),
2474
2515
  sentBack: z17.array(reviewSentBackSchema),
@@ -2496,6 +2537,7 @@ var reviewViewPartSchema = z17.object({
2496
2537
  if (v.change === "not-in-plan" && !v.built) {
2497
2538
  ctx.addIssue({ code: "custom", path: ["built"], message: "is always true for a part outside the plan" });
2498
2539
  }
2540
+ checkReviewGivenIn(v, ctx);
2499
2541
  });
2500
2542
  var reviewPhaseNumberSchema = z17.number().int().min(1).nullable();
2501
2543
  var phaseReviewNoneSchema = z17.object({
@@ -2590,6 +2632,7 @@ var recordReviewPartSchema = z18.object({
2590
2632
  built: z18.boolean(),
2591
2633
  builtWithoutApproval: z18.boolean(),
2592
2634
  decision: reviewDecisionSchema,
2635
+ givenIn: reviewGivenInSchema.optional(),
2593
2636
  reason: reviewNoteSchema.optional(),
2594
2637
  carriedTo: reviewCarriedToSchema.optional(),
2595
2638
  sentBack: z18.array(reviewSentBackSchema),
@@ -2603,6 +2646,7 @@ var recordReviewPartSchema = z18.object({
2603
2646
  if (v.carriedTo !== void 0 !== (v.decision === "carried")) {
2604
2647
  ctx.addIssue({ code: "custom", path: ["carriedTo"], message: "is present exactly on a carry" });
2605
2648
  }
2649
+ checkReviewGivenIn(v, ctx);
2606
2650
  });
2607
2651
  var SPINE_ORDER = boardGateNameSchema.options;
2608
2652
  var recordReviewGatesSchema = z18.array(recordReviewGateSchema).superRefine((gates, ctx) => {
@@ -2898,6 +2942,251 @@ var phaseRecordResponseSchema = z22.discriminatedUnion("outcome", [
2898
2942
  phaseRecordRefusedSchema
2899
2943
  ]);
2900
2944
 
2945
+ // ../events/dist/owner-waits.js
2946
+ import { z as z23 } from "zod";
2947
+ var OWNER_WAIT_ITEMS = Object.freeze([
2948
+ Object.freeze({ id: "delete-users-data", words: "Deleting or replacing data people already have." }),
2949
+ Object.freeze({
2950
+ id: "change-promises",
2951
+ words: "Changing what the product promises its users, such as pricing, terms or privacy."
2952
+ }),
2953
+ Object.freeze({ id: "spend-or-sign-up", words: "Anything that costs money or signs up for a service." }),
2954
+ Object.freeze({ id: "publish-or-release", words: "Publishing or releasing to users." }),
2955
+ Object.freeze({ id: "reshape-shared-data", words: "Dropping or reshaping stored data that other parts read." }),
2956
+ Object.freeze({ id: "change-standing-rule", words: "Changing a standing rule." })
2957
+ ]);
2958
+ var OWNER_WAIT_ITEM_IDS = Object.freeze(OWNER_WAIT_ITEMS.map((item) => item.id));
2959
+ var ownerWaitItemIdSchema = z23.enum(OWNER_WAIT_ITEM_IDS);
2960
+ var OWNER_WAIT_FIXED_COUNT = OWNER_WAIT_ITEMS.length;
2961
+ var OWNER_WAIT_ADDITIONS_MAX = 20;
2962
+ var OWNER_WAIT_TEXT_MAX = 240;
2963
+ var ownerWaitItemRefSchema = z23.union([
2964
+ z23.number().int().min(1),
2965
+ z23.string().uuid({ message: "must be a number from 1 to 6, or the id shown beside an added item" })
2966
+ ]);
2967
+ var ownerWaitText = descriptionText(OWNER_WAIT_TEXT_MAX);
2968
+ var ownerWaitAddRequestSchema = z23.object({
2969
+ engagementId: z23.string().min(1),
2970
+ text: ownerWaitText,
2971
+ reason: ownerWaitText
2972
+ }).strict();
2973
+ var ownerWaitAddResultSchema = z23.discriminatedUnion("outcome", [
2974
+ z23.object({ outcome: z23.literal("added"), id: z23.string().uuid(), addedAt: utcIso8601 }).strict(),
2975
+ z23.object({ outcome: z23.literal("refused"), reason: z23.literal("too-many") }).strict()
2976
+ ]);
2977
+ var ownerWaitUndoableSchema = z23.enum(["yes", "no"]);
2978
+ var ownerWaitDeclareRequestSchema = z23.object({
2979
+ engagementId: z23.string().min(1),
2980
+ item: ownerWaitItemRefSchema,
2981
+ question: ownerWaitText,
2982
+ recommendation: ownerWaitText,
2983
+ task: buildTaskIdSchema.optional(),
2984
+ by: agentNameSchema.optional(),
2985
+ undoable: ownerWaitUndoableSchema.optional(),
2986
+ how: statementText(STATEMENT_MAX).optional()
2987
+ }).strict().refine((r) => r.undoable === "yes" === (r.how !== void 0), {
2988
+ message: "says how to undo it exactly when it can be undone",
2989
+ path: ["how"]
2990
+ });
2991
+ var OWNER_WAIT_DECLARE_REFUSALS = ["no-open-phase", "unknown-item", "say-if-undoable"];
2992
+ var ownerWaitDeclareResultSchema = z23.discriminatedUnion("outcome", [
2993
+ z23.object({ outcome: z23.literal("recorded"), id: z23.string().uuid(), say: z23.string().min(1) }).strict(),
2994
+ z23.object({
2995
+ outcome: z23.literal("go-ahead"),
2996
+ id: z23.string().uuid(),
2997
+ callId: z23.number().int().positive(),
2998
+ say: z23.string().min(1)
2999
+ }).strict(),
3000
+ z23.object({ outcome: z23.literal("refused"), reason: z23.enum(OWNER_WAIT_DECLARE_REFUSALS) }).strict()
3001
+ ]);
3002
+ var ownerWaitAddedItemSchema = z23.object({
3003
+ id: z23.string().uuid(),
3004
+ words: z23.string().min(1),
3005
+ reason: z23.string().min(1),
3006
+ addedAt: utcIso8601
3007
+ }).strict();
3008
+ var ownerWaitListSchema = z23.object({
3009
+ fixed: z23.array(z23.object({ id: ownerWaitItemIdSchema, words: z23.string().min(1) }).strict()).length(OWNER_WAIT_FIXED_COUNT),
3010
+ added: z23.array(ownerWaitAddedItemSchema).max(OWNER_WAIT_ADDITIONS_MAX)
3011
+ }).strict();
3012
+ var ownerWaitDeclarationSchema = z23.object({
3013
+ id: z23.string().uuid(),
3014
+ fixedItem: ownerWaitItemIdSchema.nullable(),
3015
+ addition: z23.object({ id: z23.string().uuid(), words: z23.string().min(1) }).strict().nullable(),
3016
+ question: z23.string().min(1),
3017
+ recommendation: z23.string().min(1),
3018
+ task: buildTaskIdSchema.nullable(),
3019
+ declaredAt: utcIso8601
3020
+ }).strict().refine((d) => d.fixedItem === null !== (d.addition === null), {
3021
+ message: "names exactly one item: a fixed one or an added one",
3022
+ path: ["fixedItem"]
3023
+ });
3024
+ var ownerWaitDeclarationsSchema = z23.object({ declarations: z23.array(ownerWaitDeclarationSchema) }).strict();
3025
+ var OWNER_WAIT_TOO_MANY = `This project already has ${String(OWNER_WAIT_ADDITIONS_MAX)} things added to what waits for the owner. Ask the owner to take one off in the console first.`;
3026
+
3027
+ // ../events/dist/autonomy.js
3028
+ import { z as z24 } from "zod";
3029
+ var AUTONOMY_CHANGED_IN = ["console", "chat"];
3030
+ var autonomyChangedInSchema = z24.enum(AUTONOMY_CHANGED_IN);
3031
+ var autonomySettingsSchema = z24.object({
3032
+ on: z24.boolean(),
3033
+ // whether builds run on their own
3034
+ mayPublish: z24.boolean(),
3035
+ // whether builds may publish or release without waiting
3036
+ maySpend: z24.boolean(),
3037
+ // whether builds may spend money or sign up without waiting
3038
+ changedAt: utcIso8601.nullable(),
3039
+ // when the setting last changed; null if never
3040
+ changedIn: autonomyChangedInSchema.nullable()
3041
+ // where it last changed; null if never
3042
+ }).strict().refine((v) => v.changedAt === null === (v.changedIn === null), {
3043
+ message: "says when and where it changed, or neither",
3044
+ path: ["changedIn"]
3045
+ });
3046
+ var autonomyChangeRequestSchema = z24.object({
3047
+ on: z24.boolean(),
3048
+ // the switch
3049
+ mayPublish: z24.boolean(),
3050
+ // the publish box
3051
+ maySpend: z24.boolean()
3052
+ // the spend box
3053
+ }).strict();
3054
+ var AUTONOMY_CHAT_ACTS = ["on", "off"];
3055
+ var autonomyChatActSchema = z24.enum(AUTONOMY_CHAT_ACTS);
3056
+ var autonomyChatRequestSchema = z24.object({
3057
+ act: autonomyChatActSchema
3058
+ // what the owner typed
3059
+ }).strict();
3060
+ var autonomyChatResultSchema = z24.discriminatedUnion("outcome", [
3061
+ z24.object({
3062
+ outcome: z24.literal("recorded"),
3063
+ on: z24.boolean()
3064
+ // whether builds now run on their own
3065
+ }).strict(),
3066
+ z24.object({ outcome: z24.literal("not-owner") }).strict(),
3067
+ z24.object({ outcome: z24.literal("no-owner") }).strict(),
3068
+ z24.object({ outcome: z24.literal("not-offered") }).strict(),
3069
+ z24.object({ outcome: z24.literal("unavailable") }).strict()
3070
+ ]);
3071
+ var autonomyStateSchema = z24.object({
3072
+ on: z24.boolean(),
3073
+ // whether builds run on their own
3074
+ mayPublish: z24.boolean(),
3075
+ // whether builds may publish or release without waiting
3076
+ maySpend: z24.boolean()
3077
+ // whether builds may spend money or sign up without waiting
3078
+ }).strict();
3079
+ var autonomyStateFileSchema = autonomyStateSchema.extend({
3080
+ readAt: utcIso8601
3081
+ // when this copy was read from Halfcycle
3082
+ }).strict();
3083
+ var AUTONOMY_STATE_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
3084
+ var AUTONOMY_ACTIVITY_KINDS = ["went-ahead", "waiting", "approved", "closed", "waiting-on-owner"];
3085
+ var autonomyActivityKindSchema = z24.enum(AUTONOMY_ACTIVITY_KINDS);
3086
+ var autonomyActivityRowSchema = z24.object({
3087
+ kind: autonomyActivityKindSchema,
3088
+ // what happened
3089
+ phase: scopePhaseSchema,
3090
+ // the phase it happened in
3091
+ question: ownerWaitText.optional(),
3092
+ // the agent's question, for a decision
3093
+ chose: ownerWaitText.optional(),
3094
+ // what the agent chose or recommends, for a decision
3095
+ undoable: ownerWaitUndoableSchema.optional(),
3096
+ // whether the agent says it can be undone
3097
+ how: statementText(STATEMENT_MAX).optional(),
3098
+ // how to undo it, when it can be
3099
+ at: utcIso8601
3100
+ // when it happened
3101
+ }).strict().superRefine((row, ctx) => {
3102
+ const decision = row.kind === "went-ahead" || row.kind === "waiting";
3103
+ if (decision && row.question === void 0) {
3104
+ ctx.addIssue({ code: "custom", message: "a decision says its question", path: ["question"] });
3105
+ }
3106
+ if (!decision) {
3107
+ for (const key of ["question", "chose", "undoable", "how"]) {
3108
+ if (row[key] !== void 0) {
3109
+ ctx.addIssue({ code: "custom", message: "only a decision carries this", path: [key] });
3110
+ }
3111
+ }
3112
+ }
3113
+ if (row.kind === "went-ahead") {
3114
+ if (row.chose === void 0) {
3115
+ ctx.addIssue({ code: "custom", message: "a decision that went ahead says what was chosen", path: ["chose"] });
3116
+ }
3117
+ if (row.undoable === void 0) {
3118
+ ctx.addIssue({
3119
+ code: "custom",
3120
+ message: "a decision that went ahead says whether it can be undone",
3121
+ path: ["undoable"]
3122
+ });
3123
+ }
3124
+ }
3125
+ if (row.kind === "waiting" && (row.undoable !== void 0 || row.how !== void 0)) {
3126
+ ctx.addIssue({ code: "custom", message: "a waiting decision has not been made yet", path: ["undoable"] });
3127
+ }
3128
+ if (row.kind === "went-ahead" && row.undoable === "yes" !== (row.how !== void 0)) {
3129
+ ctx.addIssue({ code: "custom", message: "says how to undo it exactly when it can be undone", path: ["how"] });
3130
+ }
3131
+ });
3132
+ var autonomyActivitySchema = z24.object({
3133
+ rows: z24.array(autonomyActivityRowSchema)
3134
+ // newest first
3135
+ }).strict();
3136
+ var savedIdSchema = z24.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be lower-case letters, digits and single hyphens");
3137
+ var SAVED_REF_PREFIX = "refs/halfcycle/saved/";
3138
+ function savedRefOf(id) {
3139
+ return `${SAVED_REF_PREFIX}${id}`;
3140
+ }
3141
+ var SAVED_KEEP_MS = 7 * 24 * 60 * 60 * 1e3;
3142
+ function isProjectRelativePath(p) {
3143
+ if (p.length === 0 || hasControlCharacter(p))
3144
+ return false;
3145
+ if (p.startsWith("/") || p.startsWith("\\") || /^[A-Za-z]:/.test(p))
3146
+ return false;
3147
+ return p.split(/[\\/]/).every((segment) => segment !== "" && windowsNameOf(segment) !== "" && !isGitFolderSegment(segment));
3148
+ }
3149
+ function windowsNameOf(segment) {
3150
+ return segment.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u206a-\u206f\ufeff]/g, "").split(":")[0].replace(/[. ]+$/, "").toLowerCase();
3151
+ }
3152
+ function isGitFolderSegment(segment) {
3153
+ const t = windowsNameOf(segment);
3154
+ return t === ".git" || /^git~\d+$/.test(t);
3155
+ }
3156
+ var SAVED_PATH_KEPT_IN = ["commit", "copy"];
3157
+ var savedPathSchema = z24.object({
3158
+ path: z24.string().max(4096).refine(isProjectRelativePath, { message: "must be a path inside the project" }),
3159
+ // the file, relative to the project
3160
+ keptIn: z24.enum(SAVED_PATH_KEPT_IN)
3161
+ // in the save's commit, or copied beside this file
3162
+ }).strict();
3163
+ var savedCommitSchema = z24.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/, "must be a full commit hash");
3164
+ var savedBranchSchema = z24.object({
3165
+ name: z24.string().min(1).max(255).refine((n) => !hasControlCharacter(n) && !n.startsWith("-") && !n.includes("..") && n !== "HEAD", {
3166
+ message: "must be a branch name"
3167
+ }),
3168
+ // the branch's name
3169
+ commit: savedCommitSchema
3170
+ // the commit it pointed at
3171
+ }).strict();
3172
+ var savedSchema = z24.object({
3173
+ id: savedIdSchema,
3174
+ // what restore is typed with
3175
+ savedAt: utcIso8601,
3176
+ // when the copy was made
3177
+ ref: z24.string().min(1),
3178
+ // where the saved commit is kept
3179
+ what: z24.string().min(1).max(2e3),
3180
+ // what the action would have changed, in words
3181
+ paths: z24.array(savedPathSchema),
3182
+ // every saved file
3183
+ branches: z24.array(savedBranchSchema)
3184
+ // every saved line of work
3185
+ }).strict().refine((s) => s.ref === savedRefOf(s.id), { message: "must be the ref of this save", path: ["ref"] });
3186
+ function autonomyC8(agent, question, chose) {
3187
+ return `${agent} decided "${question}" without asking you, under your autonomy setting: chose "${chose}". It is on the list of things that wait for you, so it was sent to you as it was made.`;
3188
+ }
3189
+
2901
3190
  // ../events/dist/work-words.js
2902
3191
  var CALLS_HEADING = "Calls made without asking you";
2903
3192
  var CALLS_EXPLAINED = "These are the decisions the agents recorded taking on their own, each against a stated default. Halfcycle lists what they recorded; it cannot see a call an agent did not record.";
@@ -3207,44 +3496,68 @@ function mintIdentity(targetRepoRoot) {
3207
3496
  };
3208
3497
  }
3209
3498
  function repairRemoteCredential(targetRepoRoot) {
3210
- const path = join3(targetRepoRoot, ".halfcycle", "project.json");
3499
+ const repaired = plannedRemoteRepair(targetRepoRoot);
3500
+ if (repaired !== null)
3501
+ replaceProjectIdentityFile(targetRepoRoot, repaired);
3502
+ }
3503
+ function plannedRemoteRepair(targetRepoRoot) {
3504
+ const path = join3(targetRepoRoot, PROJECT_IDENTITY_REL);
3211
3505
  let onDisk;
3212
3506
  try {
3213
3507
  const parsed = JSON.parse(readFileSync2(path, "utf-8"));
3214
3508
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
3215
- return;
3509
+ return null;
3216
3510
  onDisk = parsed;
3217
3511
  } catch {
3218
- return;
3512
+ return null;
3219
3513
  }
3220
3514
  const storedRemote = onDisk.remote ?? null;
3221
3515
  const remote = redactRemoteCredential(storedRemote);
3222
3516
  if (remote === storedRemote)
3223
- return;
3224
- const identity = {
3517
+ return null;
3518
+ return serializeProjectIdentity({
3225
3519
  projectId: onDisk.projectId ?? "",
3226
3520
  rootCommit: onDisk.rootCommit ?? null,
3227
3521
  remote,
3228
3522
  note: PROJECT_IDENTITY_NOTE
3229
- };
3523
+ });
3524
+ }
3525
+ function replaceProjectIdentityFile(targetRepoRoot, text) {
3526
+ const path = join3(targetRepoRoot, PROJECT_IDENTITY_REL);
3230
3527
  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
3231
3528
  try {
3232
- writeFileSync2(temporary, serializeProjectIdentity(identity), { encoding: "utf-8", flag: "wx" });
3529
+ writeFileSync2(temporary, text, { encoding: "utf-8", flag: "wx" });
3233
3530
  renameSync(temporary, path);
3234
3531
  } catch {
3235
3532
  } finally {
3236
3533
  rmSync(temporary, { force: true });
3237
3534
  }
3238
3535
  }
3536
+ function planProjectIdentity(targetRepoRoot) {
3537
+ const path = join3(targetRepoRoot, PROJECT_IDENTITY_REL);
3538
+ if (!existsSync(path)) {
3539
+ return { action: "add", after: serializeProjectIdentity(mintIdentity(targetRepoRoot)) };
3540
+ }
3541
+ const repaired = plannedRemoteRepair(targetRepoRoot);
3542
+ if (repaired !== null)
3543
+ return { action: "change", after: repaired };
3544
+ let current;
3545
+ try {
3546
+ current = readFileSync2(path, "utf-8");
3547
+ } catch {
3548
+ current = null;
3549
+ }
3550
+ return { action: "leave", after: current };
3551
+ }
3239
3552
  function serializeProjectIdentity(identity) {
3240
3553
  return JSON.stringify(identity, null, 2) + "\n";
3241
3554
  }
3242
- function createProjectIdentityFile(targetRepoRoot) {
3555
+ function createProjectIdentityFile(targetRepoRoot, text) {
3243
3556
  const path = join3(targetRepoRoot, PROJECT_IDENTITY_REL);
3244
3557
  mkdirSync2(dirname2(path), { recursive: true });
3245
3558
  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
3246
3559
  try {
3247
- writeFileSync2(temporary, serializeProjectIdentity(mintIdentity(targetRepoRoot)), {
3560
+ writeFileSync2(temporary, text ?? serializeProjectIdentity(mintIdentity(targetRepoRoot)), {
3248
3561
  encoding: "utf-8",
3249
3562
  flag: "wx"
3250
3563
  });
@@ -3259,6 +3572,10 @@ function createProjectIdentityFile(targetRepoRoot) {
3259
3572
  }
3260
3573
  }
3261
3574
 
3575
+ // dist/install-plan.js
3576
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
3577
+ import { join as join5, posix } from "node:path";
3578
+
3262
3579
  // dist/install-manifest.js
3263
3580
  import { Buffer as Buffer2 } from "node:buffer";
3264
3581
  import { createHash } from "node:crypto";
@@ -3557,6 +3874,469 @@ function union(a, b) {
3557
3874
  return [.../* @__PURE__ */ new Set([...a, ...b])];
3558
3875
  }
3559
3876
 
3877
+ // dist/scan.js
3878
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
3879
+ import { join as join4 } from "node:path";
3880
+ var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
3881
+ var HALFCYCLE_STATE_NOTE = [
3882
+ "This file is a local cache written ONCE when Halfcycle was installed. It is not a",
3883
+ "record of what this project has done, and it is never updated as work happens.",
3884
+ 'An empty or missing "layers" means the install-time scan found no pre-existing',
3885
+ "artefacts on disk \u2014 it does NOT mean nothing has been done since, and it is not",
3886
+ "evidence that any step is unfinished or any approval is outstanding.",
3887
+ "The authoritative position lives in the Halfcycle service: call the",
3888
+ "halfcycle_resolve tool to ask where this project stands. Do not answer that",
3889
+ "question from this file."
3890
+ ].join(" ");
3891
+ var LAYER_ARTEFACT_PATHS = {
3892
+ l0: ["ENGAGEMENT.md"],
3893
+ l1: ["docs/product-spec.md"],
3894
+ l2: ["docs/architecture-overview.md"],
3895
+ l3: ["docs/invariants.md"],
3896
+ l4: ["docs/features"],
3897
+ l5: ["docs/phases"]
3898
+ };
3899
+ function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOString()) {
3900
+ const layers = {};
3901
+ for (const [layer, paths] of Object.entries(LAYER_ARTEFACT_PATHS)) {
3902
+ const found = paths.filter((p) => existsSync2(join4(targetRepo, p)));
3903
+ if (found.length > 0) {
3904
+ layers[layer] = {
3905
+ value: "asserted",
3906
+ source: "bootstrap-scan",
3907
+ foundPaths: found,
3908
+ scannedAt
3909
+ };
3910
+ }
3911
+ }
3912
+ return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
3913
+ }
3914
+ var BOOTSTRAP_STATE_REL = ".halfcycle/state.json";
3915
+ function runBootstrapScan(targetRepo) {
3916
+ const planned = planBootstrapScan(targetRepo);
3917
+ if (planned.text !== null)
3918
+ writeBootstrapState(targetRepo, planned.text);
3919
+ return { state: planned.state, ran: planned.text !== null };
3920
+ }
3921
+ function planBootstrapScan(targetRepo) {
3922
+ const statePath = join4(targetRepo, BOOTSTRAP_STATE_REL);
3923
+ if (existsSync2(statePath)) {
3924
+ const existing = JSON.parse(readFileSync3(statePath, "utf-8"));
3925
+ return { state: existing, text: null };
3926
+ }
3927
+ const state = scanLayers(targetRepo);
3928
+ return { state, text: JSON.stringify(state, null, 2) + "\n" };
3929
+ }
3930
+ function writeBootstrapState(targetRepo, text) {
3931
+ mkdirSync3(join4(targetRepo, ".halfcycle"), { recursive: true });
3932
+ writeFileSync3(join4(targetRepo, BOOTSTRAP_STATE_REL), text, "utf-8");
3933
+ }
3934
+ function mergeWithRecords(state, layersWithRealRecords) {
3935
+ const merged = {};
3936
+ const allLayers = /* @__PURE__ */ new Set([
3937
+ ...Object.keys(state.layers),
3938
+ ...layersWithRealRecords
3939
+ ]);
3940
+ for (const layer of allLayers) {
3941
+ if (layersWithRealRecords.has(layer)) {
3942
+ merged[layer] = "complete";
3943
+ } else if (state.layers[layer]) {
3944
+ merged[layer] = "asserted";
3945
+ }
3946
+ }
3947
+ return merged;
3948
+ }
3949
+
3950
+ // dist/install-plan.js
3951
+ var PENDING = /* @__PURE__ */ Symbol("pending");
3952
+ var YOURS = "a file of yours with the same name; Halfcycle will not touch it";
3953
+ var SAME = "already as Halfcycle would write it";
3954
+ var PREVIEW_WORDS = Object.freeze({
3955
+ command: {
3956
+ add: "a command your coding agent can run to take the next Halfcycle step",
3957
+ change: "a newer version of this Halfcycle command",
3958
+ leave: SAME,
3959
+ yours: YOURS
3960
+ },
3961
+ "check-script": {
3962
+ add: "a check Halfcycle runs before and after your agent's actions",
3963
+ change: "a newer version of this Halfcycle check",
3964
+ leave: SAME,
3965
+ yours: YOURS
3966
+ },
3967
+ settings: {
3968
+ add: "your coding agent's settings, with the checks Halfcycle runs",
3969
+ change: "adds the lines Halfcycle needs; your own lines stay as they are",
3970
+ leave: "already has the lines Halfcycle needs",
3971
+ yours: YOURS
3972
+ },
3973
+ "server-entry": {
3974
+ add: "tells your coding agent how to reach Halfcycle's service",
3975
+ change: "adds Halfcycle's entry; your own entries stay as they are",
3976
+ leave: "already has Halfcycle's entry",
3977
+ yours: YOURS
3978
+ },
3979
+ "ignore-list": {
3980
+ add: "keeps Halfcycle's files for this computer only out of git",
3981
+ change: "adds the lines Halfcycle needs; your own lines stay as they are",
3982
+ leave: "already has the lines Halfcycle needs",
3983
+ yours: YOURS
3984
+ },
3985
+ "line-endings": {
3986
+ add: "keeps Halfcycle's checks working in a Windows copy of this project",
3987
+ change: "adds the lines Halfcycle needs; your own lines stay as they are",
3988
+ leave: "already has the lines Halfcycle needs",
3989
+ yours: YOURS
3990
+ },
3991
+ "halfcycle-folder": {
3992
+ add: "a file in Halfcycle's own folder",
3993
+ change: "a newer version of this file in Halfcycle's own folder",
3994
+ leave: SAME,
3995
+ yours: YOURS
3996
+ },
3997
+ runner: {
3998
+ add: "the program Halfcycle's checks run",
3999
+ change: "a newer version of the program Halfcycle's checks run",
4000
+ leave: SAME,
4001
+ yours: YOURS
4002
+ },
4003
+ "captured-index": {
4004
+ add: "the list of real examples your project's tests are checked against",
4005
+ change: "a newer version of this list",
4006
+ leave: SAME,
4007
+ yours: YOURS
4008
+ },
4009
+ "old-sign-in": {
4010
+ add: "an older Halfcycle sign-in",
4011
+ change: "takes out an older Halfcycle sign-in; your own lines stay as they are",
4012
+ leave: "an older Halfcycle sign-in that cannot be moved yet; left as it is",
4013
+ yours: YOURS
4014
+ }
4015
+ });
4016
+ var kindTable;
4017
+ function kinds() {
4018
+ kindTable ??= /* @__PURE__ */ new Map([
4019
+ [".claude/hooks/guard-runner.sh", "check-script"],
4020
+ [".claude/hooks/user-prompt-reminder.sh", "check-script"],
4021
+ [".claude/hooks/user-prompt-scope.sh", "check-script"],
4022
+ [".claude/hooks/user-prompt-findings.sh", "check-script"],
4023
+ [".claude/hooks/session-end-marker.sh", "check-script"],
4024
+ [".claude/hooks/session-start-marker.sh", "check-script"],
4025
+ [SETTINGS_REL, "settings"],
4026
+ [MCP_REGISTRATION_REL, "server-entry"],
4027
+ [GITIGNORE_REL, "ignore-list"],
4028
+ [GITATTRIBUTES_REL, "line-endings"],
4029
+ [VENDORED_BIN_REL, "runner"],
4030
+ [CREW_ROSTER_REL, "halfcycle-folder"],
4031
+ [BUNDLE_PIN_REL, "halfcycle-folder"],
4032
+ [PROJECT_IDENTITY_REL, "halfcycle-folder"],
4033
+ [BOOTSTRAP_STATE_REL, "halfcycle-folder"],
4034
+ [INSTALL_MANIFEST_REL, "halfcycle-folder"],
4035
+ [RETIRED_MCP_HEADERS_HELPER_REL, "halfcycle-folder"],
4036
+ [LEGACY_CI_STANZA_REL, "halfcycle-folder"],
4037
+ [CAPTURED_INDEX_REL, "captured-index"],
4038
+ [LEGACY_ENV_LOCAL_REL, "old-sign-in"]
4039
+ ]);
4040
+ return kindTable;
4041
+ }
4042
+ function previewKindOf(rel) {
4043
+ if (/^\.claude\/commands\/[^/]+\.md$/.test(rel))
4044
+ return "command";
4045
+ return kinds().get(rel);
4046
+ }
4047
+ function kindOf(rel) {
4048
+ const kind = previewKindOf(rel);
4049
+ if (kind === void 0)
4050
+ throw new Error(`[halfcycle] the installer plans ${rel}, which has no preview words.`);
4051
+ return kind;
4052
+ }
4053
+ var STEPS = /* @__PURE__ */ new WeakMap();
4054
+ function planInstall(options) {
4055
+ const { targetRepo, engagementId, engagementType, credential, home, env, createdByAccountId } = options;
4056
+ const preview = engagementId === void 0;
4057
+ if (!preview) {
4058
+ assertEngagementId(engagementId);
4059
+ if (engagementType === void 0) {
4060
+ throw new Error("[halfcycle] a plan for a project needs its kind as well as its id.");
4061
+ }
4062
+ }
4063
+ if (!existsSync3(targetRepo)) {
4064
+ throw new Error(`[halfcycle] Target repo does not exist: ${targetRepo}`);
4065
+ }
4066
+ const writeSet = closedWriteSet();
4067
+ const previous = readPreviousManifest(targetRepo);
4068
+ const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
4069
+ const { version, commands } = planCommandStubs(targetRepo, previous, writeSet);
4070
+ const vendored = planVendoredBinary(targetRepo);
4071
+ let settings;
4072
+ try {
4073
+ settings = planSettings(targetRepo, previous, home, env);
4074
+ } catch (err) {
4075
+ if (err instanceof SettingsParseRefused)
4076
+ return Object.freeze({ entries: [], outside: [], refusal: err.message });
4077
+ throw err;
4078
+ }
4079
+ const hooks = planHookScripts(targetRepo);
4080
+ const ciStanza = planLegacyCiStanzaRemoval(targetRepo);
4081
+ const mcp = planMcpRegistration(targetRepo, preview ? "pending" : credential);
4082
+ const mcpHelper = mcp.kind === "none" ? null : planMcpHeadersHelperRemoval(targetRepo);
4083
+ const gitignore = planGitignore(targetRepo);
4084
+ const gitattributes = planGitattributes(targetRepo);
4085
+ const identity = planProjectIdentity(targetRepo);
4086
+ if (identity.after === null)
4087
+ readFileSync4(join5(targetRepo, PROJECT_IDENTITY_REL));
4088
+ const credentialPlan = !preview && credential !== void 0 ? planEngagementCredential(credential, engagementId, home) : null;
4089
+ const credentialWrite = credentialPlan === null ? null : { outcome: credentialPlan.outcome, existed: credentialPlan.existed, path: engagementEnvPath(engagementId, home) };
4090
+ const storedAfter = credentialPlan?.storedAfter ?? (preview ? Object.fromEntries(ENGAGEMENT_ENV_KEYS.map((key) => [key, "supplied at sign-in"])) : readEngagementEnv(engagementId, home) ?? {});
4091
+ const legacy = migrateLegacyEnvLocal(targetRepo, storedAfter);
4092
+ const pin = preview ? planBundlePinPreview(targetRepo, version, home) : planBundlePin(targetRepo, version, engagementId, engagementType, createdByAccountId, credential?.methodVersion);
4093
+ const crew = planCrewRoster(targetRepo);
4094
+ const scan = { text: planBootstrapScan(targetRepo).text };
4095
+ const steps = {
4096
+ targetRepo,
4097
+ version,
4098
+ engagementId,
4099
+ engagementType,
4100
+ credential: preview ? void 0 : credential,
4101
+ home,
4102
+ writeSet,
4103
+ previous,
4104
+ dirsBefore,
4105
+ commands,
4106
+ vendored,
4107
+ settings,
4108
+ hooks,
4109
+ ciStanza,
4110
+ mcp,
4111
+ mcpHelper,
4112
+ gitignore,
4113
+ gitattributes,
4114
+ identity,
4115
+ credentialWrite,
4116
+ legacy,
4117
+ pin,
4118
+ crew,
4119
+ scan
4120
+ };
4121
+ const record2 = preview ? null : planInstallRecord(targetRepo, accountInstall(steps, predicted(steps)).manifestText);
4122
+ const plan = Object.freeze({
4123
+ entries: Object.freeze(entriesOf(steps, record2)),
4124
+ outside: Object.freeze(outsideOf(steps))
4125
+ });
4126
+ STEPS.set(plan, steps);
4127
+ return plan;
4128
+ }
4129
+ function predicted(steps) {
4130
+ const after = /* @__PURE__ */ new Map();
4131
+ const dirs = /* @__PURE__ */ new Set();
4132
+ const lands = (rel, text, writes) => {
4133
+ after.set(rel, text);
4134
+ if (!writes)
4135
+ return;
4136
+ for (let dir = posix.dirname(rel); dir !== "."; dir = posix.dirname(dir))
4137
+ dirs.add(dir);
4138
+ };
4139
+ const file = (planned) => {
4140
+ if (planned.outcome !== "collided")
4141
+ lands(planned.rel, planned.text, planned.outcome === "written");
4142
+ };
4143
+ steps.commands.forEach(file);
4144
+ file(steps.vendored);
4145
+ file(steps.settings.file);
4146
+ steps.hooks.forEach(file);
4147
+ if (steps.mcp.kind === "write")
4148
+ lands(MCP_REGISTRATION_REL, steps.mcp.text, steps.mcp.outcome !== "skipped");
4149
+ if (steps.identity.after !== null)
4150
+ lands(PROJECT_IDENTITY_REL, steps.identity.after, steps.identity.action === "add");
4151
+ if (steps.pin !== null)
4152
+ file(steps.pin);
4153
+ file(steps.crew);
4154
+ if (steps.scan.text !== null)
4155
+ lands(BOOTSTRAP_STATE_REL, steps.scan.text, true);
4156
+ const mergeAfter = (planned) => planned.outcome === "written" ? planned.after : planned.before;
4157
+ return {
4158
+ ciStanza: steps.ciStanza,
4159
+ mcpHelper: steps.mcpHelper,
4160
+ gitignore: steps.gitignore.outcome,
4161
+ gitignoreAfter: mergeAfter(steps.gitignore),
4162
+ gitattributes: steps.gitattributes.outcome,
4163
+ gitattributesAfter: mergeAfter(steps.gitattributes),
4164
+ credential: steps.credentialWrite?.outcome ?? null,
4165
+ legacy: steps.legacy,
4166
+ bytesAfter: (rel) => {
4167
+ const text = after.get(rel);
4168
+ if (text === void 0)
4169
+ throw new Error(`[halfcycle] the plan has no bytes for ${rel}.`);
4170
+ return Buffer.from(text, "utf-8");
4171
+ },
4172
+ dirExistsAfter: (dir) => steps.dirsBefore.has(dir) || dirs.has(dir),
4173
+ stateExistsAfter: true
4174
+ };
4175
+ }
4176
+ function entriesOf(steps, record2) {
4177
+ const { targetRepo } = steps;
4178
+ const entries = [];
4179
+ const fromFile = (planned) => {
4180
+ const kind = kindOf(planned.rel);
4181
+ if (planned.outcome === "written") {
4182
+ entries.push({ path: planned.rel, action: planned.replacedExisting ? "change" : "add", kind, bytes: planned.text });
4183
+ } else if (planned.outcome === "collided") {
4184
+ entries.push({ path: planned.rel, action: "leave", kind, bytes: null, yours: true });
4185
+ } else {
4186
+ entries.push({ path: planned.rel, action: "leave", kind, bytes: null });
4187
+ }
4188
+ };
4189
+ const retired = (rel, decided) => {
4190
+ if (decided === null || decided === "absent")
4191
+ return;
4192
+ if (decided === "removed")
4193
+ entries.push({ path: rel, action: "remove", kind: kindOf(rel), bytes: null });
4194
+ else if (decided === "kept-foreign")
4195
+ entries.push({ path: rel, action: "leave", kind: kindOf(rel), bytes: null, yours: true });
4196
+ else
4197
+ entries.push({ path: rel, action: "leave", kind: kindOf(rel), bytes: null });
4198
+ };
4199
+ const merge = (rel, planned) => {
4200
+ if (planned.outcome === "written" && planned.after !== null) {
4201
+ entries.push({ path: rel, action: planned.before === null ? "add" : "change", kind: kindOf(rel), bytes: planned.after });
4202
+ } else if (planned.before !== null || planned.outcome === "failed") {
4203
+ entries.push({ path: rel, action: "leave", kind: kindOf(rel), bytes: null });
4204
+ }
4205
+ };
4206
+ const pending = (rel) => {
4207
+ const there = existsSync3(join5(targetRepo, rel));
4208
+ entries.push({ path: rel, action: there ? "change" : "add", kind: kindOf(rel), bytes: PENDING });
4209
+ };
4210
+ steps.commands.forEach(fromFile);
4211
+ fromFile(steps.vendored);
4212
+ const settings = steps.settings;
4213
+ entries.push(settings.file.outcome === "written" ? { path: SETTINGS_REL, action: settings.preexisted ? "change" : "add", kind: "settings", bytes: settings.file.text } : { path: SETTINGS_REL, action: "leave", kind: "settings", bytes: null });
4214
+ steps.hooks.forEach(fromFile);
4215
+ retired(LEGACY_CI_STANZA_REL, steps.ciStanza);
4216
+ const mcp = steps.mcp;
4217
+ if (mcp.kind === "write") {
4218
+ const action = mcp.outcome === "written" ? "add" : mcp.outcome === "merged" ? "change" : "leave";
4219
+ entries.push({ path: MCP_REGISTRATION_REL, action, kind: "server-entry", bytes: action === "leave" ? null : mcp.text });
4220
+ } else if (mcp.kind === "pending") {
4221
+ pending(MCP_REGISTRATION_REL);
4222
+ } else {
4223
+ entries.push({ path: MCP_REGISTRATION_REL, action: "leave", kind: "server-entry", bytes: null });
4224
+ }
4225
+ retired(RETIRED_MCP_HEADERS_HELPER_REL, steps.mcpHelper);
4226
+ merge(GITIGNORE_REL, steps.gitignore);
4227
+ merge(GITATTRIBUTES_REL, steps.gitattributes);
4228
+ const identity = steps.identity;
4229
+ entries.push({
4230
+ path: PROJECT_IDENTITY_REL,
4231
+ action: identity.action,
4232
+ kind: "halfcycle-folder",
4233
+ bytes: identity.action === "leave" ? null : identity.after
4234
+ });
4235
+ const legacy = steps.legacy;
4236
+ if (legacy.outcome === "removed") {
4237
+ entries.push({ path: LEGACY_ENV_LOCAL_REL, action: "remove", kind: "old-sign-in", bytes: null });
4238
+ } else if (legacy.outcome === "stripped") {
4239
+ entries.push({ path: LEGACY_ENV_LOCAL_REL, action: "change", kind: "old-sign-in", bytes: legacy.keptText ?? null });
4240
+ } else if (legacy.outcome !== "absent") {
4241
+ entries.push({ path: LEGACY_ENV_LOCAL_REL, action: "leave", kind: "old-sign-in", bytes: null });
4242
+ }
4243
+ if (steps.pin === null)
4244
+ pending(BUNDLE_PIN_REL);
4245
+ else
4246
+ fromFile(steps.pin);
4247
+ fromFile(steps.crew);
4248
+ entries.push(steps.scan.text !== null ? { path: BOOTSTRAP_STATE_REL, action: "add", kind: "halfcycle-folder", bytes: steps.scan.text } : { path: BOOTSTRAP_STATE_REL, action: "leave", kind: "halfcycle-folder", bytes: null });
4249
+ if (record2 === null)
4250
+ pending(INSTALL_MANIFEST_REL);
4251
+ else
4252
+ fromFile(record2);
4253
+ return entries;
4254
+ }
4255
+ function outsideOf(steps) {
4256
+ if (steps.engagementId === void 0) {
4257
+ const outside = [{ kind: "sign-in", path: null, action: "pending" }];
4258
+ if (!projectPinned(steps.targetRepo))
4259
+ outside.push({ kind: "new-project" });
4260
+ return outside;
4261
+ }
4262
+ const write = steps.credentialWrite;
4263
+ if (write === null)
4264
+ return [];
4265
+ const action = write.outcome === "skipped" ? "leave" : write.existed ? "change" : "add";
4266
+ return [{ kind: "sign-in", path: write.path, action }];
4267
+ }
4268
+ function projectPinned(targetRepo) {
4269
+ try {
4270
+ const id = readBundlePin(targetRepo)?.engagementId;
4271
+ return typeof id === "string" && id.trim() !== "";
4272
+ } catch {
4273
+ return false;
4274
+ }
4275
+ }
4276
+ function applyInstallPlan(plan) {
4277
+ if (plan.refusal !== void 0)
4278
+ throw new SettingsParseRefused(plan.refusal);
4279
+ const steps = STEPS.get(plan);
4280
+ if (steps === void 0)
4281
+ throw new Error("[halfcycle] only a plan made by planInstall can be applied.");
4282
+ const { targetRepo, engagementId, pin } = steps;
4283
+ if (engagementId === void 0 || pin === null || steps.mcp.kind === "pending") {
4284
+ throw new Error("[halfcycle] a preview cannot be applied: it is still waiting for the service.");
4285
+ }
4286
+ for (const command of steps.commands)
4287
+ putPlannedFile(targetRepo, command);
4288
+ putPlannedFile(targetRepo, steps.vendored);
4289
+ putPlannedFile(targetRepo, steps.settings.file);
4290
+ for (const hook of steps.hooks)
4291
+ putPlannedFile(targetRepo, hook);
4292
+ const ciStanza = steps.ciStanza === "removed" ? removePlannedRetiredFile(targetRepo, LEGACY_CI_STANZA_REL) : steps.ciStanza;
4293
+ putPlannedMcp(targetRepo, steps.mcp);
4294
+ const mcpHelper = steps.mcpHelper === "removed" ? removePlannedRetiredFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL) : steps.mcpHelper;
4295
+ const gitignore = putPlannedGitignore(targetRepo, steps.gitignore);
4296
+ const gitignorePath = join5(targetRepo, GITIGNORE_REL);
4297
+ const gitignoreAfter = existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null;
4298
+ const gitattributes = putPlannedGitattributes(targetRepo, steps.gitattributes);
4299
+ const gitattributesAfter = readTextOrNull(join5(targetRepo, GITATTRIBUTES_REL));
4300
+ writeProjectIdentity(targetRepo, steps.identity);
4301
+ const credential = steps.credential;
4302
+ const credentialOutcome = steps.credentialWrite !== null && credential !== void 0 ? writeEngagementCredential(credential, engagementId, steps.home) : null;
4303
+ const stored = readEngagementEnv(engagementId, steps.home) ?? {};
4304
+ const legacy = applyLegacyMigration(targetRepo, migrateLegacyEnvLocal(targetRepo, stored));
4305
+ writeBundlePin(targetRepo, pin, []);
4306
+ putPlannedFile(targetRepo, steps.crew);
4307
+ if (steps.scan.text !== null)
4308
+ writeBootstrapState(targetRepo, steps.scan.text);
4309
+ const { report, installManifest, manifestText } = accountInstall(steps, {
4310
+ ciStanza,
4311
+ mcpHelper,
4312
+ gitignore,
4313
+ gitignoreAfter,
4314
+ gitattributes,
4315
+ gitattributesAfter,
4316
+ credential: credentialOutcome,
4317
+ legacy,
4318
+ bytesAfter: (rel) => readFileSync4(join5(targetRepo, rel)),
4319
+ dirExistsAfter: (dir) => existsSync3(join5(targetRepo, dir)),
4320
+ stateExistsAfter: existsSync3(join5(targetRepo, BOOTSTRAP_STATE_REL))
4321
+ });
4322
+ const record2 = planInstallRecord(targetRepo, manifestText);
4323
+ putPlannedFile(targetRepo, record2);
4324
+ recordInstallRecord(report, record2);
4325
+ const notShared = findNotShared(targetRepo, installManifest, report.collidedPaths);
4326
+ return {
4327
+ version: steps.version,
4328
+ writtenPaths: report.writtenPaths,
4329
+ skippedPaths: report.skippedPaths,
4330
+ mergedPaths: report.mergedPaths,
4331
+ collidedPaths: report.collidedPaths,
4332
+ editedCollidedPaths: report.editedCollidedPaths,
4333
+ replacedPaths: report.replacedPaths,
4334
+ bootstrapScanRan: steps.scan.text !== null,
4335
+ legacyCredentialKept: legacy.outcome === "kept" ? { keys: legacy.unbacked, missing: legacy.missingForAdoption } : null,
4336
+ notShared
4337
+ };
4338
+ }
4339
+
3560
4340
  // dist/mcp-endpoint.js
3561
4341
  var MCP_ENDPOINT_PATH = "/mcp";
3562
4342
  function mcpEndpointUrl(origin) {
@@ -3636,71 +4416,6 @@ function mergePermissions(existing, generated, retiredOurs) {
3636
4416
  return result;
3637
4417
  }
3638
4418
 
3639
- // dist/scan.js
3640
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
3641
- import { join as join4 } from "node:path";
3642
- var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
3643
- var HALFCYCLE_STATE_NOTE = [
3644
- "This file is a local cache written ONCE when Halfcycle was installed. It is not a",
3645
- "record of what this project has done, and it is never updated as work happens.",
3646
- 'An empty or missing "layers" means the install-time scan found no pre-existing',
3647
- "artefacts on disk \u2014 it does NOT mean nothing has been done since, and it is not",
3648
- "evidence that any step is unfinished or any approval is outstanding.",
3649
- "The authoritative position lives in the Halfcycle service: call the",
3650
- "halfcycle_resolve tool to ask where this project stands. Do not answer that",
3651
- "question from this file."
3652
- ].join(" ");
3653
- var LAYER_ARTEFACT_PATHS = {
3654
- l0: ["ENGAGEMENT.md"],
3655
- l1: ["docs/product-spec.md"],
3656
- l2: ["docs/architecture-overview.md"],
3657
- l3: ["docs/invariants.md"],
3658
- l4: ["docs/features"],
3659
- l5: ["docs/phases"]
3660
- };
3661
- function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOString()) {
3662
- const layers = {};
3663
- for (const [layer, paths] of Object.entries(LAYER_ARTEFACT_PATHS)) {
3664
- const found = paths.filter((p) => existsSync2(join4(targetRepo, p)));
3665
- if (found.length > 0) {
3666
- layers[layer] = {
3667
- value: "asserted",
3668
- source: "bootstrap-scan",
3669
- foundPaths: found,
3670
- scannedAt
3671
- };
3672
- }
3673
- }
3674
- return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
3675
- }
3676
- var BOOTSTRAP_STATE_REL = ".halfcycle/state.json";
3677
- function runBootstrapScan(targetRepo) {
3678
- const statePath = join4(targetRepo, BOOTSTRAP_STATE_REL);
3679
- if (existsSync2(statePath)) {
3680
- const existing = JSON.parse(readFileSync3(statePath, "utf-8"));
3681
- return { state: existing, ran: false };
3682
- }
3683
- const state = scanLayers(targetRepo);
3684
- mkdirSync3(join4(targetRepo, ".halfcycle"), { recursive: true });
3685
- writeFileSync3(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
3686
- return { state, ran: true };
3687
- }
3688
- function mergeWithRecords(state, layersWithRealRecords) {
3689
- const merged = {};
3690
- const allLayers = /* @__PURE__ */ new Set([
3691
- ...Object.keys(state.layers),
3692
- ...layersWithRealRecords
3693
- ]);
3694
- for (const layer of allLayers) {
3695
- if (layersWithRealRecords.has(layer)) {
3696
- merged[layer] = "complete";
3697
- } else if (state.layers[layer]) {
3698
- merged[layer] = "asserted";
3699
- }
3700
- }
3701
- return merged;
3702
- }
3703
-
3704
4419
  // dist/install.js
3705
4420
  var ZONE_B_DIR = ".workbench";
3706
4421
  var __filename = fileURLToPath(import.meta.url);
@@ -3708,36 +4423,36 @@ var __dirname = dirname3(__filename);
3708
4423
  function findBundleRoot(startDir) {
3709
4424
  let candidate = startDir;
3710
4425
  for (let i = 0; i < 4; i++) {
3711
- const manifest = join5(candidate, ".claude-plugin", "plugin.json");
4426
+ const manifest = join6(candidate, ".claude-plugin", "plugin.json");
3712
4427
  try {
3713
- if (existsSync3(manifest))
4428
+ if (existsSync4(manifest))
3714
4429
  return candidate;
3715
4430
  } catch {
3716
4431
  }
3717
4432
  candidate = dirname3(candidate);
3718
4433
  }
3719
- return join5(startDir, "..");
4434
+ return join6(startDir, "..");
3720
4435
  }
3721
4436
  var BUNDLE_ROOT = findBundleRoot(__dirname);
3722
4437
  function resolveVendoredBinary() {
3723
4438
  const candidates = [
3724
- join5(BUNDLE_ROOT, "bin", "bin.bundle.mjs"),
3725
- join5(BUNDLE_ROOT, "..", "runner", "dist", "bin.bundle.mjs")
4439
+ join6(BUNDLE_ROOT, "bin", "bin.bundle.mjs"),
4440
+ join6(BUNDLE_ROOT, "..", "runner", "dist", "bin.bundle.mjs")
3726
4441
  ];
3727
4442
  for (const candidate of candidates) {
3728
- if (existsSync3(candidate))
4443
+ if (existsSync4(candidate))
3729
4444
  return candidate;
3730
4445
  }
3731
4446
  throw new Error(`[halfcycle] Cannot find the self-contained guard binary bin.bundle.mjs. This copy of the halfcycle package is incomplete \u2014 reinstall it, or rebuild it if you are working on it. Looked in: ${candidates.join(", ")}.`);
3732
4447
  }
3733
4448
  function resolvePreFilterShell() {
3734
4449
  const candidates = [
3735
- join5(BUNDLE_ROOT, "bin", PRE_FILTER_SHELL_FILENAME),
3736
- join5(BUNDLE_ROOT, "..", "runner", "dist", PRE_FILTER_SHELL_FILENAME)
4450
+ join6(BUNDLE_ROOT, "bin", PRE_FILTER_SHELL_FILENAME),
4451
+ join6(BUNDLE_ROOT, "..", "runner", "dist", PRE_FILTER_SHELL_FILENAME)
3737
4452
  ];
3738
4453
  for (const candidate of candidates) {
3739
- if (existsSync3(candidate))
3740
- return readFileSync4(candidate, "utf-8");
4454
+ if (existsSync4(candidate))
4455
+ return readFileSync5(candidate, "utf-8");
3741
4456
  }
3742
4457
  throw new Error(`[halfcycle] Cannot find ${PRE_FILTER_SHELL_FILENAME}. This copy of the halfcycle package is incomplete \u2014 reinstall it, or rebuild it if you are working on it. Looked in: ${candidates.join(", ")}.`);
3743
4458
  }
@@ -3766,7 +4481,7 @@ function isAllowlisted(targetRelPath) {
3766
4481
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
3767
4482
  var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
3768
4483
  function capturedIndexScaffold() {
3769
- return join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
4484
+ return join6(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
3770
4485
  }
3771
4486
  var GITIGNORE_REL = ".gitignore";
3772
4487
  var GITATTRIBUTES_REL = ".gitattributes";
@@ -3804,7 +4519,7 @@ function closedWriteSet() {
3804
4519
  const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
3805
4520
  const dirs = /* @__PURE__ */ new Set();
3806
4521
  for (const path of [...files, ...merged]) {
3807
- for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
4522
+ for (let dir = posix2.dirname(path); dir !== "."; dir = posix2.dirname(dir))
3808
4523
  dirs.add(dir);
3809
4524
  }
3810
4525
  const ownedDirs = new Set([...dirs].filter((dir) => `${dir}/`.startsWith(INSTALLER_OWNED_DIR)));
@@ -3828,11 +4543,11 @@ function closedWriteSet() {
3828
4543
  function sha256OfWhatThisVersionWrites(commands) {
3829
4544
  const out = /* @__PURE__ */ new Map();
3830
4545
  const fromFile = (rel, src) => {
3831
- if (existsSync3(src))
3832
- out.set(rel, fileSha256(readFileSync4(src)));
4546
+ if (existsSync4(src))
4547
+ out.set(rel, fileSha256(readFileSync5(src)));
3833
4548
  };
3834
4549
  for (const cmd of commands)
3835
- fromFile(commandStubRel(cmd.name), join5(BUNDLE_ROOT, ".claude-plugin", cmd.path));
4550
+ fromFile(commandStubRel(cmd.name), join6(BUNDLE_ROOT, ".claude-plugin", cmd.path));
3836
4551
  for (const [rel, content] of hookScriptsThisVersionWrites())
3837
4552
  out.set(rel, fileSha256(content));
3838
4553
  fromFile(CAPTURED_INDEX_REL, capturedIndexScaffold());
@@ -3845,17 +4560,17 @@ function bytesAreOurs(path, bytes, recorded, writeSet) {
3845
4560
  }
3846
4561
  function readPreviousManifest(targetRepo) {
3847
4562
  try {
3848
- return parseManifest(readFileSync4(join5(targetRepo, INSTALL_MANIFEST_REL), "utf-8"));
4563
+ return parseManifest(readFileSync5(join6(targetRepo, INSTALL_MANIFEST_REL), "utf-8"));
3849
4564
  } catch {
3850
4565
  return null;
3851
4566
  }
3852
4567
  }
3853
- function noteFile(draft, targetRepo, rel, outcome) {
4568
+ function noteFile(draft, rel, outcome, after) {
3854
4569
  if (outcome === "collided") {
3855
4570
  recordCollision(draft, rel);
3856
4571
  return;
3857
4572
  }
3858
- recordInstalledFile(draft, rel, readFileSync4(join5(targetRepo, rel)));
4573
+ recordInstalledFile(draft, rel, after(rel));
3859
4574
  }
3860
4575
  function recordSettingsMerge(draft, existing, generated, preexisted, merged) {
3861
4576
  const generatedHooks = generated.hooks ?? {};
@@ -3944,11 +4659,11 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
3944
4659
  const collided = new Set(collidedPaths);
3945
4660
  const dirVerdicts = /* @__PURE__ */ new Map();
3946
4661
  const travelsThroughPlainDirs = (rel) => {
3947
- for (let dir = posix.dirname(rel); dir !== "."; dir = posix.dirname(dir)) {
4662
+ for (let dir = posix2.dirname(rel); dir !== "."; dir = posix2.dirname(dir)) {
3948
4663
  let plain = dirVerdicts.get(dir);
3949
4664
  if (plain === void 0) {
3950
- const abs = join5(targetRepo, dir);
3951
- plain = !lstatOrNull(abs)?.isSymbolicLink() && lstatOrNull(join5(abs, ".git")) === null;
4665
+ const abs = join6(targetRepo, dir);
4666
+ plain = !lstatOrNull(abs)?.isSymbolicLink() && lstatOrNull(join6(abs, ".git")) === null;
3952
4667
  dirVerdicts.set(dir, plain);
3953
4668
  }
3954
4669
  if (!plain)
@@ -3962,7 +4677,7 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
3962
4677
  ...merged,
3963
4678
  INSTALL_MANIFEST_REL
3964
4679
  ])
3965
- ].filter((rel) => !collided.has(rel)).filter((rel) => lstatOrNull(join5(targetRepo, rel)) !== null).filter(travelsThroughPlainDirs).sort();
4680
+ ].filter((rel) => !collided.has(rel)).filter((rel) => lstatOrNull(join6(targetRepo, rel)) !== null).filter(travelsThroughPlainDirs).sort();
3966
4681
  const ignored = readIgnoredPaths(targetRepo, paths);
3967
4682
  if (ignored === null)
3968
4683
  return null;
@@ -3985,7 +4700,7 @@ function lstatOrNull(path) {
3985
4700
  }
3986
4701
  function readFileOrNull(path) {
3987
4702
  try {
3988
- return readFileSync4(path, "utf-8");
4703
+ return readFileSync5(path, "utf-8");
3989
4704
  } catch {
3990
4705
  return null;
3991
4706
  }
@@ -3994,28 +4709,33 @@ function isPlainObject(value) {
3994
4709
  return value !== null && typeof value === "object" && !Array.isArray(value);
3995
4710
  }
3996
4711
  function readPluginManifest() {
3997
- const manifestPath = join5(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
3998
- const raw = readFileSync4(manifestPath, "utf-8");
4712
+ const manifestPath = join6(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
4713
+ const raw = readFileSync5(manifestPath, "utf-8");
3999
4714
  return JSON.parse(raw);
4000
4715
  }
4001
- function copyManifestCommands(manifest, targetRepo, report, draft, writeSet) {
4002
- for (const cmd of manifest.commands ?? []) {
4003
- const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
4004
- if (!existsSync3(srcFile)) {
4716
+ function planManifestCommands(manifest, targetRepo, previous, writeSet) {
4717
+ const recordedFiles = new Map((previous?.files ?? []).filter((file) => writeSet.files.has(file.path)).map((file) => [file.path, file]));
4718
+ return (manifest.commands ?? []).map((cmd) => {
4719
+ const srcFile = join6(BUNDLE_ROOT, ".claude-plugin", cmd.path);
4720
+ if (!existsSync4(srcFile)) {
4005
4721
  throw new Error(`[halfcycle] plugin.json declares command "${cmd.name}" at "${cmd.path}", but no file exists there (${srcFile}). The manifest and commands/ must agree.`);
4006
4722
  }
4007
4723
  const rel = commandStubRel(cmd.name);
4008
- const destFile = join5(targetRepo, rel);
4009
- const content = readFileSync4(srcFile, "utf-8");
4010
- const recorded = draft.files.get(rel);
4011
- const { outcome, replacedExisting } = writeCollisionSafe(destFile, targetRepo, content, (current) => bytesAreOurs(rel, current, recorded, writeSet));
4012
- record(report, outcome, rel);
4013
- if (replacedExisting)
4014
- report.replacedPaths.push(rel);
4015
- if (outcome === "collided" && recorded !== void 0)
4016
- report.editedCollidedPaths.push(rel);
4017
- noteFile(draft, targetRepo, rel, outcome);
4724
+ const content = readFileSync5(srcFile, "utf-8");
4725
+ const recorded = recordedFiles.get(rel);
4726
+ const planned = decideCollisionSafe(join6(targetRepo, rel), targetRepo, content, (current) => bytesAreOurs(rel, current, recorded, writeSet));
4727
+ return { ...planned, editedCollided: planned.outcome === "collided" && recorded !== void 0 };
4728
+ });
4729
+ }
4730
+ function putPlannedFile(targetRepoRoot, planned) {
4731
+ if (planned.outcome !== "written")
4732
+ return;
4733
+ if (!isAllowlisted(planned.rel)) {
4734
+ throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${planned.rel}". Only the method surface and scaffolding paths are writable.`);
4018
4735
  }
4736
+ const target = join6(targetRepoRoot, planned.rel);
4737
+ mkdirSync4(dirname3(target), { recursive: true });
4738
+ writeFileSync4(target, planned.text, "utf-8");
4019
4739
  }
4020
4740
  function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
4021
4741
  const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
@@ -4028,76 +4748,58 @@ function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths)
4028
4748
  writeFileSync4(targetAbsPath, content, "utf-8");
4029
4749
  writtenPaths.push(rel);
4030
4750
  }
4031
- function writeCollisionSafe(targetAbsPath, targetRepoRoot, content, isOurs) {
4751
+ function decideCollisionSafe(targetAbsPath, targetRepoRoot, content, isOurs) {
4032
4752
  const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
4033
4753
  if (!isAllowlisted(rel)) {
4034
4754
  throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
4035
4755
  }
4036
- let replacedExisting = false;
4037
- const current = existsSync3(targetAbsPath) ? readFileSync4(targetAbsPath) : null;
4756
+ const current = existsSync4(targetAbsPath) ? readFileSync5(targetAbsPath) : null;
4038
4757
  const text = inLineEndingOf(current?.toString("utf-8") ?? null, content);
4039
- if (current !== null) {
4040
- if (current.toString("utf-8") === text)
4041
- return { outcome: "skipped", replacedExisting: false };
4042
- if (!isOurs(current))
4043
- return { outcome: "collided", replacedExisting: false };
4044
- replacedExisting = true;
4045
- }
4046
- mkdirSync4(dirname3(targetAbsPath), { recursive: true });
4047
- writeFileSync4(targetAbsPath, text, "utf-8");
4048
- return { outcome: "written", replacedExisting };
4758
+ if (current === null)
4759
+ return { rel, text, outcome: "written", replacedExisting: false };
4760
+ if (current.toString("utf-8") === text)
4761
+ return { rel, text, outcome: "skipped", replacedExisting: false };
4762
+ if (!isOurs(current))
4763
+ return { rel, text, outcome: "collided", replacedExisting: false };
4764
+ return { rel, text, outcome: "written", replacedExisting: true };
4049
4765
  }
4050
4766
  var INSTALLER_OWNED_DIR = ".halfcycle/";
4051
- function writeOwned(targetAbsPath, targetRepoRoot, content) {
4767
+ function decideOwned(targetAbsPath, targetRepoRoot, content) {
4052
4768
  const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
4053
4769
  if (!isAllowlisted(rel)) {
4054
4770
  throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
4055
4771
  }
4056
4772
  if (!rel.startsWith(INSTALLER_OWNED_DIR)) {
4057
- throw new Error(`[halfcycle] 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.`);
4773
+ throw new Error(`[halfcycle] 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 (decideOwnedGenerated) rather than assumed from where it sits.`);
4058
4774
  }
4059
4775
  const current = readFileOrNull(targetAbsPath);
4060
4776
  const text = inLineEndingOf(current, content);
4061
4777
  if (current === text)
4062
- return "skipped";
4063
- mkdirSync4(dirname3(targetAbsPath), { recursive: true });
4064
- writeFileSync4(targetAbsPath, text, "utf-8");
4065
- return "written";
4778
+ return { rel, text, outcome: "skipped", replacedExisting: false };
4779
+ return { rel, text, outcome: "written", replacedExisting: existsSync4(targetAbsPath) };
4066
4780
  }
4067
4781
  var GENERATED_HEADER_LINE_INDEX = 1;
4068
- function writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker) {
4782
+ function decideOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker) {
4069
4783
  const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
4070
4784
  if (!isAllowlisted(rel)) {
4071
4785
  throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
4072
4786
  }
4073
- let replacedExisting = false;
4074
- if (existsSync3(targetAbsPath)) {
4075
- const current = readFileSync4(targetAbsPath, "utf-8");
4076
- if (current === content)
4077
- return { outcome: "skipped", replacedExisting: false };
4078
- const headerLine = current.split("\n")[GENERATED_HEADER_LINE_INDEX] ?? "";
4079
- if (!headerLine.startsWith(generatedMarker))
4080
- return { outcome: "collided", replacedExisting: false };
4081
- replacedExisting = true;
4787
+ if (!existsSync4(targetAbsPath))
4788
+ return { rel, text: content, outcome: "written", replacedExisting: false };
4789
+ const current = readFileSync5(targetAbsPath, "utf-8");
4790
+ if (current === content)
4791
+ return { rel, text: content, outcome: "skipped", replacedExisting: false };
4792
+ const headerLine = current.split("\n")[GENERATED_HEADER_LINE_INDEX] ?? "";
4793
+ if (!headerLine.startsWith(generatedMarker)) {
4794
+ return { rel, text: content, outcome: "collided", replacedExisting: false };
4082
4795
  }
4083
- mkdirSync4(dirname3(targetAbsPath), { recursive: true });
4084
- writeFileSync4(targetAbsPath, content, "utf-8");
4085
- return { outcome: "written", replacedExisting };
4796
+ return { rel, text: content, outcome: "written", replacedExisting: true };
4086
4797
  }
4087
- function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, generatedMarker, rel) {
4088
- const { outcome, replacedExisting } = writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker);
4089
- record(report, outcome, rel);
4090
- if (replacedExisting)
4091
- report.replacedPaths.push(rel);
4092
- return outcome;
4093
- }
4094
- function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
4095
- const existedBefore = existsSync3(targetAbsPath);
4096
- const outcome = writeOwned(targetAbsPath, targetRepoRoot, content);
4097
- record(report, outcome, rel);
4098
- if (outcome === "written" && existedBefore)
4099
- report.replacedPaths.push(rel);
4100
- return outcome;
4798
+ function recordOwnedGenerated(report, planned) {
4799
+ record(report, planned.outcome, planned.rel);
4800
+ if (planned.replacedExisting)
4801
+ report.replacedPaths.push(planned.rel);
4802
+ return planned.outcome;
4101
4803
  }
4102
4804
  function record(report, outcome, rel) {
4103
4805
  const bucket = {
@@ -4114,8 +4816,8 @@ var DENY_PATTERNS = [
4114
4816
  ];
4115
4817
  var USER_PROMPT_REMINDER_SENTENCE = "Where this project stands, and what order to work in, is recorded in the Halfcycle service and nowhere in this repository \u2014 call halfcycle_resolve before you answer or plan, because the files here will look complete and current and still be wrong about position.";
4116
4818
  function readExistingSettings(targetRepo) {
4117
- const settingsPath = join5(targetRepo, SETTINGS_REL);
4118
- return existsSync3(settingsPath) ? JSON.parse(readFileSync4(settingsPath, "utf-8")) : void 0;
4819
+ const settingsPath = join6(targetRepo, SETTINGS_REL);
4820
+ return existsSync4(settingsPath) ? JSON.parse(readFileSync5(settingsPath, "utf-8")) : void 0;
4119
4821
  }
4120
4822
  var SettingsParseRefused = class extends Error {
4121
4823
  constructor(message) {
@@ -4124,11 +4826,11 @@ var SettingsParseRefused = class extends Error {
4124
4826
  }
4125
4827
  };
4126
4828
  function settingsParseRefusal(targetRepo, err) {
4127
- const file = join5(targetRepo, SETTINGS_REL);
4829
+ const file = join6(targetRepo, SETTINGS_REL);
4128
4830
  const offset = /position (\d+)/.exec(err instanceof Error ? err.message : "")?.[1];
4129
4831
  let where = "";
4130
4832
  if (offset !== void 0) {
4131
- const before = readFileSync4(file, "utf-8").slice(0, Number(offset)).split("\n");
4833
+ const before = readFileSync5(file, "utf-8").slice(0, Number(offset)).split("\n");
4132
4834
  where = ` (line ${before.length}, column ${before[before.length - 1].length + 1})`;
4133
4835
  }
4134
4836
  return `${file} could not be read as JSON${where} \u2014 fix it and run "npx halfcycle" again.`;
@@ -4139,18 +4841,18 @@ var HALFCYCLE_STATUS_LINE = Object.freeze({
4139
4841
  command: 'bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/guard-runner.sh statusline'
4140
4842
  });
4141
4843
  function statusLineSetElsewhere(targetRepo, home = homedir2(), env = process.env) {
4142
- const places = [join5(targetRepo, ".claude", "settings.local.json"), join5(home, ".claude", "settings.json")];
4844
+ const places = [join6(targetRepo, ".claude", "settings.local.json"), join6(home, ".claude", "settings.json")];
4143
4845
  const configDir = env["CLAUDE_CONFIG_DIR"];
4144
4846
  if (configDir !== void 0 && configDir !== "") {
4145
- const expanded = configDir === "~" ? home : configDir.startsWith("~/") ? join5(home, configDir.slice(2)) : configDir;
4146
- places.push(join5(isAbsolute2(expanded) ? expanded : resolve(expanded), "settings.json"));
4847
+ const expanded = configDir === "~" ? home : configDir.startsWith("~/") ? join6(home, configDir.slice(2)) : configDir;
4848
+ places.push(join6(isAbsolute2(expanded) ? expanded : resolve(expanded), "settings.json"));
4147
4849
  }
4148
4850
  return places.some((path) => {
4149
- if (!existsSync3(path))
4851
+ if (!existsSync4(path))
4150
4852
  return false;
4151
4853
  let value;
4152
4854
  try {
4153
- value = JSON.parse(readFileSync4(path, "utf-8"));
4855
+ value = JSON.parse(readFileSync5(path, "utf-8"));
4154
4856
  } catch {
4155
4857
  return true;
4156
4858
  }
@@ -4387,6 +5089,10 @@ ${GENERATED_SESSION_END_HEADER} the Halfcycle installer.
4387
5089
  # written at session start. Deletion happens HERE and not at stop time: the stop
4388
5090
  # hook runs at the end of every turn, and deleting the marker there would throw
4389
5091
  # away the session's starting point for every later turn.
5092
+ #
5093
+ # Also removes the two small files this session may have left behind for its
5094
+ # checks to hand findings across turns, both named by this session's id alone,
5095
+ # with no fallback name to remove when there is none.
4390
5096
  INPUT="$(cat 2>/dev/null || true)"
4391
5097
 
4392
5098
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
@@ -4407,6 +5113,8 @@ rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}.eval" 2>/dev/null || true
4407
5113
  if [ -n "$SESSION_ID" ]; then
4408
5114
  rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.ref" 2>/dev/null || true
4409
5115
  rm -f "\${MARKER_DIR}/halfcycle-session-\${HASH}-\${SESSION_ID}.eval" 2>/dev/null || true
5116
+ rm -f "\${MARKER_DIR}/halfcycle-findings-\${SESSION_ID}.json" 2>/dev/null || true
5117
+ rm -f "\${MARKER_DIR}/halfcycle-own-\${SESSION_ID}.jsonl" 2>/dev/null || true
4410
5118
  fi
4411
5119
  exit 0
4412
5120
  `;
@@ -4466,19 +5174,20 @@ function generateUserPromptScopeHook() {
4466
5174
  return `#!/usr/bin/env bash
4467
5175
  ${GENERATED_USER_PROMPT_SCOPE_HEADER} the Halfcycle installer, now also for the close review.
4468
5176
  #
4469
- # Lets the owner of this project approve a phase's scope, or send it back, and
4470
- # decide one part of a phase's close review, by typing one line as the whole
4471
- # prompt in their own Claude Code session: "approve <phase>", "send back
4472
- # <phase>: <note>", "accept <phase>/<part>", "question <phase>/<part>: <words>",
4473
- # "carry <phase>/<part>: <words>" or "drop <phase>/<part>: <words>".
5177
+ # Lets the owner of this project approve a phase's scope, or send it back,
5178
+ # decide one part of a phase's close review, or turn builds running on their
5179
+ # own on or off, by typing one line as the whole prompt in their own Claude
5180
+ # Code session: "approve <phase>", "send back <phase>: <note>", "accept
5181
+ # <phase>/<part>", "question <phase>/<part>: <words>", "carry <phase>/<part>:
5182
+ # <words>", "drop <phase>/<part>: <words>", "autonomy on" or "autonomy off".
4474
5183
  #
4475
5184
  # AN ORDINARY PROMPT STARTS NOTHING. This script reads the hook input once and
4476
5185
  # looks only at the start of the prompt's value. Unless the prompt begins, after
4477
5186
  # any whitespace (typed, or escaped in the input), with "approve ", "send back ",
4478
- # "accept ", "question ", "carry " or "drop ", in any case, it exits 0 with no
4479
- # output, and Node never starts. A quote inside any other field arrives escaped,
4480
- # so no other field (the folder the session is in, for one) can read as the
4481
- # start of the prompt.
5187
+ # "accept ", "question ", "carry ", "drop " or "autonomy ", in any case, it exits
5188
+ # 0 with no output, and Node never starts. A quote inside any other field arrives
5189
+ # escaped, so no other field (the folder the session is in, for one) can read as
5190
+ # the start of the prompt.
4482
5191
  #
4483
5192
  # Otherwise it finds the project root from its own location, never from the
4484
5193
  # folder the session has moved to, and hands the same input to the guard
@@ -4499,7 +5208,7 @@ IFS= read -r -d '' HC_INPUT || true
4499
5208
  # \\u escape, or as the UTF-8 bytes of a Unicode space. Compared byte for byte.
4500
5209
  HC_LEAD='[[:space:]]|\\\\[nrtf]|\\\\u(000[9a-d]|0020|00a0|1680|200[0-9a]|202[89f]|205f|3000|feff)'
4501
5210
  HC_LEAD="$HC_LEAD|"$'\\xc2\\xa0|\\xe1\\x9a\\x80|\\xe2\\x80[\\x80-\\x8a\\xa8\\xa9\\xaf]|\\xe2\\x81\\x9f|\\xe3\\x80\\x80|\\xef\\xbb\\xbf'
4502
- if ! printf '%s' "$HC_INPUT" | LC_ALL=C grep -Eiq "\\"prompt\\"[[:space:]]*:[[:space:]]*\\"($HC_LEAD)*(approve|send back|accept|question|carry|drop) "; then
5211
+ if ! printf '%s' "$HC_INPUT" | LC_ALL=C grep -Eiq "\\"prompt\\"[[:space:]]*:[[:space:]]*\\"($HC_LEAD)*(approve|send back|accept|question|carry|drop|autonomy) "; then
4503
5212
  exit 0
4504
5213
  fi
4505
5214
 
@@ -4512,6 +5221,52 @@ set +e
4512
5221
  exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-submit <<<"$HC_INPUT"
4513
5222
  `;
4514
5223
  }
5224
+ var GENERATED_USER_PROMPT_FINDINGS_HEADER = "# Findings hand-over \u2014 generated by";
5225
+ function generateUserPromptFindingsHook() {
5226
+ return `#!/usr/bin/env bash
5227
+ ${GENERATED_USER_PROMPT_FINDINGS_HEADER} the Halfcycle installer.
5228
+ #
5229
+ # At the person's next message, before the agent starts, hands it the details of
5230
+ # whatever Halfcycle's checks found at an earlier turn end, once.
5231
+ #
5232
+ # AN ORDINARY PROMPT STARTS NOTHING. With no findings file for this session, this
5233
+ # script reads its session id with a bash regex (no node) and exits 0 with no
5234
+ # output once it finds no file \u2014 node and grep never start.
5235
+ trap 'exit 0' ERR
5236
+ set -e
5237
+
5238
+ HC_INPUT=""
5239
+ IFS= read -r -d '' HC_INPUT || true
5240
+
5241
+ # The session id, read with a bash regex held in a variable (a quoted operand to
5242
+ # =~ is read as a literal string, not a pattern). An id holding anything outside
5243
+ # [A-Za-z0-9-] cannot close the match and leaves this empty, which is read below
5244
+ # exactly like no session id at all.
5245
+ HC_SESSION_RE='"session_id"[[:space:]]*:[[:space:]]*"([A-Za-z0-9-]+)"'
5246
+ HC_SESSION_ID=""
5247
+ if [[ "$HC_INPUT" =~ $HC_SESSION_RE ]]; then
5248
+ HC_SESSION_ID="\${BASH_REMATCH[1]}"
5249
+ fi
5250
+ if [ -z "$HC_SESSION_ID" ]; then
5251
+ exit 0
5252
+ fi
5253
+
5254
+ HC_FINDINGS_DIR="\${TMPDIR:-/tmp}"
5255
+ HC_FINDINGS_DIR="\${HC_FINDINGS_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
5256
+ HC_FINDINGS_FILE="\${HC_FINDINGS_DIR}/halfcycle-findings-\${HC_SESSION_ID}.json"
5257
+ if [ ! -f "$HC_FINDINGS_FILE" ]; then
5258
+ exit 0
5259
+ fi
5260
+
5261
+ HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5262
+ HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
5263
+ export CLAUDE_PROJECT_DIR="$HC_PROJECT_ROOT"
5264
+
5265
+ trap - ERR
5266
+ set +e
5267
+ exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-findings <<<"$HC_INPUT"
5268
+ `;
5269
+ }
4515
5270
  var CI_STANZA_KNOWN_HEADER_HASHES = [
4516
5271
  "b225800b2d699811f32ac213238cbf81c35b5e6a74114bdf55e39cd9d7e2928f",
4517
5272
  "674181735460f051af7c157222cf851fc3000fb3b8af3a10ea0bde9fe5eaba1f",
@@ -4537,16 +5292,13 @@ function isTrackedAndClean(targetRepo, relPath) {
4537
5292
  }
4538
5293
  return true;
4539
5294
  }
4540
- function removeLegacyCiStanza(targetRepo) {
4541
- return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
4542
- }
4543
- function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
4544
- const path = join5(targetRepo, rel);
4545
- if (!existsSync3(path))
5295
+ function decideRetiredRemoval(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
5296
+ const path = join6(targetRepo, rel);
5297
+ if (!existsSync4(path))
4546
5298
  return "absent";
4547
5299
  let raw;
4548
5300
  try {
4549
- raw = readFileSync4(path, "utf-8");
5301
+ raw = readFileSync5(path, "utf-8");
4550
5302
  } catch {
4551
5303
  return "failed";
4552
5304
  }
@@ -4556,13 +5308,20 @@ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeade
4556
5308
  return "kept-foreign";
4557
5309
  if (!isTrackedAndClean(targetRepo, rel))
4558
5310
  return "kept-uncommitted";
5311
+ return "removed";
5312
+ }
5313
+ function removePlannedRetiredFile(targetRepo, rel) {
4559
5314
  try {
4560
- rmSync2(path);
5315
+ rmSync2(join6(targetRepo, rel));
4561
5316
  } catch {
4562
5317
  return "failed";
4563
5318
  }
4564
5319
  return "removed";
4565
5320
  }
5321
+ var LEGACY_CI_STANZA_REL = ".halfcycle/ci-stanza.yml";
5322
+ function planLegacyCiStanzaRemoval(targetRepo) {
5323
+ return decideRetiredRemoval(targetRepo, LEGACY_CI_STANZA_REL, 0, CI_STANZA_KNOWN_HEADER_HASHES);
5324
+ }
4566
5325
  var MCP_HEADERS_HELPER_COMMAND = `node -e "${nodeFloorProgram([
4567
5326
  `const p=require('path'),f=require('fs'),c=require('child_process');`,
4568
5327
  `let d=process.cwd();`,
@@ -4581,13 +5340,14 @@ var MCP_HEADERS_KNOWN_HEADER_HASHES = [
4581
5340
  "37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
4582
5341
  "6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
4583
5342
  ];
4584
- function removeRetiredMcpHeadersHelper(targetRepo) {
4585
- return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
5343
+ function planMcpHeadersHelperRemoval(targetRepo) {
5344
+ return decideRetiredRemoval(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
4586
5345
  }
4587
5346
  var OWNED_GENERATED_HEADERS = {
4588
5347
  ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
4589
5348
  ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
4590
5349
  ".claude/hooks/user-prompt-scope.sh": GENERATED_USER_PROMPT_SCOPE_HEADER,
5350
+ ".claude/hooks/user-prompt-findings.sh": GENERATED_USER_PROMPT_FINDINGS_HEADER,
4591
5351
  ".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
4592
5352
  ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
4593
5353
  };
@@ -4597,6 +5357,7 @@ function hookScriptsThisVersionWrites() {
4597
5357
  [".claude/hooks/session-end-marker.sh", generateSessionEndMarker()],
4598
5358
  [".claude/hooks/user-prompt-reminder.sh", generateUserPromptReminderHook()],
4599
5359
  [".claude/hooks/user-prompt-scope.sh", generateUserPromptScopeHook()],
5360
+ [".claude/hooks/user-prompt-findings.sh", generateUserPromptFindingsHook()],
4600
5361
  [".claude/hooks/session-start-marker.sh", generateSessionStartMarker()]
4601
5362
  ];
4602
5363
  }
@@ -4650,23 +5411,26 @@ function gitignoreCovers(content) {
4650
5411
  const lines = new Set(content.split("\n").map((l) => l.trim()));
4651
5412
  return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
4652
5413
  }
4653
- function reconcileGitignore(targetRepo) {
4654
- const gitignorePath = join5(targetRepo, GITIGNORE_REL);
5414
+ function planGitignore(targetRepo) {
5415
+ const gitignorePath = join6(targetRepo, GITIGNORE_REL);
4655
5416
  const header = GITIGNORE_HEADER;
5417
+ const before = existsSync4(gitignorePath) ? readFileSync5(gitignorePath, "utf-8") : null;
5418
+ if (before === null) {
5419
+ return { before, after: [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n", outcome: "written" };
5420
+ }
5421
+ if (gitignoreCovers(before))
5422
+ return { before, after: before, outcome: "skipped" };
5423
+ const existingLines = new Set(before.split("\n").map((l) => l.trim()));
5424
+ const toAdd = REQUIRED_GITIGNORE_ENTRIES.filter((entry) => !existingLines.has(entry));
5425
+ const eol = lineEndingOf(before);
5426
+ const suffix = (before.endsWith("\n") ? "" : eol) + eol + [header, ...toAdd].join(eol) + eol;
5427
+ return { before, after: before + suffix, outcome: "written" };
5428
+ }
5429
+ function putPlannedGitignore(targetRepo, planned) {
5430
+ if (planned.outcome !== "written" || planned.after === null)
5431
+ return planned.outcome;
4656
5432
  try {
4657
- if (!existsSync3(gitignorePath)) {
4658
- const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
4659
- writeFileSync4(gitignorePath, body, "utf-8");
4660
- return "written";
4661
- }
4662
- const existing = readFileSync4(gitignorePath, "utf-8");
4663
- if (gitignoreCovers(existing))
4664
- return "skipped";
4665
- const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
4666
- const toAdd = REQUIRED_GITIGNORE_ENTRIES.filter((entry) => !existingLines.has(entry));
4667
- const eol = lineEndingOf(existing);
4668
- const suffix = (existing.endsWith("\n") ? "" : eol) + eol + [header, ...toAdd].join(eol) + eol;
4669
- writeFileSync4(gitignorePath, existing + suffix, "utf-8");
5433
+ writeFileSync4(join6(targetRepo, GITIGNORE_REL), planned.after, "utf-8");
4670
5434
  return "written";
4671
5435
  } catch {
4672
5436
  return "failed";
@@ -4675,7 +5439,7 @@ function reconcileGitignore(targetRepo) {
4675
5439
  var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
4676
5440
  function readTextOrNull(path) {
4677
5441
  try {
4678
- return readFileSync4(path, "utf-8");
5442
+ return readFileSync5(path, "utf-8");
4679
5443
  } catch {
4680
5444
  return null;
4681
5445
  }
@@ -4684,26 +5448,31 @@ function lfRules(paths) {
4684
5448
  return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
4685
5449
  }
4686
5450
  var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
4687
- function reconcileGitattributes(targetRepo) {
4688
- const path = join5(targetRepo, GITATTRIBUTES_REL);
5451
+ function planGitattributes(targetRepo) {
5452
+ const path = join6(targetRepo, GITATTRIBUTES_REL);
4689
5453
  const rules = REQUIRED_GITATTRIBUTES_RULES;
4690
- const routed = [];
5454
+ const before = readTextOrNull(path);
5455
+ if (lstatOrNull(path)?.isSymbolicLink() === true)
5456
+ return { before, after: before, outcome: "failed" };
5457
+ if (!existsSync4(path)) {
5458
+ return { before, after: [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", outcome: "written" };
5459
+ }
5460
+ if (before === null)
5461
+ return { before, after: null, outcome: "failed" };
5462
+ const present = new Set(before.split("\n").map((l) => l.trim()));
5463
+ const toAdd = rules.filter((rule) => !present.has(rule));
5464
+ if (toAdd.length === 0)
5465
+ return { before, after: before, outcome: "skipped" };
5466
+ const eol = lineEndingOf(before);
5467
+ const separator = before === "" ? "" : before.endsWith("\n") ? eol : eol + eol;
5468
+ const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join(eol) + eol;
5469
+ return { before, after: before + suffix, outcome: "written" };
5470
+ }
5471
+ function putPlannedGitattributes(targetRepo, planned) {
5472
+ if (planned.outcome !== "written" || planned.after === null)
5473
+ return planned.outcome;
4691
5474
  try {
4692
- if (lstatOrNull(path)?.isSymbolicLink() === true)
4693
- return "failed";
4694
- if (!existsSync3(path)) {
4695
- writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
4696
- return "written";
4697
- }
4698
- const existing = readFileSync4(path, "utf-8");
4699
- const present = new Set(existing.split("\n").map((l) => l.trim()));
4700
- const toAdd = rules.filter((rule) => !present.has(rule));
4701
- if (toAdd.length === 0)
4702
- return "skipped";
4703
- const eol = lineEndingOf(existing);
4704
- const separator = existing === "" ? "" : existing.endsWith("\n") ? eol : eol + eol;
4705
- const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join(eol) + eol;
4706
- writeAllowlisted(path, targetRepo, existing + suffix, routed);
5475
+ writeAllowlisted(join6(targetRepo, GITATTRIBUTES_REL), targetRepo, planned.after, []);
4707
5476
  return "written";
4708
5477
  } catch {
4709
5478
  return "failed";
@@ -4722,7 +5491,7 @@ function previousPinForEngagement(targetRepoRoot, engagementId) {
4722
5491
  function nonEmpty(value) {
4723
5492
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
4724
5493
  }
4725
- function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, createdByAccountId, methodVersion, writtenPaths) {
5494
+ function planBundlePin(targetRepoRoot, version, engagementId, engagementType, createdByAccountId, methodVersion) {
4726
5495
  const previous = previousPinForEngagement(targetRepoRoot, engagementId);
4727
5496
  const carried = pinCreatedByAccountId(previous) ?? nonEmpty(createdByAccountId);
4728
5497
  const carriedMethodVersion = nonEmpty(methodVersion) ?? nonEmpty(previous?.methodVersion);
@@ -4737,9 +5506,39 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, c
4737
5506
  ...carriedMethodVersion !== void 0 ? { methodVersion: carriedMethodVersion } : {},
4738
5507
  ...projectPage !== void 0 ? { projectPage } : {}
4739
5508
  };
4740
- const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
4741
- const pinText = inLineEndingOf(readFileOrNull(pinPath), JSON.stringify(pin, null, 2) + "\n");
4742
- writeAllowlisted(pinPath, targetRepoRoot, pinText, writtenPaths);
5509
+ const current = readFileOrNull(join6(targetRepoRoot, BUNDLE_PIN_REL));
5510
+ const text = inLineEndingOf(current, JSON.stringify(pin, null, 2) + "\n");
5511
+ return {
5512
+ rel: BUNDLE_PIN_REL,
5513
+ text,
5514
+ outcome: current === text ? "skipped" : "written",
5515
+ replacedExisting: current !== null && current !== text
5516
+ };
5517
+ }
5518
+ function planBundlePinPreview(targetRepoRoot, version, home) {
5519
+ let existing;
5520
+ try {
5521
+ existing = readBundlePin(targetRepoRoot);
5522
+ } catch {
5523
+ return null;
5524
+ }
5525
+ if (existing === null || nonEmpty(existing.engagementId) === void 0 || existing.engagementType === void 0) {
5526
+ return null;
5527
+ }
5528
+ let pinned;
5529
+ try {
5530
+ pinned = readPinnedEngagement(targetRepoRoot, home);
5531
+ } catch {
5532
+ return null;
5533
+ }
5534
+ if (pinned === null || pinned.engagementId !== existing.engagementId || pinned.credential === void 0) {
5535
+ return null;
5536
+ }
5537
+ return planBundlePin(targetRepoRoot, version, existing.engagementId, existing.engagementType, void 0, void 0);
5538
+ }
5539
+ function writeBundlePin(targetRepoRoot, planned, writtenPaths) {
5540
+ const pinPath = join6(targetRepoRoot, BUNDLE_PIN_REL);
5541
+ writeAllowlisted(pinPath, targetRepoRoot, planned.text, writtenPaths);
4743
5542
  }
4744
5543
  function renderCrewRoster() {
4745
5544
  const doc = {
@@ -4749,19 +5548,40 @@ function renderCrewRoster() {
4749
5548
  return `${JSON.stringify(doc, null, 2)}
4750
5549
  `;
4751
5550
  }
4752
- function writeCrewRoster(targetRepoRoot, report) {
4753
- const rendered = renderCrewRoster();
4754
- const crewPath = join5(targetRepoRoot, CREW_ROSTER_REL);
4755
- return recordOwned(report, crewPath, targetRepoRoot, rendered, CREW_ROSTER_REL);
4756
- }
4757
5551
  function pinCreatedByAccountId(pin) {
4758
5552
  return nonEmpty(pin?.createdByAccountId) ?? nonEmpty(pin?.accountId);
4759
5553
  }
4760
5554
  function readBundlePin(targetRepoRoot) {
4761
- const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
4762
- if (!existsSync3(pinPath))
5555
+ const pinPath = join6(targetRepoRoot, BUNDLE_PIN_REL);
5556
+ if (!existsSync4(pinPath))
5557
+ return null;
5558
+ return JSON.parse(readFileSync5(pinPath, "utf-8"));
5559
+ }
5560
+ function readPinnedEngagement(targetRepoRoot, home) {
5561
+ const pin = readBundlePin(targetRepoRoot);
5562
+ const engagementId = pin?.engagementId;
5563
+ if (engagementId === void 0 || engagementId === "")
5564
+ return null;
5565
+ assertEngagementId(engagementId);
5566
+ const stored = readEngagementEnv(engagementId, home);
5567
+ const fromStore = stored === null ? void 0 : toCredential(stored);
5568
+ if (fromStore !== void 0) {
5569
+ return { engagementId, credential: fromStore, fromLegacyEnvLocal: false };
5570
+ }
5571
+ const legacy = readLegacyEnvLocal(targetRepoRoot);
5572
+ const fromLegacy = legacy === null ? void 0 : toCredential(legacy);
5573
+ return {
5574
+ engagementId,
5575
+ credential: fromLegacy,
5576
+ fromLegacyEnvLocal: fromLegacy !== void 0
5577
+ };
5578
+ }
5579
+ function readLegacyEnvLocal(targetRepoRoot) {
5580
+ try {
5581
+ return parseEnvText(readFileSync5(join6(targetRepoRoot, LEGACY_ENV_LOCAL_REL), "utf-8"));
5582
+ } catch {
4763
5583
  return null;
4764
- return JSON.parse(readFileSync4(pinPath, "utf-8"));
5584
+ }
4765
5585
  }
4766
5586
  var CREDENTIAL_REQUIRED_KEYS = [
4767
5587
  "HALFCYCLE_SERVICE_URL",
@@ -4774,6 +5594,18 @@ function missingRequiredKeys(found) {
4774
5594
  const resolved = { ...found, HALFCYCLE_SERVICE_URL: serviceUrl };
4775
5595
  return CREDENTIAL_REQUIRED_KEYS.filter((key) => (resolved[key] ?? "") === "");
4776
5596
  }
5597
+ function toCredential(found) {
5598
+ if (missingRequiredKeys(found).length > 0)
5599
+ return void 0;
5600
+ const controlTelemetryUrl = found["CONTROL_TELEMETRY_URL"];
5601
+ return {
5602
+ serviceUrl: (process.env["HALFCYCLE_SERVICE_URL"]?.trim() ?? "") || (found["HALFCYCLE_SERVICE_URL"] ?? ""),
5603
+ token: found["HALFCYCLE_TOKEN"] ?? "",
5604
+ mcpUrl: found["HALFCYCLE_MCP_URL"] ?? "",
5605
+ guardUrl: found["GUARD_SERVICE_URL"] ?? "",
5606
+ ...controlTelemetryUrl ? { controlTelemetryUrl } : {}
5607
+ };
5608
+ }
4777
5609
  function checkDrift(targetRepoRoot) {
4778
5610
  const manifest = readPluginManifest();
4779
5611
  const pin = readBundlePin(targetRepoRoot);
@@ -4781,15 +5613,32 @@ function checkDrift(targetRepoRoot) {
4781
5613
  const current = manifest.version;
4782
5614
  return { drifted: installed !== current, installed, current, methodVersion: pin?.methodVersion ?? null };
4783
5615
  }
4784
- function writeProjectIdentity(targetRepo) {
4785
- if (existsSync3(join5(targetRepo, PROJECT_IDENTITY_REL))) {
4786
- repairRemoteCredential(targetRepo);
4787
- return "skipped";
5616
+ function writeProjectIdentity(targetRepo, planned, probe = false) {
5617
+ if (planned.action === "add" || probe) {
5618
+ createProjectIdentityFile(targetRepo, planned.after ?? void 0);
4788
5619
  }
4789
- createProjectIdentityFile(targetRepo);
4790
- return "written";
5620
+ if (planned.action === "change" && planned.after !== null)
5621
+ replaceProjectIdentityFile(targetRepo, planned.after);
5622
+ return planned.action === "add" ? "written" : "skipped";
4791
5623
  }
4792
5624
  function writeEngagementCredential(credential, engagementId, home) {
5625
+ return writeEngagementEnv(engagementId, engagementCredentialValues(credential, engagementId), home);
5626
+ }
5627
+ function planEngagementCredential(credential, engagementId, home) {
5628
+ let existing;
5629
+ try {
5630
+ existing = readFileSync5(engagementEnvPath(engagementId, home), "utf-8");
5631
+ } catch {
5632
+ existing = null;
5633
+ }
5634
+ const reconciled = reconcileEnvText2(existing, engagementCredentialValues(credential, engagementId));
5635
+ return {
5636
+ outcome: existing === reconciled ? "skipped" : "written",
5637
+ existed: existing !== null,
5638
+ storedAfter: parseEnvText(reconciled)
5639
+ };
5640
+ }
5641
+ function engagementCredentialValues(credential, engagementId) {
4793
5642
  const values = {
4794
5643
  HALFCYCLE_SERVICE_URL: credential.serviceUrl,
4795
5644
  // T-22 — the second origin, persisted for the developer and for any tool that
@@ -4817,16 +5666,16 @@ function writeEngagementCredential(credential, engagementId, home) {
4817
5666
  // telemetry origin, same as every other optional field on `DeviceCredential`.
4818
5667
  CONTROL_TELEMETRY_URL: credential.controlTelemetryUrl ?? ""
4819
5668
  };
4820
- return writeEngagementEnv(engagementId, values, home);
5669
+ return values;
4821
5670
  }
4822
5671
  var LEGACY_ENV_LOCAL_REL = ".env.local";
4823
5672
  function migrateLegacyEnvLocal(targetRepo, stored) {
4824
- const path = join5(targetRepo, LEGACY_ENV_LOCAL_REL);
4825
- if (!existsSync3(path))
5673
+ const path = join6(targetRepo, LEGACY_ENV_LOCAL_REL);
5674
+ if (!existsSync4(path))
4826
5675
  return { outcome: "absent", unbacked: [], missingForAdoption: [] };
4827
5676
  let raw;
4828
5677
  try {
4829
- raw = readFileSync4(path, "utf-8");
5678
+ raw = readFileSync5(path, "utf-8");
4830
5679
  } catch {
4831
5680
  return { outcome: "failed", unbacked: [], missingForAdoption: [] };
4832
5681
  }
@@ -4847,24 +5696,91 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
4847
5696
  return !ours.has(line.slice(0, eq).replace(/^\s*export\s+/, "").trim());
4848
5697
  });
4849
5698
  const substantive = kept.filter((line) => line.trim() !== "" && !line.trim().startsWith("# Halfcycle"));
5699
+ if (substantive.length === 0)
5700
+ return { outcome: "removed", unbacked: [], missingForAdoption: [] };
5701
+ return { outcome: "stripped", unbacked: [], missingForAdoption: [], keptText: kept.join("\n") };
5702
+ }
5703
+ function applyLegacyMigration(targetRepo, decided) {
5704
+ const path = join6(targetRepo, LEGACY_ENV_LOCAL_REL);
4850
5705
  try {
4851
- if (substantive.length === 0) {
5706
+ if (decided.outcome === "removed") {
4852
5707
  rmSync2(path);
4853
- return { outcome: "removed", unbacked: [], missingForAdoption: [] };
5708
+ return decided;
5709
+ }
5710
+ if (decided.outcome === "stripped" && decided.keptText !== void 0) {
5711
+ writeFileSync4(path, decided.keptText, "utf-8");
5712
+ return decided;
4854
5713
  }
4855
- writeFileSync4(path, kept.join("\n"), "utf-8");
4856
- return { outcome: "stripped", unbacked: [], missingForAdoption: [] };
5714
+ return decided;
4857
5715
  } catch {
4858
5716
  return { outcome: "failed", unbacked: [], missingForAdoption: [] };
4859
5717
  }
4860
5718
  }
4861
5719
  async function install(options) {
4862
- const { targetRepo, engagementId, engagementType, credential, home, createdByAccountId } = options;
4863
- assertEngagementId(engagementId);
4864
- if (!existsSync3(targetRepo)) {
4865
- throw new Error(`[halfcycle] Target repo does not exist: ${targetRepo}`);
4866
- }
5720
+ return applyInstallPlan(planInstall(options));
5721
+ }
5722
+ function planCommandStubs(targetRepo, previous, writeSet) {
4867
5723
  const manifest = readPluginManifest();
5724
+ return { version: manifest.version, commands: planManifestCommands(manifest, targetRepo, previous, writeSet) };
5725
+ }
5726
+ function planVendoredBinary(targetRepo) {
5727
+ return decideOwned(join6(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs"), targetRepo, readFileSync5(resolveVendoredBinary(), "utf-8"));
5728
+ }
5729
+ function planSettings(targetRepo, previous, home, env) {
5730
+ const settingsPath = join6(targetRepo, SETTINGS_REL);
5731
+ const preexisted = existsSync4(settingsPath);
5732
+ const generated = JSON.parse(generateSettingsJson());
5733
+ let existing;
5734
+ try {
5735
+ existing = readExistingSettings(targetRepo) ?? {};
5736
+ } catch (err) {
5737
+ throw new SettingsParseRefused(settingsParseRefusal(targetRepo, err));
5738
+ }
5739
+ const retiredOurs = retiredRulesThisInstallAdded(previous);
5740
+ const statusLine = statusLineSetElsewhere(targetRepo, home, env) ? void 0 : HALFCYCLE_STATUS_LINE;
5741
+ const merged = mergeSettings(existing, generated, retiredOurs, statusLine);
5742
+ const current = readFileOrNull(settingsPath);
5743
+ const text = inLineEndingOf(current, JSON.stringify(merged, null, 2) + "\n");
5744
+ const file = {
5745
+ rel: SETTINGS_REL,
5746
+ text,
5747
+ // Identical bytes are not rewritten; the report still names the file as merged or
5748
+ // written by whether it was there, as it always has.
5749
+ outcome: current === text ? "skipped" : "written",
5750
+ replacedExisting: false
5751
+ };
5752
+ return { file, preexisted, existing, generated, merged, retiredOurs };
5753
+ }
5754
+ function planHookScripts(targetRepo) {
5755
+ return hookScriptsThisVersionWrites().map(([rel, content]) => decideOwnedGenerated(join6(targetRepo, rel), targetRepo, content, OWNED_GENERATED_HEADERS[rel]));
5756
+ }
5757
+ function planMcpRegistration(targetRepo, credential) {
5758
+ const mcpPath = join6(targetRepo, MCP_REGISTRATION_REL);
5759
+ const existing = existsSync4(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
5760
+ if (credential === void 0)
5761
+ return { kind: "none", existing };
5762
+ if (credential === "pending")
5763
+ return { kind: "pending", existing };
5764
+ const text = inLineEndingOf(existing, generateMcpRegistration(existing, credential.mcpUrl));
5765
+ const outcome = existing === null ? "written" : existing !== text ? "merged" : "skipped";
5766
+ return { kind: "write", existing, text, outcome };
5767
+ }
5768
+ function putPlannedMcp(targetRepo, planned) {
5769
+ if (planned.kind !== "write" || planned.outcome === "skipped")
5770
+ return;
5771
+ writeAllowlisted(join6(targetRepo, MCP_REGISTRATION_REL), targetRepo, planned.text, []);
5772
+ }
5773
+ function planCrewRoster(targetRepo) {
5774
+ return decideOwned(join6(targetRepo, CREW_ROSTER_REL), targetRepo, renderCrewRoster());
5775
+ }
5776
+ function planInstallRecord(targetRepo, text) {
5777
+ return decideOwned(join6(targetRepo, INSTALL_MANIFEST_REL), targetRepo, text);
5778
+ }
5779
+ function accountInstall(steps, actual) {
5780
+ const { targetRepo, writeSet, pin } = steps;
5781
+ if (pin === null || steps.mcp.kind === "pending" || steps.engagementId === void 0) {
5782
+ throw new Error("[halfcycle] a plan still waiting for the service cannot be applied or recorded.");
5783
+ }
4868
5784
  const report = {
4869
5785
  writtenPaths: [],
4870
5786
  skippedPaths: [],
@@ -4873,38 +5789,22 @@ async function install(options) {
4873
5789
  editedCollidedPaths: [],
4874
5790
  replacedPaths: []
4875
5791
  };
4876
- const writeSet = closedWriteSet();
4877
- const draft = startManifest(readPreviousManifest(targetRepo), (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
4878
- const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
4879
- copyManifestCommands(manifest, targetRepo, report, draft, writeSet);
4880
- const vendoredBinSrc = resolveVendoredBinary();
4881
- const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
4882
- const vendoredOutcome = recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"), VENDORED_BIN_REL);
4883
- noteFile(draft, targetRepo, VENDORED_BIN_REL, vendoredOutcome);
4884
- const settingsPath = join5(targetRepo, SETTINGS_REL);
4885
- const settingsPreexisted = existsSync3(settingsPath);
4886
- const generatedSettings = JSON.parse(generateSettingsJson());
4887
- let existingSettings;
4888
- try {
4889
- existingSettings = readExistingSettings(targetRepo) ?? {};
4890
- } catch (err) {
4891
- throw new SettingsParseRefused(settingsParseRefusal(targetRepo, err));
4892
- }
4893
- const retiredOurs = retiredRulesThisInstallAdded(draft.previous);
4894
- const statusLine = statusLineSetElsewhere(targetRepo, home, options.env) ? void 0 : HALFCYCLE_STATUS_LINE;
4895
- const mergedSettings = mergeSettings(existingSettings, generatedSettings, retiredOurs, statusLine);
4896
- recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted, mergedSettings);
4897
- recordDenyRetired(draft, retiredOurs.map((rule) => sha256Hex(rule)));
4898
- const mergedSettingsText = inLineEndingOf(readFileOrNull(settingsPath), JSON.stringify(mergedSettings, null, 2) + "\n");
4899
- mkdirSync4(dirname3(settingsPath), { recursive: true });
4900
- writeFileSync4(settingsPath, mergedSettingsText, "utf-8");
4901
- (settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
4902
- for (const [rel, content] of hookScriptsThisVersionWrites()) {
4903
- const hookPath = join5(targetRepo, rel);
4904
- const outcome = recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
4905
- noteFile(draft, targetRepo, rel, outcome);
4906
- }
4907
- switch (removeLegacyCiStanza(targetRepo)) {
5792
+ const draft = startManifest(steps.previous, (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
5793
+ for (const command of steps.commands) {
5794
+ recordOwnedGenerated(report, command);
5795
+ if (command.editedCollided)
5796
+ report.editedCollidedPaths.push(command.rel);
5797
+ noteFile(draft, command.rel, command.outcome, actual.bytesAfter);
5798
+ }
5799
+ noteFile(draft, VENDORED_BIN_REL, recordOwnedGenerated(report, steps.vendored), actual.bytesAfter);
5800
+ const settings = steps.settings;
5801
+ recordSettingsMerge(draft, settings.existing, settings.generated, settings.preexisted, settings.merged);
5802
+ recordDenyRetired(draft, settings.retiredOurs.map((rule) => sha256Hex(rule)));
5803
+ (settings.preexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
5804
+ for (const hook of steps.hooks) {
5805
+ noteFile(draft, hook.rel, recordOwnedGenerated(report, hook), actual.bytesAfter);
5806
+ }
5807
+ switch (actual.ciStanza) {
4908
5808
  case "removed":
4909
5809
  report.writtenPaths.push(".halfcycle/ci-stanza.yml (REMOVED \u2014 Halfcycle no longer generates this file)");
4910
5810
  break;
@@ -4920,20 +5820,17 @@ async function install(options) {
4920
5820
  case "absent":
4921
5821
  break;
4922
5822
  }
4923
- if (credential) {
4924
- const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
4925
- const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
4926
- const mcpContent = inLineEndingOf(existingMcp, generateMcpRegistration(existingMcp, credential.mcpUrl));
4927
- recordMcpMerge(draft, existingMcp, mcpContent);
4928
- if (existingMcp === null) {
4929
- writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
4930
- } else if (existingMcp !== mcpContent) {
4931
- writeAllowlisted(mcpPath, targetRepo, mcpContent, report.mergedPaths);
4932
- } else {
5823
+ const mcp = steps.mcp;
5824
+ if (mcp.kind === "write") {
5825
+ recordMcpMerge(draft, mcp.existing, mcp.text);
5826
+ if (mcp.outcome === "written")
5827
+ report.writtenPaths.push(MCP_REGISTRATION_REL);
5828
+ else if (mcp.outcome === "merged")
5829
+ report.mergedPaths.push(MCP_REGISTRATION_REL);
5830
+ else
4933
5831
  report.skippedPaths.push(MCP_REGISTRATION_REL);
4934
- }
4935
5832
  const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
4936
- switch (removeRetiredMcpHeadersHelper(targetRepo)) {
5833
+ switch (actual.mcpHelper) {
4937
5834
  case "removed":
4938
5835
  recordRemovedFile(draft, helperRel);
4939
5836
  report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
@@ -4948,43 +5845,36 @@ async function install(options) {
4948
5845
  report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
4949
5846
  break;
4950
5847
  case "absent":
5848
+ case null:
4951
5849
  break;
4952
5850
  }
4953
5851
  } else {
4954
5852
  report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
4955
- const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
4956
- if (existsSync3(mcpPath))
4957
- recordMcpAdoptionOnly(draft, readFileSync4(mcpPath, "utf-8"));
5853
+ if (mcp.existing !== null)
5854
+ recordMcpAdoptionOnly(draft, mcp.existing);
4958
5855
  }
4959
- const gitignorePath = join5(targetRepo, GITIGNORE_REL);
4960
- const gitignoreBefore = existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null;
4961
- const gitignoreOutcome = reconcileGitignore(targetRepo);
4962
- recordGitignoreReconcile(draft, gitignoreBefore, existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null, writeSet);
4963
- if (gitignoreOutcome === "failed") {
5856
+ recordGitignoreReconcile(draft, steps.gitignore.before, actual.gitignoreAfter, writeSet);
5857
+ if (actual.gitignore === "failed") {
4964
5858
  report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
4965
5859
  } else {
4966
- record(report, gitignoreOutcome, ".gitignore");
5860
+ record(report, actual.gitignore, ".gitignore");
4967
5861
  }
4968
- const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
4969
- const gitattributesBefore = readTextOrNull(gitattributesPath);
4970
- const gitattributesOutcome = reconcileGitattributes(targetRepo);
4971
- recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath), writeSet);
4972
- if (gitattributesOutcome === "failed") {
5862
+ recordGitattributesReconcile(draft, steps.gitattributes.before, actual.gitattributesAfter, writeSet);
5863
+ if (actual.gitattributes === "failed") {
4973
5864
  report.skippedPaths.push(`${GITATTRIBUTES_REL} (write failed \u2014 a clone made with core.autocrlf=true may check the hook scripts out with CRLF line endings)`);
4974
5865
  } else {
4975
- record(report, gitattributesOutcome, GITATTRIBUTES_REL);
5866
+ record(report, actual.gitattributes, GITATTRIBUTES_REL);
4976
5867
  }
4977
- const identityOutcome = writeProjectIdentity(targetRepo);
5868
+ const identityOutcome = steps.identity.action === "add" ? "written" : "skipped";
4978
5869
  record(report, identityOutcome, PROJECT_IDENTITY_REL);
4979
- noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
4980
- const credentialPath = engagementEnvPath(engagementId, home);
4981
- if (credential) {
4982
- record(report, writeEngagementCredential(credential, engagementId, home), credentialPath);
5870
+ noteFile(draft, PROJECT_IDENTITY_REL, identityOutcome, actual.bytesAfter);
5871
+ const credentialPath = engagementEnvPath(steps.engagementId, steps.home);
5872
+ if (actual.credential !== null) {
5873
+ record(report, actual.credential, credentialPath);
4983
5874
  } else {
4984
5875
  report.skippedPaths.push(`${credentialPath} (no credential supplied)`);
4985
5876
  }
4986
- const stored = readEngagementEnv(engagementId, home) ?? {};
4987
- const migration = migrateLegacyEnvLocal(targetRepo, stored);
5877
+ const migration = actual.legacy;
4988
5878
  switch (migration.outcome) {
4989
5879
  case "removed":
4990
5880
  report.writtenPaths.push(".env.local (REMOVED \u2014 the credential now lives outside this repository)");
@@ -5001,32 +5891,22 @@ async function install(options) {
5001
5891
  case "absent":
5002
5892
  break;
5003
5893
  }
5004
- writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, createdByAccountId, credential?.methodVersion, report.writtenPaths);
5005
- noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
5006
- noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
5007
- const scanResult = runBootstrapScan(targetRepo);
5008
- if (existsSync3(join5(targetRepo, BOOTSTRAP_STATE_REL)))
5894
+ if (pin.outcome === "written")
5895
+ report.writtenPaths.push(BUNDLE_PIN_REL);
5896
+ noteFile(draft, BUNDLE_PIN_REL, "written", actual.bytesAfter);
5897
+ noteFile(draft, CREW_ROSTER_REL, recordOwnedGenerated(report, steps.crew), actual.bytesAfter);
5898
+ if (actual.stateExistsAfter)
5009
5899
  recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
5010
5900
  for (const dir of writeSet.dirs) {
5011
- if (!dirsBefore.has(dir) && existsSync3(join5(targetRepo, dir)))
5901
+ if (!steps.dirsBefore.has(dir) && actual.dirExistsAfter(dir))
5012
5902
  recordCreatedDir(draft, dir);
5013
5903
  }
5014
5904
  const installManifest = finishManifest(draft);
5015
5905
  assertManifestInWriteSet(installManifest, writeSet);
5016
- recordOwned(report, join5(targetRepo, INSTALL_MANIFEST_REL), targetRepo, serializeManifest(installManifest), INSTALL_MANIFEST_REL);
5017
- const notShared = findNotShared(targetRepo, installManifest, report.collidedPaths);
5018
- return {
5019
- version: manifest.version,
5020
- writtenPaths: report.writtenPaths,
5021
- skippedPaths: report.skippedPaths,
5022
- mergedPaths: report.mergedPaths,
5023
- collidedPaths: report.collidedPaths,
5024
- editedCollidedPaths: report.editedCollidedPaths,
5025
- replacedPaths: report.replacedPaths,
5026
- bootstrapScanRan: scanResult.ran,
5027
- legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null,
5028
- notShared
5029
- };
5906
+ return { report, installManifest, manifestText: serializeManifest(installManifest) };
5907
+ }
5908
+ function recordInstallRecord(report, planned) {
5909
+ recordOwnedGenerated(report, planned);
5030
5910
  }
5031
5911
 
5032
5912
  // dist/control-origin.js
@@ -5185,8 +6065,8 @@ async function requestEngagementValues(method, url, requestBody, credential, sha
5185
6065
  }
5186
6066
 
5187
6067
  // dist/setup/manifest.js
5188
- import { existsSync as existsSync4, lstatSync as lstatSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync5 } from "node:fs";
5189
- import { dirname as dirname4, join as join6, posix as posix2 } from "node:path";
6068
+ import { existsSync as existsSync5, lstatSync as lstatSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync5 } from "node:fs";
6069
+ import { dirname as dirname4, join as join7, posix as posix3 } from "node:path";
5190
6070
  var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
5191
6071
  var DECLARED_DIR_PREFIX = "docs";
5192
6072
  var RESERVED_METHOD_PATH = "docs/method";
@@ -5197,7 +6077,7 @@ function normalise(path) {
5197
6077
  const slashed = path.replace(/\\/g, "/");
5198
6078
  if (slashed.trim() === "")
5199
6079
  return "";
5200
- return posix2.normalize(slashed).replace(/\/+$/, "");
6080
+ return posix3.normalize(slashed).replace(/\/+$/, "");
5201
6081
  }
5202
6082
  function isUnder(candidate, prefix) {
5203
6083
  return candidate === prefix || candidate.startsWith(prefix + "/");
@@ -5271,7 +6151,7 @@ function firstSymlinkOnPath(targetRepo, path) {
5271
6151
  const segments = path.split("/").filter((s) => s.length > 0);
5272
6152
  let walked = targetRepo;
5273
6153
  for (let i = 0; i < segments.length; i++) {
5274
- walked = join6(walked, segments[i]);
6154
+ walked = join7(walked, segments[i]);
5275
6155
  try {
5276
6156
  if (lstatSync2(walked).isSymbolicLink())
5277
6157
  return segments.slice(0, i + 1).join("/");
@@ -5289,12 +6169,12 @@ function symlinkEntry(path, link, isDirectoryRow) {
5289
6169
  };
5290
6170
  }
5291
6171
  function applyFileRow(targetRepo, path, content) {
5292
- const abs = join6(targetRepo, path);
6172
+ const abs = join7(targetRepo, path);
5293
6173
  const link = firstSymlinkOnPath(targetRepo, path);
5294
6174
  if (link !== null)
5295
6175
  return symlinkEntry(path, link, false);
5296
- if (existsSync4(abs)) {
5297
- const current = readFileSync5(abs, "utf-8");
6176
+ if (existsSync5(abs)) {
6177
+ const current = readFileSync6(abs, "utf-8");
5298
6178
  return current === content ? { path, outcome: "already-present", note: "already present and correct \u2014 nothing written" } : { path, outcome: "left-alone", note: "you already own this file \u2014 left exactly as it was" };
5299
6179
  }
5300
6180
  mkdirSync5(dirname4(abs), { recursive: true });
@@ -5302,17 +6182,17 @@ function applyFileRow(targetRepo, path, content) {
5302
6182
  return { path, outcome: "created", note: "created" };
5303
6183
  }
5304
6184
  function applySectionRow(targetRepo, path, heading, body) {
5305
- const abs = join6(targetRepo, path);
6185
+ const abs = join7(targetRepo, path);
5306
6186
  const link = firstSymlinkOnPath(targetRepo, path);
5307
6187
  if (link !== null)
5308
6188
  return symlinkEntry(path, link, false);
5309
6189
  const section = renderSection(heading, body);
5310
- if (!existsSync4(abs)) {
6190
+ if (!existsSync5(abs)) {
5311
6191
  mkdirSync5(dirname4(abs), { recursive: true });
5312
6192
  writeFileSync5(abs, section, "utf-8");
5313
6193
  return { path, outcome: "created", note: `created, with a "${heading}" section` };
5314
6194
  }
5315
- const existing = readFileSync5(abs, "utf-8");
6195
+ const existing = readFileSync6(abs, "utf-8");
5316
6196
  if (hasSection(existing, heading)) {
5317
6197
  return {
5318
6198
  path,
@@ -5329,11 +6209,11 @@ function applySectionRow(targetRepo, path, heading, body) {
5329
6209
  };
5330
6210
  }
5331
6211
  function applyDirectoryRow(targetRepo, path) {
5332
- const abs = join6(targetRepo, path);
6212
+ const abs = join7(targetRepo, path);
5333
6213
  const link = firstSymlinkOnPath(targetRepo, path);
5334
6214
  if (link !== null)
5335
6215
  return symlinkEntry(path, link, true);
5336
- if (existsSync4(abs)) {
6216
+ if (existsSync5(abs)) {
5337
6217
  const kind = statSync(abs).isDirectory() ? "directory" : "file";
5338
6218
  return kind === "directory" ? { path: `${path}/`, outcome: "already-present", note: "already there \u2014 nothing written" } : { path, outcome: "left-alone", note: "you already own this path \u2014 left exactly as it was" };
5339
6219
  }
@@ -5341,7 +6221,7 @@ function applyDirectoryRow(targetRepo, path) {
5341
6221
  return { path: `${path}/`, outcome: "created", note: "created" };
5342
6222
  }
5343
6223
  function applySetupWrites(targetRepo, rows) {
5344
- if (!existsSync4(targetRepo)) {
6224
+ if (!existsSync5(targetRepo)) {
5345
6225
  throw new Error(`[halfcycle setup] The project directory does not exist: ${targetRepo}`);
5346
6226
  }
5347
6227
  assertNoConflictingDuplicates(rows);
@@ -5431,11 +6311,13 @@ var SetupManifest = class {
5431
6311
  // dist/setup/rows.js
5432
6312
  var FLAT_FILE_TRACKER_HOME = "docs/tasks";
5433
6313
  function contextIndexRow(heading, body) {
5434
- return { path: "CLAUDE.md", kind: "section", heading, body };
6314
+ return { path: "AGENTS.md", kind: "section", heading, body };
5435
6315
  }
5436
- function agentsPointerRow(content) {
5437
- return { path: "AGENTS.md", kind: "file", content };
6316
+ function agentsPointerRow() {
6317
+ return { path: "CLAUDE.md", kind: "section", heading: AGENTS_POINTER_HEADING, body: AGENTS_POINTER_BODY };
5438
6318
  }
6319
+ var AGENTS_POINTER_HEADING = "Shared agent instructions";
6320
+ var AGENTS_POINTER_BODY = "@AGENTS.md";
5439
6321
  function layerDocumentRow(path, content) {
5440
6322
  return { path, kind: "file", content };
5441
6323
  }
@@ -5450,8 +6332,8 @@ function trackerHomeRow(home = FLAT_FILE_TRACKER_HOME) {
5450
6332
  }
5451
6333
 
5452
6334
  // dist/setup/discovery.js
5453
- import { existsSync as existsSync5, readFileSync as readFileSync6, readdirSync, statSync as statSync2 } from "node:fs";
5454
- import { join as join7 } from "node:path";
6335
+ import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync, statSync as statSync2 } from "node:fs";
6336
+ import { join as join8 } from "node:path";
5455
6337
  function renderAnchor(anchor) {
5456
6338
  return anchor.kind === "file" ? `${anchor.path}:${anchor.line}` : `$ ${anchor.command}`;
5457
6339
  }
@@ -5475,8 +6357,8 @@ function lineOf(text, needle) {
5475
6357
  return null;
5476
6358
  }
5477
6359
  function readOrNull(repo, rel) {
5478
- const abs = join7(repo, rel);
5479
- return existsSync5(abs) && statSync2(abs).isFile() ? readFileSync6(abs, "utf-8") : null;
6360
+ const abs = join8(repo, rel);
6361
+ return existsSync6(abs) && statSync2(abs).isFile() ? readFileSync7(abs, "utf-8") : null;
5480
6362
  }
5481
6363
  function anchoredIn(repo, rel, needle, value) {
5482
6364
  const text = readOrNull(repo, rel);
@@ -5488,8 +6370,8 @@ function anchoredIn(repo, rel, needle, value) {
5488
6370
  return { value, anchor: { kind: "file", path: rel, line } };
5489
6371
  }
5490
6372
  function presenceAnchor(repo, rel) {
5491
- const abs = join7(repo, rel);
5492
- if (!existsSync5(abs))
6373
+ const abs = join8(repo, rel);
6374
+ if (!existsSync6(abs))
5493
6375
  return null;
5494
6376
  return statSync2(abs).isDirectory() ? { kind: "command", command: `ls -d ${rel}`, output: `${rel}/` } : { kind: "file", path: rel, line: 1 };
5495
6377
  }
@@ -5528,9 +6410,10 @@ function firstMatch(items, f) {
5528
6410
  }
5529
6411
  return null;
5530
6412
  }
6413
+ var CONTEXT_INDEX_FILES = ["AGENTS.md", "CLAUDE.md"];
5531
6414
  function ciWorkflows(repo) {
5532
- const dir = join7(repo, ".github", "workflows");
5533
- if (!existsSync5(dir) || !statSync2(dir).isDirectory())
6415
+ const dir = join8(repo, ".github", "workflows");
6416
+ if (!existsSync6(dir) || !statSync2(dir).isDirectory())
5534
6417
  return [];
5535
6418
  return readdirSync(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")).sort();
5536
6419
  }
@@ -5538,21 +6421,21 @@ function existingLayerDocuments(repo) {
5538
6421
  const found = [];
5539
6422
  for (const paths of Object.values(LAYER_ARTEFACT_PATHS)) {
5540
6423
  for (const p of paths ?? []) {
5541
- if (existsSync5(join7(repo, p)))
6424
+ if (existsSync6(join8(repo, p)))
5542
6425
  found.push(p);
5543
6426
  }
5544
6427
  }
5545
6428
  return found.sort();
5546
6429
  }
5547
6430
  function discoverRepository(targetRepo) {
5548
- if (!existsSync5(targetRepo)) {
6431
+ if (!existsSync6(targetRepo)) {
5549
6432
  throw new Error(`[halfcycle setup] The project directory does not exist: ${targetRepo}`);
5550
6433
  }
5551
6434
  const findings = [];
5552
6435
  const questions = [];
5553
6436
  settleOrAsk("project-language", firstMatch(MANIFESTS, ([file, needle, language]) => anchoredIn(targetRepo, file, needle, language)), "What is this project written in?", findings, questions);
5554
6437
  settleOrAsk("package-manager", firstMatch(LOCKFILES, ([file, manager]) => anchoredByPresence(targetRepo, file, manager)), "Which package manager does this project use?", findings, questions);
5555
- settleOrAsk("repo-structure", firstMatch(WORKSPACE_MARKERS, ([file, needle, shape]) => anchoredIn(targetRepo, file, needle, shape)) ?? (existsSync5(join7(targetRepo, "package.json")) || existsSync5(join7(targetRepo, "go.mod")) || existsSync5(join7(targetRepo, "pyproject.toml")) ? anchoredByPresence(targetRepo, "package.json", "a single-package repository") ?? anchoredByPresence(targetRepo, "go.mod", "a single-package repository") ?? anchoredByPresence(targetRepo, "pyproject.toml", "a single-package repository") : null), "Is this one repository for one product, or one of several?", findings, questions);
6438
+ settleOrAsk("repo-structure", firstMatch(WORKSPACE_MARKERS, ([file, needle, shape]) => anchoredIn(targetRepo, file, needle, shape)) ?? (existsSync6(join8(targetRepo, "package.json")) || existsSync6(join8(targetRepo, "go.mod")) || existsSync6(join8(targetRepo, "pyproject.toml")) ? anchoredByPresence(targetRepo, "package.json", "a single-package repository") ?? anchoredByPresence(targetRepo, "go.mod", "a single-package repository") ?? anchoredByPresence(targetRepo, "pyproject.toml", "a single-package repository") : null), "Is this one repository for one product, or one of several?", findings, questions);
5556
6439
  const workflows = ciWorkflows(targetRepo);
5557
6440
  settleOrAsk("ci-strategy", workflows.length > 0 ? {
5558
6441
  value: `GitHub Actions \u2014 ${workflows.length === 1 ? "one workflow" : `${workflows.length} workflows`}: ${workflows.join(", ")}`,
@@ -5560,7 +6443,7 @@ function discoverRepository(targetRepo) {
5560
6443
  } : null, "How does this project run CI?", findings, questions);
5561
6444
  const layerDocs = existingLayerDocuments(targetRepo);
5562
6445
  settleOrAsk("existing-layer-documents", layerDocs.length > 0 ? anchoredByPresence(targetRepo, layerDocs[0], `already written: ${layerDocs.join(", ")}`) : null, "Which of the project documents already exist?", findings, questions);
5563
- settleOrAsk("context-index", anchoredByPresence(targetRepo, "CLAUDE.md", "a context index is already in place"), "Where should the project context index live?", findings, questions);
6446
+ settleOrAsk("context-index", firstMatch(CONTEXT_INDEX_FILES, (rel) => anchoredByPresence(targetRepo, rel, "a context index is already in place")), "Where should the project context index live?", findings, questions);
5564
6447
  settleOrAsk("tracker", anchoredByPresence(targetRepo, "docs/tasks", "a flat-file task directory is already in place") ?? anchoredByPresence(targetRepo, ".github/ISSUE_TEMPLATE", "GitHub Issues is already in use"), "Where should tasks live?", findings, questions);
5565
6448
  settleOrAsk("push-policy", null, "How does work reach the default branch?", findings, questions);
5566
6449
  settleOrAsk("branch-policy", null, "How are branches organised for a piece of work?", findings, questions);
@@ -6023,10 +6906,14 @@ function renderClose(heading, observed, attested, checks, p, closingRound = fals
6023
6906
  ];
6024
6907
  }
6025
6908
  var REVIEW_LABEL = "**Recorded by Halfcycle from the owner's review.** Each part the review held, and how the project's owner decided it. A line marked as the agent's is the agent's own answer, not Halfcycle's.";
6909
+ var REVIEW_LABEL_WITH_AUTONOMY = `**Recorded by Halfcycle from the owner's review.** Each part the review held, and how it was decided: by the project's owner, unless the part says it was ${DECIDED_BY_AUTONOMY_SENTENCE}. A line marked as the agent's is the agent's own answer, not Halfcycle's.`;
6026
6910
  var REVIEW_NO_PARTS = "The review had no parts, so it closed on one accept that names no part.";
6027
6911
  function approvalLine(approval) {
6028
6912
  if (approval.kind === "none")
6029
6913
  return "Built without an approved scope.";
6914
+ if (approval.givenIn === "autonomy") {
6915
+ return `Plan version ${String(approval.planVersion)} was ${APPROVED_IN_ADVANCE_SENTENCE}.`;
6916
+ }
6030
6917
  const where = approval.givenIn === "console" ? "in the console" : "in their own session";
6031
6918
  return `Scope approved by the project's owner, plan version ${String(approval.planVersion)}, ${where}.`;
6032
6919
  }
@@ -6040,8 +6927,11 @@ function decisionText(part, p) {
6040
6927
  return `Dropped: ${p.text(part.reason ?? "")}`;
6041
6928
  }
6042
6929
  }
6930
+ var DECIDED_BY_AUTONOMY_LINE = `${DECIDED_BY_AUTONOMY_SENTENCE.charAt(0).toUpperCase()}${DECIDED_BY_AUTONOMY_SENTENCE.slice(1)}.`;
6043
6931
  function reviewPartLines(part, p) {
6044
6932
  const lines = [`- ${p.itemStart(part.name)} \u2014 ${decisionText(part, p)}`];
6933
+ if (part.givenIn === "autonomy")
6934
+ lines.push(` - ${DECIDED_BY_AUTONOMY_LINE}`);
6045
6935
  const code = codeLinkLine(part.codeLink);
6046
6936
  if (code !== void 0)
6047
6937
  lines.push(` - ${p.text(code)}`);
@@ -6061,7 +6951,8 @@ function removalsLine(record2, p) {
6061
6951
  }
6062
6952
  function renderReview(record2, p) {
6063
6953
  const review = record2.review;
6064
- const blocks = ["## The owner's review", REVIEW_LABEL, approvalLine(review.approval)];
6954
+ const label = review.parts.some((part) => part.givenIn === "autonomy") ? REVIEW_LABEL_WITH_AUTONOMY : REVIEW_LABEL;
6955
+ const blocks = ["## The owner's review", label, approvalLine(review.approval)];
6065
6956
  blocks.push(review.parts.length === 0 ? REVIEW_NO_PARTS : review.parts.flatMap((part) => reviewPartLines(part, p)).join("\n"));
6066
6957
  const removals = removalsLine(record2, p);
6067
6958
  if (removals !== null)
@@ -6073,10 +6964,9 @@ function renderCalls(calls, p) {
6073
6964
  return [`## ${CALLS_HEADING}`, NO_CALLS_LINE];
6074
6965
  const lines = calls.flatMap((c) => {
6075
6966
  const by = p.itemStart(c.by);
6076
- return [
6077
- `- ${callMadeLine({ by, question: p.text(c.question), chose: p.text(c.chose), statedDefault: p.text(c.statedDefault) })}`,
6078
- ` - ${callUndoLine({ by: p.text(c.by), undoable: c.undoable, how: c.how === null ? null : p.text(c.how) })}`
6079
- ];
6967
+ const undo = ` - ${callUndoLine({ by: p.text(c.by), undoable: c.undoable, how: c.how === null ? null : p.text(c.how) })}`;
6968
+ const head = c.kind === "listed" ? autonomyC8(by, p.text(c.question), p.text(c.chose)) : callMadeLine({ by, question: p.text(c.question), chose: p.text(c.chose), statedDefault: p.text(c.statedDefault) });
6969
+ return [`- ${head}`, undo];
6080
6970
  });
6081
6971
  return [`## ${CALLS_HEADING}`, CALLS_EXPLAINED, lines.join("\n")];
6082
6972
  }
@@ -6176,11 +7066,11 @@ function renderBuildRecordV2Markdown(record2) {
6176
7066
  }
6177
7067
 
6178
7068
  // dist/build-record/write.js
6179
- import { existsSync as existsSync6, mkdirSync as mkdirSync6, rmSync as rmSync4 } from "node:fs";
6180
- import { basename, join as join8, resolve as resolve2, relative as relative2, isAbsolute as isAbsolute3 } from "node:path";
7069
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, rmSync as rmSync4 } from "node:fs";
7070
+ import { basename, join as join9, resolve as resolve2, relative as relative2, isAbsolute as isAbsolute3 } from "node:path";
6181
7071
 
6182
7072
  // dist/build-record/pair-io.js
6183
- import { closeSync, openSync, readFileSync as readFileSync7, renameSync as renameSync2, rmSync as rmSync3, writeSync } from "node:fs";
7073
+ import { closeSync, openSync, readFileSync as readFileSync8, renameSync as renameSync2, rmSync as rmSync3, writeSync } from "node:fs";
6184
7074
  function createExclusiveOnDisk(path, data) {
6185
7075
  const bytes = typeof data === "string" ? Buffer.from(data, "utf-8") : data;
6186
7076
  const fd = openSync(path, "wx");
@@ -6211,7 +7101,7 @@ function isEexist(err) {
6211
7101
  }
6212
7102
  function readIfPresent(path) {
6213
7103
  try {
6214
- return readFileSync7(path, "utf-8");
7104
+ return readFileSync8(path, "utf-8");
6215
7105
  } catch {
6216
7106
  return null;
6217
7107
  }
@@ -6243,7 +7133,7 @@ function replaceOne(io, tmp, finalPath, text) {
6243
7133
  }
6244
7134
 
6245
7135
  // dist/build-record/write.js
6246
- var BUILD_RECORD_DIR = join8("docs", "build-records");
7136
+ var BUILD_RECORD_DIR = join9("docs", "build-records");
6247
7137
  function assertInsideOutDir(outDir, candidate, identity) {
6248
7138
  const rel = relative2(outDir, resolve2(candidate));
6249
7139
  const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute3(rel) && rel === basename(rel);
@@ -6281,8 +7171,8 @@ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_P
6281
7171
  }
6282
7172
  const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
6283
7173
  const segment = renderPhaseSegment(phase);
6284
- const jsonPath = join8(outDir, `${segment}.json`);
6285
- const mdPath = join8(outDir, `${segment}.md`);
7174
+ const jsonPath = join9(outDir, `${segment}.json`);
7175
+ const mdPath = join9(outDir, `${segment}.md`);
6286
7176
  const jsonTmp = `${jsonPath}.tmp`;
6287
7177
  const mdTmp = `${mdPath}.tmp`;
6288
7178
  for (const candidate of [jsonPath, mdPath, jsonTmp, mdTmp]) {
@@ -6296,7 +7186,7 @@ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_P
6296
7186
  }
6297
7187
  const existingJson = readIfPresent(jsonPath);
6298
7188
  if (existingJson === null) {
6299
- return { kind: "in-the-way", path: existsSync6(jsonPath) ? jsonPath : mdPath };
7189
+ return { kind: "in-the-way", path: existsSync7(jsonPath) ? jsonPath : mdPath };
6300
7190
  }
6301
7191
  if (existingJson === jsonText) {
6302
7192
  replaceOne(io, mdTmp, mdPath, mdText);