halfcycle 0.3.37 → 0.3.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halfcycle",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "description": "Halfcycle Method bundle — resolution-stub slash commands, remote method-delivery registration, and governance-hook wiring for a Halfcycle engagement repo. It ships NO worker role files: a project's roles are authored from that project's own recorded decisions at orchestration kickoff (FX-2, W3-F-27).",
5
5
  "commands": [
6
6
  {
package/README.md CHANGED
@@ -146,6 +146,8 @@ This matters enough to be specific about.
146
146
 
147
147
  **Halfcycle never holds or proxies your Claude credential.** Inference runs on your own Claude Code, on your own subscription or seats. We are not a hosted runtime reselling you inference.
148
148
 
149
+ **Your own Halfcycle credential lives outside this repository, in a file named `env` under your home directory — never in anything you'd commit.** Closing a phase rewrites it, to clear which phase was open, and it is also renewed there automatically to keep your connection current. Nothing else changes it.
150
+
149
151
  **What the checks receive is the shape of a change, not its contents.** File paths, structural shapes, environment variable names and how they are used, the tables a write touches, route signatures, outbound host names. There is no file body and no diff in the request.
150
152
 
151
153
  **Source excerpts are off, at both ends, for every engagement today.** The wire format does have a field for them, so the honest version of this is a conditional statement rather than a flat one, and it rests on three things rather than on our good intentions: your runner does not attach an excerpt unless you turn a flag on, and that flag is off by default; anything that does arrive for an engagement that has not opted in is stripped on our side before the checks or any model see it; and there is no way to opt in today, because nothing in the product can set that flag. If that ever changes, our privacy policy changes with it, and it is the policy — not this paragraph — that states the guarantee.
@@ -15289,7 +15289,9 @@ var engagementCredentialResponseSchema = external_exports.object({
15289
15289
  sessionToken: external_exports.string(),
15290
15290
  mcpUrl: external_exports.string(),
15291
15291
  guardUrl: external_exports.string(),
15292
- controlTelemetryUrl: external_exports.string()
15292
+ controlTelemetryUrl: external_exports.string(),
15293
+ methodVersion: external_exports.string().optional(),
15294
+ consoleUrl: external_exports.string().optional()
15293
15295
  }).strict();
15294
15296
  var ciTokenExchangeResponseSchema = external_exports.object({
15295
15297
  token: external_exports.string(),
@@ -15357,7 +15359,7 @@ function hookReply(event) {
15357
15359
  };
15358
15360
  }
15359
15361
  function infraWarningText(message) {
15360
- return `[Halfcycle] WARNING: Guard service unavailable \u2014 ${message}. Session continues (fail-open).`;
15362
+ return `[Halfcycle] WARNING: Guard service unavailable \u2014 ${message}. The session continues without checks.`;
15361
15363
  }
15362
15364
  function localFaultText(message) {
15363
15365
  return `[Halfcycle] WARNING: Local fault \u2014 ${message}. The guard service was not contacted. Session continues (fail-open).`;
@@ -15434,11 +15436,8 @@ function classifyFailure(failure) {
15434
15436
  return {
15435
15437
  kind: "service",
15436
15438
  message: failure.message,
15437
- // Unchanged wording for this class ON PURPOSE — a genuine outage still says
15438
- // exactly what it said before, and still fails open locally. This task moved
15439
- // ONE condition out of this branch; it did not redefine the branch.
15440
- label: "infrastructure failure",
15441
- reason: `infrastructure failure: ${failure.message}`
15439
+ label: "service unavailable",
15440
+ reason: failure.message
15442
15441
  };
15443
15442
  }
15444
15443
  var CREDENTIAL_REJECTED_EXIT_TOOL_USE = UNCONFIGURED_EXIT_TOOL_USE;
package/dist/bin.js CHANGED
@@ -234,6 +234,11 @@ function looksLikeSecret(text) {
234
234
  var MCP_REGISTRATION_REL = ".mcp.json";
235
235
  var MCP_SERVER_KEY = "halfcycle";
236
236
 
237
+ // ../core/dist/shell-quote.js
238
+ function quoteForShell(value) {
239
+ return `'${value.replace(/'/g, `'\\''`)}'`;
240
+ }
241
+
237
242
  // ../events/dist/result.js
238
243
  import { z as z2 } from "zod";
239
244
  var firedGuardSchema = z2.object({
@@ -606,9 +611,14 @@ var closedBySchema = z4.discriminatedUnion("kind", [
606
611
  z4.object({ kind: z4.literal("account-removed") }).strict(),
607
612
  z4.object({ kind: z4.literal("not-recorded") }).strict()
608
613
  ]);
614
+ var STEP_NAME_MAX = 200;
615
+ var stepNameSchema = plainText(STEP_NAME_MAX).refine((v) => v.trim().length > 0, {
616
+ message: "must not be blank"
617
+ });
609
618
  var unfinishedStepSchema = z4.object({
610
619
  stepId: plainText(),
611
- state: z4.enum(["parked", "failed"])
620
+ state: z4.enum(["parked", "failed"]),
621
+ name: stepNameSchema.optional()
612
622
  }).strict();
613
623
  var crossedSchema = z4.discriminatedUnion("kind", [
614
624
  z4.object({ kind: z4.literal("none") }).strict(),
@@ -1217,7 +1227,9 @@ var engagementCredentialResponseSchema = z10.object({
1217
1227
  sessionToken: z10.string(),
1218
1228
  mcpUrl: z10.string(),
1219
1229
  guardUrl: z10.string(),
1220
- controlTelemetryUrl: z10.string()
1230
+ controlTelemetryUrl: z10.string(),
1231
+ methodVersion: z10.string().optional(),
1232
+ consoleUrl: z10.string().optional()
1221
1233
  }).strict();
1222
1234
  var ciTokenExchangeResponseSchema = z10.object({
1223
1235
  token: z10.string(),
@@ -2708,9 +2720,10 @@ function previousPinForEngagement(targetRepoRoot, engagementId) {
2708
2720
  function nonEmpty(value) {
2709
2721
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
2710
2722
  }
2711
- function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
2723
+ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, methodVersion, writtenPaths) {
2712
2724
  const previous = previousPinForEngagement(targetRepoRoot, engagementId);
2713
2725
  const carried = nonEmpty(accountId) ?? nonEmpty(previous?.accountId);
2726
+ const carriedMethodVersion = nonEmpty(methodVersion) ?? nonEmpty(previous?.methodVersion);
2714
2727
  const installedAt = nonEmpty(previous?.installedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
2715
2728
  const projectPage = projectPageUrl(engagementId);
2716
2729
  const pin = {
@@ -2719,6 +2732,7 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, a
2719
2732
  engagementType,
2720
2733
  installedAt,
2721
2734
  ...carried !== void 0 ? { accountId: carried } : {},
2735
+ ...carriedMethodVersion !== void 0 ? { methodVersion: carriedMethodVersion } : {},
2722
2736
  ...projectPage !== void 0 ? { projectPage } : {}
2723
2737
  };
2724
2738
  const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
@@ -2794,7 +2808,7 @@ function checkDrift(targetRepoRoot) {
2794
2808
  const pin = readBundlePin(targetRepoRoot);
2795
2809
  const installed = pin?.version ?? null;
2796
2810
  const current = manifest.version;
2797
- return { drifted: installed !== current, installed, current };
2811
+ return { drifted: installed !== current, installed, current, methodVersion: pin?.methodVersion ?? null };
2798
2812
  }
2799
2813
  function writeProjectIdentity(targetRepo) {
2800
2814
  const path = join5(targetRepo, PROJECT_IDENTITY_REL);
@@ -3025,7 +3039,7 @@ async function install(options) {
3025
3039
  case "absent":
3026
3040
  break;
3027
3041
  }
3028
- writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
3042
+ writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, credential?.methodVersion, report.writtenPaths);
3029
3043
  noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
3030
3044
  noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
3031
3045
  const scanResult = runBootstrapScan(targetRepo);
@@ -3599,7 +3613,7 @@ function planeRefusal(detail) {
3599
3613
  reason: typeof candidate.reason === "string" ? candidate.reason : void 0
3600
3614
  };
3601
3615
  }
3602
- var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
3616
+ var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
3603
3617
  async function createEngagement(baseUrl, name, credential, derivedName) {
3604
3618
  return requestEngagementValues(
3605
3619
  `${baseUrl.replace(/\/+$/, "")}/engagements`,
@@ -3679,12 +3693,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
3679
3693
  }).join(", ");
3680
3694
  throw new Error(`[bundle install] ${shape.action} response from ${url} is not the credential response this CLI is built to read (${fields}). ${shape.nothingHappened}. Either that service is a different version from this installer, or ${url} is not a Halfcycle service.`);
3681
3695
  }
3696
+ const consoleUrl = typeof body.consoleUrl === "string" && body.consoleUrl.trim() !== "" ? body.consoleUrl.trim() : void 0;
3697
+ const methodVersion = typeof body.methodVersion === "string" && body.methodVersion.trim() !== "" ? body.methodVersion.trim() : void 0;
3682
3698
  return {
3683
3699
  engagementId: body.engagementId,
3684
3700
  sessionToken: body.sessionToken,
3685
3701
  mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
3686
3702
  guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
3687
- ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
3703
+ ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {},
3704
+ ...consoleUrl !== void 0 ? { consoleUrl } : {},
3705
+ ...methodVersion !== void 0 ? { methodVersion } : {}
3688
3706
  };
3689
3707
  }
3690
3708
 
@@ -4295,37 +4313,6 @@ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps =
4295
4313
  }
4296
4314
  }
4297
4315
 
4298
- // dist/mint-board-code.js
4299
- async function mintBoardEnterCode(baseUrl, sessionToken) {
4300
- const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
4301
- let res;
4302
- try {
4303
- res = await fetch(url, {
4304
- method: "POST",
4305
- headers: {
4306
- "content-type": "application/json",
4307
- authorization: `Bearer ${sessionToken}`
4308
- },
4309
- body: "{}"
4310
- });
4311
- } catch (err) {
4312
- throw new Error(`[bundle install] Could not reach the Halfcycle board mint endpoint at ${url}: ${err instanceof Error ? err.message : String(err)}.`);
4313
- }
4314
- if (!res.ok) {
4315
- throw new Error(`[bundle install] Board code mint failed: ${url} returned ${res.status}.`);
4316
- }
4317
- const body = await res.json().catch(() => null);
4318
- if (!body || typeof body.enterCode !== "string" || typeof body.engagementId !== "string" || typeof body.boardUrl !== "string" || typeof body.expiresAt !== "string") {
4319
- throw new Error(`[bundle install] Board code mint response from ${url} did not carry {enterCode, engagementId, boardUrl, expiresAt}.`);
4320
- }
4321
- return {
4322
- enterCode: body.enterCode,
4323
- engagementId: body.engagementId,
4324
- boardUrl: body.boardUrl,
4325
- expiresAt: body.expiresAt
4326
- };
4327
- }
4328
-
4329
4316
  // dist/probe-mcp.js
4330
4317
  var PROBE_TIMEOUT_MS = 5e3;
4331
4318
  async function probeMcpOrigin(mcpUrl, token) {
@@ -4407,20 +4394,127 @@ import { basename as basename2, join as join6, resolve as resolve2, relative as
4407
4394
 
4408
4395
  // dist/build-record/render-v2.js
4409
4396
  var HIDDEN = /[\p{Cc}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
4410
- var MARKDOWN_SPECIAL = /* @__PURE__ */ new Set(["\\", "`", "*", "_", "[", "]", "<", "|", "~", "#", "&", "$"]);
4397
+ var SPACE = new RegExp("\\p{Zs}", "u");
4398
+ var WORD = /[\p{L}\p{N}]/u;
4399
+ var ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/;
4400
+ var CHARACTER_REFERENCE = /^#?[A-Za-z0-9]+;/;
4401
+ var PAIRED = ["*", "_", "~", "$"];
4402
+ function codeSpanMask(cps) {
4403
+ const mask = cps.map(() => false);
4404
+ const runs = [];
4405
+ for (let i = 0; i < cps.length; ) {
4406
+ if (cps[i] !== "`") {
4407
+ i++;
4408
+ continue;
4409
+ }
4410
+ let j = i;
4411
+ while (j < cps.length && cps[j] === "`")
4412
+ j++;
4413
+ runs.push({ start: i, length: j - i });
4414
+ i = j;
4415
+ }
4416
+ for (let k = 0; k < runs.length; ) {
4417
+ const open = runs[k];
4418
+ let m = k + 1;
4419
+ while (m < runs.length && runs[m].length !== open.length)
4420
+ m++;
4421
+ if (m === runs.length) {
4422
+ k++;
4423
+ continue;
4424
+ }
4425
+ const close = runs[m];
4426
+ for (let i = open.start; i < close.start + close.length; i++)
4427
+ mask[i] = true;
4428
+ k = m + 1;
4429
+ }
4430
+ return mask;
4431
+ }
4432
+ function escapeMask(cps, inSpan) {
4433
+ const n = cps.length;
4434
+ const escape = cps.map(() => false);
4435
+ const isSpace = (i) => i >= 0 && i < n && SPACE.test(cps[i]) && !HIDDEN.test(cps[i]);
4436
+ const isWord = (i) => i >= 0 && i < n && WORD.test(cps[i]);
4437
+ const rest = (i) => cps.slice(i).join("");
4438
+ for (let i = 0; i < n; i++) {
4439
+ if (inSpan[i])
4440
+ continue;
4441
+ const ch = cps[i];
4442
+ const next = i + 1 < n ? cps[i + 1] : null;
4443
+ switch (ch) {
4444
+ case "\\":
4445
+ escape[i] = next === null || ASCII_PUNCTUATION.test(next);
4446
+ break;
4447
+ case "<":
4448
+ escape[i] = next !== null && !isSpace(i + 1) && rest(i + 1).includes(">");
4449
+ break;
4450
+ case "&":
4451
+ escape[i] = CHARACTER_REFERENCE.test(rest(i + 1));
4452
+ break;
4453
+ case "]":
4454
+ escape[i] = next === "(";
4455
+ break;
4456
+ case "[":
4457
+ case "{":
4458
+ escape[i] = (next === "+" || next === "-") && (rest(i + 2).includes(`${next}]`) || rest(i + 2).includes(`${next}}`));
4459
+ break;
4460
+ default:
4461
+ break;
4462
+ }
4463
+ }
4464
+ for (let i = 0; i < n; ) {
4465
+ if (inSpan[i] || cps[i] !== "#") {
4466
+ i++;
4467
+ continue;
4468
+ }
4469
+ let j = i;
4470
+ while (j < n && cps[j] === "#" && !inSpan[j])
4471
+ j++;
4472
+ let k = j;
4473
+ while (k < n && isSpace(k))
4474
+ k++;
4475
+ if (k === n && (i === 0 || isSpace(i - 1)))
4476
+ for (let m = i; m < j; m++)
4477
+ escape[m] = true;
4478
+ i = j;
4479
+ }
4480
+ for (const c of PAIRED) {
4481
+ const live = [];
4482
+ for (let i = 0; i < n; ) {
4483
+ if (inSpan[i] || cps[i] !== c) {
4484
+ i++;
4485
+ continue;
4486
+ }
4487
+ let j = i;
4488
+ while (j < n && cps[j] === c && !inSpan[j])
4489
+ j++;
4490
+ const inert = c !== "$" && (isSpace(i - 1) && isSpace(j) || c === "_" && isWord(i - 1) && isWord(j));
4491
+ if (!inert)
4492
+ live.push({ start: i, end: j });
4493
+ i = j;
4494
+ }
4495
+ if (live.length < 2)
4496
+ continue;
4497
+ for (const run of live)
4498
+ for (let m = run.start; m < run.end; m++)
4499
+ escape[m] = true;
4500
+ }
4501
+ return escape;
4502
+ }
4411
4503
  function printRecordText(text) {
4412
4504
  if (text === "")
4413
4505
  return "(blank)";
4506
+ const cps = [...text];
4507
+ const escape = escapeMask(cps, codeSpanMask(cps));
4414
4508
  let out = "";
4415
- for (const ch of text) {
4509
+ cps.forEach((ch, i) => {
4416
4510
  if (HIDDEN.test(ch)) {
4417
4511
  out += `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
4418
- } else if (MARKDOWN_SPECIAL.has(ch)) {
4512
+ } else if (escape[i]) {
4419
4513
  out += `\\${ch}`;
4420
4514
  } else {
4421
4515
  out += ch;
4422
4516
  }
4423
- }
4517
+ });
4424
4518
  return out;
4425
4519
  }
4426
4520
  function makePrinter() {
@@ -4510,12 +4604,18 @@ function headline(observed, attested, checks) {
4510
4604
  if (problemsReported) {
4511
4605
  return "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk.";
4512
4606
  }
4513
- return checks === "not-observed" ? "**Closed with nothing outstanding, but no checks ran.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met, but Halfcycle has no record of any check running during this phase, so this is not a clean result." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
4607
+ return checks === "not-observed" ? "**Closed with nothing outstanding, but no check on record.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met, but Halfcycle has no record of any check running during this phase, so this is not a clean result." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
4514
4608
  case "unfinished-steps":
4515
4609
  case "clean-over-findings":
4516
- return met ? "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase stated its finish conditions were met anyway." : "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase chose to override it.";
4610
+ if (!met) {
4611
+ return "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase chose to override it.";
4612
+ }
4613
+ return problemsReported ? "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase closed it as it stood, reporting problems found in its acceptance walk." : "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase stated its finish conditions were met anyway.";
4517
4614
  case "not-recorded":
4518
- return met ? "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase stated its finish conditions were met." : "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase chose to override.";
4615
+ if (!met) {
4616
+ return "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase chose to override.";
4617
+ }
4618
+ return problemsReported ? "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase reported problems found in its acceptance walk." : "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase stated its finish conditions were met.";
4519
4619
  }
4520
4620
  }
4521
4621
  function closedAtText(closedAt, p) {
@@ -4534,30 +4634,38 @@ function signedInAs(closedBy, p) {
4534
4634
  return "not recorded for this close";
4535
4635
  }
4536
4636
  }
4637
+ var OUTSTANDING_LABEL = "- Outstanding work in Halfcycle's own records of this phase (the method's steps, and problems recorded against the phase): ";
4537
4638
  function outstanding(crossed, p) {
4538
4639
  switch (crossed.kind) {
4539
4640
  case "none":
4540
- return ["- Outstanding work Halfcycle could see: none."];
4641
+ return [`${OUTSTANDING_LABEL}none.`];
4541
4642
  case "not-recorded":
4542
- return ["- Outstanding work Halfcycle could see: not recorded for this close."];
4643
+ return [`${OUTSTANDING_LABEL}not recorded for this close.`];
4543
4644
  case "clean-over-findings":
4544
- return [
4545
- `- Outstanding work Halfcycle could see: ${counted(crossed.findings, "problem", "problems")} recorded against this phase.`
4546
- ];
4645
+ return [`${OUTSTANDING_LABEL}${counted(crossed.findings, "problem", "problems")} recorded against this phase.`];
4547
4646
  case "unfinished-steps":
4548
4647
  return [
4549
- `- Outstanding work Halfcycle could see: ${counted(crossed.steps.length, "step", "steps")} of the method left unfinished. Each is shown by the method's own label for it:`,
4550
- ...crossed.steps.map((step) => ` - Step: ${p.text(step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
4648
+ `${OUTSTANDING_LABEL}${counted(crossed.steps.length, "step", "steps")} of the method left unfinished. ${shownBy(crossed.steps)}`,
4649
+ ...crossed.steps.map((step) => ` - Step: ${p.text(step.name ?? step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
4551
4650
  ];
4552
4651
  }
4553
4652
  }
4554
- function machines(coverage) {
4653
+ function shownBy(steps) {
4654
+ const named = steps.filter((s) => s.name !== void 0).length;
4655
+ if (named === 0)
4656
+ return "Each is shown by the method's own label for it:";
4657
+ if (named === steps.length)
4658
+ return "Each is shown by the method's own name for it:";
4659
+ return "Each is shown by the method's own name for it, or by its label where the record holds no name:";
4660
+ }
4661
+ function machines(coverage, checks) {
4555
4662
  const label = "- Computers signed in to Halfcycle for this project when the phase closed: ";
4556
4663
  if (coverage === null)
4557
4664
  return `${label}not recorded.`;
4558
4665
  if (coverage.enrolled === 0)
4559
4666
  return `${label}none.`;
4560
- return `${label}${String(coverage.enrolled)}. Of these, ${String(coverage.conformant)} had reported Halfcycle's checks as fully set up.`;
4667
+ const line = `${label}${String(coverage.enrolled)}. Of these, ${String(coverage.conformant)} had reported Halfcycle's checks as fully set up.`;
4668
+ return checks === "not-observed" && coverage.conformant > 0 ? `${line} Set up is not the same as running: Halfcycle has no record of any check running during this phase.` : line;
4561
4669
  }
4562
4670
  function walkLines(walk, p) {
4563
4671
  const label = "> - Acceptance walk (the finished work tried as a user would, by someone other than its builder): ";
@@ -4577,9 +4685,15 @@ function walkLines(walk, p) {
4577
4685
  function statedOrNot(value, p) {
4578
4686
  return value === null ? "Not stated by the client." : p.text(value);
4579
4687
  }
4688
+ function decisionLine(attested) {
4689
+ if (attested.decision === "override") {
4690
+ return "> - Decision: close the phase by override, without stating its finish conditions were met.";
4691
+ }
4692
+ return attested.walk !== null && attested.walk.verdict === "defects" ? "> - Decision: close the phase as it stood, with the problems its acceptance walk found." : "> - Decision: the phase's agreed finish conditions were met.";
4693
+ }
4580
4694
  function renderStated(attested, p) {
4581
4695
  const lines = [
4582
- attested.decision === "done-when-met" ? "> - Decision: the phase's agreed finish conditions were met." : "> - Decision: close the phase by override, without stating its finish conditions were met.",
4696
+ decisionLine(attested),
4583
4697
  `> - Name given: ${attested.actor === null ? "removed with the account that made this close" : p.text(attested.actor)}`,
4584
4698
  `> - Reason given: ${attested.reason === null ? "none." : p.text(attested.reason)}`,
4585
4699
  ...walkLines(attested.walk, p),
@@ -4607,7 +4721,7 @@ function renderClose(heading, observed, attested, checks, p) {
4607
4721
  `- Closed at: ${closedAtText(observed.closedAt, p)}`,
4608
4722
  `- Signed in as: ${signedInAs(observed.closedBy, p)}`,
4609
4723
  ...outstanding(observed.crossed, p),
4610
- machines(observed.seatCoverage)
4724
+ machines(observed.seatCoverage, checks)
4611
4725
  ].join("\n"),
4612
4726
  STATED_LABEL,
4613
4727
  renderStated(attested, p)
@@ -4868,8 +4982,7 @@ async function obtainBuildRecord(credential, repoRoot, phase, io = NODE_RECORD_P
4868
4982
  };
4869
4983
  }
4870
4984
  function refetchCommand(phase) {
4871
- const quoted = /^[A-Za-z0-9._+-]+$/.test(phase) ? phase : `'${phase.replace(/'/g, `'\\''`)}'`;
4872
- return `npx halfcycle@latest build-record ${quoted}`;
4985
+ return `npx halfcycle@latest build-record ${quoteForShell(phase)}`;
4873
4986
  }
4874
4987
 
4875
4988
  // dist/open-phase.js
@@ -4878,10 +4991,12 @@ import { join as join7 } from "node:path";
4878
4991
  var OpenPhaseRefused = class extends Error {
4879
4992
  status;
4880
4993
  finding;
4881
- constructor(status, message, finding) {
4994
+ previousPhase;
4995
+ constructor(status, message, finding, previousPhase = null) {
4882
4996
  super(message);
4883
4997
  this.status = status;
4884
4998
  this.finding = finding;
4999
+ this.previousPhase = previousPhase;
4885
5000
  this.name = "OpenPhaseRefused";
4886
5001
  }
4887
5002
  };
@@ -4960,7 +5075,8 @@ async function openPhase(credential, phase, entry, home) {
4960
5075
  const body2 = await res.json().catch(() => null);
4961
5076
  const message = typeof body2?.message === "string" ? body2.message : `${url} returned ${res.status}.`;
4962
5077
  const finding = typeof body2?.finding === "string" ? body2.finding : null;
4963
- throw new OpenPhaseRefused(res.status, message, finding);
5078
+ const previousPhase = typeof body2?.previousPhase === "string" ? body2.previousPhase : null;
5079
+ throw new OpenPhaseRefused(res.status, message, finding, previousPhase);
4964
5080
  }
4965
5081
  const body = await res.json().catch(() => null);
4966
5082
  if (!body || !(typeof body.phase === "string" || body.phase === null) || !(typeof body.previousPhase === "string" || body.previousPhase === null) || typeof body.decision !== "object" || body.decision === null || typeof body.decision.decisionId !== "string") {
@@ -5186,7 +5302,7 @@ function requirementMessage(verb, flag) {
5186
5302
  function runnableInvocation(verb, phase) {
5187
5303
  const contract = contractFor(verb);
5188
5304
  const flags = (contract?.required ?? []).map((req) => `${req.flag} ${PLACEHOLDERS[req.flag] ?? '"\u2026"'}`).join(" ");
5189
- return `halfcycle ${verb} ${phase}${flags ? ` ${flags}` : ""}`;
5305
+ return `halfcycle ${verb} ${quoteForShell(phase)}${flags ? ` ${flags}` : ""}`;
5190
5306
  }
5191
5307
  var PLACEHOLDERS = {
5192
5308
  "--verdict": "<clean|defects>",
@@ -6113,7 +6229,12 @@ ${USAGE}`);
6113
6229
  // T-10 — optional; absent on a plane that has not configured
6114
6230
  // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
6115
6231
  // an absent value, never `undefined` verbatim.
6116
- controlTelemetryUrl: joined.controlTelemetryUrl
6232
+ controlTelemetryUrl: joined.controlTelemetryUrl,
6233
+ // T-01 (Connect Phase 4 follow-ups) — optional; absent on a control
6234
+ // deployment that predates it. The join route's own stamped pin,
6235
+ // which can be older than this deployment's current default.
6236
+ consoleUrl: joined.consoleUrl,
6237
+ methodVersion: joined.methodVersion
6117
6238
  };
6118
6239
  foundLine = `this repo is pinned to one of your projects \u2014 joining it as you (${shortId(joined.engagementId)})`;
6119
6240
  }
@@ -6137,7 +6258,12 @@ ${USAGE}`);
6137
6258
  // T-10 — the FOURTH origin, optional. Absent on a plane that has not
6138
6259
  // configured CONTROL_TELEMETRY_URL yet; the runner's own telemetry
6139
6260
  // emission is fail-open on that absence.
6140
- controlTelemetryUrl: created.controlTelemetryUrl
6261
+ controlTelemetryUrl: created.controlTelemetryUrl,
6262
+ // T-01 (Connect Phase 4 follow-ups) — optional; absent on a control
6263
+ // deployment that predates it. The create route's own deployment
6264
+ // default at the moment this engagement was stamped.
6265
+ consoleUrl: created.consoleUrl,
6266
+ methodVersion: created.methodVersion
6141
6267
  };
6142
6268
  foundLine = `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
6143
6269
  if (verbose) {
@@ -6239,16 +6365,14 @@ ${USAGE}`);
6239
6365
  process.stdout.write(`[halfcycle] Claude Code will then ask whether to use a new MCP server. The cursor starts on "Continue without using this MCP server" \u2014 choose to use it instead; without it Halfcycle will not connect.
6240
6366
  `);
6241
6367
  }
6242
- if (credential) {
6243
- try {
6244
- const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
6245
- process.stdout.write(`[halfcycle] Your project's page \u2014 how it is going, the agents working on it, and your other projects (first visit): ${minted.boardUrl}
6368
+ if (credential?.consoleUrl !== void 0) {
6369
+ const colour = decideColour(currentEnvironment(quiet));
6370
+ process.stdout.write(`${ink("key", `[halfcycle] Your project's console: ${credential.consoleUrl}`, colour)}
6246
6371
  `);
6247
- process.stdout.write(`[halfcycle] First-visit code \u2014 single use, expires ${minted.expiresAt}; paste it on that page, and re-run this installer for a fresh one: ${minted.enterCode}
6372
+ }
6373
+ if (credential?.methodVersion !== void 0) {
6374
+ process.stdout.write(`[halfcycle] Method version: ${credential.methodVersion}
6248
6375
  `);
6249
- } catch {
6250
- process.stdout.write("[halfcycle] Your project's page: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
6251
- }
6252
6376
  }
6253
6377
  process.exit(0);
6254
6378
  } catch (err) {
@@ -6295,7 +6419,9 @@ ${USAGE}`);
6295
6419
  return;
6296
6420
  }
6297
6421
  try {
6298
- const { drifted, installed, current } = checkDrift(targetRepo2);
6422
+ const { drifted, installed, current, methodVersion } = checkDrift(targetRepo2);
6423
+ process.stdout.write(`[halfcycle-bundle] Method version: ${methodVersion ?? "not recorded"}
6424
+ `);
6299
6425
  if (drifted) {
6300
6426
  process.stdout.write(`[halfcycle-bundle] DRIFT DETECTED: installed=${installed ?? "none"} current=${current}
6301
6427
  [halfcycle-bundle] Re-run "halfcycle-bundle install ${targetRepo2}" to sync.
@@ -6367,8 +6493,13 @@ ${USAGE}`);
6367
6493
  const opened = await openPhase(credential, toProjectScope ? null : phaseArg, entry);
6368
6494
  const what = opened.phase === null ? "project scope" : `phase "${opened.phase}"`;
6369
6495
  const from = opened.previousPhase === null ? "project scope" : `phase "${opened.previousPhase}"`;
6370
- process.stdout.write(`[halfcycle] Moved from ${from} to ${what}.
6496
+ if (opened.previousPhase === opened.phase) {
6497
+ process.stdout.write(opened.phase === null ? "[halfcycle] Already at project scope \u2014 nothing moved.\n" : `[halfcycle] Phase "${opened.phase}" is already open \u2014 nothing moved.
6371
6498
  `);
6499
+ } else {
6500
+ process.stdout.write(`[halfcycle] Moved from ${from} to ${what}.
6501
+ `);
6502
+ }
6372
6503
  process.stdout.write(`[halfcycle] Recorded as ${opened.decision.decision} by ${opened.decision.actor} at ${opened.decision.decidedAt}.
6373
6504
  `);
6374
6505
  if (opened.decision.blockingFinding) {
@@ -6385,8 +6516,7 @@ halfcycle open-phase: until ${opened.stampFailure.path} can be written, guard ev
6385
6516
  process.exit(0);
6386
6517
  } catch (err) {
6387
6518
  if (err instanceof InvalidPhaseIdentityError) {
6388
- process.stderr.write(`halfcycle open-phase: ${err.message}
6389
- halfcycle open-phase: a phase name has to work as a single folder name on this machine \u2014 that is what this machine files its guard evaluations under. Nothing was opened.
6519
+ process.stderr.write(`halfcycle open-phase: '${String(err.identity)}' cannot be used as a phase name \u2014 it has to work as a single folder name on this machine. Nothing was opened.
6390
6520
  `);
6391
6521
  process.exit(2);
6392
6522
  return;
@@ -6399,8 +6529,7 @@ halfcycle open-phase: a phase name has to work as a single folder name on this m
6399
6529
  `);
6400
6530
  }
6401
6531
  if (err.status === 409) {
6402
- const stuck = err.finding?.match(/^phase '([^']+)' is still open/)?.[1];
6403
- process.stderr.write(`halfcycle open-phase: close the phase first \u2014 \`${runnableInvocation("close-phase", stuck ?? "<phase>")}\` (add --finding "\u2026" once per finding when the verdict is defects) \u2014 or re-run with --override --reason "\u2026", which is recorded, not waived.
6532
+ process.stderr.write(`halfcycle open-phase: close the phase first \u2014 \`${runnableInvocation("close-phase", err.previousPhase ?? "<phase>")}\` (add --finding "\u2026" once per finding when the verdict is defects) \u2014 or re-run with --override --reason "\u2026", which is recorded, not waived.
6404
6533
  `);
6405
6534
  }
6406
6535
  process.exit(1);
@@ -6511,17 +6640,22 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
6511
6640
  return;
6512
6641
  }
6513
6642
  if (err instanceof ClosePhaseRefused) {
6643
+ if (err.finding) {
6644
+ process.stderr.write(`halfcycle close-phase: refused (${err.status}).
6645
+ `);
6646
+ process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${plainOutstandingFinding(err.finding)}
6647
+ `);
6648
+ process.stderr.write(`halfcycle close-phase: close it anyway with \`halfcycle close-phase ${quoteForShell(phaseArg)} --verdict ${verdict} --actor ${quoteForShell(closeActor)} --override --reason "\u2026"\` \u2014 recorded, not waived.
6649
+ `);
6650
+ process.exit(1);
6651
+ return;
6652
+ }
6514
6653
  process.stderr.write(`halfcycle close-phase: refused (${err.status}). ${err.message}
6515
6654
  `);
6516
6655
  if (err.status === 400) {
6517
6656
  process.stderr.write("halfcycle close-phase: Nothing was closed.\n");
6518
6657
  }
6519
- if (err.finding) {
6520
- process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${err.finding}
6521
- `);
6522
- process.stderr.write(`halfcycle close-phase: close it anyway with \`halfcycle close-phase ${phaseArg} --verdict ${verdict} --actor "${closeActor}" --override --reason "\u2026"\` \u2014 recorded, not waived.
6523
- `);
6524
- } else if (err.code === "PhaseAlreadyClosed") {
6658
+ if (err.code === "PhaseAlreadyClosed") {
6525
6659
  process.stderr.write("halfcycle close-phase: to close a phase again, open it again first.\nhalfcycle close-phase: To get a closed phase's Build Record: npx halfcycle@latest build-record <the phase that closed>\n");
6526
6660
  }
6527
6661
  process.exit(1);
@@ -6560,6 +6694,9 @@ function readStatementFlags(argv) {
6560
6694
  }
6561
6695
  return { thesis, outcome, deviations };
6562
6696
  }
6697
+ function plainOutstandingFinding(finding) {
6698
+ return finding.replace(/\(attempted-failed\)/g, "(attempted, and did not finish)");
6699
+ }
6563
6700
  function writtenLines(written) {
6564
6701
  return `[halfcycle] Build Record written:
6565
6702
  ${written.mdPath}