halfcycle 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // dist/bin.js
4
- import { existsSync as existsSync7, readFileSync as readFileSync13, realpathSync as realpathSync4, statSync, writeSync as writeSync2 } from "node:fs";
4
+ import { existsSync as existsSync7, readFileSync as readFileSync14, realpathSync as realpathSync4, statSync, writeSync as writeSync2 } from "node:fs";
5
5
  import { homedir as homedir3 } from "node:os";
6
- import { join as join14 } from "node:path";
6
+ import { join as join15 } from "node:path";
7
7
 
8
8
  // dist/install.js
9
9
  import { execFileSync as execFileSync2 } from "node:child_process";
@@ -143,6 +143,7 @@ var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
143
143
  var SESSION_START_MARKER_REL = ".claude/hooks/session-start-marker.sh";
144
144
  var GUARD_RUNNER_REL = ".claude/hooks/guard-runner.sh";
145
145
  var USER_PROMPT_REMINDER_REL = ".claude/hooks/user-prompt-reminder.sh";
146
+ var USER_PROMPT_SCOPE_REL = ".claude/hooks/user-prompt-scope.sh";
146
147
  var SESSION_END_MARKER_REL = ".claude/hooks/session-end-marker.sh";
147
148
  var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
148
149
  function hookCommand(script, verb) {
@@ -171,6 +172,13 @@ var HALFCYCLE_HOOK_WIRING = [
171
172
  command: hookCommand(USER_PROMPT_REMINDER_REL),
172
173
  script: USER_PROMPT_REMINDER_REL
173
174
  },
175
+ {
176
+ event: "UserPromptSubmit",
177
+ matcher: null,
178
+ timeout: 10,
179
+ command: hookCommand(USER_PROMPT_SCOPE_REL),
180
+ script: USER_PROMPT_SCOPE_REL
181
+ },
174
182
  {
175
183
  event: "PostToolUse",
176
184
  matcher: WRITE_CAPABLE_TOOLS.join("|"),
@@ -252,6 +260,131 @@ function quoteForShell(value) {
252
260
  }
253
261
  var HALFCYCLE_COMMAND = "npx halfcycle@latest";
254
262
 
263
+ // ../core/dist/shown-plans.js
264
+ var SHOWN_PLANS_FILENAME = "shown-plans.json";
265
+ var SHOWN_PLANS_FORMAT = 1;
266
+ var RECORD_KEYS = ["entries", "format"];
267
+ var ENTRY_KEYS = ["number", "phase", "shownAt", "version"];
268
+ function parseShownPlans(text) {
269
+ try {
270
+ if (typeof text !== "string")
271
+ return [];
272
+ const parsed = JSON.parse(text);
273
+ if (!isPlainObject(parsed) || !hasExactKeys(parsed, RECORD_KEYS))
274
+ return [];
275
+ if (parsed["format"] !== SHOWN_PLANS_FORMAT)
276
+ return [];
277
+ const rawEntries = parsed["entries"];
278
+ if (!Array.isArray(rawEntries))
279
+ return [];
280
+ const entries = [];
281
+ for (const raw of rawEntries) {
282
+ const entry = readEntry(raw);
283
+ if (entry === null)
284
+ return [];
285
+ entries.push(entry);
286
+ }
287
+ return hasDuplicates(entries) ? [] : entries;
288
+ } catch {
289
+ return [];
290
+ }
291
+ }
292
+ function serialiseShownPlans(entries) {
293
+ if (!Array.isArray(entries))
294
+ throw new Error(UNRECORDABLE);
295
+ const copied = [];
296
+ for (const raw of entries) {
297
+ const entry = readEntry(raw);
298
+ if (entry === null)
299
+ throw new Error(UNRECORDABLE);
300
+ copied.push(entry);
301
+ }
302
+ if (hasDuplicates(copied))
303
+ throw new Error(UNRECORDABLE);
304
+ return `${JSON.stringify({ format: SHOWN_PLANS_FORMAT, entries: copied }, null, 2)}
305
+ `;
306
+ }
307
+ var UNRECORDABLE = "the list just shown could not be recorded on this machine.";
308
+ function readEntry(raw) {
309
+ if (!isPlainObject(raw) || !hasExactKeys(raw, ENTRY_KEYS))
310
+ return null;
311
+ const phase = raw["phase"];
312
+ const number = raw["number"];
313
+ const version = raw["version"];
314
+ const shownAt = raw["shownAt"];
315
+ if (typeof phase !== "string" || phase === "")
316
+ return null;
317
+ if (!isPositiveInteger(number) || !isPositiveInteger(version))
318
+ return null;
319
+ if (!isIsoInstant(shownAt))
320
+ return null;
321
+ return { phase, number, version, shownAt };
322
+ }
323
+ function isPlainObject(value) {
324
+ if (typeof value !== "object" || value === null || Array.isArray(value))
325
+ return false;
326
+ const proto = Object.getPrototypeOf(value);
327
+ return proto === Object.prototype || proto === null;
328
+ }
329
+ function hasExactKeys(value, keys) {
330
+ const own = Object.keys(value).sort();
331
+ return own.length === keys.length && own.every((key, i) => key === keys[i]);
332
+ }
333
+ function isPositiveInteger(value) {
334
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
335
+ }
336
+ function isIsoInstant(value) {
337
+ if (typeof value !== "string")
338
+ return false;
339
+ const ms = Date.parse(value);
340
+ return Number.isFinite(ms) && new Date(ms).toISOString() === value;
341
+ }
342
+ function hasDuplicates(entries) {
343
+ const phases = new Set(entries.map((entry) => entry.phase));
344
+ const numbers = new Set(entries.map((entry) => entry.number));
345
+ return phases.size !== entries.length || numbers.size !== entries.length;
346
+ }
347
+
348
+ // ../core/dist/phase-reference.js
349
+ var TYPED_NUMBER = /^(?:0|[1-9][0-9]*)$/;
350
+ function resolvePhaseReference(typed, phases) {
351
+ if (typeof typed !== "string" || typed === "")
352
+ return null;
353
+ const byIdentity = phases.filter((entry) => entry.phase === typed);
354
+ if (byIdentity.length > 0)
355
+ return byIdentity.length === 1 ? byIdentity[0] : null;
356
+ if (!TYPED_NUMBER.test(typed))
357
+ return null;
358
+ const byNumber = phases.filter((entry) => Number.isInteger(entry.number) && String(entry.number) === typed);
359
+ return byNumber.length === 1 ? byNumber[0] : null;
360
+ }
361
+
362
+ // ../core/dist/release-version.js
363
+ var RELEASE_VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
364
+ function isReleaseVersion(value) {
365
+ return typeof value === "string" && RELEASE_VERSION_PATTERN.test(value);
366
+ }
367
+ function significant(part) {
368
+ const trimmed = part.replace(/^0+/, "");
369
+ return trimmed === "" ? "0" : trimmed;
370
+ }
371
+ function compareReleaseVersions(a, b) {
372
+ if (!isReleaseVersion(a) || !isReleaseVersion(b)) {
373
+ throw new TypeError("compareReleaseVersions takes two MAJOR.MINOR.PATCH versions");
374
+ }
375
+ const pa = a.split(".").map(significant);
376
+ const pb = b.split(".").map(significant);
377
+ for (let i = 0; i < 3; i++) {
378
+ const x = pa[i];
379
+ const y = pb[i];
380
+ if (x.length !== y.length)
381
+ return x.length - y.length;
382
+ if (x !== y)
383
+ return x < y ? -1 : 1;
384
+ }
385
+ return 0;
386
+ }
387
+
255
388
  // ../events/dist/result.js
256
389
  import { z as z2 } from "zod";
257
390
  var firedGuardSchema = z2.object({
@@ -321,18 +454,19 @@ function hasLoneSurrogate(text) {
321
454
  }
322
455
  var LONE_SURROGATE_MESSAGE = "must be valid text: it holds half of a character pair with no other half";
323
456
 
324
- // ../events/dist/build-record.js
325
- import { z as z4 } from "zod";
326
-
327
457
  // ../events/dist/state.js
328
458
  import { z as z3 } from "zod";
329
459
  var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
330
460
  var STATE_RECORD_FORMAT = "halfcycle-state-record/v1";
331
461
  var stepIdSchema = z3.string().regex(/^(l[0-5](-l[0-5])?|xl)\.[a-z0-9-]+$/);
332
- var methodVersionSchema = z3.string().regex(/^\d+\.\d+\.\d+$/);
462
+ var methodVersionSchema = z3.string().regex(RELEASE_VERSION_PATTERN);
333
463
  var anchorPathRegex = /^(?!\/)(?!.*#)(?!method\/)(?!(.*\/)?docs\/method\/).+$/;
334
464
  var recordKindSchema = z3.enum(["artefact", "step-run", "gate", "intervention"]);
335
465
  var methodLayerSchema = z3.enum(["l0", "l1", "l2", "l3", "l3-l4", "l4", "l5", "xl"]);
466
+ var PHASE_SCOPED_LAYERS = ["l4", "l5"];
467
+ function isPhaseScopedLayer(layer) {
468
+ return PHASE_SCOPED_LAYERS.includes(layer);
469
+ }
336
470
  var evidenceKindSchema = z3.enum(["anchor", "diff", "repro", "question-answer", "no-op"]);
337
471
  var declarableEvidenceKindSchema = z3.enum(["anchor", "diff", "repro", "question-answer"]);
338
472
  var dispositionSchema = z3.enum([
@@ -506,6 +640,9 @@ var stateRecordSchema = z3.discriminatedUnion("recordKind", [
506
640
  interventionRecordSchema
507
641
  ]);
508
642
 
643
+ // ../events/dist/build-record.js
644
+ import { z as z4 } from "zod";
645
+
509
646
  // ../events/dist/control-characters.js
510
647
  var CONTROL_CHARACTER_CLASS = "\\p{Cc}";
511
648
  var CONTROL_CHARACTER = new RegExp(CONTROL_CHARACTER_CLASS, "u");
@@ -609,9 +746,11 @@ function statementText(max) {
609
746
  });
610
747
  }
611
748
  var closeActorSchema = plainText(ACTOR_MAX);
749
+ var PHASE_NAME_RULE = `must be a phase name: not empty, no slash or backslash, not starting with a dot, no control characters, at most ${PHASE_NAME_MAX_BYTES} bytes`;
612
750
  var phaseNameSchema = z4.string().refine((v) => isValidPhaseIdentity(v), {
613
- message: `must be a phase name: not empty, no slash or backslash, not starting with a dot, no control characters, at most ${PHASE_NAME_MAX_BYTES} bytes`
751
+ message: PHASE_NAME_RULE
614
752
  });
753
+ var phaseIdentitySchema = z4.union([z4.string(), z4.number()], { error: "must be a phase name or a whole number" }).refine((v) => isValidPhaseIdentity(v), { message: PHASE_NAME_RULE });
615
754
  var closeStatementsSchema = z4.object({
616
755
  thesis: statementText(STATEMENT_MAX).nullable(),
617
756
  outcome: statementText(STATEMENT_MAX).nullable(),
@@ -887,17 +1026,31 @@ var phaseEntryDecisionRecordSchema = z5.object({
887
1026
  blockingFinding: z5.string().nullable(),
888
1027
  decidedAt: utcIso86012
889
1028
  }).strict();
1029
+ var methodUpdateSchema = z5.object({
1030
+ from: methodVersionSchema,
1031
+ to: methodVersionSchema
1032
+ }).strict();
890
1033
  var openPhaseResponseSchema = z5.object({
891
1034
  engagement: engagementStatusResponseSchema,
892
1035
  /** What is open now. `null` is project scope. */
893
1036
  phase: z5.string().nullable(),
894
1037
  /** What was open before this decision. `null` is project scope. */
895
1038
  previousPhase: z5.string().nullable(),
896
- decision: phaseEntryDecisionRecordSchema
1039
+ decision: phaseEntryDecisionRecordSchema,
1040
+ /** The method version this project moved to with this open, or `null` when it did not move. */
1041
+ methodUpdated: methodUpdateSchema.nullable()
1042
+ }).strict();
1043
+ var acceptPhaseResponseSchema = engagementStatusResponseSchema.extend({
1044
+ methodUpdated: methodUpdateSchema.nullable()
897
1045
  }).strict();
898
1046
 
899
1047
  // ../events/dist/crew.js
900
1048
  import { z as z6 } from "zod";
1049
+
1050
+ // ../events/dist/human-agent.js
1051
+ var HUMAN_AGENT = "Human";
1052
+
1053
+ // ../events/dist/crew.js
901
1054
  var crewGroupSchema = z6.enum(["builder", "reviewer", "always-on"]);
902
1055
  var crewTierSchema = z6.enum(["deep", "standard", "fast"]);
903
1056
  var CREW_DISCIPLINE_MAX = 64;
@@ -1090,225 +1243,181 @@ var CREW_ROSTER = [
1090
1243
  var CREW_ROSTER_SIZE = CREW_ROSTER.length;
1091
1244
  var CREW_CALLSIGNS = CREW_ROSTER.map((member) => member.callsign);
1092
1245
 
1093
- // ../events/dist/device-auth.js
1246
+ // ../events/dist/build-snapshot.js
1094
1247
  import { z as z7 } from "zod";
1095
- var utcIso86013 = z7.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
1096
- var DEVICE_AUTH_STATUS = {
1097
- /** The poll: minted, nobody has acted yet. Keep polling. 200. */
1098
- PENDING: "pending",
1099
- /** The poll: approved, and the credential is in this body. Once only. 200. */
1100
- APPROVED: "approved",
1101
- /** Confirm: the decision was recorded (approve or decline). 200. */
1102
- RECORDED: "recorded",
1103
- /** The human refused at the confirmation page. 400 on the poll. */
1104
- DECLINED: "declined",
1105
- /** The code's absolute expiry has passed. 400. */
1106
- EXPIRED: "expired",
1107
- /** Never minted, or already spent. 400. */
1108
- UNKNOWN: "unknown",
1109
- /** Confirm: neither `approve` nor `decline` was named. Nothing was bound. 400. */
1110
- INVALID_DECISION: "invalid-decision",
1111
- /** Confirm: an approval arrived with no signed-in identity. 400. */
1112
- IDENTITY_REQUIRED: "identity-required",
1113
- /** This deployment has no `SIGNIN_BASE_URL` / `DEVICE_AUTH_CONFIRM_SECRET`. 503. */
1114
- NOT_CONFIGURED: "not-configured",
1115
- /** Confirm: the shared confirmation secret was missing or wrong. 401. */
1116
- UNAUTHORIZED: "unauthorized",
1117
- /** Confirm: the code was already approved, declined or claimed. 409. */
1118
- ALREADY_DECIDED: "already-decided",
1119
- /**
1120
- * Confirm: an approval named a signed-in identity whose account has not accepted
1121
- * the terms of service and privacy policy currently in force. 400.
1122
- *
1123
- * NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
1124
- * says nothing about the code itself — the code is still `pending` after this
1125
- * response, exactly as it was before the request, so the SAME userCode can be
1126
- * confirmed again once the caller has recorded acceptance. `deviceConfirmRequestSchema`'s
1127
- * `acceptTerms` is how a second confirm says it did.
1128
- */
1129
- TERMS_REQUIRED: "terms-required"
1130
- };
1131
- var DEVICE_POLL_REFUSALS = [
1132
- DEVICE_AUTH_STATUS.DECLINED,
1133
- DEVICE_AUTH_STATUS.EXPIRED,
1134
- DEVICE_AUTH_STATUS.UNKNOWN
1135
- ];
1136
- var deviceAuthStartResponseSchema = z7.object({
1137
- deviceCode: z7.string().min(1),
1138
- userCode: z7.string().min(1),
1139
- verificationUrl: z7.string().min(1),
1140
- expiresAt: utcIso86013,
1141
- pollIntervalMs: z7.number().int().positive()
1142
- }).strict();
1143
- var devicePollPendingSchema = z7.object({
1144
- status: z7.literal(DEVICE_AUTH_STATUS.PENDING),
1145
- pollIntervalMs: z7.number().int().positive()
1146
- }).strict();
1147
- var devicePollApprovedSchema = z7.object({
1148
- status: z7.literal(DEVICE_AUTH_STATUS.APPROVED),
1149
- accountId: z7.string().min(1),
1150
- credential: z7.string().min(1),
1151
- expiresAt: utcIso86013
1152
- }).strict();
1153
- var deviceAuthRefusalSchema = z7.object({
1154
- status: z7.enum([
1155
- DEVICE_AUTH_STATUS.DECLINED,
1156
- DEVICE_AUTH_STATUS.EXPIRED,
1157
- DEVICE_AUTH_STATUS.UNKNOWN,
1158
- DEVICE_AUTH_STATUS.INVALID_DECISION,
1159
- DEVICE_AUTH_STATUS.IDENTITY_REQUIRED,
1160
- DEVICE_AUTH_STATUS.NOT_CONFIGURED,
1161
- DEVICE_AUTH_STATUS.UNAUTHORIZED,
1162
- DEVICE_AUTH_STATUS.ALREADY_DECIDED,
1163
- DEVICE_AUTH_STATUS.TERMS_REQUIRED
1164
- ]),
1165
- message: z7.string().min(1)
1166
- }).strict();
1167
- var devicePollResponseSchema = z7.union([
1168
- devicePollPendingSchema,
1169
- devicePollApprovedSchema,
1170
- deviceAuthRefusalSchema
1171
- ]);
1172
- var deviceConfirmRequestSchema = z7.object({
1173
- userCode: z7.string().min(1),
1174
- decision: z7.enum(["approve", "decline"]),
1175
- externalAuthId: z7.string().min(1).optional(),
1176
- email: z7.string().optional(),
1177
- acceptTerms: z7.boolean().optional()
1178
- }).strict();
1179
- var deviceConfirmRecordedSchema = z7.object({
1180
- status: z7.literal(DEVICE_AUTH_STATUS.RECORDED),
1181
- accountId: z7.string().min(1).optional()
1182
- }).strict();
1183
- var deviceConfirmResponseSchema = z7.union([
1184
- deviceConfirmRecordedSchema,
1185
- deviceAuthRefusalSchema
1248
+ var buildTaskStateSchema = z7.enum([
1249
+ "pending",
1250
+ "in-flight",
1251
+ "completed",
1252
+ "failed",
1253
+ "blocked"
1186
1254
  ]);
1187
- var LOOPBACK_REDIRECT_HOST = "127.0.0.1";
1188
- var LOOPBACK_PORT_MIN = 1024;
1189
- var LOOPBACK_PORT_MAX = 65535;
1190
- var LOOPBACK_CALLBACK_PATH = "/halfcycle-cli-callback";
1191
- var LOOPBACK_QUERY_PORT = "loopback_port";
1192
- var LOOPBACK_QUERY_STATE = "loopback_state";
1193
- var LOOPBACK_QUERY_RESULT = "result";
1194
- var LOOPBACK_RESULT = {
1195
- APPROVED: "approved",
1196
- DECLINED: "declined"
1197
- };
1198
- var DASHBOARD_URL = "https://app.halfcycle.ai";
1199
- function loopbackVerificationUrl(verificationUrl, target) {
1200
- let url;
1201
- try {
1202
- url = new URL(verificationUrl);
1203
- } catch {
1204
- return verificationUrl;
1255
+ var BUILD_TASK_TITLE_MAX = 240;
1256
+ var BUILD_SNAPSHOT_TASK_LIMIT = 500;
1257
+ var buildTaskSchema = z7.object({
1258
+ // The task's own id, as the coordinator's graph names it.
1259
+ id: z7.string().min(1).max(64),
1260
+ // What the task is, in the coordinator's words. A description of the work,
1261
+ // bounded and scanned — never an excerpt of anybody's code.
1262
+ title: z7.string().min(1).max(BUILD_TASK_TITLE_MAX),
1263
+ // What the task is doing right now.
1264
+ state: buildTaskStateSchema,
1265
+ // Who holds it — a bare callsign, or the value naming a person when the work is
1266
+ // a person's. Absent when nothing has been dispatched to it yet.
1267
+ agent: z7.string().min(1).max(32).optional(),
1268
+ // The kind of work the holder does. Present exactly when an agent holds it.
1269
+ discipline: z7.string().min(1).max(64).optional(),
1270
+ // How much thinking the holder brings. Present exactly when an agent holds it,
1271
+ // and there is no field naming the supplier behind it.
1272
+ tier: z7.string().min(1).max(32).optional(),
1273
+ // The tasks this one cannot start before. Always present; empty means it waits
1274
+ // on nothing, which is different from not having been computed.
1275
+ dependsOn: z7.array(z7.string().min(1).max(64)).max(BUILD_SNAPSHOT_TASK_LIMIT),
1276
+ // The one task this task checks, when it is a check on other work. Absent on a
1277
+ // task that is doing the work rather than checking it.
1278
+ reviews: z7.string().min(1).max(64).optional(),
1279
+ // Which wave of the phase this task belongs to — the wave that writes the
1280
+ // specifications, or the wave that builds them. Absent when the graph does not
1281
+ // record it, which is different from the task belonging to neither.
1282
+ layer: methodLayerSchema.optional(),
1283
+ // When it was picked up, and when it settled. A reader computes how long it
1284
+ // took; neither this row nor this payload carries a duration.
1285
+ startedAt: utcIso8601.optional(),
1286
+ settledAt: utcIso8601.optional()
1287
+ }).strict().superRefine((task, ctx) => {
1288
+ if (task.reviews !== void 0 && task.reviews === task.id) {
1289
+ ctx.addIssue({
1290
+ code: z7.ZodIssueCode.custom,
1291
+ path: ["reviews"],
1292
+ message: `'${task.id}' reviews itself \u2014 a review names the OTHER task it checks.`
1293
+ });
1205
1294
  }
1206
- url.searchParams.set(LOOPBACK_QUERY_PORT, String(target.port));
1207
- url.searchParams.set(LOOPBACK_QUERY_STATE, target.state);
1208
- return url.toString();
1209
- }
1210
-
1211
- // ../events/dist/join-refusal.js
1212
- import { z as z8 } from "zod";
1213
- var NOT_THIS_ACCOUNT_REASON = "not-this-account";
1214
- var JOIN_REFUSAL_STATUS = 403;
1215
- var notThisAccountRefusalSchema = z8.object({
1216
- statusCode: z8.literal(JOIN_REFUSAL_STATUS),
1217
- error: z8.string(),
1218
- message: z8.string(),
1219
- reason: z8.literal(NOT_THIS_ACCOUNT_REASON)
1295
+ if (task.layer !== void 0 && !isPhaseScopedLayer(task.layer)) {
1296
+ ctx.addIssue({
1297
+ code: z7.ZodIssueCode.custom,
1298
+ path: ["layer"],
1299
+ message: `'${task.layer}' is not a wave a phase runs \u2014 expected one of ${PHASE_SCOPED_LAYERS.join(", ")}.`
1300
+ });
1301
+ }
1302
+ const profiled = task.agent !== void 0 && task.agent !== HUMAN_AGENT;
1303
+ for (const field of ["discipline", "tier"]) {
1304
+ if (profiled && task[field] === void 0) {
1305
+ ctx.addIssue({
1306
+ code: z7.ZodIssueCode.custom,
1307
+ path: [field],
1308
+ message: `'${field}' is required on a task an agent holds.`
1309
+ });
1310
+ }
1311
+ if (!profiled && task[field] !== void 0) {
1312
+ ctx.addIssue({
1313
+ code: z7.ZodIssueCode.custom,
1314
+ path: [field],
1315
+ message: task.agent === void 0 ? `'${field}' must be absent on a task nobody holds.` : `'${field}' must be absent on work a person holds.`
1316
+ });
1317
+ }
1318
+ }
1319
+ });
1320
+ var buildPhaseSchema = z7.string().min(1).max(128);
1321
+ var BUILD_PHASE_TITLE_MAX = 160;
1322
+ var buildPhaseTitleSchema = z7.string().min(1).max(BUILD_PHASE_TITLE_MAX);
1323
+ var buildTaskListSchema = z7.array(buildTaskSchema).max(BUILD_SNAPSHOT_TASK_LIMIT).superRefine((tasks, ctx) => {
1324
+ const seen = /* @__PURE__ */ new Set();
1325
+ for (const [index, task] of tasks.entries()) {
1326
+ if (seen.has(task.id)) {
1327
+ ctx.addIssue({
1328
+ code: z7.ZodIssueCode.custom,
1329
+ path: [index, "id"],
1330
+ message: `duplicate task id '${task.id}' \u2014 one graph names each task once.`
1331
+ });
1332
+ }
1333
+ seen.add(task.id);
1334
+ }
1335
+ });
1336
+ var buildSnapshotSchema = z7.object({
1337
+ // Which phase this graph belongs to.
1338
+ phase: buildPhaseSchema,
1339
+ // That phase's name in words, when the project gives it one.
1340
+ phaseTitle: buildPhaseTitleSchema.optional(),
1341
+ // Every task on it. Never a partial graph and never a change since last time.
1342
+ tasks: buildTaskListSchema,
1343
+ // When this graph was last reported.
1344
+ reportedAt: utcIso8601,
1345
+ // When work on this phase was first reported with no approval of its scope in force.
1346
+ builtWithoutApproval: utcIso8601.optional()
1220
1347
  }).strict();
1221
- var joinRefusalSchema = z8.union([notThisAccountRefusalSchema, wireErrorSchema]);
1222
- function isNotThisAccountRefusal(body) {
1223
- return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
1224
- }
1225
-
1226
- // ../events/dist/account-identity.js
1227
- import { z as z9 } from "zod";
1228
- var accountIdentitySchema = z9.object({
1229
- accountId: z9.string().min(1),
1230
- email: z9.string().min(1).optional()
1348
+ var buildPhaseEntrySchema = z7.object({
1349
+ // Which phase has a stored graph.
1350
+ phase: buildPhaseSchema,
1351
+ // That phase's name in words, when the project gave it one.
1352
+ phaseTitle: buildPhaseTitleSchema.optional(),
1353
+ // When that phase's graph was last reported.
1354
+ reportedAt: utcIso8601,
1355
+ // Present when work on this phase was reported with no approval of its scope in force.
1356
+ builtWithoutApproval: z7.literal(true).optional()
1231
1357
  }).strict();
1232
- function safeParseAccountIdentity(payload) {
1233
- return accountIdentitySchema.safeParse(payload);
1234
- }
1235
-
1236
- // ../events/dist/credential-wire.js
1237
- import { z as z10 } from "zod";
1238
- var engagementCredentialResponseSchema = z10.object({
1239
- engagementId: z10.string(),
1240
- sessionToken: z10.string(),
1241
- mcpUrl: z10.string(),
1242
- guardUrl: z10.string(),
1243
- controlTelemetryUrl: z10.string(),
1244
- methodVersion: z10.string().optional(),
1245
- consoleUrl: z10.string().optional()
1358
+ var buildSnapshotReportSchema = z7.object({
1359
+ // Which phase this graph belongs to.
1360
+ phase: buildPhaseSchema,
1361
+ // That phase's name in words, when the project gives it one.
1362
+ phaseTitle: buildPhaseTitleSchema.optional(),
1363
+ // The whole graph, every time. Reporting a change instead of the graph is the
1364
+ // one thing this route does not accept.
1365
+ tasks: buildTaskListSchema
1246
1366
  }).strict();
1247
- var ciTokenExchangeResponseSchema = z10.object({
1248
- token: z10.string(),
1249
- expiresAt: utcIso8601,
1250
- engagementId: z10.string()
1367
+ var buildSnapshotReportResultSchema = z7.object({
1368
+ // What happened. One outcome today; a refusal travels as the shared wire error.
1369
+ outcome: z7.literal("recorded"),
1370
+ // Which phase the graph was recorded against.
1371
+ phase: buildPhaseSchema,
1372
+ // When it was recorded.
1373
+ reportedAt: utcIso8601
1251
1374
  }).strict();
1252
- var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
1253
- var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
1254
- var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
1255
- var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
1256
- var NO_CREDENTIAL_SENTENCE = 'Halfcycle is not connected: this machine has no working Halfcycle credential for this project. Run "npx halfcycle" in the project folder to sign in again.';
1257
- var NOT_THIS_ACCOUNT_SENTENCE = `Halfcycle is not connected: this project belongs to another Halfcycle account. To switch, run "npx halfcycle" in this project and ${SWITCH_ACCOUNT_ANSWER}.`;
1258
- var UPDATE_CLAUDE_CODE_SENTENCE = "Halfcycle is not connected: this Claude Code version does not name the server it connects to, so no credential was sent. Update Claude Code, then reopen.";
1259
- var NO_NODE_SENTENCE = "Halfcycle is not connected: Node is not installed, or not on the path this session uses, so nothing here is checked. Install Node, then reopen.";
1260
- var PLACEHOLDER_SENTENCES = Object.freeze({
1261
- [NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
1262
- [NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
1263
- [UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
1264
- });
1265
1375
 
1266
- // ../events/dist/project-engagement.js
1267
- import { z as z11 } from "zod";
1268
- var projectIdSchema = z11.string().uuid();
1269
- var projectEngagementRequestSchema = z11.object({
1270
- derivedName: z11.string()
1271
- }).strict();
1376
+ // ../events/dist/phase-plan.js
1377
+ import { z as z9 } from "zod";
1272
1378
 
1273
- // ../events/dist/phase-record.js
1274
- import { z as z12 } from "zod";
1275
- var phaseRecordRefusalReasonSchema = z12.enum([
1276
- "still-open",
1277
- "reopened",
1278
- "never-opened",
1279
- "never-closed",
1280
- "closed-before-records"
1281
- ]);
1282
- var phaseRecordRecordSchema = z12.object({
1283
- outcome: z12.literal("record"),
1284
- phase: phaseNameSchema,
1285
- record: buildRecordV2Schema
1286
- }).strict().refine((body) => body.record.phase === body.phase, {
1287
- message: "the record is for a different phase than the one asked for",
1288
- path: ["record", "phase"]
1289
- });
1290
- var phaseRecordWithheldSchema = z12.object({
1291
- outcome: z12.literal("withheld"),
1292
- phase: phaseNameSchema,
1293
- sequence: z12.number().int().min(1),
1294
- closedAt: utcIso8601,
1295
- message: z12.string()
1296
- }).strict();
1297
- var phaseRecordRefusedSchema = wireErrorSchema.extend({
1298
- outcome: z12.literal("refused"),
1299
- phase: phaseNameSchema,
1300
- statusCode: z12.literal(409),
1301
- error: z12.literal("PhaseRecordRefused"),
1302
- reason: phaseRecordRefusalReasonSchema
1303
- }).strict();
1304
- var phaseRecordResponseSchema = z12.discriminatedUnion("outcome", [
1305
- phaseRecordRecordSchema,
1306
- phaseRecordWithheldSchema,
1307
- phaseRecordRefusedSchema
1308
- ]);
1379
+ // ../events/dist/check-problems.js
1380
+ function isRecord(value) {
1381
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1382
+ }
1383
+ function dotted(path) {
1384
+ return path.map((key) => String(key)).join(".");
1385
+ }
1386
+ function isMissingKey(raw, path) {
1387
+ if (path.length === 0)
1388
+ return false;
1389
+ let parent = raw;
1390
+ for (const key of path.slice(0, -1)) {
1391
+ if (!isRecord(parent) && !Array.isArray(parent))
1392
+ return false;
1393
+ parent = parent[key];
1394
+ }
1395
+ return isRecord(parent) && !(String(path[path.length - 1]) in parent);
1396
+ }
1397
+ function shapeProblemsOf(schema, raw, undeclared) {
1398
+ const parsed = schema.safeParse(raw);
1399
+ if (parsed.success)
1400
+ return [];
1401
+ const problems = [];
1402
+ for (const issue of parsed.error.issues) {
1403
+ if (issue.code === "unrecognized_keys") {
1404
+ for (const key of issue.keys) {
1405
+ problems.push({ path: dotted([...issue.path, key]), message: undeclared });
1406
+ }
1407
+ } else if (isMissingKey(raw, issue.path)) {
1408
+ problems.push({ path: dotted(issue.path), message: "is required" });
1409
+ } else {
1410
+ problems.push({ path: dotted(issue.path), message: issue.message });
1411
+ }
1412
+ }
1413
+ return problems;
1414
+ }
1415
+ function shown(value) {
1416
+ return looksLikeSecret(value) ? "a value that looks like a credential" : `"${value}"`;
1417
+ }
1309
1418
 
1310
1419
  // ../events/dist/system-description.js
1311
- import { z as z13 } from "zod";
1420
+ import { z as z8 } from "zod";
1312
1421
  var SYSTEM_DESCRIPTION_FORMAT = 1;
1313
1422
  var SYSTEM_PARTS_MAX = 60;
1314
1423
  var SYSTEM_CONNECTIONS_MAX = 400;
@@ -1320,11 +1429,11 @@ var SYSTEM_PART_DESCRIPTION_MAX = 160;
1320
1429
  var SYSTEM_CONNECTION_LABEL_MAX = 60;
1321
1430
  var SYSTEM_PART_ID_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
1322
1431
  var SYSTEM_LANES = ["clients", "edge", "services", "data", "external"];
1323
- var systemLaneSchema = z13.enum(SYSTEM_LANES, {
1432
+ var systemLaneSchema = z8.enum(SYSTEM_LANES, {
1324
1433
  error: `must be one of ${SYSTEM_LANES.join(", ")}`
1325
1434
  });
1326
1435
  var SYSTEM_CONNECTION_KINDS = ["call", "event", "write", "external"];
1327
- var systemConnectionKindSchema = z13.enum(SYSTEM_CONNECTION_KINDS, {
1436
+ var systemConnectionKindSchema = z8.enum(SYSTEM_CONNECTION_KINDS, {
1328
1437
  error: `must be one of ${SYSTEM_CONNECTION_KINDS.join(", ")}`
1329
1438
  });
1330
1439
  var LINE_SEPARATOR = /[\u2028\u2029]/;
@@ -1333,7 +1442,7 @@ function descriptionText(max) {
1333
1442
  message: "must be one line: it holds a line or paragraph separator"
1334
1443
  });
1335
1444
  }
1336
- var systemPartIdSchema = z13.string().regex(SYSTEM_PART_ID_PATTERN, {
1445
+ var systemPartIdSchema = z8.string().regex(SYSTEM_PART_ID_PATTERN, {
1337
1446
  message: "must be a short id: a lowercase letter, then lowercase letters, digits or hyphens, 40 characters at most"
1338
1447
  }).refine((v) => !looksLikeSecret(v), { message: "looks like a password or key; choose another id" });
1339
1448
  function hasEmptySegment(pattern) {
@@ -1349,74 +1458,39 @@ var SEGMENT_CHARACTERS = /^[A-Za-z0-9._\-*]*$/;
1349
1458
  function hasForeignCharacter(pattern) {
1350
1459
  return pattern.split("/").some((segment) => !SEGMENT_CHARACTERS.test(segment));
1351
1460
  }
1352
- var systemOwnedPatternSchema = z13.string().max(SYSTEM_OWNED_PATTERN_MAX, { message: `must be at most ${SYSTEM_OWNED_PATTERN_MAX} characters` }).refine((v) => !hasEmptySegment(v), {
1461
+ var systemOwnedPatternSchema = z8.string().max(SYSTEM_OWNED_PATTERN_MAX, { message: `must be at most ${SYSTEM_OWNED_PATTERN_MAX} characters` }).refine((v) => !hasEmptySegment(v), {
1353
1462
  message: "must not start or end with a slash, or have two slashes together"
1354
1463
  }).refine((v) => !hasDotSegment(v), { message: 'must not have a "." or ".." folder in it' }).refine((v) => !hasPartialDoubleStar(v), { message: '"**" must stand alone between slashes' }).refine((v) => !hasForeignCharacter(v), {
1355
1464
  message: 'may use only letters, digits, ".", "_", "-" and "*" between slashes'
1356
1465
  }).refine((v) => !looksLikeSecret(v), {
1357
1466
  message: "this path looks like a credential; list a shorter folder above it"
1358
1467
  });
1359
- var systemPartSchema = z13.object({
1468
+ var systemPartSchema = z8.object({
1360
1469
  id: systemPartIdSchema,
1361
1470
  name: descriptionText(SYSTEM_PART_NAME_MAX),
1362
1471
  description: descriptionText(SYSTEM_PART_DESCRIPTION_MAX),
1363
1472
  lane: systemLaneSchema,
1364
- slot: z13.number({ error: `must be a whole number from 0 to ${SYSTEM_SLOT_MAX}` }).int({ error: `must be a whole number from 0 to ${SYSTEM_SLOT_MAX}` }).min(0, { error: `must be a whole number from 0 to ${SYSTEM_SLOT_MAX}` }).max(SYSTEM_SLOT_MAX, { error: `must be a whole number from 0 to ${SYSTEM_SLOT_MAX}` }),
1365
- outside: z13.boolean(),
1366
- owns: z13.array(systemOwnedPatternSchema).max(SYSTEM_OWNS_MAX, { message: `can list at most ${SYSTEM_OWNS_MAX} paths` })
1473
+ slot: z8.number({ error: `must be a whole number from 0 to ${SYSTEM_SLOT_MAX}` }).int({ error: `must be a whole number from 0 to ${SYSTEM_SLOT_MAX}` }).min(0, { error: `must be a whole number from 0 to ${SYSTEM_SLOT_MAX}` }).max(SYSTEM_SLOT_MAX, { error: `must be a whole number from 0 to ${SYSTEM_SLOT_MAX}` }),
1474
+ outside: z8.boolean(),
1475
+ owns: z8.array(systemOwnedPatternSchema).max(SYSTEM_OWNS_MAX, { message: `can list at most ${SYSTEM_OWNS_MAX} paths` })
1367
1476
  }).strict();
1368
- var systemConnectionSchema = z13.object({
1477
+ var systemConnectionSchema = z8.object({
1369
1478
  from: systemPartIdSchema,
1370
1479
  to: systemPartIdSchema,
1371
1480
  kind: systemConnectionKindSchema,
1372
1481
  label: descriptionText(SYSTEM_CONNECTION_LABEL_MAX)
1373
1482
  }).strict();
1374
- var systemDescriptionSchema = z13.object({
1375
- format: z13.literal(SYSTEM_DESCRIPTION_FORMAT, { error: `must be ${SYSTEM_DESCRIPTION_FORMAT}` }),
1376
- parts: z13.array(systemPartSchema).min(1, { message: "must list at least one part" }).max(SYSTEM_PARTS_MAX, { message: `can hold at most ${SYSTEM_PARTS_MAX} parts (five lanes of twelve slots)` }),
1377
- connections: z13.array(systemConnectionSchema).max(SYSTEM_CONNECTIONS_MAX, { message: `can hold at most ${SYSTEM_CONNECTIONS_MAX} connections` })
1483
+ var systemDescriptionSchema = z8.object({
1484
+ format: z8.literal(SYSTEM_DESCRIPTION_FORMAT, { error: `must be ${SYSTEM_DESCRIPTION_FORMAT}` }),
1485
+ parts: z8.array(systemPartSchema).min(1, { message: "must list at least one part" }).max(SYSTEM_PARTS_MAX, { message: `can hold at most ${SYSTEM_PARTS_MAX} parts (five lanes of twelve slots)` }),
1486
+ connections: z8.array(systemConnectionSchema).max(SYSTEM_CONNECTIONS_MAX, { message: `can hold at most ${SYSTEM_CONNECTIONS_MAX} connections` })
1378
1487
  }).strict();
1379
- var systemDescriptionProblemSchema = z13.object({
1380
- path: z13.string(),
1381
- message: z13.string()
1488
+ var systemDescriptionProblemSchema = z8.object({
1489
+ path: z8.string(),
1490
+ message: z8.string()
1382
1491
  }).strict();
1383
- function isRecord(value) {
1384
- return typeof value === "object" && value !== null && !Array.isArray(value);
1385
- }
1386
- function dotted(path) {
1387
- return path.map((key) => String(key)).join(".");
1388
- }
1389
- function isMissingKey(raw, path) {
1390
- if (path.length === 0)
1391
- return false;
1392
- let parent = raw;
1393
- for (const key of path.slice(0, -1)) {
1394
- if (!isRecord(parent) && !Array.isArray(parent))
1395
- return false;
1396
- parent = parent[key];
1397
- }
1398
- return isRecord(parent) && !(String(path[path.length - 1]) in parent);
1399
- }
1400
1492
  function shapeProblems(raw) {
1401
- const parsed = systemDescriptionSchema.safeParse(raw);
1402
- if (parsed.success)
1403
- return [];
1404
- const problems = [];
1405
- for (const issue of parsed.error.issues) {
1406
- if (issue.code === "unrecognized_keys") {
1407
- for (const key of issue.keys) {
1408
- problems.push({ path: dotted([...issue.path, key]), message: "is not a field a description has" });
1409
- }
1410
- } else if (isMissingKey(raw, issue.path)) {
1411
- problems.push({ path: dotted(issue.path), message: "is required" });
1412
- } else {
1413
- problems.push({ path: dotted(issue.path), message: issue.message });
1414
- }
1415
- }
1416
- return problems;
1417
- }
1418
- function shown(value) {
1419
- return looksLikeSecret(value) ? "a value that looks like a credential" : `"${value}"`;
1493
+ return shapeProblemsOf(systemDescriptionSchema, raw, "is not a field a description has");
1420
1494
  }
1421
1495
  function quoted(value) {
1422
1496
  return typeof value === "string" ? shown(value) : "another part";
@@ -1573,19 +1647,19 @@ function ownerOf(path, description) {
1573
1647
  }
1574
1648
  return best === null ? null : best.id;
1575
1649
  }
1576
- var systemReportRecordedSchema = z13.object({
1577
- outcome: z13.literal("recorded"),
1578
- version: z13.number().int().min(1),
1650
+ var systemReportRecordedSchema = z8.object({
1651
+ outcome: z8.literal("recorded"),
1652
+ version: z8.number().int().min(1),
1579
1653
  reportedAt: utcIso8601
1580
1654
  }).strict();
1581
- var systemReportRefusedSchema = z13.object({
1582
- outcome: z13.literal("refused"),
1583
- problems: z13.array(systemDescriptionProblemSchema).min(1)
1655
+ var systemReportRefusedSchema = z8.object({
1656
+ outcome: z8.literal("refused"),
1657
+ problems: z8.array(systemDescriptionProblemSchema).min(1)
1584
1658
  }).strict();
1585
- var systemReportUnavailableSchema = z13.object({
1586
- outcome: z13.literal("unavailable")
1659
+ var systemReportUnavailableSchema = z8.object({
1660
+ outcome: z8.literal("unavailable")
1587
1661
  }).strict();
1588
- var systemReportResultSchema = z13.discriminatedUnion("outcome", [
1662
+ var systemReportResultSchema = z8.discriminatedUnion("outcome", [
1589
1663
  systemReportRecordedSchema,
1590
1664
  systemReportRefusedSchema,
1591
1665
  systemReportUnavailableSchema
@@ -1594,10 +1668,19 @@ var SYSTEM_DRAW_SVG_MAX_BYTES = 2 * 1024 * 1024;
1594
1668
  var SYSTEM_DRAW_MERMAID_MAX_BYTES = 256 * 1024;
1595
1669
  var SYSTEM_DRAW_PNG_MAX_BYTES = 8 * 1024 * 1024;
1596
1670
  var SYSTEM_DIAGRAM_MARKER_LINE = "<!-- drawn by Halfcycle from docs/system.json -->";
1597
- var systemDrawRequestSchema = z13.object({
1598
- version: z13.number().int().min(1),
1599
- picture: z13.literal("architecture")
1671
+ var systemDrawArchitectureRequestSchema = z8.object({
1672
+ picture: z8.literal("architecture"),
1673
+ version: z8.number().int().min(1)
1674
+ }).strict();
1675
+ var systemDrawPhaseRequestSchema = z8.object({
1676
+ picture: z8.literal("phase"),
1677
+ phase: phaseIdentitySchema,
1678
+ planVersion: z8.number().int().min(1)
1600
1679
  }).strict();
1680
+ var systemDrawRequestSchema = z8.discriminatedUnion("picture", [
1681
+ systemDrawArchitectureRequestSchema,
1682
+ systemDrawPhaseRequestSchema
1683
+ ]);
1601
1684
  function utf8ByteLength2(text) {
1602
1685
  let bytes = 0;
1603
1686
  for (let i = 0; i < text.length; i++) {
@@ -1625,31 +1708,458 @@ function base64DecodedLength(text) {
1625
1708
  return text.length / 4 * 3 - padding;
1626
1709
  }
1627
1710
  var SYSTEM_PICTURE_THEMES = ["broadsheet", "flightdeck"];
1628
- var systemPictureSchema = z13.object({
1629
- svg: z13.string().min(1).refine((v) => utf8ByteLength2(v) <= SYSTEM_DRAW_SVG_MAX_BYTES, { message: "the picture is larger than 2 MB" }),
1630
- png: z13.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" })
1711
+ var systemPictureSchema = z8.object({
1712
+ svg: z8.string().min(1).refine((v) => utf8ByteLength2(v) <= SYSTEM_DRAW_SVG_MAX_BYTES, { message: "the picture is larger than 2 MB" }),
1713
+ png: z8.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" })
1631
1714
  }).strict();
1632
- var systemPicturesSchema = z13.object(Object.fromEntries(SYSTEM_PICTURE_THEMES.map((theme) => [theme, systemPictureSchema]))).strict();
1633
- var systemDrawDrawnSchema = z13.object({
1634
- outcome: z13.literal("drawn"),
1635
- mermaid: z13.string().min(1).refine((v) => utf8ByteLength2(v) <= SYSTEM_DRAW_MERMAID_MAX_BYTES, {
1715
+ var systemPicturesSchema = /* @__PURE__ */ z8.object(Object.fromEntries(SYSTEM_PICTURE_THEMES.map((theme) => [theme, systemPictureSchema]))).strict();
1716
+ var systemDrawDrawnSchema = /* @__PURE__ */ z8.object({
1717
+ outcome: z8.literal("drawn"),
1718
+ mermaid: z8.string().min(1).refine((v) => utf8ByteLength2(v) <= SYSTEM_DRAW_MERMAID_MAX_BYTES, {
1636
1719
  message: "the diagram text is larger than 256 KB"
1637
1720
  }),
1638
1721
  pictures: systemPicturesSchema
1639
1722
  }).strict();
1640
- var systemDrawRefusedSchema = z13.object({
1641
- outcome: z13.literal("refused"),
1642
- reason: z13.string().min(1)
1723
+ var systemDrawRefusedSchema = z8.object({
1724
+ outcome: z8.literal("refused"),
1725
+ reason: z8.string().min(1)
1643
1726
  }).strict();
1644
- var systemDrawUnavailableSchema = z13.object({
1645
- outcome: z13.literal("unavailable")
1727
+ var systemDrawUnavailableSchema = z8.object({
1728
+ outcome: z8.literal("unavailable")
1646
1729
  }).strict();
1647
- var systemDrawResultSchema = z13.discriminatedUnion("outcome", [
1730
+ var systemDrawResultSchema = /* @__PURE__ */ z8.discriminatedUnion("outcome", [
1648
1731
  systemDrawDrawnSchema,
1649
1732
  systemDrawRefusedSchema,
1650
1733
  systemDrawUnavailableSchema
1651
1734
  ]);
1652
1735
 
1736
+ // ../events/dist/phase-plan.js
1737
+ var PHASE_PLAN_FORMAT = 1;
1738
+ var PHASE_PLAN_CHANGES_MAX = 60;
1739
+ var PHASE_PLAN_SUMMARY_MAX = 160;
1740
+ var SCOPE_NOTE_MAX = 500;
1741
+ var PHASE_PLAN_PHASE_MAX = buildPhaseSchema.maxLength ?? 0;
1742
+ var PHASE_PLAN_CHANGE_KINDS = ["add", "change", "remove"];
1743
+ var phasePlanChangeSchema = z9.enum(PHASE_PLAN_CHANGE_KINDS, {
1744
+ error: `must be one of ${PHASE_PLAN_CHANGE_KINDS.join(", ")}`
1745
+ });
1746
+ var phasePlanPhaseSchema = phaseIdentitySchema.refine((v) => !isValidPhaseIdentity(v) || buildPhaseSchema.safeParse(String(v)).success, { message: `must be at most ${PHASE_PLAN_PHASE_MAX} characters` });
1747
+ var phasePlanSummarySchema = descriptionText(PHASE_PLAN_SUMMARY_MAX);
1748
+ var phasePlanEntrySchema = z9.object({
1749
+ part: systemPartIdSchema,
1750
+ change: phasePlanChangeSchema,
1751
+ summary: phasePlanSummarySchema
1752
+ }).strict();
1753
+ var phasePlanSchema = z9.object({
1754
+ format: z9.literal(PHASE_PLAN_FORMAT, { error: `must be ${PHASE_PLAN_FORMAT}` }),
1755
+ phase: phasePlanPhaseSchema,
1756
+ changes: z9.array(phasePlanEntrySchema).min(1, { message: "must list at least one part" }).max(PHASE_PLAN_CHANGES_MAX, { message: `can list at most ${PHASE_PLAN_CHANGES_MAX} parts` })
1757
+ }).strict();
1758
+ function planCrossFieldProblems(raw, description) {
1759
+ const problems = [];
1760
+ if (!isRecord(raw) || !Array.isArray(raw.changes))
1761
+ return problems;
1762
+ const known = new Set(description.parts.map((p) => p.id));
1763
+ const named = /* @__PURE__ */ new Map();
1764
+ raw.changes.forEach((change, i) => {
1765
+ if (!isRecord(change) || typeof change.part !== "string")
1766
+ return;
1767
+ if (!known.has(change.part)) {
1768
+ problems.push({ path: `changes.${i}.part`, message: `names no part in the system description: ${shown(change.part)}` });
1769
+ }
1770
+ const earlier = named.get(change.part);
1771
+ if (earlier === void 0) {
1772
+ named.set(change.part, i);
1773
+ } else {
1774
+ problems.push({ path: `changes.${i}.part`, message: `${shown(change.part)} is already listed at changes.${earlier}` });
1775
+ }
1776
+ });
1777
+ return problems;
1778
+ }
1779
+ function checkPhasePlan(value, description) {
1780
+ const problems = [
1781
+ ...shapeProblemsOf(phasePlanSchema, value, "is not a field a plan has"),
1782
+ ...planCrossFieldProblems(value, description)
1783
+ ];
1784
+ if (problems.length > 0)
1785
+ return { ok: false, problems };
1786
+ return { ok: true, plan: phasePlanSchema.parse(value) };
1787
+ }
1788
+ var scopePhaseSchema = buildPhaseSchema.refine((v) => isValidPhaseIdentity(v), {
1789
+ message: "must be a phase name"
1790
+ });
1791
+ var SCOPE_STATE_NAMES = [
1792
+ "not-planned",
1793
+ "waiting",
1794
+ "sent-back",
1795
+ "approved",
1796
+ "partly-stale",
1797
+ "withdrawn"
1798
+ ];
1799
+ var scopeStateNameSchema = z9.enum(SCOPE_STATE_NAMES);
1800
+ var plannedScopeStateSchema = scopeStateNameSchema.exclude(["not-planned"]);
1801
+ var SCOPE_PART_STATUSES = ["approved", "waiting"];
1802
+ var scopePartStatusSchema = z9.enum(SCOPE_PART_STATUSES);
1803
+ var SCOPE_ACTS = ["approve", "send-back", "withdraw"];
1804
+ var scopeActSchema = z9.enum(SCOPE_ACTS);
1805
+ var SCOPE_GIVEN_IN = ["console", "chat"];
1806
+ var scopeGivenInSchema = z9.enum(SCOPE_GIVEN_IN);
1807
+ var scopeNoteSchema = descriptionText(SCOPE_NOTE_MAX);
1808
+ var scopeNoteWireSchema = z9.string().max(SCOPE_NOTE_MAX);
1809
+ var SCOPE_PART_VERDICTS = ["ok", "change"];
1810
+ var scopePartVerdictSchema = z9.enum(SCOPE_PART_VERDICTS);
1811
+ var scopeDecisionPartSchema = z9.object({
1812
+ part: systemPartIdSchema,
1813
+ verdict: scopePartVerdictSchema,
1814
+ note: scopeNoteWireSchema.optional()
1815
+ }).strict();
1816
+ var planVersionSchema = z9.number().int().min(1);
1817
+ var phasePlanRequestSchema = z9.object({
1818
+ descriptionVersion: z9.number().int().min(1),
1819
+ plan: z9.unknown()
1820
+ }).strict();
1821
+ var phasePlanRecordedSchema = z9.object({
1822
+ outcome: z9.literal("recorded"),
1823
+ version: planVersionSchema,
1824
+ phaseNumber: z9.number().int().min(1),
1825
+ state: plannedScopeStateSchema,
1826
+ changedParts: z9.array(systemPartIdSchema).max(PHASE_PLAN_CHANGES_MAX),
1827
+ typedApprovalOffered: z9.boolean(),
1828
+ reportedAt: utcIso8601
1829
+ }).strict();
1830
+ var phasePlanRefusedSchema = z9.object({
1831
+ outcome: z9.literal("refused"),
1832
+ problems: z9.array(systemDescriptionProblemSchema).min(1)
1833
+ }).strict();
1834
+ var phasePlanUnavailableSchema = z9.object({
1835
+ outcome: z9.literal("unavailable")
1836
+ }).strict();
1837
+ var phasePlanResultSchema = z9.discriminatedUnion("outcome", [
1838
+ phasePlanRecordedSchema,
1839
+ phasePlanRefusedSchema,
1840
+ phasePlanUnavailableSchema
1841
+ ]);
1842
+ var scopeApproveRequestSchema = z9.object({
1843
+ act: z9.literal("approve"),
1844
+ planVersion: planVersionSchema,
1845
+ parts: z9.array(scopeDecisionPartSchema).max(PHASE_PLAN_CHANGES_MAX)
1846
+ }).strict();
1847
+ var scopeSendBackRequestSchema = z9.object({
1848
+ act: z9.literal("send-back"),
1849
+ planVersion: planVersionSchema,
1850
+ parts: z9.array(scopeDecisionPartSchema).max(PHASE_PLAN_CHANGES_MAX),
1851
+ note: scopeNoteWireSchema.optional()
1852
+ }).strict();
1853
+ var scopeWithdrawRequestSchema = z9.object({
1854
+ act: z9.literal("withdraw"),
1855
+ planVersion: planVersionSchema
1856
+ }).strict();
1857
+ var scopeDecisionRequestSchema = z9.discriminatedUnion("act", [
1858
+ scopeApproveRequestSchema,
1859
+ scopeSendBackRequestSchema,
1860
+ scopeWithdrawRequestSchema
1861
+ ]);
1862
+ var scopeDecisionRecordedSchema = z9.object({
1863
+ outcome: z9.literal("recorded"),
1864
+ act: scopeActSchema,
1865
+ phase: scopePhaseSchema,
1866
+ planVersion: planVersionSchema,
1867
+ state: plannedScopeStateSchema,
1868
+ decidedAt: utcIso8601
1869
+ }).strict();
1870
+ var scopeDecisionAlreadyApprovedSchema = z9.object({
1871
+ outcome: z9.literal("already-approved"),
1872
+ replanNeeded: z9.boolean(),
1873
+ reason: z9.string().min(1)
1874
+ }).strict();
1875
+ var scopeDecisionRefusedSchema = z9.object({
1876
+ outcome: z9.literal("refused"),
1877
+ problems: z9.array(systemDescriptionProblemSchema).min(1)
1878
+ }).strict();
1879
+ var scopeDecisionUnavailableSchema = z9.object({
1880
+ outcome: z9.literal("unavailable")
1881
+ }).strict();
1882
+ function reasonOutcome(outcome) {
1883
+ return z9.object({ outcome: z9.literal(outcome), reason: z9.string().min(1) }).strict();
1884
+ }
1885
+ var scopeDecisionResultSchema = z9.discriminatedUnion("outcome", [
1886
+ scopeDecisionRecordedSchema,
1887
+ scopeDecisionAlreadyApprovedSchema,
1888
+ scopeDecisionRefusedSchema,
1889
+ scopeDecisionUnavailableSchema,
1890
+ reasonOutcome("not-owner"),
1891
+ reasonOutcome("no-owner"),
1892
+ reasonOutcome("plan-changed"),
1893
+ reasonOutcome("nothing-to-withdraw"),
1894
+ reasonOutcome("build-started")
1895
+ ]);
1896
+ var scopeReadPartSchema = z9.object({
1897
+ part: systemPartIdSchema,
1898
+ name: systemPartSchema.shape.name,
1899
+ change: phasePlanChangeSchema,
1900
+ summary: phasePlanSummarySchema,
1901
+ status: scopePartStatusSchema,
1902
+ changed: z9.boolean()
1903
+ }).strict();
1904
+ var scopeReadDecisionSchema = z9.object({
1905
+ act: scopeActSchema,
1906
+ planVersion: planVersionSchema,
1907
+ givenIn: scopeGivenInSchema,
1908
+ at: utcIso8601,
1909
+ parts: z9.array(scopeDecisionPartSchema).max(PHASE_PLAN_CHANGES_MAX),
1910
+ note: scopeNoteWireSchema.optional()
1911
+ }).strict();
1912
+ var scopeReadNotPlannedSchema = z9.object({
1913
+ phase: scopePhaseSchema,
1914
+ state: z9.literal("not-planned"),
1915
+ canDecide: z9.boolean()
1916
+ }).strict();
1917
+ var scopeReadPlannedSchema = z9.object({
1918
+ phase: scopePhaseSchema,
1919
+ state: plannedScopeStateSchema,
1920
+ phaseNumber: z9.number().int().min(1),
1921
+ planVersion: planVersionSchema,
1922
+ parts: z9.array(scopeReadPartSchema).min(1).max(PHASE_PLAN_CHANGES_MAX),
1923
+ decisions: z9.array(scopeReadDecisionSchema),
1924
+ canDecide: z9.boolean(),
1925
+ replanLine: z9.string().min(1).optional()
1926
+ }).strict();
1927
+ var scopeReadSchema = z9.union([scopeReadNotPlannedSchema, scopeReadPlannedSchema]);
1928
+
1929
+ // ../events/dist/client-version.js
1930
+ var CHAT_APPROVAL_CLIENT_VERSION = "0.4.7";
1931
+ function clientMeetsChatApprovalFloor(version) {
1932
+ return isReleaseVersion(version) && compareReleaseVersions(version, CHAT_APPROVAL_CLIENT_VERSION) >= 0;
1933
+ }
1934
+ var TYPED_APPROVAL_OFF_SENTENCE = "Approving by typing is switched off for now.";
1935
+ var CLIENT_UPDATE_SENTENCE = `To approve by typing in a session, Halfcycle needs updating: run \`${HALFCYCLE_COMMAND}\` in this project's folder, then reopen your session and ask again.`;
1936
+
1937
+ // ../events/dist/device-auth.js
1938
+ import { z as z10 } from "zod";
1939
+ var utcIso86013 = z10.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z");
1940
+ var DEVICE_AUTH_STATUS = {
1941
+ /** The poll: minted, nobody has acted yet. Keep polling. 200. */
1942
+ PENDING: "pending",
1943
+ /** The poll: approved, and the credential is in this body. Once only. 200. */
1944
+ APPROVED: "approved",
1945
+ /** Confirm: the decision was recorded (approve or decline). 200. */
1946
+ RECORDED: "recorded",
1947
+ /** The human refused at the confirmation page. 400 on the poll. */
1948
+ DECLINED: "declined",
1949
+ /** The code's absolute expiry has passed. 400. */
1950
+ EXPIRED: "expired",
1951
+ /** Never minted, or already spent. 400. */
1952
+ UNKNOWN: "unknown",
1953
+ /** Confirm: neither `approve` nor `decline` was named. Nothing was bound. 400. */
1954
+ INVALID_DECISION: "invalid-decision",
1955
+ /** Confirm: an approval arrived with no signed-in identity. 400. */
1956
+ IDENTITY_REQUIRED: "identity-required",
1957
+ /** This deployment has no `SIGNIN_BASE_URL` / `DEVICE_AUTH_CONFIRM_SECRET`. 503. */
1958
+ NOT_CONFIGURED: "not-configured",
1959
+ /** Confirm: the shared confirmation secret was missing or wrong. 401. */
1960
+ UNAUTHORIZED: "unauthorized",
1961
+ /** Confirm: the code was already approved, declined or claimed. 409. */
1962
+ ALREADY_DECIDED: "already-decided",
1963
+ /**
1964
+ * Confirm: an approval named a signed-in identity whose account has not accepted
1965
+ * the terms of service and privacy policy currently in force. 400.
1966
+ *
1967
+ * NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
1968
+ * says nothing about the code itself — the code is still `pending` after this
1969
+ * response, exactly as it was before the request, so the SAME userCode can be
1970
+ * confirmed again once the caller has recorded acceptance. `deviceConfirmRequestSchema`'s
1971
+ * `acceptTerms` is how a second confirm says it did.
1972
+ */
1973
+ TERMS_REQUIRED: "terms-required"
1974
+ };
1975
+ var DEVICE_POLL_REFUSALS = [
1976
+ DEVICE_AUTH_STATUS.DECLINED,
1977
+ DEVICE_AUTH_STATUS.EXPIRED,
1978
+ DEVICE_AUTH_STATUS.UNKNOWN
1979
+ ];
1980
+ var deviceAuthStartResponseSchema = z10.object({
1981
+ deviceCode: z10.string().min(1),
1982
+ userCode: z10.string().min(1),
1983
+ verificationUrl: z10.string().min(1),
1984
+ expiresAt: utcIso86013,
1985
+ pollIntervalMs: z10.number().int().positive()
1986
+ }).strict();
1987
+ var devicePollPendingSchema = z10.object({
1988
+ status: z10.literal(DEVICE_AUTH_STATUS.PENDING),
1989
+ pollIntervalMs: z10.number().int().positive()
1990
+ }).strict();
1991
+ var devicePollApprovedSchema = z10.object({
1992
+ status: z10.literal(DEVICE_AUTH_STATUS.APPROVED),
1993
+ accountId: z10.string().min(1),
1994
+ credential: z10.string().min(1),
1995
+ expiresAt: utcIso86013
1996
+ }).strict();
1997
+ var deviceAuthRefusalSchema = z10.object({
1998
+ status: z10.enum([
1999
+ DEVICE_AUTH_STATUS.DECLINED,
2000
+ DEVICE_AUTH_STATUS.EXPIRED,
2001
+ DEVICE_AUTH_STATUS.UNKNOWN,
2002
+ DEVICE_AUTH_STATUS.INVALID_DECISION,
2003
+ DEVICE_AUTH_STATUS.IDENTITY_REQUIRED,
2004
+ DEVICE_AUTH_STATUS.NOT_CONFIGURED,
2005
+ DEVICE_AUTH_STATUS.UNAUTHORIZED,
2006
+ DEVICE_AUTH_STATUS.ALREADY_DECIDED,
2007
+ DEVICE_AUTH_STATUS.TERMS_REQUIRED
2008
+ ]),
2009
+ message: z10.string().min(1)
2010
+ }).strict();
2011
+ var devicePollResponseSchema = z10.union([
2012
+ devicePollPendingSchema,
2013
+ devicePollApprovedSchema,
2014
+ deviceAuthRefusalSchema
2015
+ ]);
2016
+ var deviceConfirmRequestSchema = z10.object({
2017
+ userCode: z10.string().min(1),
2018
+ decision: z10.enum(["approve", "decline"]),
2019
+ externalAuthId: z10.string().min(1).optional(),
2020
+ email: z10.string().optional(),
2021
+ acceptTerms: z10.boolean().optional()
2022
+ }).strict();
2023
+ var deviceConfirmRecordedSchema = z10.object({
2024
+ status: z10.literal(DEVICE_AUTH_STATUS.RECORDED),
2025
+ accountId: z10.string().min(1).optional()
2026
+ }).strict();
2027
+ var deviceConfirmResponseSchema = z10.union([
2028
+ deviceConfirmRecordedSchema,
2029
+ deviceAuthRefusalSchema
2030
+ ]);
2031
+ var LOOPBACK_REDIRECT_HOST = "127.0.0.1";
2032
+ var LOOPBACK_PORT_MIN = 1024;
2033
+ var LOOPBACK_PORT_MAX = 65535;
2034
+ var LOOPBACK_CALLBACK_PATH = "/halfcycle-cli-callback";
2035
+ var LOOPBACK_QUERY_PORT = "loopback_port";
2036
+ var LOOPBACK_QUERY_STATE = "loopback_state";
2037
+ var LOOPBACK_QUERY_RESULT = "result";
2038
+ var LOOPBACK_RESULT = {
2039
+ APPROVED: "approved",
2040
+ DECLINED: "declined"
2041
+ };
2042
+ var DASHBOARD_URL = "https://app.halfcycle.ai";
2043
+ function loopbackVerificationUrl(verificationUrl, target) {
2044
+ let url;
2045
+ try {
2046
+ url = new URL(verificationUrl);
2047
+ } catch {
2048
+ return verificationUrl;
2049
+ }
2050
+ url.searchParams.set(LOOPBACK_QUERY_PORT, String(target.port));
2051
+ url.searchParams.set(LOOPBACK_QUERY_STATE, target.state);
2052
+ return url.toString();
2053
+ }
2054
+
2055
+ // ../events/dist/join-refusal.js
2056
+ import { z as z11 } from "zod";
2057
+ var NOT_THIS_ACCOUNT_REASON = "not-this-account";
2058
+ var JOIN_REFUSAL_STATUS = 403;
2059
+ var notThisAccountRefusalSchema = z11.object({
2060
+ statusCode: z11.literal(JOIN_REFUSAL_STATUS),
2061
+ error: z11.string(),
2062
+ message: z11.string(),
2063
+ reason: z11.literal(NOT_THIS_ACCOUNT_REASON)
2064
+ }).strict();
2065
+ var joinRefusalSchema = z11.union([notThisAccountRefusalSchema, wireErrorSchema]);
2066
+ function isNotThisAccountRefusal(body) {
2067
+ return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
2068
+ }
2069
+
2070
+ // ../events/dist/project-page.js
2071
+ function projectPageUrl(engagementId) {
2072
+ return isEngagementId(engagementId) ? `${DASHBOARD_URL}/${encodeURIComponent(engagementId)}` : void 0;
2073
+ }
2074
+ function requestAccessSentence(engagementId) {
2075
+ const page = projectPageUrl(engagementId) ?? "this project's page on Halfcycle";
2076
+ return `To ask for access, open ${page} signed in as this account and choose "Request access". Then let the project's owner know, so they can approve it.`;
2077
+ }
2078
+
2079
+ // ../events/dist/account-identity.js
2080
+ import { z as z12 } from "zod";
2081
+ var accountIdentitySchema = z12.object({
2082
+ accountId: z12.string().min(1),
2083
+ email: z12.string().min(1).optional()
2084
+ }).strict();
2085
+ function safeParseAccountIdentity(payload) {
2086
+ return accountIdentitySchema.safeParse(payload);
2087
+ }
2088
+
2089
+ // ../events/dist/credential-wire.js
2090
+ import { z as z13 } from "zod";
2091
+ var engagementCredentialResponseSchema = z13.object({
2092
+ engagementId: z13.string(),
2093
+ sessionToken: z13.string(),
2094
+ mcpUrl: z13.string(),
2095
+ guardUrl: z13.string(),
2096
+ controlTelemetryUrl: z13.string(),
2097
+ methodVersion: z13.string().optional(),
2098
+ consoleUrl: z13.string().optional()
2099
+ }).strict();
2100
+ var ciTokenExchangeResponseSchema = z13.object({
2101
+ token: z13.string(),
2102
+ expiresAt: utcIso8601,
2103
+ engagementId: z13.string()
2104
+ }).strict();
2105
+ var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
2106
+ var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
2107
+ var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
2108
+ var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
2109
+ var NO_CREDENTIAL_SENTENCE = 'Halfcycle is not connected: this machine has no working Halfcycle credential for this project. Run "npx halfcycle" in the project folder to sign in again.';
2110
+ var NOT_THIS_ACCOUNT_SENTENCE = `Halfcycle is not connected: this project belongs to another Halfcycle account. To switch, run "npx halfcycle" in this project and ${SWITCH_ACCOUNT_ANSWER}.`;
2111
+ var UPDATE_CLAUDE_CODE_SENTENCE = "Halfcycle is not connected: this Claude Code version does not name the server it connects to, so no credential was sent. Update Claude Code, then reopen.";
2112
+ var NO_NODE_SENTENCE = "Halfcycle is not connected: Node is not installed, or not on the path this session uses, so nothing here is checked. Install Node, then reopen.";
2113
+ var PLACEHOLDER_SENTENCES = Object.freeze({
2114
+ [NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
2115
+ [NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
2116
+ [UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
2117
+ });
2118
+
2119
+ // ../events/dist/project-engagement.js
2120
+ import { z as z14 } from "zod";
2121
+ var projectIdSchema = z14.string().uuid();
2122
+ var projectEngagementRequestSchema = z14.object({
2123
+ derivedName: z14.string()
2124
+ }).strict();
2125
+
2126
+ // ../events/dist/phase-record.js
2127
+ import { z as z15 } from "zod";
2128
+ var phaseRecordRefusalReasonSchema = z15.enum([
2129
+ "still-open",
2130
+ "reopened",
2131
+ "never-opened",
2132
+ "never-closed",
2133
+ "closed-before-records"
2134
+ ]);
2135
+ var phaseRecordRecordSchema = z15.object({
2136
+ outcome: z15.literal("record"),
2137
+ phase: phaseNameSchema,
2138
+ record: buildRecordV2Schema
2139
+ }).strict().refine((body) => body.record.phase === body.phase, {
2140
+ message: "the record is for a different phase than the one asked for",
2141
+ path: ["record", "phase"]
2142
+ });
2143
+ var phaseRecordWithheldSchema = z15.object({
2144
+ outcome: z15.literal("withheld"),
2145
+ phase: phaseNameSchema,
2146
+ sequence: z15.number().int().min(1),
2147
+ closedAt: utcIso8601,
2148
+ message: z15.string()
2149
+ }).strict();
2150
+ var phaseRecordRefusedSchema = wireErrorSchema.extend({
2151
+ outcome: z15.literal("refused"),
2152
+ phase: phaseNameSchema,
2153
+ statusCode: z15.literal(409),
2154
+ error: z15.literal("PhaseRecordRefused"),
2155
+ reason: phaseRecordRefusalReasonSchema
2156
+ }).strict();
2157
+ var phaseRecordResponseSchema = z15.discriminatedUnion("outcome", [
2158
+ phaseRecordRecordSchema,
2159
+ phaseRecordWithheldSchema,
2160
+ phaseRecordRefusedSchema
2161
+ ]);
2162
+
1653
2163
  // dist/engagement-credential.js
1654
2164
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1655
2165
  import { platform as platform2 } from "node:os";
@@ -2005,17 +2515,17 @@ function fileSha256(bytes) {
2005
2515
  const text = typeof bytes === "string" ? bytes : Buffer2.from(bytes).toString("utf-8");
2006
2516
  return sha256Hex(text.split("\n").map(lineKey).join("\n"));
2007
2517
  }
2008
- function canonicalJson(value) {
2518
+ function canonicalJson2(value) {
2009
2519
  if (Array.isArray(value))
2010
- return value.map(canonicalJson);
2520
+ return value.map(canonicalJson2);
2011
2521
  if (value !== null && typeof value === "object") {
2012
- const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, canonicalJson(v)]);
2522
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, canonicalJson2(v)]);
2013
2523
  return Object.fromEntries(entries);
2014
2524
  }
2015
2525
  return value;
2016
2526
  }
2017
2527
  function canonicalSha256(value) {
2018
- return sha256Hex(JSON.stringify(canonicalJson(value)));
2528
+ return sha256Hex(JSON.stringify(canonicalJson2(value)));
2019
2529
  }
2020
2530
  function byString(a, b) {
2021
2531
  return a < b ? -1 : a > b ? 1 : 0;
@@ -2034,7 +2544,7 @@ function serializeManifest(manifest) {
2034
2544
  leftAlone: [...manifest.leftAlone].sort(byString),
2035
2545
  settings
2036
2546
  };
2037
- return JSON.stringify(canonicalJson(ordered), null, 2) + "\n";
2547
+ return JSON.stringify(canonicalJson2(ordered), null, 2) + "\n";
2038
2548
  }
2039
2549
  var UnreadableManifestError = class extends Error {
2040
2550
  constructor(reason) {
@@ -2287,11 +2797,6 @@ function nodeFloorProgram(guarded) {
2287
2797
  return `if(+process.versions.node.split('.')[0]<${NODE_FLOOR_MAJOR}){console.error('${NODE_FLOOR_SENTENCE_HEAD}'+process.versions.node);process.exitCode=1}else{${guarded}}`;
2288
2798
  }
2289
2799
 
2290
- // dist/project-page.js
2291
- function projectPageUrl(engagementId) {
2292
- return isEngagementId(engagementId) ? `${DASHBOARD_URL}/${encodeURIComponent(engagementId)}` : void 0;
2293
- }
2294
-
2295
2800
  // dist/merge-settings.js
2296
2801
  function isHalfcycleCommand(hook, halfcycleCommands) {
2297
2802
  if (typeof hook !== "object" || hook === null)
@@ -2540,7 +3045,7 @@ function recordSettingsMerge(draft, existing, generated, preexisted) {
2540
3045
  const generatedHooks = generated.hooks ?? {};
2541
3046
  const hookCommands = Object.values(generatedHooks).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
2542
3047
  const ours = /* @__PURE__ */ new Set([...hookCommands, ...HALFCYCLE_HOOK_COMMANDS]);
2543
- const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : void 0;
3048
+ const existingHooks = isPlainObject2(existing.hooks) ? existing.hooks : void 0;
2544
3049
  const heldOurs = Object.values(existingHooks ?? {}).some((entries) => Array.isArray(entries) && entries.some((entry) => isHalfcycleEntry(entry, ours)));
2545
3050
  const existingDeny = Array.isArray(existing.permissions?.deny) ? existing.permissions.deny : [];
2546
3051
  const schemaChanged = generated.$schema !== void 0 && existing.$schema !== generated.$schema;
@@ -2558,12 +3063,12 @@ function recordSettingsMerge(draft, existing, generated, preexisted) {
2558
3063
  }
2559
3064
  function recordMcpMerge(draft, existingText, writtenText) {
2560
3065
  const before = existingText === null ? void 0 : JSON.parse(existingText);
2561
- const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
3066
+ const servers = isPlainObject2(before) ? before["mcpServers"] : void 0;
2562
3067
  const written = JSON.parse(writtenText);
2563
3068
  recordMcp(draft, {
2564
3069
  created: existingText === null,
2565
- adopted: draft.previous === null && isPlainObject(servers) && MCP_SERVER_KEY in servers,
2566
- mcpServersCreated: !(isPlainObject(before) && "mcpServers" in before),
3070
+ adopted: draft.previous === null && isPlainObject2(servers) && MCP_SERVER_KEY in servers,
3071
+ mcpServersCreated: !(isPlainObject2(before) && "mcpServers" in before),
2567
3072
  entrySha256: canonicalSha256(written.mcpServers[MCP_SERVER_KEY])
2568
3073
  });
2569
3074
  }
@@ -2576,8 +3081,8 @@ function recordMcpAdoptionOnly(draft, existingText) {
2576
3081
  } catch {
2577
3082
  return;
2578
3083
  }
2579
- const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
2580
- if (!isPlainObject(servers) || !(MCP_SERVER_KEY in servers))
3084
+ const servers = isPlainObject2(before) ? before["mcpServers"] : void 0;
3085
+ if (!isPlainObject2(servers) || !(MCP_SERVER_KEY in servers))
2581
3086
  return;
2582
3087
  recordMcp(draft, { created: false, adopted: true, mcpServersCreated: false });
2583
3088
  }
@@ -2662,7 +3167,7 @@ function readFileOrNull(path) {
2662
3167
  return null;
2663
3168
  }
2664
3169
  }
2665
- function isPlainObject(value) {
3170
+ function isPlainObject2(value) {
2666
3171
  return value !== null && typeof value === "object" && !Array.isArray(value);
2667
3172
  }
2668
3173
  function readPluginManifest() {
@@ -3010,6 +3515,54 @@ printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
3010
3515
  exit 0
3011
3516
  `;
3012
3517
  }
3518
+ var GENERATED_USER_PROMPT_SCOPE_HEADER = "# Typed scope approval \u2014 generated by";
3519
+ function generateUserPromptScopeHook() {
3520
+ return `#!/usr/bin/env bash
3521
+ ${GENERATED_USER_PROMPT_SCOPE_HEADER} the Halfcycle installer.
3522
+ #
3523
+ # Lets the owner of this project approve a phase's scope, or send it back, by
3524
+ # typing "approve <phase>" or "send back <phase>: <note>" as the whole prompt in
3525
+ # their own Claude Code session.
3526
+ #
3527
+ # AN ORDINARY PROMPT STARTS NOTHING. This script reads the hook input once and
3528
+ # looks only at the start of the prompt's value. Unless the prompt begins, after
3529
+ # any whitespace (typed, or escaped in the input), with "approve " or
3530
+ # "send back " in any case, it exits 0 with no output, and Node never starts.
3531
+ # A quote inside any other field arrives escaped, so no other field (the folder
3532
+ # the session is in, for one) can read as the start of the prompt.
3533
+ #
3534
+ # Otherwise it finds the project root from its own location, never from the
3535
+ # folder the session has moved to, and hands the same input to the guard
3536
+ # wrapper beside it. That decides whether the line counts and prints at most
3537
+ # one plain line. Nothing here ever blocks a prompt.
3538
+ #
3539
+ # FAILS OPEN up to that hand-over: any error before it exits 0 with no output.
3540
+ # After it, the wrapper's own exit stands.
3541
+ trap 'exit 0' ERR
3542
+ set -e
3543
+
3544
+ # Read with the shell's own builtin, so the check below is the only program an
3545
+ # ordinary prompt starts.
3546
+ HC_INPUT=""
3547
+ IFS= read -r -d '' HC_INPUT || true
3548
+
3549
+ # What may come before the phrase: whitespace as typed, as a short escape, as a
3550
+ # \\u escape, or as the UTF-8 bytes of a Unicode space. Compared byte for byte.
3551
+ HC_LEAD='[[:space:]]|\\\\[nrtf]|\\\\u(000[9a-d]|0020|00a0|1680|200[0-9a]|202[89f]|205f|3000|feff)'
3552
+ 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'
3553
+ if ! printf '%s' "$HC_INPUT" | LC_ALL=C grep -Eiq "\\"prompt\\"[[:space:]]*:[[:space:]]*\\"($HC_LEAD)*(approve|send back) "; then
3554
+ exit 0
3555
+ fi
3556
+
3557
+ HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
3558
+ HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
3559
+ export CLAUDE_PROJECT_DIR="$HC_PROJECT_ROOT"
3560
+
3561
+ trap - ERR
3562
+ set +e
3563
+ exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-submit <<<"$HC_INPUT"
3564
+ `;
3565
+ }
3013
3566
  var CI_STANZA_KNOWN_HEADER_HASHES = [
3014
3567
  "b225800b2d699811f32ac213238cbf81c35b5e6a74114bdf55e39cd9d7e2928f",
3015
3568
  "674181735460f051af7c157222cf851fc3000fb3b8af3a10ea0bde9fe5eaba1f",
@@ -3085,6 +3638,7 @@ function removeRetiredMcpHeadersHelper(targetRepo) {
3085
3638
  var OWNED_GENERATED_HEADERS = {
3086
3639
  ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
3087
3640
  ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
3641
+ ".claude/hooks/user-prompt-scope.sh": GENERATED_USER_PROMPT_SCOPE_HEADER,
3088
3642
  ".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
3089
3643
  ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
3090
3644
  };
@@ -3093,6 +3647,7 @@ function hookScriptsThisVersionWrites() {
3093
3647
  [".claude/hooks/guard-runner.sh", generateGuardRunnerWrapper()],
3094
3648
  [".claude/hooks/session-end-marker.sh", generateSessionEndMarker()],
3095
3649
  [".claude/hooks/user-prompt-reminder.sh", generateUserPromptReminderHook()],
3650
+ [".claude/hooks/user-prompt-scope.sh", generateUserPromptScopeHook()],
3096
3651
  [".claude/hooks/session-start-marker.sh", generateSessionStartMarker()]
3097
3652
  ];
3098
3653
  }
@@ -3237,6 +3792,25 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, c
3237
3792
  const pinText = inLineEndingOf(readFileOrNull(pinPath), JSON.stringify(pin, null, 2) + "\n");
3238
3793
  writeAllowlisted(pinPath, targetRepoRoot, pinText, writtenPaths);
3239
3794
  }
3795
+ function updatePinnedMethodVersion(repoRoot, engagementId, version) {
3796
+ let pin;
3797
+ try {
3798
+ pin = readBundlePin(repoRoot);
3799
+ } catch {
3800
+ return;
3801
+ }
3802
+ if (pin === null || pin.engagementId !== engagementId)
3803
+ return;
3804
+ const pinPath = join5(repoRoot, BUNDLE_PIN_REL);
3805
+ const updated = { ...pin, methodVersion: version };
3806
+ const pinText = inLineEndingOf(readFileOrNull(pinPath), JSON.stringify(updated, null, 2) + "\n");
3807
+ try {
3808
+ writeAllowlisted(pinPath, repoRoot, pinText, []);
3809
+ } catch {
3810
+ process.stderr.write(`[halfcycle] Could not update ${BUNDLE_PIN_REL} with the new method version.
3811
+ `);
3812
+ }
3813
+ }
3240
3814
  function writeCrewRoster(targetRepoRoot, report) {
3241
3815
  const doc = {
3242
3816
  $comment: "GENERATED by the Halfcycle installer \u2014 do not edit by hand. Re-run the installer to pick up a roster change.",
@@ -4851,10 +5425,10 @@ var SYSTEM_FOLDERS = [
4851
5425
  "C:\\Program Files",
4852
5426
  "C:\\Program Files (x86)"
4853
5427
  ];
4854
- function refuseSystemFolder(targetRepo, platform4 = process.platform) {
5428
+ function refuseSystemFolder(targetRepo, platform5 = process.platform) {
4855
5429
  const key = (folder) => {
4856
5430
  const real = realOrLiteral(folder);
4857
- return platform4 === "win32" ? real.toLowerCase() : real;
5431
+ return platform5 === "win32" ? real.toLowerCase() : real;
4858
5432
  };
4859
5433
  const target = key(targetRepo);
4860
5434
  if (SYSTEM_FOLDERS.some((folder) => key(folder) === target)) {
@@ -4987,11 +5561,11 @@ import { execFileSync as execFileSync3 } from "node:child_process";
4987
5561
  import { existsSync as existsSync5, realpathSync as realpathSync2 } from "node:fs";
4988
5562
  import { delimiter as delimiter2, join as join7 } from "node:path";
4989
5563
  function probeClaudeCodeVersion(timeoutMs, deps = {}) {
4990
- const platform4 = deps.platform ?? process.platform;
5564
+ const platform5 = deps.platform ?? process.platform;
4991
5565
  const exec = deps.exec ?? execFileSync3;
4992
5566
  let raw;
4993
5567
  try {
4994
- raw = platform4 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", '"claude --version"'], {
5568
+ raw = platform5 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", '"claude --version"'], {
4995
5569
  encoding: "utf-8",
4996
5570
  timeout: timeoutMs,
4997
5571
  stdio: ["ignore", "pipe", "ignore"],
@@ -5021,7 +5595,7 @@ var INSTALL_METHOD_RULES = [
5021
5595
  ];
5022
5596
  function probeClaudeCodeInstallMethod(deps = {}) {
5023
5597
  const env = deps.env ?? process.env;
5024
- const platform4 = deps.platform ?? process.platform;
5598
+ const platform5 = deps.platform ?? process.platform;
5025
5599
  const exists = deps.exists ?? existsSync5;
5026
5600
  const realpath = deps.realpath ?? ((file) => {
5027
5601
  try {
@@ -5030,7 +5604,7 @@ function probeClaudeCodeInstallMethod(deps = {}) {
5030
5604
  return file;
5031
5605
  }
5032
5606
  });
5033
- const names = platform4 === "win32" ? ["claude.exe", "claude.cmd"] : ["claude"];
5607
+ const names = platform5 === "win32" ? ["claude.exe", "claude.cmd"] : ["claude"];
5034
5608
  const dirs = (env["PATH"] ?? "").split(delimiter2).filter((dir) => dir !== "");
5035
5609
  for (const dir of dirs) {
5036
5610
  for (const name of names) {
@@ -5413,7 +5987,7 @@ function renderBuildRecordV2Markdown(record2) {
5413
5987
  `Phase: ${phase} (the name the client gave it)`,
5414
5988
  OBSERVED_LABEL,
5415
5989
  [
5416
- `- Method version: ${p.text(record2.observed.methodVersion)}. The Halfcycle method is updated from time to time, and each update has its own version number. This is the one the project was run under; it does not change during a project.`,
5990
+ `- Method version: ${p.text(record2.observed.methodVersion)}. The Halfcycle method is updated from time to time, and each update has its own version number. This is the one the phase was run under. A project moves to a newer version only between phases.`,
5417
5991
  `- Project identifier: ${p.text(record2.observed.engagementId)}. Halfcycle keeps this project's records under this identifier.`,
5418
5992
  `- Record format: ${p.text(record2.format)}. This names the layout this file and the .json file beside it follow, for software that reads them.`
5419
5993
  ].join("\n"),
@@ -5672,6 +6246,10 @@ function refetchCommand(phase) {
5672
6246
  // dist/open-phase.js
5673
6247
  import { readFileSync as readFileSync8 } from "node:fs";
5674
6248
  import { join as join9 } from "node:path";
6249
+ function readMethodUpdate(value) {
6250
+ const parsed = methodUpdateSchema.safeParse(value);
6251
+ return parsed.success ? parsed.data : null;
6252
+ }
5675
6253
  function describeOpen(opened) {
5676
6254
  const { phase, previousPhase } = opened;
5677
6255
  if (previousPhase === phase) {
@@ -5781,13 +6359,18 @@ async function openPhase(credential, phase, entry, home) {
5781
6359
  phase: body.phase,
5782
6360
  previousPhase: body.previousPhase,
5783
6361
  stampFailure,
5784
- decision: body.decision
6362
+ decision: body.decision,
6363
+ methodUpdated: readMethodUpdate(body.methodUpdated)
5785
6364
  };
5786
6365
  }
5787
6366
 
5788
6367
  // dist/close-phase.js
5789
6368
  import { execFileSync as execFileSync4 } from "node:child_process";
5790
6369
  var NO_STATEMENTS = { thesis: null, outcome: null, deviations: [] };
6370
+ function readMethodUpdate2(value) {
6371
+ const parsed = methodUpdateSchema.safeParse(value);
6372
+ return parsed.success ? parsed.data : null;
6373
+ }
5791
6374
  function readCloseCommit(repoRoot) {
5792
6375
  if (repoRoot === void 0) {
5793
6376
  return {
@@ -5876,7 +6459,13 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot, sta
5876
6459
  throw new Error(`[halfcycle] The response from ${url} did not carry { engagementId, status }. The phase may or may not have closed \u2014 read the engagement's status before retrying.`);
5877
6460
  }
5878
6461
  const stampFailure = writePhaseStamp(credential.engagementId, null, home);
5879
- return { engagementId: answer.engagementId, status: answer.status, stampFailure, closeCommit };
6462
+ return {
6463
+ engagementId: answer.engagementId,
6464
+ status: answer.status,
6465
+ stampFailure,
6466
+ closeCommit,
6467
+ methodUpdated: readMethodUpdate2(answer.methodUpdated)
6468
+ };
5880
6469
  }
5881
6470
  function describeIssuePath(path) {
5882
6471
  const [head, field] = path;
@@ -5965,8 +6554,8 @@ function unownedPatterns(description, tracked) {
5965
6554
  }
5966
6555
  return found;
5967
6556
  }
5968
- function unownedPatternLines(unowned) {
5969
- return unowned.map((u) => `halfcycle system: warning: "${u.pattern}" (part "${u.partId}") matches no file in this repository. Correct the path, or remove it if the code moved.
6557
+ function unownedPatternLines(unowned, verb) {
6558
+ return unowned.map((u) => `halfcycle ${verb}: warning: "${u.pattern}" (part "${u.partId}") matches no file in this repository. Correct the path, or remove it if the code moved.
5970
6559
  `).join("");
5971
6560
  }
5972
6561
  function systemReportUrl(credential) {
@@ -6062,8 +6651,8 @@ async function checkAndReportDescription(repoRoot, deps, verb, beforeSend = () =
6062
6651
  };
6063
6652
  }
6064
6653
  const unowned = unownedPatterns(checked.description, deps.listTracked(repoRoot));
6065
- const warnings = unowned === null ? "" : unownedPatternLines(unowned);
6066
- const refusal = beforeSend();
6654
+ const warnings = unowned === null ? "" : unownedPatternLines(unowned, verb);
6655
+ const refusal = beforeSend(checked.description);
6067
6656
  if (refusal !== null)
6068
6657
  return { kind: "stopped", stderr: warnings + refusal };
6069
6658
  let credential;
@@ -6116,17 +6705,351 @@ async function runSystem(repoRoot, deps) {
6116
6705
  }
6117
6706
  }
6118
6707
 
6708
+ // dist/plan.js
6709
+ import { chmodSync as chmodSync3, mkdirSync as mkdirSync7, readFileSync as readFileSync10, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync6 } from "node:fs";
6710
+ import { platform as platform4 } from "node:os";
6711
+ import { join as join11 } from "node:path";
6712
+ var PLAN_USAGE = `${HALFCYCLE_COMMAND} plan <phase> [--show] [--repo <root>]`;
6713
+ var PLAN_DIR_REL = "docs/phases";
6714
+ function planFileRel(phase) {
6715
+ return `${PLAN_DIR_REL}/${renderPhaseSegment(phase)}.plan.json`;
6716
+ }
6717
+ var PLAN_CHANGE_LABELS = {
6718
+ add: "Will be added",
6719
+ change: "Will change",
6720
+ remove: "Will be removed"
6721
+ };
6722
+ function parsePlanArgs(argv) {
6723
+ const positionals = [];
6724
+ let repoRoot;
6725
+ let show = false;
6726
+ for (let i = 0; i < argv.length; i++) {
6727
+ const arg = argv[i];
6728
+ if (arg === "--repo") {
6729
+ const value = argv[i + 1];
6730
+ if (repoRoot !== void 0 || value === void 0 || value === "" || value.startsWith("-"))
6731
+ return { ok: false };
6732
+ repoRoot = value;
6733
+ i++;
6734
+ continue;
6735
+ }
6736
+ if (arg === "--show") {
6737
+ if (show)
6738
+ return { ok: false };
6739
+ show = true;
6740
+ continue;
6741
+ }
6742
+ if (arg.startsWith("-"))
6743
+ return { ok: false };
6744
+ positionals.push(arg);
6745
+ }
6746
+ if (positionals.length !== 1)
6747
+ return { ok: false };
6748
+ return { ok: true, phase: positionals[0], show, repoRoot };
6749
+ }
6750
+ function planProblemPlace(raw, fileRel, path) {
6751
+ if (path === "")
6752
+ return fileRel;
6753
+ const [list, index] = path.split(".");
6754
+ const position = Number(index);
6755
+ if (list !== "changes" || !Number.isInteger(position) || position < 0)
6756
+ return path;
6757
+ const changes = typeof raw === "object" && raw !== null ? raw["changes"] : void 0;
6758
+ const item = Array.isArray(changes) ? changes[position] : void 0;
6759
+ const part = typeof item === "object" && item !== null ? item["part"] : void 0;
6760
+ return typeof part === "string" && part !== "" ? `${path} (part "${part}")` : `${path} (part ${position + 1})`;
6761
+ }
6762
+ function planProblemLines(raw, fileRel, problems) {
6763
+ return problems.map((p) => ` ${planProblemPlace(raw, fileRel, p.path)}: ${p.message}
6764
+ `).join("");
6765
+ }
6766
+ function readAndCheckPlan(repoRoot, phase, description, verb) {
6767
+ const p = `halfcycle ${verb}:`;
6768
+ const fileRel = planFileRel(phase);
6769
+ let text;
6770
+ try {
6771
+ text = readFileSync10(join11(repoRoot, fileRel), "utf-8");
6772
+ } catch (err) {
6773
+ const code = err.code;
6774
+ if (code === "ENOENT" || code === "ENOTDIR") {
6775
+ return {
6776
+ ok: false,
6777
+ line: `${p} there is no ${fileRel} in this project yet. Write the plan in the scoping step, then run this again. Nothing was sent.
6778
+ `
6779
+ };
6780
+ }
6781
+ return {
6782
+ ok: false,
6783
+ line: `${p} ${fileRel} could not be read. Check that it is a file you can open, then run this again. Nothing was sent.
6784
+ `
6785
+ };
6786
+ }
6787
+ let raw;
6788
+ try {
6789
+ raw = JSON.parse(text);
6790
+ } catch (err) {
6791
+ const why = err instanceof Error ? err.message : String(err);
6792
+ return { ok: false, line: `${p} ${fileRel} is not valid JSON (${why}). Fix it, then run this again. Nothing was sent.
6793
+ ` };
6794
+ }
6795
+ const named = typeof raw === "object" && raw !== null ? raw["phase"] : void 0;
6796
+ if (isValidPhaseIdentity(named) && renderPhaseSegment(named) !== renderPhaseSegment(phase)) {
6797
+ return {
6798
+ ok: false,
6799
+ line: `${p} ${fileRel} is the plan for phase "${String(named)}", not phase "${phase}". Run this for the phase the plan names, or correct the plan's phase. Nothing was sent.
6800
+ `
6801
+ };
6802
+ }
6803
+ const checked = checkPhasePlan(raw, description);
6804
+ if (!checked.ok) {
6805
+ const n = checked.problems.length;
6806
+ return {
6807
+ ok: false,
6808
+ line: `${p} ${fileRel} has ${n} ${n === 1 ? "problem" : "problems"}, so nothing was sent:
6809
+ ` + planProblemLines(raw, fileRel, checked.problems)
6810
+ };
6811
+ }
6812
+ return { ok: true, plan: checked.plan };
6813
+ }
6814
+ function planReportUrl(credential, phase) {
6815
+ return `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phases/${encodeURIComponent(String(phase))}/plan`;
6816
+ }
6817
+ function serviceHost2(serviceUrl) {
6818
+ try {
6819
+ return new URL(serviceUrl).host;
6820
+ } catch {
6821
+ return serviceUrl;
6822
+ }
6823
+ }
6824
+ async function reportPhasePlan(credential, plan, descriptionVersion) {
6825
+ const body = { descriptionVersion, plan };
6826
+ let res;
6827
+ let text;
6828
+ try {
6829
+ res = await fetch(planReportUrl(credential, plan.phase), {
6830
+ method: "POST",
6831
+ headers: { authorization: `Bearer ${credential.token}`, "content-type": "application/json" },
6832
+ body: JSON.stringify(body)
6833
+ });
6834
+ text = await res.text();
6835
+ } catch {
6836
+ return {
6837
+ kind: "unavailable",
6838
+ why: `could not reach Halfcycle at ${serviceHost2(credential.serviceUrl)}. Check this machine's connection, then run this again.`
6839
+ };
6840
+ }
6841
+ let raw = null;
6842
+ try {
6843
+ raw = JSON.parse(text);
6844
+ } catch {
6845
+ raw = null;
6846
+ }
6847
+ const parsed = phasePlanResultSchema.safeParse(raw);
6848
+ if (parsed.success) {
6849
+ const answer = parsed.data;
6850
+ if (answer.outcome === "recorded") {
6851
+ return {
6852
+ kind: "recorded",
6853
+ version: answer.version,
6854
+ phaseNumber: answer.phaseNumber,
6855
+ state: answer.state,
6856
+ changedParts: answer.changedParts,
6857
+ typedApprovalOffered: answer.typedApprovalOffered
6858
+ };
6859
+ }
6860
+ if (answer.outcome === "refused")
6861
+ return { kind: "refused", problems: answer.problems };
6862
+ return {
6863
+ kind: "unavailable",
6864
+ why: "Halfcycle could not record the plan just now. Nothing was recorded; run this again in a minute."
6865
+ };
6866
+ }
6867
+ if (isMissingRoute("POST", res.status, raw))
6868
+ return { kind: "no-route" };
6869
+ if (res.ok) {
6870
+ return {
6871
+ kind: "unavailable",
6872
+ why: "Halfcycle sent an answer this command could not read. The plan may not be recorded; run this again later."
6873
+ };
6874
+ }
6875
+ const message = typeof raw === "object" && raw !== null && typeof raw.message === "string" ? raw.message : null;
6876
+ return {
6877
+ kind: "unavailable",
6878
+ why: `Halfcycle answered ${res.status}${message === null ? "." : `: ${message}`}`
6879
+ };
6880
+ }
6881
+ function writeShownPlan(engagementId, entry, home) {
6882
+ let temp = null;
6883
+ try {
6884
+ const dir = engagementStateDir(engagementId, home);
6885
+ const path = join11(dir, SHOWN_PLANS_FILENAME);
6886
+ let existing = [];
6887
+ try {
6888
+ existing = parseShownPlans(readFileSync10(path, "utf-8"));
6889
+ } catch {
6890
+ existing = [];
6891
+ }
6892
+ const kept = existing.filter((e) => e.phase !== entry.phase && e.number !== entry.number);
6893
+ const entries = [...kept, entry];
6894
+ const body = serialiseShownPlans(entries);
6895
+ mkdirSync7(dir, { recursive: true, mode: 448 });
6896
+ temp = join11(dir, `.${SHOWN_PLANS_FILENAME}.${process.pid}.tmp`);
6897
+ writeFileSync6(temp, body, { mode: 384 });
6898
+ if (platform4() !== "win32")
6899
+ chmodSync3(temp, 384);
6900
+ renameSync2(temp, path);
6901
+ return { ok: true, entries };
6902
+ } catch {
6903
+ if (temp !== null) {
6904
+ try {
6905
+ unlinkSync(temp);
6906
+ } catch {
6907
+ }
6908
+ }
6909
+ return { ok: false };
6910
+ }
6911
+ }
6912
+ function readPinnedClientVersion(repoRoot) {
6913
+ try {
6914
+ const version = readBundlePin(repoRoot)?.version;
6915
+ return typeof version === "string" ? version : void 0;
6916
+ } catch {
6917
+ return void 0;
6918
+ }
6919
+ }
6920
+ function planRefusedLines(verb, phase, plan, problems) {
6921
+ const fileRel = planFileRel(phase);
6922
+ const n = problems.length;
6923
+ return `halfcycle ${verb}: Halfcycle refused ${fileRel} (${n} ${n === 1 ? "problem" : "problems"}); the plan was not recorded:
6924
+ ` + planProblemLines(plan, fileRel, problems);
6925
+ }
6926
+ function notAPhaseNameLine(verb, phase) {
6927
+ return `halfcycle ${verb}: "${phase}" is not a phase name: a name cannot be empty, start with a dot, or hold a slash, a backslash or a control character. Nothing was sent.
6928
+ `;
6929
+ }
6930
+ function planStateSentence(state) {
6931
+ switch (state) {
6932
+ case "waiting":
6933
+ return "Waiting for the owner's approval.";
6934
+ case "approved":
6935
+ return "The owner has approved this plan.";
6936
+ case "partly-stale":
6937
+ return "The owner approved an earlier plan; the parts marked as changed wait for the owner's approval.";
6938
+ case "sent-back":
6939
+ return "The owner sent this plan back. Change it to answer the owner's notes, then run this again.";
6940
+ case "withdrawn":
6941
+ return "The owner withdrew their approval of this plan. It waits for the owner's approval again.";
6942
+ }
6943
+ }
6944
+ function approvalLine(phase, number, typedApprovalOffered, pinnedVersion, shown2) {
6945
+ if (!typedApprovalOffered)
6946
+ return TYPED_APPROVAL_OFF_SENTENCE;
6947
+ if (!clientMeetsChatApprovalFloor(pinnedVersion))
6948
+ return CLIENT_UPDATE_SENTENCE;
6949
+ const byNumber = String(number);
6950
+ const numberReachesThis = byNumber !== phase && resolvePhaseReference(byNumber, shown2)?.phase === phase;
6951
+ const typed = numberReachesThis ? `\`approve ${phase}\` (or \`approve ${byNumber}\`)` : `\`approve ${phase}\``;
6952
+ return `To approve it, the project's owner types ${typed} in their own Claude Code session. Only the owner can.`;
6953
+ }
6954
+ function changeLines(plan, description, changed) {
6955
+ const names = new Map(description.parts.map((part) => [part.id, part.name]));
6956
+ const marked = new Set(changed);
6957
+ return plan.changes.map((c) => {
6958
+ const mark = marked.has(c.part) ? " (changed since the owner last decided)" : "";
6959
+ return ` - ${names.get(c.part) ?? c.part} \xB7 ${PLAN_CHANGE_LABELS[c.change]} \xB7 ${c.summary}${mark}
6960
+ `;
6961
+ }).join("");
6962
+ }
6963
+ async function runPlan(repoRoot, phase, show, deps) {
6964
+ const p = "halfcycle plan:";
6965
+ if (!isValidPhaseIdentity(phase))
6966
+ return { stdout: "", stderr: notAPhaseNameLine("plan", phase), exitCode: 1 };
6967
+ let checkedPlan = null;
6968
+ let checkedDescription = null;
6969
+ const stage = await checkAndReportDescription(repoRoot, deps, "plan", (description2) => {
6970
+ const checked = readAndCheckPlan(repoRoot, phase, description2, "plan");
6971
+ if (!checked.ok)
6972
+ return checked.line;
6973
+ checkedPlan = checked.plan;
6974
+ checkedDescription = description2;
6975
+ return null;
6976
+ });
6977
+ if (stage.kind === "stopped")
6978
+ return { stdout: "", stderr: stage.stderr, exitCode: 1 };
6979
+ const cannotTakeYet = `${p} this Halfcycle service cannot take a phase plan yet. Nothing was recorded.
6980
+ `;
6981
+ if (stage.kind === "no-route")
6982
+ return { stdout: "", stderr: stage.warnings + cannotTakeYet, exitCode: 1 };
6983
+ const warnings = stage.warnings;
6984
+ const plan = checkedPlan;
6985
+ const description = checkedDescription;
6986
+ if (plan === null || description === null) {
6987
+ return { stdout: "", stderr: warnings + `${p} the plan was not checked, so nothing more was sent.
6988
+ `, exitCode: 1 };
6989
+ }
6990
+ const outcome = await deps.reportPlan(stage.credential, plan, stage.version);
6991
+ switch (outcome.kind) {
6992
+ case "no-route":
6993
+ return { stdout: "", stderr: warnings + cannotTakeYet, exitCode: 1 };
6994
+ case "unavailable":
6995
+ return { stdout: "", stderr: warnings + `${p} ${outcome.why}
6996
+ `, exitCode: 1 };
6997
+ case "refused":
6998
+ return { stdout: "", stderr: warnings + planRefusedLines("plan", phase, plan, outcome.problems), exitCode: 1 };
6999
+ case "recorded":
7000
+ break;
7001
+ }
7002
+ const shownPhase = String(plan.phase);
7003
+ const recorded = `[halfcycle] Plan for ${shownPhase} recorded: plan version ${outcome.version} (phase ${outcome.phaseNumber}).`;
7004
+ const stateSentence = planStateSentence(outcome.state);
7005
+ if (!show)
7006
+ return { stdout: `${recorded} ${stateSentence}
7007
+ `, stderr: warnings, exitCode: 0 };
7008
+ const entry = {
7009
+ phase: shownPhase,
7010
+ number: outcome.phaseNumber,
7011
+ version: outcome.version,
7012
+ shownAt: deps.now().toISOString()
7013
+ };
7014
+ const written = deps.recordShown(stage.credential.engagementId, entry);
7015
+ const listed = `${recorded}
7016
+ ` + changeLines(plan, description, outcome.changedParts) + `${stateSentence}
7017
+ `;
7018
+ if (!written.ok) {
7019
+ return {
7020
+ stdout: listed,
7021
+ stderr: warnings + `${p} the list above could not be recorded on this machine, so typing an approval for it would not work. Check that the .halfcycle folder in your home folder can be written to, then run this again.
7022
+ `,
7023
+ exitCode: 1
7024
+ };
7025
+ }
7026
+ const stdout = listed + `${approvalLine(shownPhase, outcome.phaseNumber, outcome.typedApprovalOffered, deps.pinnedClientVersion(repoRoot), written.entries)}
7027
+ `;
7028
+ return { stdout, stderr: warnings, exitCode: 0 };
7029
+ }
7030
+
6119
7031
  // dist/draw/write.js
6120
- import { closeSync as closeSync2, lstatSync as lstatSync2, mkdirSync as mkdirSync7, openSync as openSync2, readFileSync as readFileSync10, readSync, realpathSync as realpathSync3, rmSync as rmSync4 } from "node:fs";
6121
- import { dirname as dirname5, isAbsolute as isAbsolute3, join as join11, relative as relative4, resolve as resolve3, sep } from "node:path";
7032
+ import { closeSync as closeSync2, lstatSync as lstatSync2, mkdirSync as mkdirSync8, openSync as openSync2, readFileSync as readFileSync11, readSync, realpathSync as realpathSync3, rmSync as rmSync4 } from "node:fs";
7033
+ import { dirname as dirname5, isAbsolute as isAbsolute3, join as join12, relative as relative4, resolve as resolve3, sep } from "node:path";
6122
7034
  var DOCS_DIR = "docs";
6123
7035
  var DIAGRAMS_DIR = "diagrams";
6124
7036
  var DIAGRAM_DIR_REL = `${DOCS_DIR}/${DIAGRAMS_DIR}`;
6125
7037
  var ARCHITECTURE_NAME = "architecture";
6126
- var PICTURE_NAMES = {
6127
- broadsheet: ARCHITECTURE_NAME,
6128
- flightdeck: `${ARCHITECTURE_NAME}.flightdeck`
6129
- };
7038
+ var ARCHITECTURE_BASE = ARCHITECTURE_NAME;
7039
+ function phaseDiagramBase(phase) {
7040
+ return renderPhaseSegment(phase);
7041
+ }
7042
+ var FLIGHTDECK_SUFFIX = ".flightdeck";
7043
+ function baseTakesFlightDeckNames(base) {
7044
+ return base.toLowerCase().endsWith(FLIGHTDECK_SUFFIX);
7045
+ }
7046
+ function pictureNames(base) {
7047
+ const names = {
7048
+ broadsheet: base,
7049
+ flightdeck: `${base}${FLIGHTDECK_SUFFIX}`
7050
+ };
7051
+ return names;
7052
+ }
6130
7053
  var EMBEDDED_LOOK = "broadsheet";
6131
7054
  var DIAGRAM_MARKER_LINE = SYSTEM_DIAGRAM_MARKER_LINE;
6132
7055
  var PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);
@@ -6141,22 +7064,35 @@ var DIAGRAM_FILES = [
6141
7064
  ...SYSTEM_PICTURE_THEMES.map(pngOf),
6142
7065
  "mmd"
6143
7066
  ];
6144
- var FILE_NAMES = Object.fromEntries([
6145
- ...SYSTEM_PICTURE_THEMES.flatMap((look) => [
6146
- [svgOf(look), `${PICTURE_NAMES[look]}.svg`],
6147
- [pngOf(look), `${PICTURE_NAMES[look]}.png`]
6148
- ]),
6149
- ["mmd", `${ARCHITECTURE_NAME}.mmd`]
6150
- ]);
6151
- var EMBED_IMAGE_REL = `${DIAGRAMS_DIR}/${FILE_NAMES[pngOf(EMBEDDED_LOOK)]}`;
6152
- function diagramPaths(repoRoot) {
7067
+ function fileNames(base) {
7068
+ const looks = pictureNames(base);
7069
+ return Object.fromEntries([
7070
+ ...SYSTEM_PICTURE_THEMES.flatMap((look) => [
7071
+ [svgOf(look), `${looks[look]}.svg`],
7072
+ [pngOf(look), `${looks[look]}.png`]
7073
+ ]),
7074
+ ["mmd", `${base}.mmd`]
7075
+ ]);
7076
+ }
7077
+ function embedImageName(base) {
7078
+ return fileNames(base)[pngOf(EMBEDDED_LOOK)];
7079
+ }
7080
+ function markdownLinkName(name) {
7081
+ return encodeURIComponent(name).replace(/\(/g, "%28").replace(/\)/g, "%29");
7082
+ }
7083
+ function embedImageLink(base, doc) {
7084
+ const up = doc === "docs" ? "" : "../";
7085
+ return `${up}${DIAGRAMS_DIR}/${markdownLinkName(embedImageName(base))}`;
7086
+ }
7087
+ function diagramPaths(repoRoot, base) {
6153
7088
  const root = resolve3(repoRoot);
6154
- const dir = join11(root, DOCS_DIR, DIAGRAMS_DIR);
6155
- const file = Object.fromEntries(DIAGRAM_FILES.map((which) => [which, join11(dir, FILE_NAMES[which])]));
7089
+ const dir = join12(root, DOCS_DIR, DIAGRAMS_DIR);
7090
+ const names = fileNames(base);
7091
+ const file = Object.fromEntries(DIAGRAM_FILES.map((which) => [which, join12(dir, names[which])]));
6156
7092
  return { repoRoot: root, dir, file };
6157
7093
  }
6158
- function diagramRel(which) {
6159
- return `${DIAGRAM_DIR_REL}/${FILE_NAMES[which]}`;
7094
+ function diagramRel(base, which) {
7095
+ return `${DIAGRAM_DIR_REL}/${fileNames(base)[which]}`;
6160
7096
  }
6161
7097
  var DIAGRAM_FILES_AS_LISTED = [
6162
7098
  ...SYSTEM_PICTURE_THEMES.flatMap((look) => [pngOf(look), svgOf(look)]),
@@ -6247,7 +7183,7 @@ function readExistingSet(paths) {
6247
7183
  if (!stat.isFile())
6248
7184
  return { ok: false, inTheWay: which, temp: false };
6249
7185
  try {
6250
- found[which] = readFileSync10(paths.file[which]);
7186
+ found[which] = readFileSync11(paths.file[which]);
6251
7187
  } catch {
6252
7188
  return { ok: false, inTheWay: which, temp: false };
6253
7189
  }
@@ -6293,15 +7229,15 @@ function readExistingSet(paths) {
6293
7229
  function tempPath(paths, which) {
6294
7230
  return `${paths.file[which]}.tmp`;
6295
7231
  }
6296
- function inTheWayLine(which, temp = false) {
6297
- return `${diagramRel(which)}${temp ? ".tmp" : ""} is already there and was not drawn by this command. Move it or delete it, then run this again. Nothing was written.`;
7232
+ function inTheWayLine(base, which, temp = false) {
7233
+ return `${diagramRel(base, which)}${temp ? ".tmp" : ""} is already there and was not drawn by this command. Move it or delete it, then run this again. Nothing was written.`;
6298
7234
  }
6299
- function checkPictures(drawn) {
7235
+ function checkPictures(drawn, base) {
6300
7236
  const set = {};
6301
7237
  for (const look of SYSTEM_PICTURE_THEMES) {
6302
7238
  const picture = drawn.pictures[look];
6303
- const pngFor = diagramRel(pngOf(look));
6304
- const svgFor = diagramRel(svgOf(look));
7239
+ const pngFor = diagramRel(base, pngOf(look));
7240
+ const svgFor = diagramRel(base, svgOf(look));
6305
7241
  const png = Buffer.from(picture.png, "base64");
6306
7242
  if (png.length > SYSTEM_DRAW_PNG_MAX_BYTES)
6307
7243
  return { ok: false, why: `the image for ${pngFor} is larger than 8 MB` };
@@ -6337,8 +7273,8 @@ function removeQuietly(path) {
6337
7273
  function sameBytes(a, b) {
6338
7274
  return a !== null && Buffer.from(a).equals(b);
6339
7275
  }
6340
- function writeDiagramSet(repoRoot, set, io = NODE_RECORD_PAIR_IO) {
6341
- const paths = diagramPaths(repoRoot);
7276
+ function writeDiagramSet(repoRoot, base, set, io = NODE_RECORD_PAIR_IO) {
7277
+ const paths = diagramPaths(repoRoot, base);
6342
7278
  const place = checkDiagramPlace(paths);
6343
7279
  if (!place.ok)
6344
7280
  return { kind: "unsafe", why: place.why };
@@ -6350,7 +7286,7 @@ function writeDiagramSet(repoRoot, set, io = NODE_RECORD_PAIR_IO) {
6350
7286
  if (changed.length === 0)
6351
7287
  return { kind: "up-to-date" };
6352
7288
  try {
6353
- mkdirSync7(paths.dir, { recursive: true });
7289
+ mkdirSync8(paths.dir, { recursive: true });
6354
7290
  } catch {
6355
7291
  return { kind: "failed", file: changed[0], removed: [] };
6356
7292
  }
@@ -6411,7 +7347,7 @@ function restore(io, paths, renamed, existing) {
6411
7347
  }
6412
7348
 
6413
7349
  // dist/draw/index.js
6414
- var DRAW_USAGE = `${HALFCYCLE_COMMAND} draw architecture [--repo <root>]`;
7350
+ var DRAW_USAGE = `${HALFCYCLE_COMMAND} draw (architecture | phase <phase>) [--repo <root>]`;
6415
7351
  function parseDrawArgs(argv) {
6416
7352
  const positionals = [];
6417
7353
  let repoRoot;
@@ -6429,35 +7365,38 @@ function parseDrawArgs(argv) {
6429
7365
  return { ok: false };
6430
7366
  positionals.push(arg);
6431
7367
  }
6432
- if (positionals.length !== 1 || positionals[0] !== "architecture")
6433
- return { ok: false };
6434
- return { ok: true, repoRoot };
7368
+ if (positionals.length === 1 && positionals[0] === "architecture") {
7369
+ return { ok: true, picture: { kind: "architecture" }, repoRoot };
7370
+ }
7371
+ if (positionals.length === 2 && positionals[0] === "phase") {
7372
+ return { ok: true, picture: { kind: "phase", phase: positionals[1] }, repoRoot };
7373
+ }
7374
+ return { ok: false };
6435
7375
  }
6436
7376
  function systemDrawUrl(credential) {
6437
7377
  return `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/system/draw`;
6438
7378
  }
6439
- function serviceHost2(serviceUrl) {
7379
+ function serviceHost3(serviceUrl) {
6440
7380
  try {
6441
7381
  return new URL(serviceUrl).host;
6442
7382
  } catch {
6443
7383
  return serviceUrl;
6444
7384
  }
6445
7385
  }
6446
- async function requestDrawing(credential, version) {
6447
- const body = { version, picture: "architecture" };
7386
+ async function requestDrawing(credential, request) {
6448
7387
  let res;
6449
7388
  let text;
6450
7389
  try {
6451
7390
  res = await fetch(systemDrawUrl(credential), {
6452
7391
  method: "POST",
6453
7392
  headers: { authorization: `Bearer ${credential.token}`, "content-type": "application/json" },
6454
- body: JSON.stringify(body)
7393
+ body: JSON.stringify(request)
6455
7394
  });
6456
7395
  text = await res.text();
6457
7396
  } catch {
6458
7397
  return {
6459
7398
  kind: "unavailable",
6460
- why: `could not reach Halfcycle at ${serviceHost2(credential.serviceUrl)}. Check this machine's connection, then run this again. Nothing was written.`
7399
+ why: `could not reach Halfcycle at ${serviceHost3(credential.serviceUrl)}. Check this machine's connection, then run this again. Nothing was written.`
6461
7400
  };
6462
7401
  }
6463
7402
  let raw = null;
@@ -6495,39 +7434,101 @@ async function requestDrawing(credential, version) {
6495
7434
  var P = "halfcycle draw:";
6496
7435
  var CANNOT_DRAW_YET = `${P} this Halfcycle service cannot draw pictures yet. Nothing was written.
6497
7436
  `;
6498
- function pasteLines(mermaid) {
7437
+ var CANNOT_DRAW_PHASE_YET = `${P} this Halfcycle service cannot draw phase pictures yet. Nothing was written.
7438
+ `;
7439
+ function markdownAltText(text) {
7440
+ return text.replace(/[[\]\\]/g, (ch) => `\\${ch}`);
7441
+ }
7442
+ var ARCHITECTURE_PASTE = { doc: "docs", alt: "Architecture", base: ARCHITECTURE_BASE };
7443
+ function phasePaste(phase) {
7444
+ return { doc: "docs/phases", alt: `Phase ${phase}`, base: phaseDiagramBase(phase) };
7445
+ }
7446
+ function pasteLines(mermaid, target) {
6499
7447
  const block = mermaid.endsWith("\n") ? mermaid : `${mermaid}
6500
7448
  `;
7449
+ const where = target.doc === "docs" ? "a document directly under docs/" : "this phase's document in docs/phases/";
6501
7450
  return `
6502
- Paste this into a document directly under docs/:
7451
+ Paste this into ${where}:
6503
7452
 
6504
- ![Architecture](${EMBED_IMAGE_REL})
7453
+ ![${markdownAltText(target.alt)}](${embedImageLink(target.base, target.doc)})
6505
7454
 
6506
7455
  \`\`\`mermaid
6507
7456
  ` + block + "```\n";
6508
7457
  }
6509
- function localRefusal(repoRoot) {
6510
- const paths = diagramPaths(repoRoot);
7458
+ function localRefusal(repoRoot, base) {
7459
+ const paths = diagramPaths(repoRoot, base);
6511
7460
  const place = checkDiagramPlace(paths);
6512
7461
  if (!place.ok)
6513
7462
  return `${P} ${place.why}
6514
7463
  `;
6515
7464
  const read = readExistingSet(paths);
6516
7465
  if (!read.ok)
6517
- return `${P} ${inTheWayLine(read.inTheWay, read.temp)}
7466
+ return `${P} ${inTheWayLine(base, read.inTheWay, read.temp)}
6518
7467
  `;
6519
7468
  return null;
6520
7469
  }
6521
- async function runDraw(repoRoot, deps) {
6522
- const stage = await checkAndReportDescription(repoRoot, deps, "draw", () => localRefusal(repoRoot));
7470
+ async function runDraw(repoRoot, picture, deps) {
7471
+ if (picture.kind === "architecture") {
7472
+ const stage2 = await checkAndReportDescription(repoRoot, deps, "draw", () => localRefusal(repoRoot, ARCHITECTURE_BASE));
7473
+ if (stage2.kind === "stopped")
7474
+ return { stdout: "", stderr: stage2.stderr, exitCode: 1 };
7475
+ if (stage2.kind === "no-route")
7476
+ return { stdout: "", stderr: stage2.warnings + CANNOT_DRAW_YET, exitCode: 1 };
7477
+ return drawAndWrite(repoRoot, deps, stage2.credential, { version: stage2.version, picture: "architecture" }, ARCHITECTURE_PASTE, stage2.warnings, CANNOT_DRAW_YET);
7478
+ }
7479
+ const phase = picture.phase;
7480
+ if (!isValidPhaseIdentity(phase))
7481
+ return { stdout: "", stderr: notAPhaseNameLine("draw", phase), exitCode: 1 };
7482
+ const base = phaseDiagramBase(phase);
7483
+ if (baseTakesFlightDeckNames(base)) {
7484
+ return {
7485
+ stdout: "",
7486
+ stderr: `${P} the phase "${phase}" cannot be drawn: its pictures would take the names of another phase's Flight Deck pictures. Name the phase so it does not end in ".flightdeck", then run this again. Nothing was sent.
7487
+ `,
7488
+ exitCode: 1
7489
+ };
7490
+ }
7491
+ let checkedPlan = null;
7492
+ const beforeSend = (description) => {
7493
+ const checked = readAndCheckPlan(repoRoot, phase, description, "draw");
7494
+ if (!checked.ok)
7495
+ return checked.line;
7496
+ const refusal = localRefusal(repoRoot, base);
7497
+ if (refusal !== null)
7498
+ return refusal;
7499
+ checkedPlan = checked.plan;
7500
+ return null;
7501
+ };
7502
+ const stage = await checkAndReportDescription(repoRoot, deps, "draw", beforeSend);
6523
7503
  if (stage.kind === "stopped")
6524
7504
  return { stdout: "", stderr: stage.stderr, exitCode: 1 };
6525
7505
  if (stage.kind === "no-route")
6526
- return { stdout: "", stderr: stage.warnings + CANNOT_DRAW_YET, exitCode: 1 };
7506
+ return { stdout: "", stderr: stage.warnings + CANNOT_DRAW_PHASE_YET, exitCode: 1 };
6527
7507
  const warnings = stage.warnings;
6528
- const outcome = await deps.draw(stage.credential, stage.version);
7508
+ const plan = checkedPlan;
7509
+ if (plan === null) {
7510
+ return { stdout: "", stderr: warnings + `${P} the plan was not checked, so nothing more was sent.
7511
+ `, exitCode: 1 };
7512
+ }
7513
+ const reported = await deps.reportPlan(stage.credential, plan, stage.version);
7514
+ switch (reported.kind) {
7515
+ case "no-route":
7516
+ return { stdout: "", stderr: warnings + CANNOT_DRAW_PHASE_YET, exitCode: 1 };
7517
+ case "unavailable":
7518
+ return { stdout: "", stderr: warnings + `${P} ${reported.why}
7519
+ `, exitCode: 1 };
7520
+ case "refused":
7521
+ return { stdout: "", stderr: warnings + planRefusedLines("draw", phase, plan, reported.problems), exitCode: 1 };
7522
+ case "recorded":
7523
+ break;
7524
+ }
7525
+ return drawAndWrite(repoRoot, deps, stage.credential, { picture: "phase", phase: plan.phase, planVersion: reported.version }, phasePaste(phase), warnings, CANNOT_DRAW_PHASE_YET);
7526
+ }
7527
+ async function drawAndWrite(repoRoot, deps, credential, request, target, warnings, cannotDrawYet) {
7528
+ const base = target.base;
7529
+ const outcome = await deps.draw(credential, request);
6529
7530
  if (outcome.kind === "no-route")
6530
- return { stdout: "", stderr: warnings + CANNOT_DRAW_YET, exitCode: 1 };
7531
+ return { stdout: "", stderr: warnings + cannotDrawYet, exitCode: 1 };
6531
7532
  if (outcome.kind === "refused") {
6532
7533
  return {
6533
7534
  stdout: "",
@@ -6539,7 +7540,7 @@ async function runDraw(repoRoot, deps) {
6539
7540
  if (outcome.kind === "unavailable")
6540
7541
  return { stdout: "", stderr: warnings + `${P} ${outcome.why}
6541
7542
  `, exitCode: 1 };
6542
- const checked = checkPictures(outcome.drawn);
7543
+ const checked = checkPictures(outcome.drawn, base);
6543
7544
  if (!checked.ok) {
6544
7545
  return {
6545
7546
  stdout: "",
@@ -6548,33 +7549,34 @@ async function runDraw(repoRoot, deps) {
6548
7549
  exitCode: 1
6549
7550
  };
6550
7551
  }
6551
- const result = deps.write(repoRoot, checked.set);
7552
+ const rel = (which) => diagramRel(base, which);
7553
+ const result = deps.write(repoRoot, base, checked.set);
6552
7554
  switch (result.kind) {
6553
7555
  case "up-to-date":
6554
7556
  return {
6555
7557
  stdout: `[halfcycle] The pictures in ${DIAGRAM_DIR_REL}/ are up to date.
6556
- ` + pasteLines(outcome.drawn.mermaid),
7558
+ ` + pasteLines(outcome.drawn.mermaid, target),
6557
7559
  stderr: warnings,
6558
7560
  exitCode: 0
6559
7561
  };
6560
7562
  case "written":
6561
7563
  return {
6562
- stdout: `[halfcycle] Wrote ${spokenList(DIAGRAM_FILES_AS_LISTED.map(diagramRel))}.
6563
- ` + pasteLines(outcome.drawn.mermaid),
7564
+ stdout: `[halfcycle] Wrote ${spokenList(DIAGRAM_FILES_AS_LISTED.map(rel))}.
7565
+ ` + pasteLines(outcome.drawn.mermaid, target),
6564
7566
  stderr: warnings,
6565
7567
  exitCode: 0
6566
7568
  };
6567
7569
  case "in-the-way":
6568
- return { stdout: "", stderr: warnings + `${P} ${inTheWayLine(result.file, result.temp)}
7570
+ return { stdout: "", stderr: warnings + `${P} ${inTheWayLine(base, result.file, result.temp)}
6569
7571
  `, exitCode: 1 };
6570
7572
  case "unsafe":
6571
7573
  return { stdout: "", stderr: warnings + `${P} ${result.why}
6572
7574
  `, exitCode: 1 };
6573
7575
  case "failed": {
6574
- const lost = result.removed.length === 0 ? "The pictures already there are as they were." : `${spokenList(result.removed.map(diagramRel))} could not be put back as ${result.removed.length === 1 ? "it was, so it was" : "they were, so they were"} removed; the other pictures are as they were.`;
7576
+ const lost = result.removed.length === 0 ? "The pictures already there are as they were." : `${spokenList(result.removed.map(rel))} could not be put back as ${result.removed.length === 1 ? "it was, so it was" : "they were, so they were"} removed; the other pictures are as they were.`;
6575
7577
  return {
6576
7578
  stdout: "",
6577
- stderr: warnings + `${P} ${diagramRel(result.file)} could not be written. ${lost} Check that you can write to ${DIAGRAM_DIR_REL}, then run this again.
7579
+ stderr: warnings + `${P} ${rel(result.file)} could not be written. ${lost} Check that you can write to ${DIAGRAM_DIR_REL}, then run this again.
6578
7580
  `,
6579
7581
  exitCode: 1
6580
7582
  };
@@ -6583,13 +7585,14 @@ async function runDraw(repoRoot, deps) {
6583
7585
  }
6584
7586
 
6585
7587
  // dist/cli-contract.js
6586
- var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "system", "draw", "uninstall"];
7588
+ var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "system", "draw", "plan", "uninstall"];
6587
7589
  var CONTRACTS = [
6588
7590
  { verb: "install", required: [], conditional: [] },
6589
7591
  { verb: "check-drift", required: [], conditional: [] },
6590
7592
  { verb: "build-record", required: [], conditional: [] },
6591
7593
  { verb: "system", required: [], conditional: [] },
6592
7594
  { verb: "draw", required: [], conditional: [] },
7595
+ { verb: "plan", required: [], conditional: [] },
6593
7596
  { verb: "uninstall", required: [], conditional: [] },
6594
7597
  {
6595
7598
  verb: "open-phase",
@@ -6726,9 +7729,9 @@ function placeholderRefusal(verb, tokens) {
6726
7729
  }
6727
7730
 
6728
7731
  // dist/uninstall.js
6729
- import { lstatSync as lstatSync3, readFileSync as readFileSync11, readdirSync, rmSync as rmSync5, rmdirSync, writeFileSync as writeFileSync6 } from "node:fs";
7732
+ import { lstatSync as lstatSync3, readFileSync as readFileSync12, readdirSync, rmSync as rmSync5, rmdirSync, writeFileSync as writeFileSync7 } from "node:fs";
6730
7733
  import { homedir as homedir2 } from "node:os";
6731
- import { dirname as dirname6, join as join12, relative as relative5, sep as sep2 } from "node:path";
7734
+ import { dirname as dirname6, join as join13, relative as relative5, sep as sep2 } from "node:path";
6732
7735
  var REMOVE_CREDENTIAL_FLAG = "--remove-credential";
6733
7736
  function parseUninstallArgs(args2) {
6734
7737
  let removeCredential = false;
@@ -6754,7 +7757,7 @@ function lexists(path) {
6754
7757
  function symlinkOnPath(root, rel) {
6755
7758
  let current = root;
6756
7759
  for (const segment of rel.split("/")) {
6757
- current = join12(current, segment);
7760
+ current = join13(current, segment);
6758
7761
  try {
6759
7762
  if (lstatSync3(current).isSymbolicLink())
6760
7763
  return true;
@@ -6764,7 +7767,7 @@ function symlinkOnPath(root, rel) {
6764
7767
  }
6765
7768
  return false;
6766
7769
  }
6767
- function isPlainObject2(value) {
7770
+ function isPlainObject3(value) {
6768
7771
  return value !== null && typeof value === "object" && !Array.isArray(value);
6769
7772
  }
6770
7773
  function serializeJson(value) {
@@ -6776,13 +7779,13 @@ function readMergedJson(targetRepo, rule) {
6776
7779
  const { rel } = rule;
6777
7780
  if (symlinkOnPath(targetRepo, rel))
6778
7781
  return { state: "symlink" };
6779
- const path = join12(targetRepo, rel);
7782
+ const path = join13(targetRepo, rel);
6780
7783
  if (!lexists(path))
6781
7784
  return { state: "absent" };
6782
7785
  let text;
6783
7786
  let value;
6784
7787
  try {
6785
- text = readFileSync11(path, "utf-8");
7788
+ text = readFileSync12(path, "utf-8");
6786
7789
  } catch {
6787
7790
  text = "";
6788
7791
  }
@@ -6791,7 +7794,7 @@ function readMergedJson(targetRepo, rule) {
6791
7794
  } catch {
6792
7795
  value = void 0;
6793
7796
  }
6794
- if (isPlainObject2(value))
7797
+ if (isPlainObject3(value))
6795
7798
  return { state: "object", text, value };
6796
7799
  if (!rule.holdsOurs(text))
6797
7800
  return { state: "not-ours" };
@@ -6799,15 +7802,15 @@ function readMergedJson(targetRepo, rule) {
6799
7802
  }
6800
7803
  function validate(options, rules) {
6801
7804
  const { targetRepo, removeCredential, home } = options;
6802
- const manifestPath = join12(targetRepo, INSTALL_MANIFEST_REL);
6803
- const pinPath = join12(targetRepo, BUNDLE_PIN_REL);
7805
+ const manifestPath = join13(targetRepo, INSTALL_MANIFEST_REL);
7806
+ const pinPath = join13(targetRepo, BUNDLE_PIN_REL);
6804
7807
  if (symlinkOnPath(targetRepo, INSTALL_MANIFEST_REL) || symlinkOnPath(targetRepo, BUNDLE_PIN_REL)) {
6805
7808
  throw new Refusal(`.halfcycle, or a file Halfcycle keeps in it, is a symbolic link here, so nothing was followed and nothing was removed.`);
6806
7809
  }
6807
7810
  let manifest = null;
6808
7811
  if (lexists(manifestPath)) {
6809
7812
  try {
6810
- manifest = parseManifest(readFileSync11(manifestPath, "utf-8"));
7813
+ manifest = parseManifest(readFileSync12(manifestPath, "utf-8"));
6811
7814
  } catch (err) {
6812
7815
  const why = err instanceof UnreadableManifestError ? err.message : "it could not be read";
6813
7816
  throw new Refusal(`${INSTALL_MANIFEST_REL} cannot be read (${why}), so nothing was removed. Restore it from git, or run "${HALFCYCLE_COMMAND}" once to rewrite it, then run "${HALFCYCLE_COMMAND} uninstall" again.`);
@@ -6817,8 +7820,8 @@ function validate(options, rules) {
6817
7820
  let pinReadable = true;
6818
7821
  if (lexists(pinPath)) {
6819
7822
  try {
6820
- const pin = JSON.parse(readFileSync11(pinPath, "utf-8"));
6821
- const id = isPlainObject2(pin) ? pin[PIN_ENGAGEMENT_ID_FIELD] : void 0;
7823
+ const pin = JSON.parse(readFileSync12(pinPath, "utf-8"));
7824
+ const id = isPlainObject3(pin) ? pin[PIN_ENGAGEMENT_ID_FIELD] : void 0;
6822
7825
  engagementId = typeof id === "string" && id !== "" ? id : void 0;
6823
7826
  } catch {
6824
7827
  pinReadable = false;
@@ -6841,7 +7844,7 @@ function validate(options, rules) {
6841
7844
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} is not a valid Halfcycle project id, so it cannot name a folder on this machine. Nothing was removed, here or under ~/.halfcycle.`);
6842
7845
  }
6843
7846
  if (engagementId !== void 0) {
6844
- const expectedParent = join12(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
7847
+ const expectedParent = join13(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
6845
7848
  if (dirname6(engagementStateDir(engagementId, home)) !== expectedParent) {
6846
7849
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} does not name a folder under ~/.halfcycle/engagements. Nothing was removed.`);
6847
7850
  }
@@ -6865,13 +7868,13 @@ function mergedFileRules(writeSet) {
6865
7868
  function writeBack(targetRepo, rel, current, next, lists, name) {
6866
7869
  if (next === current)
6867
7870
  return;
6868
- const path = join12(targetRepo, rel);
7871
+ const path = join13(targetRepo, rel);
6869
7872
  try {
6870
7873
  if (next === null) {
6871
7874
  rmSync5(path);
6872
7875
  name(lists.removed, rel, rel);
6873
7876
  } else {
6874
- writeFileSync6(path, next, "utf-8");
7877
+ writeFileSync7(path, next, "utf-8");
6875
7878
  name(lists.restored, rel, rel);
6876
7879
  }
6877
7880
  } catch {
@@ -6883,7 +7886,7 @@ function noRecord(targetRepo, writeSet) {
6883
7886
  const claim = (rel, header, lines) => {
6884
7887
  let text;
6885
7888
  try {
6886
- text = symlinkOnPath(targetRepo, rel) ? "" : readFileSync11(join12(targetRepo, rel), "utf-8");
7889
+ text = symlinkOnPath(targetRepo, rel) ? "" : readFileSync12(join13(targetRepo, rel), "utf-8");
6887
7890
  } catch {
6888
7891
  text = "";
6889
7892
  }
@@ -6912,16 +7915,16 @@ function noRecord(targetRepo, writeSet) {
6912
7915
  }
6913
7916
  function ourHookEntries(value, commands) {
6914
7917
  const hooks = value["hooks"];
6915
- if (!isPlainObject2(hooks))
7918
+ if (!isPlainObject3(hooks))
6916
7919
  return [];
6917
7920
  const found = [];
6918
7921
  for (const [event, entries] of Object.entries(hooks)) {
6919
7922
  if (!Array.isArray(entries))
6920
7923
  continue;
6921
7924
  for (const entry of entries) {
6922
- if (!isPlainObject2(entry) || !Array.isArray(entry["hooks"]))
7925
+ if (!isPlainObject3(entry) || !Array.isArray(entry["hooks"]))
6923
7926
  continue;
6924
- const objects = entry["hooks"].filter(isPlainObject2);
7927
+ const objects = entry["hooks"].filter(isPlainObject3);
6925
7928
  if (isHalfcycleEntry({ hooks: objects }, commands)) {
6926
7929
  found.push({ event, entry });
6927
7930
  }
@@ -6931,7 +7934,7 @@ function ourHookEntries(value, commands) {
6931
7934
  }
6932
7935
  function ourMcpServers(value) {
6933
7936
  const servers = value["mcpServers"];
6934
- return isPlainObject2(servers) && MCP_SERVER_KEY in servers ? servers : null;
7937
+ return isPlainObject3(servers) && MCP_SERVER_KEY in servers ? servers : null;
6935
7938
  }
6936
7939
  function unmergeSettings(value, rec, writeSet) {
6937
7940
  let changed = false;
@@ -6942,7 +7945,7 @@ function unmergeSettings(value, rec, writeSet) {
6942
7945
  refused.push(`${SETTINGS_REL}: a deny rule the install record names but Halfcycle never adds (it was kept)`);
6943
7946
  }
6944
7947
  const hooks = value["hooks"];
6945
- if (isPlainObject2(hooks)) {
7948
+ if (isPlainObject3(hooks)) {
6946
7949
  const ours = new Set(ourHookEntries(value, commands).map(({ entry }) => entry));
6947
7950
  let emptiedAnEvent = false;
6948
7951
  for (const event of Object.keys(hooks)) {
@@ -6977,7 +7980,7 @@ function unmergeSettings(value, rec, writeSet) {
6977
7980
  }
6978
7981
  }
6979
7982
  const permissions = value["permissions"];
6980
- if (isPlainObject2(permissions) && Array.isArray(permissions["deny"])) {
7983
+ if (isPlainObject3(permissions) && Array.isArray(permissions["deny"])) {
6981
7984
  const deny = permissions["deny"];
6982
7985
  for (const hash of denyHashes) {
6983
7986
  for (let i = deny.length - 1; i >= 0; i--) {
@@ -7009,7 +8012,7 @@ function unmergeSettings(value, rec, writeSet) {
7009
8012
  }
7010
8013
  function unmergeMcp(value, rec) {
7011
8014
  const servers = value["mcpServers"];
7012
- if (!isPlainObject2(servers))
8015
+ if (!isPlainObject3(servers))
7013
8016
  return { changed: false };
7014
8017
  let changed = false;
7015
8018
  let removedEntry = false;
@@ -7109,7 +8112,7 @@ function lastOccurrence(text, block) {
7109
8112
  }
7110
8113
  function coveredPathExists(targetRepo, line) {
7111
8114
  const path = line.trim().replace(/^\//, "").replace(/\/$/, "");
7112
- return path !== "" && lexists(join12(targetRepo, path));
8115
+ return path !== "" && lexists(join13(targetRepo, path));
7113
8116
  }
7114
8117
  function uninstall(options) {
7115
8118
  const result = {
@@ -7189,7 +8192,7 @@ function uninstall(options) {
7189
8192
  const gitattributes = manifest.gitattributes ?? { created: false, appended: [] };
7190
8193
  trimAppendedFile(targetRepo, gitattributes, deleteWhenEmpty(gitattributes.created), gitattributesRules(writeSet), result, name);
7191
8194
  const foundNothing = result.removed.length + result.restored.length + result.kept.length + result.failed.length === 0 && result.keptIgnoreLines.length === 0;
7192
- if (checked.manifest === null && !lexists(join12(targetRepo, BUNDLE_PIN_REL)) && foundNothing) {
8195
+ if (checked.manifest === null && !lexists(join13(targetRepo, BUNDLE_PIN_REL)) && foundNothing) {
7193
8196
  return { outcome: "not-installed", exitCode: 0, ...result, credential: nothingToSay };
7194
8197
  }
7195
8198
  const credential = handleCredential(options, checked);
@@ -7197,7 +8200,7 @@ function uninstall(options) {
7197
8200
  if (result.failed.length === 0 && !credentialFailed) {
7198
8201
  removeInstalledFile(targetRepo, BUNDLE_PIN_REL, recorded.get(BUNDLE_PIN_REL), writeSet, result, name);
7199
8202
  try {
7200
- rmSync5(join12(targetRepo, INSTALL_MANIFEST_REL), { force: true });
8203
+ rmSync5(join13(targetRepo, INSTALL_MANIFEST_REL), { force: true });
7201
8204
  named.add(INSTALL_MANIFEST_REL);
7202
8205
  } catch {
7203
8206
  name(result.failed, INSTALL_MANIFEST_REL, INSTALL_MANIFEST_REL);
@@ -7232,7 +8235,7 @@ function applyMergedJson(targetRepo, rule, file, deleteWhenEmpty, lists, name, e
7232
8235
  return;
7233
8236
  const empty = Object.keys(value).length === 0 && deleteWhenEmpty;
7234
8237
  writeBack(targetRepo, rel, file.text, empty ? null : inLineEndingOf(file.text, serializeJson(value)), lists, name);
7235
- if (adopted && lexists(join12(targetRepo, rel))) {
8238
+ if (adopted && lexists(join13(targetRepo, rel))) {
7236
8239
  name(lists.leftAlone, `${rel} (Halfcycle's hooks were taken out. There is no record of this file from before the install, so a permission rule or "$schema" Halfcycle added may still be there \u2014 it cannot be told apart from your own)`, rel);
7237
8240
  }
7238
8241
  }
@@ -7245,7 +8248,7 @@ function removeInstalledFile(targetRepo, path, entry, writeSet, lists, name) {
7245
8248
  name(lists.leftAlone, `${path} (a symbolic link, or inside one \u2014 not followed)`, path);
7246
8249
  return;
7247
8250
  }
7248
- const abs = join12(targetRepo, path);
8251
+ const abs = join13(targetRepo, path);
7249
8252
  let isFile;
7250
8253
  try {
7251
8254
  isFile = lstatSync3(abs).isFile();
@@ -7259,7 +8262,7 @@ function removeInstalledFile(targetRepo, path, entry, writeSet, lists, name) {
7259
8262
  if (!writeSet.ownedDirs.has(path.slice(0, path.lastIndexOf("/")))) {
7260
8263
  let bytes;
7261
8264
  try {
7262
- bytes = readFileSync11(abs);
8265
+ bytes = readFileSync12(abs);
7263
8266
  } catch {
7264
8267
  name(lists.failed, path, path);
7265
8268
  return;
@@ -7279,7 +8282,7 @@ function removeInstalledFile(targetRepo, path, entry, writeSet, lists, name) {
7279
8282
  function removeEmptyDir(targetRepo, dir, writeSet, lists, name) {
7280
8283
  if (!writeSet.dirs.has(dir) || symlinkOnPath(targetRepo, dir))
7281
8284
  return;
7282
- const abs = join12(targetRepo, dir);
8285
+ const abs = join13(targetRepo, dir);
7283
8286
  try {
7284
8287
  if (!lstatSync3(abs).isDirectory() || readdirSync(abs).length > 0)
7285
8288
  return;
@@ -7298,10 +8301,10 @@ function trimAppendedFile(targetRepo, rec, deleteWhenEmpty, rules, lists, name)
7298
8301
  name(lists.leftAlone, `${rel} (a symbolic link \u2014 not followed)`, rel);
7299
8302
  return [];
7300
8303
  }
7301
- const path = join12(targetRepo, rel);
8304
+ const path = join13(targetRepo, rel);
7302
8305
  let before;
7303
8306
  try {
7304
- before = readFileSync11(path, "utf-8");
8307
+ before = readFileSync12(path, "utf-8");
7305
8308
  } catch {
7306
8309
  return [];
7307
8310
  }
@@ -7345,16 +8348,16 @@ function filesUnder(root, rel) {
7345
8348
  const walk = (dirRel) => {
7346
8349
  let names;
7347
8350
  try {
7348
- if (!lstatSync3(join12(root, dirRel)).isDirectory())
8351
+ if (!lstatSync3(join13(root, dirRel)).isDirectory())
7349
8352
  return;
7350
- names = readdirSync(join12(root, dirRel));
8353
+ names = readdirSync(join13(root, dirRel));
7351
8354
  } catch {
7352
8355
  return;
7353
8356
  }
7354
8357
  for (const entry of names.sort()) {
7355
8358
  const childRel = `${dirRel}/${entry}`;
7356
8359
  try {
7357
- if (lstatSync3(join12(root, childRel)).isDirectory())
8360
+ if (lstatSync3(join13(root, childRel)).isDirectory())
7358
8361
  walk(childRel);
7359
8362
  else
7360
8363
  out.push(childRel);
@@ -7426,8 +8429,8 @@ function renderUninstallReport(result, home) {
7426
8429
  }
7427
8430
 
7428
8431
  // dist/banner-facts.js
7429
- import { readFileSync as readFileSync12 } from "node:fs";
7430
- import { basename as basename3, join as join13, resolve as resolve4 } from "node:path";
8432
+ import { readFileSync as readFileSync13 } from "node:fs";
8433
+ import { basename as basename3, join as join14, resolve as resolve4 } from "node:path";
7431
8434
  var BRAND_URL = "halfcycle.ai";
7432
8435
  var TAGLINE = [
7433
8436
  "Fewer cycles.",
@@ -7440,7 +8443,7 @@ var TAGLINE_NARROW = [
7440
8443
  ];
7441
8444
  function bundleVersion() {
7442
8445
  try {
7443
- const pkg = JSON.parse(readFileSync12(join13(BUNDLE_ROOT, "package.json"), "utf-8"));
8446
+ const pkg = JSON.parse(readFileSync13(join14(BUNDLE_ROOT, "package.json"), "utf-8"));
7444
8447
  return typeof pkg.version === "string" ? pkg.version : void 0;
7445
8448
  } catch {
7446
8449
  return void 0;
@@ -7583,7 +8586,9 @@ var USAGE = ` ${HALFCYCLE_COMMAND} [install] [target-repo] [engagement-id] [sel
7583
8586
  ${HALFCYCLE_COMMAND} system [--repo <root>]
7584
8587
  check docs/system.json, the description of your system, and send it to Halfcycle
7585
8588
  ${DRAW_USAGE}
7586
- draw the architecture pictures from docs/system.json into docs/diagrams/
8589
+ draw the architecture picture, or one phase's plan, from docs/system.json into docs/diagrams/
8590
+ ${PLAN_USAGE}
8591
+ check a phase's plan in docs/phases/ and send it to Halfcycle; --show lists it for the owner
7587
8592
  ${HALFCYCLE_COMMAND} uninstall [${REMOVE_CREDENTIAL_FLAG}]
7588
8593
  take out what the installer added to this project, keeping anything you changed
7589
8594
  ${REMOVE_CREDENTIAL_FLAG}: also remove this machine's credential for the project, which every
@@ -7596,7 +8601,7 @@ var USAGE = ` ${HALFCYCLE_COMMAND} [install] [target-repo] [engagement-id] [sel
7596
8601
  `;
7597
8602
  function isHalfcycleMonorepo(dir) {
7598
8603
  try {
7599
- const pkg = JSON.parse(readFileSync13(join14(dir, "package.json"), "utf-8"));
8604
+ const pkg = JSON.parse(readFileSync14(join15(dir, "package.json"), "utf-8"));
7600
8605
  return pkg.name === "halfcycle-monorepo";
7601
8606
  } catch {
7602
8607
  return false;
@@ -7885,7 +8890,7 @@ ${PROJECT_RESET_LINE}
7885
8890
  `);
7886
8891
  }
7887
8892
  if (credential === void 0 && notThisAccountMessage !== void 0) {
7888
- process.stdout.write(`[halfcycle] ${notThisAccountMessage} Your work carries on exactly as it would without Halfcycle: no edit is evaluated, and nothing you do here is reported as having passed. Ask the project's owner for view access. ${identitySwitchRemedy(void 0, isInteractiveRun())}
8893
+ process.stdout.write(`[halfcycle] ${notThisAccountMessage} Your work carries on exactly as it would without Halfcycle: no edit is evaluated, and nothing you do here is reported as having passed. ${requestAccessSentence(engagementId)} ${identitySwitchRemedy(void 0, isInteractiveRun())}
7889
8894
  `);
7890
8895
  }
7891
8896
  reportClaudeCodeVersion(verbose);
@@ -8055,12 +9060,38 @@ ${USAGE}`);
8055
9060
  return;
8056
9061
  }
8057
9062
  const repoRoot = parsed.repoRoot ?? process.cwd();
8058
- const run = await runDraw(repoRoot, {
9063
+ const run = await runDraw(repoRoot, parsed.picture, {
8059
9064
  readCredential: (root) => readRepoCredential(root),
8060
9065
  listTracked: listTrackedFiles,
8061
9066
  report: reportSystemDescription,
9067
+ reportPlan: reportPhasePlan,
8062
9068
  draw: requestDrawing,
8063
- write: (root, set) => writeDiagramSet(root, set)
9069
+ write: (root, base, set) => writeDiagramSet(root, base, set)
9070
+ });
9071
+ if (run.stdout !== "")
9072
+ process.stdout.write(run.stdout);
9073
+ if (run.stderr !== "")
9074
+ process.stderr.write(run.stderr);
9075
+ process.exitCode = run.exitCode;
9076
+ return;
9077
+ }
9078
+ if (cmd === "plan") {
9079
+ const parsed = parsePlanArgs(rest);
9080
+ if (!parsed.ok) {
9081
+ process.stderr.write(`halfcycle plan: usage: ${PLAN_USAGE}
9082
+ `);
9083
+ process.exitCode = 2;
9084
+ return;
9085
+ }
9086
+ const repoRoot = parsed.repoRoot ?? process.cwd();
9087
+ const run = await runPlan(repoRoot, parsed.phase, parsed.show, {
9088
+ readCredential: (root) => readRepoCredential(root),
9089
+ listTracked: listTrackedFiles,
9090
+ report: reportSystemDescription,
9091
+ reportPlan: reportPhasePlan,
9092
+ pinnedClientVersion: readPinnedClientVersion,
9093
+ recordShown: (engagementId, entry) => writeShownPlan(engagementId, entry),
9094
+ now: () => /* @__PURE__ */ new Date()
8064
9095
  });
8065
9096
  if (run.stdout !== "")
8066
9097
  process.stdout.write(run.stdout);
@@ -8113,6 +9144,11 @@ ${USAGE}`);
8113
9144
  process.stdout.write(`[halfcycle] This decision CROSSED: ${opened.decision.blockingFinding}
8114
9145
  `);
8115
9146
  }
9147
+ if (opened.methodUpdated) {
9148
+ process.stdout.write(`[halfcycle] ${openMethodMovedLine(opened.methodUpdated)}
9149
+ `);
9150
+ updatePinnedMethodVersion(repoRoot, credential.engagementId, opened.methodUpdated.to);
9151
+ }
8116
9152
  if (opened.stampFailure) {
8117
9153
  process.stderr.write(`halfcycle open-phase: the phase moved, but this machine's copy of it could not be written: ${opened.stampFailure.reason}
8118
9154
  halfcycle open-phase: until ${opened.stampFailure.path} can be written, guard evaluations here are filed under whatever phase that file still names. Fix the file and re-run this command \u2014 re-running it is safe.
@@ -8230,6 +9266,11 @@ halfcycle open-phase: until ${opened.stampFailure.path} can be written, guard ev
8230
9266
  process.stdout.write(`[halfcycle] This close was an OVERRIDE: ${closeDecision.reason}
8231
9267
  `);
8232
9268
  }
9269
+ if (closed.methodUpdated) {
9270
+ process.stdout.write(`[halfcycle] ${closeMethodMovedLine(closed.methodUpdated)}
9271
+ `);
9272
+ updatePinnedMethodVersion(repoRoot, credential.engagementId, closed.methodUpdated.to);
9273
+ }
8233
9274
  if (!closed.closeCommit.read) {
8234
9275
  process.stderr.write(`halfcycle close-phase: the commit this phase closed at could not be read (${closed.closeCommit.reason}), so its Build Record shows the commit as not stated.
8235
9276
  `);
@@ -8316,6 +9357,12 @@ function readStatementFlags(argv) {
8316
9357
  function plainOutstandingFinding(finding) {
8317
9358
  return finding.replace(/\(attempted-failed\)/g, "(attempted, and did not finish)");
8318
9359
  }
9360
+ function closeMethodMovedLine(moved) {
9361
+ return `Halfcycle's method has a newer version, ${moved.to}. Your next phase will follow it. The phase you just closed stays recorded under ${moved.from}, the version it ran under.`;
9362
+ }
9363
+ function openMethodMovedLine(moved) {
9364
+ return `This phase follows Halfcycle's newer method version, ${moved.to}. The project was on ${moved.from}.`;
9365
+ }
8319
9366
  function writtenLines(written) {
8320
9367
  return `[halfcycle] Build Record written:
8321
9368
  ${written.mdPath}