halfcycle 0.3.17 → 0.3.19
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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/bin.bundle.mjs +224 -33
- package/dist/bin.js +571 -198
- package/dist/bin.js.map +3 -3
- package/dist/build-record/close-record.d.ts +124 -0
- package/dist/build-record/close-record.d.ts.map +1 -0
- package/dist/build-record/index.d.ts +8 -5
- package/dist/build-record/index.d.ts.map +1 -1
- package/dist/build-record/sources.d.ts +49 -18
- package/dist/build-record/sources.d.ts.map +1 -1
- package/dist/build-record/template.d.ts.map +1 -1
- package/dist/build-record/types.d.ts +49 -12
- package/dist/build-record/types.d.ts.map +1 -1
- package/dist/build-record/write.d.ts +60 -3
- package/dist/build-record/write.d.ts.map +1 -1
- package/dist/close-phase.d.ts +21 -2
- package/dist/close-phase.d.ts.map +1 -1
- package/dist/confirm-identity.d.ts +25 -0
- package/dist/confirm-identity.d.ts.map +1 -1
- package/dist/create-engagement.d.ts +29 -1
- package/dist/create-engagement.d.ts.map +1 -1
- package/dist/engagement-credential.d.ts +85 -2
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/index.js +412 -142
- package/dist/index.js.map +3 -3
- package/dist/install.d.ts.map +1 -1
- package/dist/open-phase.d.ts +58 -1
- package/dist/open-phase.d.ts.map +1 -1
- package/dist/own-engagement.d.ts +50 -10
- package/dist/own-engagement.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// dist/bin.js
|
|
4
4
|
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
5
5
|
import { readFileSync as readFileSync9 } from "node:fs";
|
|
6
|
-
import { join as
|
|
6
|
+
import { join as join11 } from "node:path";
|
|
7
7
|
|
|
8
8
|
// dist/install.js
|
|
9
9
|
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
@@ -61,11 +61,26 @@ var firedGuardSchema = z2.object({
|
|
|
61
61
|
*/
|
|
62
62
|
path: z2.string().optional()
|
|
63
63
|
}).strict();
|
|
64
|
+
var notRunCheckSchema = z2.object({
|
|
65
|
+
/** Which check did not run, by the same reference a fired check carries. */
|
|
66
|
+
patternRef: z2.string(),
|
|
67
|
+
/**
|
|
68
|
+
* Why it could not run, in plain words — what this client did not send, or
|
|
69
|
+
* that the check's kind is not implemented yet. Never the check's own rule.
|
|
70
|
+
*/
|
|
71
|
+
reason: z2.string()
|
|
72
|
+
}).strict();
|
|
64
73
|
var resultEnvelopeSchema = z2.object({
|
|
65
74
|
guardsFired: z2.array(firedGuardSchema),
|
|
66
75
|
severity: severitySchema.nullable(),
|
|
67
76
|
blocking: z2.boolean(),
|
|
68
|
-
explanation: z2.string()
|
|
77
|
+
explanation: z2.string(),
|
|
78
|
+
/**
|
|
79
|
+
* The checks that could not run on this edit, each named with the reason.
|
|
80
|
+
* Absent when every check ran — so an evaluation where nothing was skipped
|
|
81
|
+
* looks exactly as it always has, and an absent list is never an empty one.
|
|
82
|
+
*/
|
|
83
|
+
notRun: z2.array(notRunCheckSchema).optional()
|
|
69
84
|
}).strict();
|
|
70
85
|
var wireErrorSchema = z2.object({
|
|
71
86
|
statusCode: z2.number(),
|
|
@@ -426,16 +441,92 @@ function loopbackVerificationUrl(verificationUrl, target) {
|
|
|
426
441
|
return url.toString();
|
|
427
442
|
}
|
|
428
443
|
|
|
429
|
-
// ../events/dist/
|
|
444
|
+
// ../events/dist/join-refusal.js
|
|
430
445
|
import { z as z7 } from "zod";
|
|
431
|
-
var
|
|
432
|
-
|
|
433
|
-
|
|
446
|
+
var NOT_THIS_ACCOUNT_REASON = "not-this-account";
|
|
447
|
+
var JOIN_REFUSAL_STATUS = 403;
|
|
448
|
+
var notThisAccountRefusalSchema = z7.object({
|
|
449
|
+
statusCode: z7.literal(JOIN_REFUSAL_STATUS),
|
|
450
|
+
error: z7.string(),
|
|
451
|
+
message: z7.string(),
|
|
452
|
+
reason: z7.literal(NOT_THIS_ACCOUNT_REASON)
|
|
453
|
+
}).strict();
|
|
454
|
+
var joinRefusalSchema = z7.union([notThisAccountRefusalSchema, wireErrorSchema]);
|
|
455
|
+
function isNotThisAccountRefusal(body) {
|
|
456
|
+
return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ../events/dist/account-identity.js
|
|
460
|
+
import { z as z8 } from "zod";
|
|
461
|
+
var accountIdentitySchema = z8.object({
|
|
462
|
+
accountId: z8.string().min(1),
|
|
463
|
+
email: z8.string().min(1).optional()
|
|
434
464
|
}).strict();
|
|
435
465
|
function safeParseAccountIdentity(payload) {
|
|
436
466
|
return accountIdentitySchema.safeParse(payload);
|
|
437
467
|
}
|
|
438
468
|
|
|
469
|
+
// ../events/dist/phase-identity.js
|
|
470
|
+
var PHASE_SEGMENT_PREFIX = "phase-";
|
|
471
|
+
function isValidPhaseIdentity(identity) {
|
|
472
|
+
if (typeof identity === "number")
|
|
473
|
+
return Number.isInteger(identity) && identity >= 0;
|
|
474
|
+
if (typeof identity !== "string")
|
|
475
|
+
return false;
|
|
476
|
+
if (identity === "")
|
|
477
|
+
return false;
|
|
478
|
+
if (identity.includes("/"))
|
|
479
|
+
return false;
|
|
480
|
+
if (identity.includes("\\"))
|
|
481
|
+
return false;
|
|
482
|
+
if (identity.includes("\0"))
|
|
483
|
+
return false;
|
|
484
|
+
if (identity.startsWith("."))
|
|
485
|
+
return false;
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
var InvalidPhaseIdentityError = class extends Error {
|
|
489
|
+
/** The value that was refused, exactly as supplied. */
|
|
490
|
+
identity;
|
|
491
|
+
constructor(identity, allowedLocation) {
|
|
492
|
+
const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
|
|
493
|
+
const where = allowedLocation === void 0 ? "" : `
|
|
494
|
+
Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
|
|
495
|
+
super(`that phase identity cannot name ${subject}.
|
|
496
|
+
identity: ${describeIdentity(identity)}` + where);
|
|
497
|
+
this.name = "InvalidPhaseIdentityError";
|
|
498
|
+
this.identity = identity;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
function assertValidPhaseIdentity(identity, allowedLocation) {
|
|
502
|
+
if (!isValidPhaseIdentity(identity)) {
|
|
503
|
+
throw new InvalidPhaseIdentityError(identity, allowedLocation);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
function renderPhaseSegment(identity) {
|
|
507
|
+
if (!isValidPhaseIdentity(identity)) {
|
|
508
|
+
throw new InvalidPhaseIdentityError(identity);
|
|
509
|
+
}
|
|
510
|
+
return `${PHASE_SEGMENT_PREFIX}${identity}`;
|
|
511
|
+
}
|
|
512
|
+
function describeIdentity(identity) {
|
|
513
|
+
if (identity === "")
|
|
514
|
+
return "(empty)";
|
|
515
|
+
if (typeof identity === "string")
|
|
516
|
+
return identity;
|
|
517
|
+
if (identity === null)
|
|
518
|
+
return "null";
|
|
519
|
+
if (identity === void 0)
|
|
520
|
+
return "none supplied";
|
|
521
|
+
if (typeof identity === "number" || typeof identity === "boolean")
|
|
522
|
+
return String(identity);
|
|
523
|
+
if (Array.isArray(identity))
|
|
524
|
+
return "(a list)";
|
|
525
|
+
if (typeof identity === "object")
|
|
526
|
+
return "(an object)";
|
|
527
|
+
return `(a ${typeof identity})`;
|
|
528
|
+
}
|
|
529
|
+
|
|
439
530
|
// dist/engagement-credential.js
|
|
440
531
|
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
441
532
|
import { platform as platform2 } from "node:os";
|
|
@@ -521,6 +612,18 @@ function engagementStateDir(engagementId, home) {
|
|
|
521
612
|
function engagementEnvPath(engagementId, home) {
|
|
522
613
|
return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
|
|
523
614
|
}
|
|
615
|
+
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
616
|
+
function notThisAccountMarkerPath(engagementId, home) {
|
|
617
|
+
return join2(engagementStateDir(engagementId, home), NOT_THIS_ACCOUNT_MARKER_FILENAME);
|
|
618
|
+
}
|
|
619
|
+
function writeNotThisAccountMarker(engagementId, home) {
|
|
620
|
+
const dir = engagementStateDir(engagementId, home);
|
|
621
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
622
|
+
const path = notThisAccountMarkerPath(engagementId, home);
|
|
623
|
+
writeFileSync2(path, `${(/* @__PURE__ */ new Date()).toISOString()}
|
|
624
|
+
`, { mode: 384 });
|
|
625
|
+
applyOwnerOnly(path, dir);
|
|
626
|
+
}
|
|
524
627
|
var GUARD_ENV_KEYS = [
|
|
525
628
|
"GUARD_SERVICE_URL",
|
|
526
629
|
"GUARD_SERVICE_TOKEN",
|
|
@@ -535,6 +638,7 @@ var ENGAGEMENT_ENV_KEYS = [
|
|
|
535
638
|
...GUARD_ENV_KEYS,
|
|
536
639
|
"CONTROL_TELEMETRY_URL"
|
|
537
640
|
];
|
|
641
|
+
var PHASE_STAMP_ENV_KEY = "HALFCYCLE_PHASE";
|
|
538
642
|
var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
|
|
539
643
|
function shq(value) {
|
|
540
644
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -562,33 +666,50 @@ function parseEnvText(raw) {
|
|
|
562
666
|
return out;
|
|
563
667
|
}
|
|
564
668
|
function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
|
|
565
|
-
const desired = new Map(keys.map((k) =>
|
|
669
|
+
const desired = new Map(keys.map((k) => {
|
|
670
|
+
const value = values[k];
|
|
671
|
+
return [k, value === void 0 ? "" : value];
|
|
672
|
+
}));
|
|
566
673
|
const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
|
|
674
|
+
const written = keys.filter((k) => desired.get(k) !== null);
|
|
567
675
|
if (existing === null) {
|
|
676
|
+
if (written.length === 0)
|
|
677
|
+
return "";
|
|
568
678
|
return `${ENV_HEADER}
|
|
569
|
-
` +
|
|
679
|
+
` + written.map(line).join("\n") + "\n";
|
|
570
680
|
}
|
|
571
681
|
const seen = /* @__PURE__ */ new Set();
|
|
572
|
-
const out =
|
|
682
|
+
const out = [];
|
|
683
|
+
for (const existingLine of existing.split("\n")) {
|
|
573
684
|
const eq = existingLine.indexOf("=");
|
|
574
|
-
if (eq === -1)
|
|
575
|
-
|
|
685
|
+
if (eq === -1) {
|
|
686
|
+
out.push(existingLine);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
576
689
|
const lhs = existingLine.slice(0, eq);
|
|
577
690
|
const exported = /^\s*export\s+/.exec(lhs);
|
|
578
691
|
const prefix = exported === null ? "" : exported[0];
|
|
579
692
|
const key = lhs.slice(prefix.length).trim();
|
|
580
|
-
if (desired.has(key)) {
|
|
581
|
-
|
|
582
|
-
|
|
693
|
+
if (!desired.has(key)) {
|
|
694
|
+
out.push(existingLine);
|
|
695
|
+
continue;
|
|
583
696
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
697
|
+
seen.add(key);
|
|
698
|
+
if (desired.get(key) === null)
|
|
699
|
+
continue;
|
|
700
|
+
out.push(`${prefix}${line(key)}`);
|
|
701
|
+
}
|
|
702
|
+
const missing = written.filter((k) => !seen.has(k));
|
|
587
703
|
if (missing.length > 0) {
|
|
704
|
+
const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
|
|
705
|
+
`;
|
|
706
|
+
const block = header + missing.map(line).join("\n");
|
|
588
707
|
const trailingBlank = out.length > 0 && out[out.length - 1] === "";
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
708
|
+
if (trailingBlank)
|
|
709
|
+
out.splice(out.length - 1, 0, block);
|
|
710
|
+
else
|
|
711
|
+
out.push(`
|
|
712
|
+
${block}`);
|
|
592
713
|
}
|
|
593
714
|
let result = out.join("\n");
|
|
594
715
|
if (existing.endsWith("\n") && !result.endsWith("\n"))
|
|
@@ -602,17 +723,19 @@ function readEngagementEnv(engagementId, home) {
|
|
|
602
723
|
return null;
|
|
603
724
|
}
|
|
604
725
|
}
|
|
605
|
-
function writeEngagementEnv(engagementId, values, home) {
|
|
726
|
+
function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KEYS) {
|
|
606
727
|
const path = engagementEnvPath(engagementId, home);
|
|
607
728
|
const dir = engagementStateDir(engagementId, home);
|
|
608
|
-
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
609
729
|
let existing;
|
|
610
730
|
try {
|
|
611
731
|
existing = readFileSync2(path, "utf-8");
|
|
612
732
|
} catch {
|
|
613
733
|
existing = null;
|
|
614
734
|
}
|
|
615
|
-
const reconciled = reconcileEnvText(existing, values);
|
|
735
|
+
const reconciled = reconcileEnvText(existing, values, keys);
|
|
736
|
+
if (existing === null && reconciled === "")
|
|
737
|
+
return "skipped";
|
|
738
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
616
739
|
const outcome = existing === reconciled ? "skipped" : "written";
|
|
617
740
|
if (outcome === "written") {
|
|
618
741
|
writeFileSync2(path, reconciled, { mode: 384 });
|
|
@@ -642,14 +765,15 @@ function engagementResolutionShell() {
|
|
|
642
765
|
# desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
|
|
643
766
|
# file, because sed is line-oriented and the pin's line breaks are not a contract.
|
|
644
767
|
#
|
|
645
|
-
# Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT
|
|
646
|
-
# the CALLER's shell and returns a status. Call
|
|
647
|
-
# never as \`$(halfcycle_env_file \u2026)\` \u2014 a
|
|
648
|
-
# would discard everything it set.
|
|
768
|
+
# Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT, HALFCYCLE_ENV_PROBLEM and
|
|
769
|
+
# HALFCYCLE_ENV_NOT_THIS_ACCOUNT in the CALLER's shell and returns a status. Call
|
|
770
|
+
# it as \`if halfcycle_env_file \u2026\`, never as \`$(halfcycle_env_file \u2026)\` \u2014 a
|
|
771
|
+
# command substitution is a subshell and would discard everything it set.
|
|
649
772
|
halfcycle_env_file() {
|
|
650
773
|
HALFCYCLE_ENV_FILE=""
|
|
651
774
|
HALFCYCLE_ENV_ENGAGEMENT=""
|
|
652
775
|
HALFCYCLE_ENV_PROBLEM=""
|
|
776
|
+
HALFCYCLE_ENV_NOT_THIS_ACCOUNT=""
|
|
653
777
|
hc_pin="$1/.halfcycle/bundle.json"
|
|
654
778
|
if [ ! -f "$hc_pin" ]; then
|
|
655
779
|
HALFCYCLE_ENV_PROBLEM="no-pin"
|
|
@@ -665,6 +789,9 @@ halfcycle_env_file() {
|
|
|
665
789
|
HALFCYCLE_ENV_PROBLEM="no-home"
|
|
666
790
|
return 1
|
|
667
791
|
fi
|
|
792
|
+
if [ -f "$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
|
|
793
|
+
HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
|
|
794
|
+
fi
|
|
668
795
|
hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
|
|
669
796
|
if [ ! -f "$hc_env" ]; then
|
|
670
797
|
HALFCYCLE_ENV_PROBLEM="no-credential"
|
|
@@ -1036,6 +1163,13 @@ if halfcycle_env_file "$PROJECT_DIR"; then
|
|
|
1036
1163
|
set +a
|
|
1037
1164
|
fi
|
|
1038
1165
|
|
|
1166
|
+
# Pass along WHY this machine has no credential, as a plain variable \u2014 the
|
|
1167
|
+
# marker check above already answered it, and this is the one bit of that
|
|
1168
|
+
# answer the runner binary needs to say the right thing on every edit.
|
|
1169
|
+
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
1170
|
+
export HALFCYCLE_NOT_THIS_ACCOUNT=1
|
|
1171
|
+
fi
|
|
1172
|
+
|
|
1039
1173
|
exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
1040
1174
|
`;
|
|
1041
1175
|
}
|
|
@@ -1051,13 +1185,20 @@ MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
|
|
|
1051
1185
|
git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
|
|
1052
1186
|
|
|
1053
1187
|
# ---------------------------------------------------------------------------
|
|
1054
|
-
# STANDING GUARD-COVERAGE STATEMENT (T-27).
|
|
1188
|
+
# STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
|
|
1055
1189
|
#
|
|
1056
1190
|
# A repository with no credential on this machine is UNGUARDED: the PostToolUse
|
|
1057
1191
|
# hook will find nothing to evaluate with, and it says so once per edit, after
|
|
1058
1192
|
# the edit. This says it once, up front, before anything is written \u2014 which is
|
|
1059
1193
|
# the one thing a session-start hook can do that nothing else can.
|
|
1060
1194
|
#
|
|
1195
|
+
# TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
|
|
1196
|
+
# solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
|
|
1197
|
+
# fixed by re-running the installer; "this is not your project" is permanent,
|
|
1198
|
+
# and the same remedy would send the reader in a circle forever. The two are
|
|
1199
|
+
# told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
|
|
1200
|
+
# below from a machine-level marker beside the credential store.
|
|
1201
|
+
#
|
|
1061
1202
|
# Everything below runs in a SUBSHELL: it sources a credential store, and a
|
|
1062
1203
|
# session-start hook must not leak an engagement's variables into whatever the
|
|
1063
1204
|
# editor runs next. Any failure inside it is swallowed; this script's exit
|
|
@@ -1071,18 +1212,33 @@ ${engagementResolutionShell()}
|
|
|
1071
1212
|
. "$HALFCYCLE_ENV_FILE"
|
|
1072
1213
|
set +a
|
|
1073
1214
|
fi
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
echo "[halfcycle]
|
|
1081
|
-
echo "[halfcycle]
|
|
1082
|
-
echo "[halfcycle]
|
|
1083
|
-
echo "[halfcycle]
|
|
1084
|
-
echo "[halfcycle]
|
|
1085
|
-
echo "[halfcycle]
|
|
1215
|
+
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
1216
|
+
# OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
|
|
1217
|
+
# case below wearing different words: there is nothing to fix, and the loop
|
|
1218
|
+
# that follows is skipped entirely rather than run and then overridden \u2014 a
|
|
1219
|
+
# repository whose credential is absent for THIS reason must never be told
|
|
1220
|
+
# to re-run the installer, which can never obtain one for it.
|
|
1221
|
+
echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
|
|
1222
|
+
echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
|
|
1223
|
+
echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
|
|
1224
|
+
echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
|
|
1225
|
+
echo "[halfcycle] Ask the project's owner for view access."
|
|
1226
|
+
echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
|
|
1227
|
+
echo "[halfcycle] \\"npx halfcycle\\" again."
|
|
1228
|
+
else
|
|
1229
|
+
hc_missing=""
|
|
1230
|
+
for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
|
|
1231
|
+
eval "hc_value=\\\${$hc_key:-}"
|
|
1232
|
+
if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
|
|
1233
|
+
done
|
|
1234
|
+
if [ -n "$hc_missing" ]; then
|
|
1235
|
+
echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
|
|
1236
|
+
echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
|
|
1237
|
+
echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
|
|
1238
|
+
echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
|
|
1239
|
+
echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
|
|
1240
|
+
echo "[halfcycle] and needs nothing configured first."
|
|
1241
|
+
fi
|
|
1086
1242
|
fi
|
|
1087
1243
|
) 2>/dev/null || true
|
|
1088
1244
|
exit 0
|
|
@@ -1242,8 +1398,20 @@ if [ -z "$TOKEN" ]; then
|
|
|
1242
1398
|
no-home)
|
|
1243
1399
|
echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
|
|
1244
1400
|
*)
|
|
1245
|
-
|
|
1246
|
-
|
|
1401
|
+
# OUTCOME B (T-02b, solo-project-ownership) is a \`no-credential\` PROBLEM
|
|
1402
|
+
# with a REASON attached, checked here rather than as a fifth case value
|
|
1403
|
+
# \u2014 see \`engagementResolutionShell\`'s own docblock for why. Re-running
|
|
1404
|
+
# this installer can never obtain a credential for this reason, so it is
|
|
1405
|
+
# the one sentence in this file that must not say to.
|
|
1406
|
+
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
1407
|
+
echo "halfcycle: this project belongs to another Halfcycle account, so this machine has no" >&2
|
|
1408
|
+
echo "halfcycle: credential for it and the Halfcycle tools are not available here. Nothing is" >&2
|
|
1409
|
+
echo "halfcycle: broken \u2014 ask the project's owner for view access, or sign in as the account" >&2
|
|
1410
|
+
echo "halfcycle: that owns it and run \\"npx halfcycle\\" again." >&2
|
|
1411
|
+
else
|
|
1412
|
+
echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
|
|
1413
|
+
echo "halfcycle: run \\"npx halfcycle\\" in this repository \u2014 it will sign you in through your browser if needed, and write the credential to $HOME/.halfcycle, outside this repository." >&2
|
|
1414
|
+
fi ;;
|
|
1247
1415
|
esac
|
|
1248
1416
|
exit 1
|
|
1249
1417
|
fi
|
|
@@ -2048,11 +2216,13 @@ var CreateEngagementRefused = class extends Error {
|
|
|
2048
2216
|
status;
|
|
2049
2217
|
serverMessage;
|
|
2050
2218
|
errorCode;
|
|
2051
|
-
|
|
2219
|
+
reason;
|
|
2220
|
+
constructor(status, serverMessage, errorCode, reason, message) {
|
|
2052
2221
|
super(message);
|
|
2053
2222
|
this.status = status;
|
|
2054
2223
|
this.serverMessage = serverMessage;
|
|
2055
2224
|
this.errorCode = errorCode;
|
|
2225
|
+
this.reason = reason;
|
|
2056
2226
|
this.name = "CreateEngagementRefused";
|
|
2057
2227
|
}
|
|
2058
2228
|
/**
|
|
@@ -2097,7 +2267,8 @@ function planeRefusal(detail) {
|
|
|
2097
2267
|
return void 0;
|
|
2098
2268
|
return {
|
|
2099
2269
|
message: candidate.message,
|
|
2100
|
-
error: typeof candidate.error === "string" ? candidate.error : void 0
|
|
2270
|
+
error: typeof candidate.error === "string" ? candidate.error : void 0,
|
|
2271
|
+
reason: typeof candidate.reason === "string" ? candidate.reason : void 0
|
|
2101
2272
|
};
|
|
2102
2273
|
}
|
|
2103
2274
|
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.`;
|
|
@@ -2138,7 +2309,7 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
2138
2309
|
const detail = await res.text().catch(() => "");
|
|
2139
2310
|
const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
|
|
2140
2311
|
if (refusal) {
|
|
2141
|
-
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
|
|
2312
|
+
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, refusal.reason, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
|
|
2142
2313
|
}
|
|
2143
2314
|
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
|
|
2144
2315
|
throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
@@ -2619,6 +2790,9 @@ function armScript(credential, act, label, plainLabel, home) {
|
|
|
2619
2790
|
}
|
|
2620
2791
|
}
|
|
2621
2792
|
}
|
|
2793
|
+
function identitySwitchRemedy(home) {
|
|
2794
|
+
return `To act as a different account: run this again and answer "s" when asked, set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`;
|
|
2795
|
+
}
|
|
2622
2796
|
async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
|
|
2623
2797
|
const write = deps.write ?? ((text) => process.stdout.write(text));
|
|
2624
2798
|
const colour = deps.colour ?? decideColour({ isTTY: process.stdout.isTTY === true, env: deps.env ?? process.env });
|
|
@@ -2684,6 +2858,9 @@ function deriveProjectName(targetRepoRoot) {
|
|
|
2684
2858
|
}
|
|
2685
2859
|
|
|
2686
2860
|
// dist/own-engagement.js
|
|
2861
|
+
function isOutcomeBRefusal(result) {
|
|
2862
|
+
return "outcome" in result && result.outcome === "not-this-account";
|
|
2863
|
+
}
|
|
2687
2864
|
async function createOwnedEngagement(serviceUrl, targetRepo, deps = {}) {
|
|
2688
2865
|
let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "create", targetRepo }, deps);
|
|
2689
2866
|
for (; ; ) {
|
|
@@ -2718,6 +2895,13 @@ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps =
|
|
|
2718
2895
|
if (!(err instanceof CreateEngagementRefused))
|
|
2719
2896
|
throw err;
|
|
2720
2897
|
if (err.status === 403) {
|
|
2898
|
+
if (isNotThisAccountRefusal({ reason: err.reason })) {
|
|
2899
|
+
return {
|
|
2900
|
+
outcome: "not-this-account",
|
|
2901
|
+
message: err.serverMessage,
|
|
2902
|
+
actingAccountId: credential.accountId
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2721
2905
|
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
|
|
2722
2906
|
}
|
|
2723
2907
|
if (err.status !== 401) {
|
|
@@ -2816,8 +3000,203 @@ function projectSeedGuard(fired, includeExplanation) {
|
|
|
2816
3000
|
// dist/build-record/sources.js
|
|
2817
3001
|
import { readFileSync as readFileSync6, existsSync as existsSync4 } from "node:fs";
|
|
2818
3002
|
import { createHash } from "node:crypto";
|
|
2819
|
-
import { join as
|
|
3003
|
+
import { join as join8 } from "node:path";
|
|
3004
|
+
|
|
3005
|
+
// dist/build-record/close-record.js
|
|
2820
3006
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
3007
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
3008
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
3009
|
+
var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
|
|
3010
|
+
var BUILD_RECORD_ZONE_B_DIR = join6(ZONE_B_DIR, "build-record");
|
|
3011
|
+
var InvalidCloseRecordError = class extends Error {
|
|
3012
|
+
constructor(path, detail) {
|
|
3013
|
+
super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
|
|
3014
|
+
this.name = "InvalidCloseRecordError";
|
|
3015
|
+
}
|
|
3016
|
+
};
|
|
3017
|
+
function closeRecordPath(repoRoot, phase) {
|
|
3018
|
+
assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
|
|
3019
|
+
return join6(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
|
|
3020
|
+
}
|
|
3021
|
+
function resolveCloseAtHead(repoRoot) {
|
|
3022
|
+
try {
|
|
3023
|
+
const closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
|
|
3024
|
+
encoding: "utf-8",
|
|
3025
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
3026
|
+
}).trim();
|
|
3027
|
+
const closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
|
|
3028
|
+
encoding: "utf-8",
|
|
3029
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
3030
|
+
}).trim();
|
|
3031
|
+
if (closeCommit === "" || closedDate === "")
|
|
3032
|
+
return null;
|
|
3033
|
+
return { closeCommit, closedDate };
|
|
3034
|
+
} catch {
|
|
3035
|
+
return null;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
function writeCloseRecord(repoRoot, phase) {
|
|
3039
|
+
let path;
|
|
3040
|
+
try {
|
|
3041
|
+
path = closeRecordPath(repoRoot, phase);
|
|
3042
|
+
} catch (err) {
|
|
3043
|
+
return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
|
|
3044
|
+
}
|
|
3045
|
+
const at = resolveCloseAtHead(repoRoot);
|
|
3046
|
+
if (at === null) {
|
|
3047
|
+
return {
|
|
3048
|
+
recorded: false,
|
|
3049
|
+
path,
|
|
3050
|
+
reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
const record2 = {
|
|
3054
|
+
format: CLOSE_RECORD_FORMAT,
|
|
3055
|
+
phase,
|
|
3056
|
+
closeCommit: at.closeCommit,
|
|
3057
|
+
closedDate: at.closedDate
|
|
3058
|
+
};
|
|
3059
|
+
try {
|
|
3060
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
3061
|
+
writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
|
|
3062
|
+
} catch (err) {
|
|
3063
|
+
return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
|
|
3064
|
+
}
|
|
3065
|
+
return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
|
|
3066
|
+
}
|
|
3067
|
+
function parseCloseRecord(path, parsed) {
|
|
3068
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3069
|
+
throw new InvalidCloseRecordError(path, "it does not hold a record");
|
|
3070
|
+
}
|
|
3071
|
+
const record2 = parsed;
|
|
3072
|
+
if (record2.format !== CLOSE_RECORD_FORMAT) {
|
|
3073
|
+
throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
|
|
3074
|
+
}
|
|
3075
|
+
const commit = record2.closeCommit;
|
|
3076
|
+
const date = record2.closedDate;
|
|
3077
|
+
const missing = [];
|
|
3078
|
+
if (typeof commit !== "string" || commit.trim() === "")
|
|
3079
|
+
missing.push("closeCommit");
|
|
3080
|
+
if (typeof date !== "string" || date.trim() === "")
|
|
3081
|
+
missing.push("closedDate");
|
|
3082
|
+
if (missing.length > 0) {
|
|
3083
|
+
throw new InvalidCloseRecordError(path, `${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} not recorded in it. A close records the commit and its date together or records neither, so a file carrying one of them was edited by hand.`);
|
|
3084
|
+
}
|
|
3085
|
+
if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
|
|
3086
|
+
throw new InvalidCloseRecordError(path, "it does not say which phase closed");
|
|
3087
|
+
}
|
|
3088
|
+
return {
|
|
3089
|
+
format: CLOSE_RECORD_FORMAT,
|
|
3090
|
+
phase: record2.phase,
|
|
3091
|
+
closeCommit: commit,
|
|
3092
|
+
closedDate: date
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
// dist/build-record/write.js
|
|
3097
|
+
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
|
|
3098
|
+
import { basename as basename2, join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
|
|
3099
|
+
|
|
3100
|
+
// dist/build-record/template.js
|
|
3101
|
+
function severityLabel(seed) {
|
|
3102
|
+
return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
|
|
3103
|
+
}
|
|
3104
|
+
var NOT_RECORDED = "not recorded";
|
|
3105
|
+
function renderCloseLine(record2) {
|
|
3106
|
+
const status = `**Status:** ${record2.status.toUpperCase()}`;
|
|
3107
|
+
const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
|
|
3108
|
+
const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
|
|
3109
|
+
return `${status} \xB7 ${closed} \xB7 ${commit}`;
|
|
3110
|
+
}
|
|
3111
|
+
function renderCloseProvenance(record2) {
|
|
3112
|
+
return record2.closeCommit === null && record2.closedDate === null ? "*The close date and the commit were not recorded when this phase closed, so this record does not name them. Nothing has been substituted for them.*" : "*The close date and the commit are the client's own assertion about their own repository, written down when the phase was closed. Halfcycle did not observe them and does not check them.*";
|
|
3113
|
+
}
|
|
3114
|
+
function renderDoneWhenRows(record2) {
|
|
3115
|
+
const rows = [];
|
|
3116
|
+
for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
|
|
3117
|
+
const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
|
|
3118
|
+
rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
|
|
3119
|
+
}
|
|
3120
|
+
return rows.join("\n");
|
|
3121
|
+
}
|
|
3122
|
+
function renderBuildRecordMarkdown(record2) {
|
|
3123
|
+
const { phase } = record2;
|
|
3124
|
+
const companion = `${renderPhaseSegment(phase.id)}.json`;
|
|
3125
|
+
const guardsList = record2.seedGuards.map(severityLabel).join(", ");
|
|
3126
|
+
const lines = [
|
|
3127
|
+
// Fixed prose (vault-canonical template) + projected title slots.
|
|
3128
|
+
`# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
|
|
3129
|
+
"",
|
|
3130
|
+
`**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
|
|
3131
|
+
"",
|
|
3132
|
+
"> This Build Record was assembled automatically at phase close by the method bundle (`packages/bundle`). Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
|
|
3133
|
+
"",
|
|
3134
|
+
`**Engagement:** ${record2.engagement} (${record2.engagementType})`,
|
|
3135
|
+
`**Phase:** ${phase.id} \u2014 ${phase.name}`,
|
|
3136
|
+
renderCloseLine(record2),
|
|
3137
|
+
`**Companion:** [\`${companion}\`](${companion})`,
|
|
3138
|
+
"",
|
|
3139
|
+
renderCloseProvenance(record2),
|
|
3140
|
+
"",
|
|
3141
|
+
"## What this phase proved",
|
|
3142
|
+
"",
|
|
3143
|
+
`${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
|
|
3144
|
+
"",
|
|
3145
|
+
"## Delivered",
|
|
3146
|
+
"",
|
|
3147
|
+
`- **Packages:** ${record2.delivered.packages.join(", ")}`,
|
|
3148
|
+
`- **Services:** ${record2.delivered.services.join(", ")}`,
|
|
3149
|
+
`- **Tools:** ${record2.delivered.tools.join(", ")}`,
|
|
3150
|
+
`- **Dogfood:** ${record2.delivered.dogfood}`,
|
|
3151
|
+
"",
|
|
3152
|
+
guardsList ? `Guards evaluated (INV-007-safe results): ${guardsList}. *(Guard \`channel\`/\`version\` are guard-asset metadata, excluded from the v1 record \u2014 INV-007/INV-001.)*` : "No guards fired during this phase.",
|
|
3153
|
+
"",
|
|
3154
|
+
`## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
|
|
3155
|
+
"",
|
|
3156
|
+
`**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
|
|
3157
|
+
"",
|
|
3158
|
+
"| Done-when | Result | Evidence |",
|
|
3159
|
+
"|---|---|---|",
|
|
3160
|
+
renderDoneWhenRows(record2),
|
|
3161
|
+
""
|
|
3162
|
+
];
|
|
3163
|
+
if (record2.acceptance.inv002ProdBypassProbe) {
|
|
3164
|
+
lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
|
|
3165
|
+
}
|
|
3166
|
+
lines.push("## Gates that earned their keep", "", `**${record2.gatesEarnedKeep.defectsCaughtPreHuman}** defects were caught by the gates before the human walk; the walk itself found **${record2.gatesEarnedKeep.bugsReachingHumanWalk}**. Named: ${record2.gatesEarnedKeep.named.join("; ")}.`, "", "## Instruments", "", `- **Marginal-cost self-accounting:** ${record2.instruments.marginalCostSelfAccounting}`, `- **COE add-rate:** seam-new ${record2.instruments.coeAddRate.seamNew}, seam-repeat ${record2.instruments.coeAddRate.seamRepeat}, model-limitation ${record2.instruments.coeAddRate.modelLimitation}. ${record2.instruments.coeAddRate.notes}`, "", "## Invariants exercised", "", record2.invariantsExercised.join(", "), "", "## Deviations & decisions recorded", "", ...record2.deviations.map((d) => `- ${d}`), "", "## Tasks", "", `- **Planned:** ${record2.tasks.planned} \xB7 **Landed:** ${record2.tasks.landed} \xB7 **Cancelled:** ${record2.tasks.cancelled.length > 0 ? record2.tasks.cancelled.join(", ") : "none"}`, "");
|
|
3167
|
+
return lines.join("\n");
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
// dist/build-record/write.js
|
|
3171
|
+
var BUILD_RECORD_DIR = join7("docs", "build-records");
|
|
3172
|
+
function serialiseRecordJson(record2) {
|
|
3173
|
+
return JSON.stringify(record2, null, 2) + "\n";
|
|
3174
|
+
}
|
|
3175
|
+
function assertInsideOutDir(outDir, candidate, identity) {
|
|
3176
|
+
const rel = relative2(outDir, resolve2(candidate));
|
|
3177
|
+
const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute(rel) && rel === basename2(rel);
|
|
3178
|
+
if (!isPlainFilename) {
|
|
3179
|
+
throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
function writeBuildRecord(repoRoot, record2) {
|
|
3183
|
+
const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
|
|
3184
|
+
const identity = record2.phase.id;
|
|
3185
|
+
if (!isValidPhaseIdentity(identity)) {
|
|
3186
|
+
throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
|
|
3187
|
+
}
|
|
3188
|
+
const segment = renderPhaseSegment(identity);
|
|
3189
|
+
const jsonPath = join7(outDir, `${segment}.json`);
|
|
3190
|
+
const mdPath = join7(outDir, `${segment}.md`);
|
|
3191
|
+
assertInsideOutDir(outDir, jsonPath, identity);
|
|
3192
|
+
assertInsideOutDir(outDir, mdPath, identity);
|
|
3193
|
+
mkdirSync6(outDir, { recursive: true });
|
|
3194
|
+
writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
|
|
3195
|
+
writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
|
|
3196
|
+
return { jsonPath, mdPath };
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
// dist/build-record/sources.js
|
|
2821
3200
|
var MissingSourceError = class extends Error {
|
|
2822
3201
|
constructor(sourceName, detail) {
|
|
2823
3202
|
super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
|
|
@@ -2873,7 +3252,7 @@ function syntheticRunId(record2) {
|
|
|
2873
3252
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
2874
3253
|
}
|
|
2875
3254
|
function readGuardEvalLog(logDir, phaseId) {
|
|
2876
|
-
const logPath =
|
|
3255
|
+
const logPath = join8(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
|
|
2877
3256
|
if (!existsSync4(logPath)) {
|
|
2878
3257
|
throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
|
|
2879
3258
|
}
|
|
@@ -2889,7 +3268,7 @@ function readGuardEvalLog(logDir, phaseId) {
|
|
|
2889
3268
|
return runs;
|
|
2890
3269
|
}
|
|
2891
3270
|
function readOrchestrationState(phasesDir, phaseId) {
|
|
2892
|
-
const statePath =
|
|
3271
|
+
const statePath = join8(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
|
|
2893
3272
|
if (!existsSync4(statePath)) {
|
|
2894
3273
|
throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
|
|
2895
3274
|
}
|
|
@@ -2906,22 +3285,21 @@ function readOrchestrationState(phasesDir, phaseId) {
|
|
|
2906
3285
|
}
|
|
2907
3286
|
return { tasks };
|
|
2908
3287
|
}
|
|
2909
|
-
function
|
|
2910
|
-
|
|
2911
|
-
|
|
3288
|
+
function readPhaseClose(repoRoot, phaseId) {
|
|
3289
|
+
const path = closeRecordPath(repoRoot, phaseId);
|
|
3290
|
+
if (!existsSync4(path))
|
|
3291
|
+
return { closeCommit: null, closedDate: null };
|
|
3292
|
+
let parsed;
|
|
2912
3293
|
try {
|
|
2913
|
-
|
|
2914
|
-
encoding: "utf-8"
|
|
2915
|
-
}).trim();
|
|
2916
|
-
closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], { encoding: "utf-8" }).trim();
|
|
3294
|
+
parsed = JSON.parse(readFileSync6(path, "utf-8"));
|
|
2917
3295
|
} catch (err) {
|
|
2918
|
-
throw new
|
|
3296
|
+
throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
3297
|
+
}
|
|
3298
|
+
const record2 = parseCloseRecord(path, parsed);
|
|
3299
|
+
if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
|
|
3300
|
+
throw new InvalidCloseRecordError(path, `it records the close of ${String(record2.phase)}, not of ${String(phaseId)}`);
|
|
2919
3301
|
}
|
|
2920
|
-
|
|
2921
|
-
throw new MissingSourceError("git HEAD", `empty commit sha in ${repoRoot}`);
|
|
2922
|
-
if (!closedDate)
|
|
2923
|
-
throw new MissingSourceError("git HEAD", `empty commit date in ${repoRoot}`);
|
|
2924
|
-
return { closeCommit, closedDate };
|
|
3302
|
+
return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
|
|
2925
3303
|
}
|
|
2926
3304
|
function validateNarrated(narrated) {
|
|
2927
3305
|
const req = (v, name) => {
|
|
@@ -2932,6 +3310,7 @@ function validateNarrated(narrated) {
|
|
|
2932
3310
|
req(narrated.engagement, "engagement");
|
|
2933
3311
|
req(narrated.engagementType, "engagementType");
|
|
2934
3312
|
req(narrated.phase?.name, "phase.name");
|
|
3313
|
+
assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
|
|
2935
3314
|
req(narrated.status, "status");
|
|
2936
3315
|
req(narrated.thesis, "thesis");
|
|
2937
3316
|
req(narrated.delivered, "delivered");
|
|
@@ -2947,7 +3326,7 @@ function validateNarrated(narrated) {
|
|
|
2947
3326
|
function loadSources(opts) {
|
|
2948
3327
|
const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
|
|
2949
3328
|
const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
|
|
2950
|
-
const
|
|
3329
|
+
const close = readPhaseClose(opts.repoRoot, opts.phaseId);
|
|
2951
3330
|
const narrated = validateNarrated(opts.narrated);
|
|
2952
3331
|
if (!Array.isArray(opts.touchedInvariants)) {
|
|
2953
3332
|
throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
|
|
@@ -2955,7 +3334,7 @@ function loadSources(opts) {
|
|
|
2955
3334
|
return {
|
|
2956
3335
|
orchestration,
|
|
2957
3336
|
narrated,
|
|
2958
|
-
|
|
3337
|
+
close,
|
|
2959
3338
|
guardEvalRuns,
|
|
2960
3339
|
touchedInvariants: opts.touchedInvariants
|
|
2961
3340
|
};
|
|
@@ -2985,15 +3364,15 @@ function deriveInvariantsExercised(touched) {
|
|
|
2985
3364
|
return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
|
|
2986
3365
|
}
|
|
2987
3366
|
function assembleBuildRecord(sources) {
|
|
2988
|
-
const { narrated,
|
|
3367
|
+
const { narrated, close, orchestration } = sources;
|
|
2989
3368
|
return {
|
|
2990
3369
|
format: BUILD_RECORD_FORMAT,
|
|
2991
3370
|
engagement: narrated.engagement,
|
|
2992
3371
|
engagementType: narrated.engagementType,
|
|
2993
3372
|
phase: { id: narrated.phase.id, name: narrated.phase.name },
|
|
2994
3373
|
status: narrated.status,
|
|
2995
|
-
closedDate:
|
|
2996
|
-
closeCommit:
|
|
3374
|
+
closedDate: close.closedDate,
|
|
3375
|
+
closeCommit: close.closeCommit,
|
|
2997
3376
|
thesis: narrated.thesis,
|
|
2998
3377
|
thesisHeld: deriveThesisHeld(narrated.acceptance),
|
|
2999
3378
|
delivered: narrated.delivered,
|
|
@@ -3007,85 +3386,6 @@ function assembleBuildRecord(sources) {
|
|
|
3007
3386
|
};
|
|
3008
3387
|
}
|
|
3009
3388
|
|
|
3010
|
-
// dist/build-record/template.js
|
|
3011
|
-
function severityLabel(seed) {
|
|
3012
|
-
return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
|
|
3013
|
-
}
|
|
3014
|
-
function renderDoneWhenRows(record2) {
|
|
3015
|
-
const rows = [];
|
|
3016
|
-
for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
|
|
3017
|
-
const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
|
|
3018
|
-
rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
|
|
3019
|
-
}
|
|
3020
|
-
return rows.join("\n");
|
|
3021
|
-
}
|
|
3022
|
-
function renderBuildRecordMarkdown(record2) {
|
|
3023
|
-
const { phase } = record2;
|
|
3024
|
-
const guardsList = record2.seedGuards.map(severityLabel).join(", ");
|
|
3025
|
-
const lines = [
|
|
3026
|
-
// Fixed prose (vault-canonical template) + projected title slots.
|
|
3027
|
-
`# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
|
|
3028
|
-
"",
|
|
3029
|
-
`**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
|
|
3030
|
-
"",
|
|
3031
|
-
"> This Build Record was assembled automatically at phase close by the method bundle (`packages/bundle`). Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
|
|
3032
|
-
"",
|
|
3033
|
-
`**Engagement:** ${record2.engagement} (${record2.engagementType})`,
|
|
3034
|
-
`**Phase:** ${phase.id} \u2014 ${phase.name}`,
|
|
3035
|
-
`**Status:** ${record2.status.toUpperCase()} \xB7 **Closed:** ${record2.closedDate} \xB7 **Close commit:** \`${record2.closeCommit}\``,
|
|
3036
|
-
`**Companion:** [\`phase-${phase.id}.json\`](phase-${phase.id}.json)`,
|
|
3037
|
-
"",
|
|
3038
|
-
"## What this phase proved",
|
|
3039
|
-
"",
|
|
3040
|
-
`${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
|
|
3041
|
-
"",
|
|
3042
|
-
"## Delivered",
|
|
3043
|
-
"",
|
|
3044
|
-
`- **Packages:** ${record2.delivered.packages.join(", ")}`,
|
|
3045
|
-
`- **Services:** ${record2.delivered.services.join(", ")}`,
|
|
3046
|
-
`- **Tools:** ${record2.delivered.tools.join(", ")}`,
|
|
3047
|
-
`- **Dogfood:** ${record2.delivered.dogfood}`,
|
|
3048
|
-
"",
|
|
3049
|
-
guardsList ? `Guards evaluated (INV-007-safe results): ${guardsList}. *(Guard \`channel\`/\`version\` are guard-asset metadata, excluded from the v1 record \u2014 INV-007/INV-001.)*` : "No guards fired during this phase.",
|
|
3050
|
-
"",
|
|
3051
|
-
`## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
|
|
3052
|
-
"",
|
|
3053
|
-
`**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
|
|
3054
|
-
"",
|
|
3055
|
-
"| Done-when | Result | Evidence |",
|
|
3056
|
-
"|---|---|---|",
|
|
3057
|
-
renderDoneWhenRows(record2),
|
|
3058
|
-
""
|
|
3059
|
-
];
|
|
3060
|
-
if (record2.acceptance.inv002ProdBypassProbe) {
|
|
3061
|
-
lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
|
|
3062
|
-
}
|
|
3063
|
-
lines.push("## Gates that earned their keep", "", `**${record2.gatesEarnedKeep.defectsCaughtPreHuman}** defects were caught by the gates before the human walk; the walk itself found **${record2.gatesEarnedKeep.bugsReachingHumanWalk}**. Named: ${record2.gatesEarnedKeep.named.join("; ")}.`, "", "## Instruments", "", `- **Marginal-cost self-accounting:** ${record2.instruments.marginalCostSelfAccounting}`, `- **COE add-rate:** seam-new ${record2.instruments.coeAddRate.seamNew}, seam-repeat ${record2.instruments.coeAddRate.seamRepeat}, model-limitation ${record2.instruments.coeAddRate.modelLimitation}. ${record2.instruments.coeAddRate.notes}`, "", "## Invariants exercised", "", record2.invariantsExercised.join(", "), "", "## Deviations & decisions recorded", "", ...record2.deviations.map((d) => `- ${d}`), "", "## Tasks", "", `- **Planned:** ${record2.tasks.planned} \xB7 **Landed:** ${record2.tasks.landed} \xB7 **Cancelled:** ${record2.tasks.cancelled.length > 0 ? record2.tasks.cancelled.join(", ") : "none"}`, "");
|
|
3064
|
-
return lines.join("\n");
|
|
3065
|
-
}
|
|
3066
|
-
|
|
3067
|
-
// dist/build-record/write.js
|
|
3068
|
-
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
3069
|
-
import { join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
|
|
3070
|
-
var BUILD_RECORD_DIR = join7("docs", "build-records");
|
|
3071
|
-
function serialiseRecordJson(record2) {
|
|
3072
|
-
return JSON.stringify(record2, null, 2) + "\n";
|
|
3073
|
-
}
|
|
3074
|
-
function writeBuildRecord(repoRoot, record2) {
|
|
3075
|
-
const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
|
|
3076
|
-
const expected = resolve2(repoRoot, BUILD_RECORD_DIR);
|
|
3077
|
-
const rel = relative2(expected, outDir);
|
|
3078
|
-
if (rel !== "" || isAbsolute(rel)) {
|
|
3079
|
-
throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
|
|
3080
|
-
}
|
|
3081
|
-
mkdirSync5(outDir, { recursive: true });
|
|
3082
|
-
const jsonPath = join7(outDir, `phase-${record2.phase.id}.json`);
|
|
3083
|
-
const mdPath = join7(outDir, `phase-${record2.phase.id}.md`);
|
|
3084
|
-
writeFileSync5(jsonPath, serialiseRecordJson(record2), "utf-8");
|
|
3085
|
-
writeFileSync5(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
|
|
3086
|
-
return { jsonPath, mdPath };
|
|
3087
|
-
}
|
|
3088
|
-
|
|
3089
3389
|
// dist/build-record/index.js
|
|
3090
3390
|
function assemblePhaseBuildRecord(opts) {
|
|
3091
3391
|
const sources = loadSources({
|
|
@@ -3102,7 +3402,7 @@ function assemblePhaseBuildRecord(opts) {
|
|
|
3102
3402
|
|
|
3103
3403
|
// dist/open-phase.js
|
|
3104
3404
|
import { readFileSync as readFileSync7 } from "node:fs";
|
|
3105
|
-
import { join as
|
|
3405
|
+
import { join as join9 } from "node:path";
|
|
3106
3406
|
var OpenPhaseRefused = class extends Error {
|
|
3107
3407
|
status;
|
|
3108
3408
|
finding;
|
|
@@ -3121,7 +3421,7 @@ var OPEN_PHASE_ENV_KEYS = [
|
|
|
3121
3421
|
function readRepoCredential(repoRoot, env = process.env, home) {
|
|
3122
3422
|
const pinned = readPinnedEngagementId(repoRoot);
|
|
3123
3423
|
const fromStore = pinned === null ? null : readEngagementEnv(pinned, home);
|
|
3124
|
-
const looked = pinned === null ? `${
|
|
3424
|
+
const looked = pinned === null ? `${join9(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
|
|
3125
3425
|
const values = {};
|
|
3126
3426
|
for (const key of OPEN_PHASE_ENV_KEYS) {
|
|
3127
3427
|
const value = (env[key] ?? fromStore?.[key] ?? "").trim();
|
|
@@ -3138,18 +3438,33 @@ function readRepoCredential(repoRoot, env = process.env, home) {
|
|
|
3138
3438
|
}
|
|
3139
3439
|
function readPinnedEngagementId(repoRoot) {
|
|
3140
3440
|
try {
|
|
3141
|
-
const pin = JSON.parse(readFileSync7(
|
|
3441
|
+
const pin = JSON.parse(readFileSync7(join9(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
|
|
3142
3442
|
const id = pin[PIN_ENGAGEMENT_ID_FIELD];
|
|
3143
3443
|
return typeof id === "string" && id !== "" ? id : null;
|
|
3144
3444
|
} catch {
|
|
3145
3445
|
return null;
|
|
3146
3446
|
}
|
|
3147
3447
|
}
|
|
3148
|
-
|
|
3448
|
+
function writePhaseStamp(engagementId, phase, home) {
|
|
3449
|
+
const path = engagementEnvPath(engagementId, home);
|
|
3450
|
+
if (phase !== null && !isValidPhaseIdentity(phase)) {
|
|
3451
|
+
return { path, reason: new InvalidPhaseIdentityError(phase).message };
|
|
3452
|
+
}
|
|
3453
|
+
try {
|
|
3454
|
+
writeEngagementEnv(engagementId, { [PHASE_STAMP_ENV_KEY]: phase }, home, [PHASE_STAMP_ENV_KEY]);
|
|
3455
|
+
return null;
|
|
3456
|
+
} catch (err) {
|
|
3457
|
+
return { path, reason: err instanceof Error ? err.message : String(err) };
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
async function openPhase(credential, phase, entry, home) {
|
|
3149
3461
|
const parsed = phaseEntryDecisionSchema.safeParse(entry);
|
|
3150
3462
|
if (!parsed.success) {
|
|
3151
3463
|
throw new Error(`[halfcycle] The entry decision is not a decision this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
|
|
3152
3464
|
}
|
|
3465
|
+
if (phase !== null && !isValidPhaseIdentity(phase)) {
|
|
3466
|
+
throw new InvalidPhaseIdentityError(phase);
|
|
3467
|
+
}
|
|
3153
3468
|
const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phase`;
|
|
3154
3469
|
let res;
|
|
3155
3470
|
try {
|
|
@@ -3174,9 +3489,11 @@ async function openPhase(credential, phase, entry) {
|
|
|
3174
3489
|
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") {
|
|
3175
3490
|
throw new Error(`[halfcycle] The response from ${url} did not carry { phase, previousPhase, decision }. The phase may or may not have moved \u2014 read the engagement's decision history before retrying.`);
|
|
3176
3491
|
}
|
|
3492
|
+
const stampFailure = writePhaseStamp(credential.engagementId, body.phase, home);
|
|
3177
3493
|
return {
|
|
3178
3494
|
phase: body.phase,
|
|
3179
3495
|
previousPhase: body.previousPhase,
|
|
3496
|
+
stampFailure,
|
|
3180
3497
|
decision: body.decision
|
|
3181
3498
|
};
|
|
3182
3499
|
}
|
|
@@ -3194,7 +3511,7 @@ var ClosePhaseRefused = class extends Error {
|
|
|
3194
3511
|
this.name = "ClosePhaseRefused";
|
|
3195
3512
|
}
|
|
3196
3513
|
};
|
|
3197
|
-
async function closePhase(credential, phase, outcome, close) {
|
|
3514
|
+
async function closePhase(credential, phase, outcome, close, home, repoRoot) {
|
|
3198
3515
|
const parsed = acceptanceOutcomeSchema.safeParse(outcome);
|
|
3199
3516
|
if (!parsed.success) {
|
|
3200
3517
|
throw new Error(`[halfcycle] That is not an acceptance outcome this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
|
|
@@ -3228,7 +3545,13 @@ async function closePhase(credential, phase, outcome, close) {
|
|
|
3228
3545
|
if (!body || typeof body.engagementId !== "string" || typeof body.status !== "string") {
|
|
3229
3546
|
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.`);
|
|
3230
3547
|
}
|
|
3231
|
-
|
|
3548
|
+
const closeRecord = repoRoot === void 0 ? {
|
|
3549
|
+
recorded: false,
|
|
3550
|
+
path: null,
|
|
3551
|
+
reason: "no repository was named for this close, so there was nowhere to read a commit from"
|
|
3552
|
+
} : writeCloseRecord(repoRoot, phase);
|
|
3553
|
+
const stampFailure = writePhaseStamp(credential.engagementId, null, home);
|
|
3554
|
+
return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
|
|
3232
3555
|
}
|
|
3233
3556
|
|
|
3234
3557
|
// dist/cli-contract.js
|
|
@@ -3349,7 +3672,7 @@ var PLACEHOLDERS = {
|
|
|
3349
3672
|
// dist/banner-facts.js
|
|
3350
3673
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
3351
3674
|
import { readFileSync as readFileSync8 } from "node:fs";
|
|
3352
|
-
import { basename as
|
|
3675
|
+
import { basename as basename3, join as join10, resolve as resolve3 } from "node:path";
|
|
3353
3676
|
var BRAND_URL = "halfcycle.ai";
|
|
3354
3677
|
var TAGLINE = [
|
|
3355
3678
|
"Fewer cycles.",
|
|
@@ -3362,7 +3685,7 @@ var TAGLINE_NARROW = [
|
|
|
3362
3685
|
];
|
|
3363
3686
|
function bundleVersion() {
|
|
3364
3687
|
try {
|
|
3365
|
-
const pkg = JSON.parse(readFileSync8(
|
|
3688
|
+
const pkg = JSON.parse(readFileSync8(join10(BUNDLE_ROOT, "package.json"), "utf-8"));
|
|
3366
3689
|
return typeof pkg.version === "string" ? pkg.version : void 0;
|
|
3367
3690
|
} catch {
|
|
3368
3691
|
return void 0;
|
|
@@ -3451,7 +3774,7 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
|
|
|
3451
3774
|
}
|
|
3452
3775
|
function safeBasename(dir) {
|
|
3453
3776
|
try {
|
|
3454
|
-
const name =
|
|
3777
|
+
const name = basename3(resolve3(dir)).trim();
|
|
3455
3778
|
return name === "" ? "project" : name;
|
|
3456
3779
|
} catch {
|
|
3457
3780
|
return "project";
|
|
@@ -3512,7 +3835,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
|
|
|
3512
3835
|
install into target (default: the current directory)
|
|
3513
3836
|
--yes / -y: use this machine's saved sign-in without being asked
|
|
3514
3837
|
halfcycle check-drift <target-repo>
|
|
3515
|
-
halfcycle build-record <phase
|
|
3838
|
+
halfcycle build-record <phase> [--repo <root>]
|
|
3516
3839
|
halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
|
|
3517
3840
|
halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
|
|
3518
3841
|
|
|
@@ -3521,7 +3844,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
|
|
|
3521
3844
|
`;
|
|
3522
3845
|
function isHalfcycleMonorepo(dir) {
|
|
3523
3846
|
try {
|
|
3524
|
-
const pkg = JSON.parse(readFileSync9(
|
|
3847
|
+
const pkg = JSON.parse(readFileSync9(join11(dir, "package.json"), "utf-8"));
|
|
3525
3848
|
return pkg.name === "halfcycle-monorepo";
|
|
3526
3849
|
} catch {
|
|
3527
3850
|
return false;
|
|
@@ -3573,6 +3896,7 @@ ${USAGE}`);
|
|
|
3573
3896
|
const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
|
|
3574
3897
|
let engagementId;
|
|
3575
3898
|
let credential;
|
|
3899
|
+
let notThisAccountMessage;
|
|
3576
3900
|
let actingAccountId;
|
|
3577
3901
|
let foundLine = "";
|
|
3578
3902
|
if (reusable !== void 0 && pinned !== null) {
|
|
@@ -3593,21 +3917,30 @@ ${USAGE}`);
|
|
|
3593
3917
|
}
|
|
3594
3918
|
} else if (requestedId !== void 0) {
|
|
3595
3919
|
const joined = await joinPinnedEngagement(serviceUrl, requestedId, targetRepo, { assumeYes, verbose });
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3920
|
+
if (isOutcomeBRefusal(joined)) {
|
|
3921
|
+
engagementId = requestedId;
|
|
3922
|
+
actingAccountId = joined.actingAccountId;
|
|
3923
|
+
credential = void 0;
|
|
3924
|
+
notThisAccountMessage = joined.message;
|
|
3925
|
+
foundLine = `this repo is pinned to a project that belongs to a different Halfcycle account`;
|
|
3926
|
+
writeNotThisAccountMarker(engagementId);
|
|
3927
|
+
} else {
|
|
3928
|
+
engagementId = joined.engagementId;
|
|
3929
|
+
actingAccountId = joined.actingAccountId;
|
|
3930
|
+
credential = {
|
|
3931
|
+
serviceUrl,
|
|
3932
|
+
token: joined.sessionToken,
|
|
3933
|
+
mcpUrl: joined.mcpUrl,
|
|
3934
|
+
guardUrl: joined.guardUrl,
|
|
3935
|
+
// T-10 — optional; absent on a plane that has not configured
|
|
3936
|
+
// CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
|
|
3937
|
+
// an absent value, never `undefined` verbatim.
|
|
3938
|
+
controlTelemetryUrl: joined.controlTelemetryUrl
|
|
3939
|
+
};
|
|
3940
|
+
foundLine = `this repo is pinned to a shared project \u2014 joining it as you (${shortId(joined.engagementId)})`;
|
|
3941
|
+
}
|
|
3942
|
+
if (verbose && credential !== void 0) {
|
|
3943
|
+
process.stdout.write(`[halfcycle] Joined engagement ${engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
|
|
3611
3944
|
`);
|
|
3612
3945
|
}
|
|
3613
3946
|
} else {
|
|
@@ -3668,18 +4001,20 @@ ${USAGE}`);
|
|
|
3668
4001
|
[halfcycle] ${missing.length === 1 ? "it" : "them"}) and re-run, and the credential moves out of the tree.
|
|
3669
4002
|
`);
|
|
3670
4003
|
}
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
if (
|
|
3674
|
-
|
|
4004
|
+
if (credential) {
|
|
4005
|
+
const probe = await probeMcpOrigin(credential.mcpUrl);
|
|
4006
|
+
if (probe.reached) {
|
|
4007
|
+
if (verbose) {
|
|
4008
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
|
|
3675
4009
|
`);
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
4010
|
+
}
|
|
4011
|
+
} else {
|
|
4012
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
|
|
3679
4013
|
[halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not the CONTROL origin (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
|
|
3680
4014
|
`);
|
|
4015
|
+
}
|
|
3681
4016
|
}
|
|
3682
|
-
if (verbose) {
|
|
4017
|
+
if (credential && verbose) {
|
|
3683
4018
|
process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_COVERAGE_REQUIRED_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
|
|
3684
4019
|
`);
|
|
3685
4020
|
process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through .halfcycle/mcp-headers.sh, so there is no token in this tree to commit and nothing for you to export.
|
|
@@ -3687,30 +4022,38 @@ ${USAGE}`);
|
|
|
3687
4022
|
process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the helper is a shell command, and Claude Code will not run it until you accept. Needs Claude Code ${MIN_HEADERS_HELPER_VERSION} or newer (${MIN_HEADER_ROTATION_VERSION}+ to re-authenticate a rotated token without restarting).
|
|
3688
4023
|
`);
|
|
3689
4024
|
process.stdout.write(`[halfcycle] If Halfcycle tools appear but every call returns 401, the helper could not read HALFCYCLE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
|
|
4025
|
+
`);
|
|
4026
|
+
}
|
|
4027
|
+
if (credential === void 0 && notThisAccountMessage !== void 0) {
|
|
4028
|
+
process.stdout.write(`[halfcycle] ${notThisAccountMessage} Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming. 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()}
|
|
3690
4029
|
`);
|
|
3691
4030
|
}
|
|
3692
4031
|
reportClaudeCodeVersion(verbose);
|
|
3693
|
-
const identity = await describeAccount(credential.serviceUrl, credential.token);
|
|
4032
|
+
const identity = credential ? await describeAccount(credential.serviceUrl, credential.token) : void 0;
|
|
3694
4033
|
const written = result.writtenPaths.length;
|
|
3695
4034
|
const merged = result.mergedPaths.length;
|
|
3696
4035
|
process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
|
|
3697
4036
|
`);
|
|
3698
4037
|
process.stdout.write(`[halfcycle] Found: ${foundLine}
|
|
3699
4038
|
`);
|
|
3700
|
-
process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, guards armed on every edit
|
|
4039
|
+
process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, ${credential ? "guards armed on every edit" : "not checked \u2014 this copy is not this account\u2019s project"}
|
|
3701
4040
|
`);
|
|
3702
|
-
|
|
4041
|
+
if (credential) {
|
|
4042
|
+
process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
|
|
3703
4043
|
`);
|
|
3704
|
-
|
|
4044
|
+
process.stdout.write(`[halfcycle] Claude Code will also ask permission the first time Halfcycle needs to look up your next step. Choose allow \u2014 without it nothing can run.
|
|
3705
4045
|
`);
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
4046
|
+
}
|
|
4047
|
+
if (credential) {
|
|
4048
|
+
try {
|
|
4049
|
+
const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
|
|
4050
|
+
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}
|
|
3709
4051
|
`);
|
|
3710
|
-
|
|
4052
|
+
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}
|
|
3711
4053
|
`);
|
|
3712
|
-
|
|
3713
|
-
|
|
4054
|
+
} catch {
|
|
4055
|
+
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");
|
|
4056
|
+
}
|
|
3714
4057
|
}
|
|
3715
4058
|
process.exit(0);
|
|
3716
4059
|
} catch (err) {
|
|
@@ -3755,30 +4098,31 @@ ${USAGE}`);
|
|
|
3755
4098
|
if (cmd === "build-record") {
|
|
3756
4099
|
const phaseArg = rest[0];
|
|
3757
4100
|
const repoRoot = rest.includes("--repo") ? rest[rest.indexOf("--repo") + 1] : process.cwd();
|
|
3758
|
-
if (
|
|
3759
|
-
process.stderr.write("halfcycle
|
|
4101
|
+
if (phaseArg === void 0) {
|
|
4102
|
+
process.stderr.write("halfcycle: usage: halfcycle build-record <phase> [--repo <root>]\n");
|
|
3760
4103
|
process.exit(1);
|
|
3761
4104
|
return;
|
|
3762
4105
|
}
|
|
3763
|
-
const phaseId =
|
|
4106
|
+
const phaseId = phaseArg;
|
|
3764
4107
|
try {
|
|
3765
|
-
|
|
4108
|
+
assertValidPhaseIdentity(phaseId, BUILD_RECORD_DIR);
|
|
4109
|
+
const inputPath = join11(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
|
|
3766
4110
|
const input = JSON.parse(readFileSync9(inputPath, "utf-8"));
|
|
3767
4111
|
const result = assemblePhaseBuildRecord({
|
|
3768
4112
|
repoRoot,
|
|
3769
|
-
phasesDir:
|
|
3770
|
-
guardEvalLogDir: input.guardEvalLogDir ??
|
|
4113
|
+
phasesDir: join11(repoRoot, "docs", "phases"),
|
|
4114
|
+
guardEvalLogDir: input.guardEvalLogDir ?? join11(repoRoot, ".workbench", "guard-eval-log"),
|
|
3771
4115
|
phaseId,
|
|
3772
4116
|
narrated: input.narrated,
|
|
3773
4117
|
touchedInvariants: input.touchedInvariants
|
|
3774
4118
|
});
|
|
3775
|
-
process.stdout.write(`[halfcycle
|
|
4119
|
+
process.stdout.write(`[halfcycle] Build Record written:
|
|
3776
4120
|
${result.jsonPath}
|
|
3777
4121
|
${result.mdPath}
|
|
3778
4122
|
`);
|
|
3779
4123
|
process.exit(0);
|
|
3780
4124
|
} catch (err) {
|
|
3781
|
-
process.stderr.write(`halfcycle
|
|
4125
|
+
process.stderr.write(`halfcycle build-record failed: ${err instanceof Error ? err.message : String(err)}
|
|
3782
4126
|
`);
|
|
3783
4127
|
process.exit(1);
|
|
3784
4128
|
}
|
|
@@ -3820,8 +4164,22 @@ ${USAGE}`);
|
|
|
3820
4164
|
process.stdout.write(`[halfcycle] This decision CROSSED: ${opened.decision.blockingFinding}
|
|
3821
4165
|
`);
|
|
3822
4166
|
}
|
|
4167
|
+
if (opened.stampFailure) {
|
|
4168
|
+
process.stderr.write(`halfcycle open-phase: the phase moved, but this machine's copy of it could not be written: ${opened.stampFailure.reason}
|
|
4169
|
+
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.
|
|
4170
|
+
`);
|
|
4171
|
+
process.exit(1);
|
|
4172
|
+
return;
|
|
4173
|
+
}
|
|
3823
4174
|
process.exit(0);
|
|
3824
4175
|
} catch (err) {
|
|
4176
|
+
if (err instanceof InvalidPhaseIdentityError) {
|
|
4177
|
+
process.stderr.write(`halfcycle open-phase: ${err.message}
|
|
4178
|
+
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.
|
|
4179
|
+
`);
|
|
4180
|
+
process.exit(2);
|
|
4181
|
+
return;
|
|
4182
|
+
}
|
|
3825
4183
|
if (err instanceof OpenPhaseRefused) {
|
|
3826
4184
|
process.stderr.write(`halfcycle open-phase: refused (${err.status}). ${err.message}
|
|
3827
4185
|
`);
|
|
@@ -3901,15 +4259,30 @@ ${USAGE}`);
|
|
|
3901
4259
|
verdict,
|
|
3902
4260
|
findings: findingsDetail.length,
|
|
3903
4261
|
findingsDetail
|
|
3904
|
-
}, closeDecision);
|
|
4262
|
+
}, closeDecision, void 0, repoRoot);
|
|
3905
4263
|
process.stdout.write(`[halfcycle] Phase "${phaseArg}" closed as ${verdict}${findingsDetail.length > 0 ? ` with ${findingsDetail.length} finding(s)` : ""} by ${closeDecision.actor}. The engagement is now ${closed.status}.
|
|
3906
4264
|
`);
|
|
3907
4265
|
if (closeDecision.decision === "override") {
|
|
3908
4266
|
process.stdout.write(`[halfcycle] This close was an OVERRIDE: ${closeDecision.reason}
|
|
4267
|
+
`);
|
|
4268
|
+
}
|
|
4269
|
+
if (closed.closeRecord.recorded) {
|
|
4270
|
+
process.stdout.write(`[halfcycle] Closed at commit ${closed.closeRecord.closeCommit} (${closed.closeRecord.closedDate}). The Build Record for this phase will name it.
|
|
4271
|
+
`);
|
|
4272
|
+
} else {
|
|
4273
|
+
process.stderr.write(`halfcycle close-phase: the phase closed, but the commit it closed at was not recorded on this machine: ${closed.closeRecord.reason}
|
|
4274
|
+
halfcycle close-phase: this phase's Build Record will say the close commit and the close date were not recorded.
|
|
3909
4275
|
`);
|
|
3910
4276
|
}
|
|
3911
4277
|
process.stdout.write(`[halfcycle] Open the next phase with: halfcycle open-phase <phase> --actor "you" --evidence "\u2026"
|
|
3912
4278
|
`);
|
|
4279
|
+
if (closed.stampFailure) {
|
|
4280
|
+
process.stderr.write(`halfcycle close-phase: the phase closed, but this machine's record of it could not be cleared: ${closed.stampFailure.reason}
|
|
4281
|
+
halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard evaluations here are still filed under the phase you just closed.
|
|
4282
|
+
`);
|
|
4283
|
+
process.exit(1);
|
|
4284
|
+
return;
|
|
4285
|
+
}
|
|
3913
4286
|
process.exit(0);
|
|
3914
4287
|
} catch (err) {
|
|
3915
4288
|
if (err instanceof ClosePhaseRefused) {
|