halfcycle 0.4.14 → 0.4.16

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