halfcycle 0.3.7
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 +16 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/bin/bin.bundle.mjs +16771 -0
- package/commands/agent-teams-orchestration.md +11 -0
- package/commands/consistency-check.md +11 -0
- package/commands/context-update.md +11 -0
- package/commands/halfcycle-design.md +11 -0
- package/commands/halfcycle-setup.md +11 -0
- package/commands/stack-assembly-worker.md +11 -0
- package/commands/task-execution.md +11 -0
- package/commands/task-graph.md +11 -0
- package/commands/write-spec.md +11 -0
- package/dist/account-credential.d.ts +94 -0
- package/dist/account-credential.d.ts.map +1 -0
- package/dist/bin.d.ts +34 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +3156 -0
- package/dist/bin.js.map +6 -0
- package/dist/build-record/assemble.d.ts +37 -0
- package/dist/build-record/assemble.d.ts.map +1 -0
- package/dist/build-record/index.d.ts +39 -0
- package/dist/build-record/index.d.ts.map +1 -0
- package/dist/build-record/sources.d.ts +80 -0
- package/dist/build-record/sources.d.ts.map +1 -0
- package/dist/build-record/template.d.ts +18 -0
- package/dist/build-record/template.d.ts.map +1 -0
- package/dist/build-record/types.d.ts +163 -0
- package/dist/build-record/types.d.ts.map +1 -0
- package/dist/build-record/write.d.ts +26 -0
- package/dist/build-record/write.d.ts.map +1 -0
- package/dist/cli-contract.d.ts +120 -0
- package/dist/cli-contract.d.ts.map +1 -0
- package/dist/close-phase.d.ts +114 -0
- package/dist/close-phase.d.ts.map +1 -0
- package/dist/create-engagement.d.ts +248 -0
- package/dist/create-engagement.d.ts.map +1 -0
- package/dist/device-signin.d.ts +255 -0
- package/dist/device-signin.d.ts.map +1 -0
- package/dist/engagement-credential.d.ts +267 -0
- package/dist/engagement-credential.d.ts.map +1 -0
- package/dist/identity.d.ts +76 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2083 -0
- package/dist/index.js.map +6 -0
- package/dist/install.d.ts +355 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/loopback-signin.d.ts +173 -0
- package/dist/loopback-signin.d.ts.map +1 -0
- package/dist/mcp-endpoint.d.ts +35 -0
- package/dist/mcp-endpoint.d.ts.map +1 -0
- package/dist/merge-settings.d.ts +57 -0
- package/dist/merge-settings.d.ts.map +1 -0
- package/dist/mint-board-code.d.ts +41 -0
- package/dist/mint-board-code.d.ts.map +1 -0
- package/dist/open-phase.d.ts +107 -0
- package/dist/open-phase.d.ts.map +1 -0
- package/dist/probe-mcp.d.ts +47 -0
- package/dist/probe-mcp.d.ts.map +1 -0
- package/dist/resolve-credential.d.ts +126 -0
- package/dist/resolve-credential.d.ts.map +1 -0
- package/dist/scan.d.ts +131 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/setup/discovery.d.ts +73 -0
- package/dist/setup/discovery.d.ts.map +1 -0
- package/dist/setup/engagement.d.ts +99 -0
- package/dist/setup/engagement.d.ts.map +1 -0
- package/dist/setup/index.d.ts +17 -0
- package/dist/setup/index.d.ts.map +1 -0
- package/dist/setup/manifest.d.ts +155 -0
- package/dist/setup/manifest.d.ts.map +1 -0
- package/dist/setup/rows.d.ts +57 -0
- package/dist/setup/rows.d.ts.map +1 -0
- package/package.json +55 -0
- package/scaffolding/test/fixtures/captured/.gitkeep +0 -0
- package/scaffolding/test/fixtures/captured/manifest.json +4 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,3156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// dist/bin.js
|
|
4
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
5
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
6
|
+
import { join as join9 } from "node:path";
|
|
7
|
+
|
|
8
|
+
// dist/install.js
|
|
9
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
10
|
+
import { dirname as dirname2, join as join5, relative } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
// dist/engagement-credential.js
|
|
14
|
+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
15
|
+
import { platform as platform2 } from "node:os";
|
|
16
|
+
import { join as join2 } from "node:path";
|
|
17
|
+
|
|
18
|
+
// dist/account-credential.js
|
|
19
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { homedir, platform } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
function halfcycleHome(home) {
|
|
23
|
+
return join(home ?? homedir(), ".halfcycle");
|
|
24
|
+
}
|
|
25
|
+
function accountStorePath(home) {
|
|
26
|
+
return join(halfcycleHome(home), "account.json");
|
|
27
|
+
}
|
|
28
|
+
function normaliseOrigin(serviceUrl) {
|
|
29
|
+
return serviceUrl.trim().replace(/\/+$/, "").toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
function readStore(home) {
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = readFileSync(accountStorePath(home), "utf-8");
|
|
35
|
+
} catch {
|
|
36
|
+
return { version: 1, accounts: {} };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.accounts !== "object") {
|
|
41
|
+
return { version: 1, accounts: {} };
|
|
42
|
+
}
|
|
43
|
+
return { version: 1, accounts: parsed.accounts ?? {} };
|
|
44
|
+
} catch {
|
|
45
|
+
return { version: 1, accounts: {} };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function writeStore(store, home) {
|
|
49
|
+
const path = accountStorePath(home);
|
|
50
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
51
|
+
writeFileSync(path, `${JSON.stringify(store, null, 2)}
|
|
52
|
+
`, { mode: 384 });
|
|
53
|
+
if (platform() !== "win32") {
|
|
54
|
+
chmodSync(path, 384);
|
|
55
|
+
try {
|
|
56
|
+
chmodSync(dirname(path), 448);
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function readStoredCredential(serviceUrl, home) {
|
|
62
|
+
const entry = readStore(home).accounts[normaliseOrigin(serviceUrl)];
|
|
63
|
+
if (!entry || typeof entry.credential !== "string" || entry.credential.trim() === "") {
|
|
64
|
+
return void 0;
|
|
65
|
+
}
|
|
66
|
+
return entry;
|
|
67
|
+
}
|
|
68
|
+
function writeStoredCredential(serviceUrl, entry, home) {
|
|
69
|
+
const store = readStore(home);
|
|
70
|
+
store.accounts[normaliseOrigin(serviceUrl)] = {
|
|
71
|
+
accountId: entry.accountId,
|
|
72
|
+
credential: entry.credential,
|
|
73
|
+
expiresAt: entry.expiresAt,
|
|
74
|
+
signedInAt: entry.signedInAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
75
|
+
};
|
|
76
|
+
writeStore(store, home);
|
|
77
|
+
}
|
|
78
|
+
function forgetStoredCredential(serviceUrl, home) {
|
|
79
|
+
const store = readStore(home);
|
|
80
|
+
const key = normaliseOrigin(serviceUrl);
|
|
81
|
+
if (!(key in store.accounts))
|
|
82
|
+
return false;
|
|
83
|
+
delete store.accounts[key];
|
|
84
|
+
writeStore(store, home);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// dist/engagement-credential.js
|
|
89
|
+
var ENGAGEMENTS_DIR = "engagements";
|
|
90
|
+
var ENV_FILENAME = "env";
|
|
91
|
+
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
92
|
+
function engagementStateDir(engagementId, home) {
|
|
93
|
+
return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
|
|
94
|
+
}
|
|
95
|
+
function engagementEnvPath(engagementId, home) {
|
|
96
|
+
return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
|
|
97
|
+
}
|
|
98
|
+
var GUARD_ENV_KEYS = [
|
|
99
|
+
"GUARD_SERVICE_URL",
|
|
100
|
+
"GUARD_SERVICE_TOKEN",
|
|
101
|
+
"GUARD_ENGAGEMENT_ID"
|
|
102
|
+
];
|
|
103
|
+
var ENGAGEMENT_ENV_KEYS = [
|
|
104
|
+
"HALFCYCLE_SERVICE_URL",
|
|
105
|
+
"HALFCYCLE_MCP_URL",
|
|
106
|
+
"HALFCYCLE_TOKEN",
|
|
107
|
+
"HALFCYCLE_ENGAGEMENT_ID",
|
|
108
|
+
...GUARD_ENV_KEYS,
|
|
109
|
+
"CONTROL_TELEMETRY_URL"
|
|
110
|
+
];
|
|
111
|
+
var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
|
|
112
|
+
function shq(value) {
|
|
113
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
114
|
+
}
|
|
115
|
+
function unquote(value) {
|
|
116
|
+
const v = value.trim();
|
|
117
|
+
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
|
|
118
|
+
return v.slice(1, -1).split(`'\\''`).join(`'`);
|
|
119
|
+
}
|
|
120
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
|
|
121
|
+
return v.slice(1, -1);
|
|
122
|
+
return v;
|
|
123
|
+
}
|
|
124
|
+
function parseEnvText(raw) {
|
|
125
|
+
const out = {};
|
|
126
|
+
for (const line of raw.split("\n")) {
|
|
127
|
+
const eq = line.indexOf("=");
|
|
128
|
+
if (eq === -1)
|
|
129
|
+
continue;
|
|
130
|
+
const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
|
|
131
|
+
if (key === "" || key.startsWith("#"))
|
|
132
|
+
continue;
|
|
133
|
+
out[key] = unquote(line.slice(eq + 1));
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
|
|
138
|
+
const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
|
|
139
|
+
const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
|
|
140
|
+
if (existing === null) {
|
|
141
|
+
return `${ENV_HEADER}
|
|
142
|
+
` + keys.map(line).join("\n") + "\n";
|
|
143
|
+
}
|
|
144
|
+
const seen = /* @__PURE__ */ new Set();
|
|
145
|
+
const out = existing.split("\n").map((existingLine) => {
|
|
146
|
+
const eq = existingLine.indexOf("=");
|
|
147
|
+
if (eq === -1)
|
|
148
|
+
return existingLine;
|
|
149
|
+
const lhs = existingLine.slice(0, eq);
|
|
150
|
+
const exported = /^\s*export\s+/.exec(lhs);
|
|
151
|
+
const prefix = exported === null ? "" : exported[0];
|
|
152
|
+
const key = lhs.slice(prefix.length).trim();
|
|
153
|
+
if (desired.has(key)) {
|
|
154
|
+
seen.add(key);
|
|
155
|
+
return `${prefix}${line(key)}`;
|
|
156
|
+
}
|
|
157
|
+
return existingLine;
|
|
158
|
+
});
|
|
159
|
+
const missing = keys.filter((k) => !seen.has(k));
|
|
160
|
+
if (missing.length > 0) {
|
|
161
|
+
const trailingBlank = out.length > 0 && out[out.length - 1] === "";
|
|
162
|
+
const prefix = trailingBlank ? "" : existing.endsWith("\n") ? "" : "\n";
|
|
163
|
+
out.push(`${prefix}${ENV_HEADER}
|
|
164
|
+
` + missing.map(line).join("\n"));
|
|
165
|
+
}
|
|
166
|
+
let result = out.join("\n");
|
|
167
|
+
if (existing.endsWith("\n") && !result.endsWith("\n"))
|
|
168
|
+
result += "\n";
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
function readEngagementEnv(engagementId, home) {
|
|
172
|
+
try {
|
|
173
|
+
return parseEnvText(readFileSync2(engagementEnvPath(engagementId, home), "utf-8"));
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function writeEngagementEnv(engagementId, values, home) {
|
|
179
|
+
const path = engagementEnvPath(engagementId, home);
|
|
180
|
+
const dir = engagementStateDir(engagementId, home);
|
|
181
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
182
|
+
let existing;
|
|
183
|
+
try {
|
|
184
|
+
existing = readFileSync2(path, "utf-8");
|
|
185
|
+
} catch {
|
|
186
|
+
existing = null;
|
|
187
|
+
}
|
|
188
|
+
const reconciled = reconcileEnvText(existing, values);
|
|
189
|
+
const outcome = existing === reconciled ? "skipped" : "written";
|
|
190
|
+
if (outcome === "written") {
|
|
191
|
+
writeFileSync2(path, reconciled, { mode: 384 });
|
|
192
|
+
}
|
|
193
|
+
applyOwnerOnly(path, dir);
|
|
194
|
+
return outcome;
|
|
195
|
+
}
|
|
196
|
+
function applyOwnerOnly(path, dir) {
|
|
197
|
+
if (platform2() === "win32")
|
|
198
|
+
return;
|
|
199
|
+
try {
|
|
200
|
+
chmodSync2(path, 384);
|
|
201
|
+
} catch {
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
chmodSync2(dir, 448);
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function engagementResolutionShell() {
|
|
209
|
+
return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
|
|
210
|
+
# THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
|
|
211
|
+
# $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
|
|
212
|
+
# written owner-only by \`npx halfcycle\`. What this repository holds is the
|
|
213
|
+
# engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
|
|
214
|
+
# the pin, then name the file. There is no jq here and node is not dependable on a
|
|
215
|
+
# desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
|
|
216
|
+
# file, because sed is line-oriented and the pin's line breaks are not a contract.
|
|
217
|
+
#
|
|
218
|
+
# Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT and HALFCYCLE_ENV_PROBLEM in
|
|
219
|
+
# the CALLER's shell and returns a status. Call it as \`if halfcycle_env_file \u2026\`,
|
|
220
|
+
# never as \`$(halfcycle_env_file \u2026)\` \u2014 a command substitution is a subshell and
|
|
221
|
+
# would discard everything it set.
|
|
222
|
+
halfcycle_env_file() {
|
|
223
|
+
HALFCYCLE_ENV_FILE=""
|
|
224
|
+
HALFCYCLE_ENV_ENGAGEMENT=""
|
|
225
|
+
HALFCYCLE_ENV_PROBLEM=""
|
|
226
|
+
hc_pin="$1/.halfcycle/bundle.json"
|
|
227
|
+
if [ ! -f "$hc_pin" ]; then
|
|
228
|
+
HALFCYCLE_ENV_PROBLEM="no-pin"
|
|
229
|
+
return 1
|
|
230
|
+
fi
|
|
231
|
+
hc_id=$(tr -d '\\n' < "$hc_pin" | sed -n 's/.*"${PIN_ENGAGEMENT_ID_FIELD}"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
|
|
232
|
+
if [ -z "$hc_id" ]; then
|
|
233
|
+
HALFCYCLE_ENV_PROBLEM="no-id"
|
|
234
|
+
return 1
|
|
235
|
+
fi
|
|
236
|
+
HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
|
|
237
|
+
if [ -z "\${HOME:-}" ]; then
|
|
238
|
+
HALFCYCLE_ENV_PROBLEM="no-home"
|
|
239
|
+
return 1
|
|
240
|
+
fi
|
|
241
|
+
hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
|
|
242
|
+
if [ ! -f "$hc_env" ]; then
|
|
243
|
+
HALFCYCLE_ENV_PROBLEM="no-credential"
|
|
244
|
+
return 1
|
|
245
|
+
fi
|
|
246
|
+
HALFCYCLE_ENV_FILE="$hc_env"
|
|
247
|
+
return 0
|
|
248
|
+
}
|
|
249
|
+
# --- end Halfcycle credential resolution ------------------------------------`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// dist/identity.js
|
|
253
|
+
import { execFileSync } from "node:child_process";
|
|
254
|
+
import { existsSync, readFileSync as readFileSync3 } from "node:fs";
|
|
255
|
+
import { join as join3 } from "node:path";
|
|
256
|
+
import { randomUUID } from "node:crypto";
|
|
257
|
+
var PROJECT_IDENTITY_NOTE = 'projectId is NOT the engagement id \u2014 it identifies this repository/worktree to Halfcycle, not an engagement. The engagement id lives in .halfcycle/bundle.json, in its "engagementId" field.';
|
|
258
|
+
function git(targetRepoRoot, args2) {
|
|
259
|
+
try {
|
|
260
|
+
const out = execFileSync("git", ["-C", targetRepoRoot, ...args2], {
|
|
261
|
+
encoding: "utf-8",
|
|
262
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
263
|
+
});
|
|
264
|
+
const trimmed = out.trim();
|
|
265
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function readRootCommit(targetRepoRoot) {
|
|
271
|
+
const roots = git(targetRepoRoot, ["rev-list", "--max-parents=0", "HEAD"]);
|
|
272
|
+
if (!roots)
|
|
273
|
+
return null;
|
|
274
|
+
const lines = roots.split("\n").filter((l) => l.length > 0);
|
|
275
|
+
return lines.length > 0 ? lines[lines.length - 1] : null;
|
|
276
|
+
}
|
|
277
|
+
function readRemote(targetRepoRoot) {
|
|
278
|
+
return git(targetRepoRoot, ["remote", "get-url", "origin"]);
|
|
279
|
+
}
|
|
280
|
+
function mintOrReadIdentity(targetRepoRoot) {
|
|
281
|
+
const path = join3(targetRepoRoot, ".halfcycle", "project.json");
|
|
282
|
+
if (existsSync(path)) {
|
|
283
|
+
const onDisk = JSON.parse(readFileSync3(path, "utf-8"));
|
|
284
|
+
const identity2 = {
|
|
285
|
+
projectId: onDisk.projectId ?? "",
|
|
286
|
+
rootCommit: onDisk.rootCommit ?? null,
|
|
287
|
+
remote: onDisk.remote ?? null,
|
|
288
|
+
note: PROJECT_IDENTITY_NOTE
|
|
289
|
+
};
|
|
290
|
+
return { identity: identity2, minted: false };
|
|
291
|
+
}
|
|
292
|
+
const identity = {
|
|
293
|
+
projectId: randomUUID(),
|
|
294
|
+
rootCommit: readRootCommit(targetRepoRoot),
|
|
295
|
+
remote: readRemote(targetRepoRoot),
|
|
296
|
+
note: PROJECT_IDENTITY_NOTE
|
|
297
|
+
};
|
|
298
|
+
return { identity, minted: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// dist/mcp-endpoint.js
|
|
302
|
+
var MCP_ENDPOINT_PATH = "/mcp";
|
|
303
|
+
function mcpEndpointUrl(origin) {
|
|
304
|
+
return `${origin.trim().replace(/\/+$/, "")}${MCP_ENDPOINT_PATH}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// dist/merge-settings.js
|
|
308
|
+
function isHalfcycleEntry(entry, halfcycleCommands) {
|
|
309
|
+
return entry.hooks.some((h) => halfcycleCommands.has(h.command));
|
|
310
|
+
}
|
|
311
|
+
function mergeSettings(existing, generated) {
|
|
312
|
+
const merged = { ...existing };
|
|
313
|
+
if (generated.$schema !== void 0) {
|
|
314
|
+
merged.$schema = generated.$schema;
|
|
315
|
+
}
|
|
316
|
+
const generatedHooks = generated.hooks ?? {};
|
|
317
|
+
const halfcycleCommands = /* @__PURE__ */ new Set();
|
|
318
|
+
for (const entries of Object.values(generatedHooks)) {
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
for (const h of entry.hooks)
|
|
321
|
+
halfcycleCommands.add(h.command);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const existingHooks = existing.hooks ?? {};
|
|
325
|
+
const mergedHooks = {};
|
|
326
|
+
const events = /* @__PURE__ */ new Set([...Object.keys(existingHooks), ...Object.keys(generatedHooks)]);
|
|
327
|
+
for (const event of events) {
|
|
328
|
+
const developerEntries = (existingHooks[event] ?? []).filter((entry) => !isHalfcycleEntry(entry, halfcycleCommands));
|
|
329
|
+
const halfcycleEntries = generatedHooks[event] ?? [];
|
|
330
|
+
mergedHooks[event] = [...developerEntries, ...halfcycleEntries];
|
|
331
|
+
}
|
|
332
|
+
merged.hooks = mergedHooks;
|
|
333
|
+
merged.permissions = mergePermissions(existing.permissions, generated.permissions);
|
|
334
|
+
return merged;
|
|
335
|
+
}
|
|
336
|
+
function mergePermissions(existing, generated) {
|
|
337
|
+
if (existing === void 0 && generated === void 0)
|
|
338
|
+
return void 0;
|
|
339
|
+
const result = { ...existing ?? {} };
|
|
340
|
+
const generatedDeny = generated?.deny ?? [];
|
|
341
|
+
if (generatedDeny.length > 0) {
|
|
342
|
+
const theirs = existing?.deny ?? [];
|
|
343
|
+
const seen = new Set(theirs);
|
|
344
|
+
result.deny = [...theirs, ...generatedDeny.filter((p) => !seen.has(p))];
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// dist/scan.js
|
|
350
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
351
|
+
import { join as join4 } from "node:path";
|
|
352
|
+
|
|
353
|
+
// ../events/dist/result.js
|
|
354
|
+
import { z as z2 } from "zod";
|
|
355
|
+
|
|
356
|
+
// ../core/dist/guard-record.js
|
|
357
|
+
import { z } from "zod";
|
|
358
|
+
var severitySchema = z.enum(["info", "warn", "block"]);
|
|
359
|
+
var channelSchema = z.enum(["stable", "candidate", "rented"]);
|
|
360
|
+
var matcherKindSchema = z.enum(["grep", "ast", "integrationTest", "llm"]);
|
|
361
|
+
var matcherConfigSchema = z.record(z.string(), z.unknown());
|
|
362
|
+
var grepMatcherSchema = z.object({ kind: z.literal("grep"), config: matcherConfigSchema }).strict();
|
|
363
|
+
var astMatcherSchema = z.object({ kind: z.literal("ast"), config: matcherConfigSchema }).strict();
|
|
364
|
+
var integrationTestMatcherSchema = z.object({ kind: z.literal("integrationTest"), config: matcherConfigSchema }).strict();
|
|
365
|
+
var llmMatcherSchema = z.object({ kind: z.literal("llm"), config: matcherConfigSchema }).strict();
|
|
366
|
+
var matcherSchema = z.discriminatedUnion("kind", [
|
|
367
|
+
grepMatcherSchema,
|
|
368
|
+
astMatcherSchema,
|
|
369
|
+
integrationTestMatcherSchema,
|
|
370
|
+
llmMatcherSchema
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
// ../events/dist/result.js
|
|
374
|
+
var firedGuardSchema = z2.object({
|
|
375
|
+
guardId: z2.string(),
|
|
376
|
+
patternRef: z2.string(),
|
|
377
|
+
severity: severitySchema,
|
|
378
|
+
explanation: z2.string()
|
|
379
|
+
}).strict();
|
|
380
|
+
var resultEnvelopeSchema = z2.object({
|
|
381
|
+
guardsFired: z2.array(firedGuardSchema),
|
|
382
|
+
severity: severitySchema.nullable(),
|
|
383
|
+
blocking: z2.boolean(),
|
|
384
|
+
explanation: z2.string()
|
|
385
|
+
}).strict();
|
|
386
|
+
var wireErrorSchema = z2.object({
|
|
387
|
+
statusCode: z2.number(),
|
|
388
|
+
error: z2.string(),
|
|
389
|
+
message: z2.string()
|
|
390
|
+
}).strict();
|
|
391
|
+
|
|
392
|
+
// ../events/dist/telemetry.js
|
|
393
|
+
import { z as z3 } from "zod";
|
|
394
|
+
var utcIso8601 = z3.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
|
|
395
|
+
var guardEvalOutcomeSchema = z3.enum([
|
|
396
|
+
"evaluated",
|
|
397
|
+
"infra-error",
|
|
398
|
+
"contract-error",
|
|
399
|
+
"unconfigured"
|
|
400
|
+
]);
|
|
401
|
+
var guardEvalRunSchema = z3.object({
|
|
402
|
+
// `runId`/`outcome` are the E2 run-observability additions, declared
|
|
403
|
+
// field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
|
|
404
|
+
// migration. Every new record populated by the migrated writer (T-07)
|
|
405
|
+
// carries both; a legacy line predating the amendment carries neither and
|
|
406
|
+
// must still parse (the mixed-era guard-eval log the Build-Record reader
|
|
407
|
+
// assembles from, and the runner emit not yet migrated). So the presence
|
|
408
|
+
// refinement below is skipped for a legacy line (no `outcome`), and the
|
|
409
|
+
// per-writer guarantee lives with the writer + reader-normalisation (T-07),
|
|
410
|
+
// where the schema may then be tightened to required. runId is uuid-checked
|
|
411
|
+
// WHEN present; outcome is enum-checked when present.
|
|
412
|
+
runId: z3.string().uuid().optional(),
|
|
413
|
+
engagementId: z3.string().optional(),
|
|
414
|
+
runType: z3.enum(["hook", "ci"]),
|
|
415
|
+
occurredAt: utcIso8601,
|
|
416
|
+
outcome: guardEvalOutcomeSchema.optional(),
|
|
417
|
+
failureReason: z3.string().optional(),
|
|
418
|
+
phase: z3.string().optional(),
|
|
419
|
+
scope: z3.record(z3.string(), z3.unknown()),
|
|
420
|
+
cost: z3.record(z3.string(), z3.unknown()).optional(),
|
|
421
|
+
result: resultEnvelopeSchema.optional()
|
|
422
|
+
}).strict().superRefine((run, ctx) => {
|
|
423
|
+
if (run.outcome === void 0)
|
|
424
|
+
return;
|
|
425
|
+
if (run.outcome === "evaluated" && run.result === void 0) {
|
|
426
|
+
ctx.addIssue({
|
|
427
|
+
code: z3.ZodIssueCode.custom,
|
|
428
|
+
path: ["result"],
|
|
429
|
+
message: "result is required when outcome is 'evaluated'"
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
if (run.outcome !== "evaluated" && run.result !== void 0) {
|
|
433
|
+
ctx.addIssue({
|
|
434
|
+
code: z3.ZodIssueCode.custom,
|
|
435
|
+
path: ["result"],
|
|
436
|
+
message: "result is only present when outcome is 'evaluated'"
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
|
|
440
|
+
ctx.addIssue({
|
|
441
|
+
code: z3.ZodIssueCode.custom,
|
|
442
|
+
path: ["engagementId"],
|
|
443
|
+
message: "engagementId is required unless outcome is 'unconfigured'"
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ../events/dist/engagement-lifecycle.js
|
|
449
|
+
import { z as z4 } from "zod";
|
|
450
|
+
var utcIso86012 = z4.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
|
|
451
|
+
var engagementTypeSchema = z4.enum(["client"]);
|
|
452
|
+
var lifecycleStatusSchema = z4.enum(["active", "delivering", "closed"]);
|
|
453
|
+
var createEngagementRequestSchema = z4.object({
|
|
454
|
+
name: z4.string(),
|
|
455
|
+
region: z4.string(),
|
|
456
|
+
engagementType: engagementTypeSchema
|
|
457
|
+
}).strict();
|
|
458
|
+
var createEngagementResponseSchema = z4.object({
|
|
459
|
+
engagementId: z4.string(),
|
|
460
|
+
name: z4.string(),
|
|
461
|
+
region: z4.string(),
|
|
462
|
+
status: lifecycleStatusSchema,
|
|
463
|
+
createdAt: utcIso86012,
|
|
464
|
+
sessionToken: z4.string()
|
|
465
|
+
}).strict();
|
|
466
|
+
var engagementStatusResponseSchema = z4.object({
|
|
467
|
+
engagementId: z4.string(),
|
|
468
|
+
name: z4.string(),
|
|
469
|
+
region: z4.string(),
|
|
470
|
+
status: lifecycleStatusSchema,
|
|
471
|
+
installLinked: z4.boolean(),
|
|
472
|
+
createdAt: utcIso86012,
|
|
473
|
+
updatedAt: utcIso86012
|
|
474
|
+
}).strict();
|
|
475
|
+
var linkInstallationRequestSchema = z4.object({
|
|
476
|
+
installationId: z4.string(),
|
|
477
|
+
engagementId: z4.string()
|
|
478
|
+
}).strict();
|
|
479
|
+
var startBuildRequestSchema = z4.object({
|
|
480
|
+
engagementId: z4.string()
|
|
481
|
+
}).strict();
|
|
482
|
+
var startBuildResponseSchema = z4.object({
|
|
483
|
+
engagementId: z4.string(),
|
|
484
|
+
status: lifecycleStatusSchema,
|
|
485
|
+
enqueuedAt: utcIso86012
|
|
486
|
+
}).strict();
|
|
487
|
+
var acceptanceOutcomeSchema = z4.object({
|
|
488
|
+
verdict: z4.enum(["clean", "defects"]),
|
|
489
|
+
findings: z4.number().int().nonnegative(),
|
|
490
|
+
findingsDetail: z4.array(z4.object({ summary: z4.string() }).strict())
|
|
491
|
+
}).strict();
|
|
492
|
+
var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
|
|
493
|
+
z4.object({
|
|
494
|
+
decision: z4.literal("done-when-met"),
|
|
495
|
+
/** Who is closing the phase. */
|
|
496
|
+
actor: z4.string()
|
|
497
|
+
}).strict(),
|
|
498
|
+
z4.object({
|
|
499
|
+
decision: z4.literal("override"),
|
|
500
|
+
/** Who is closing it anyway. */
|
|
501
|
+
actor: z4.string(),
|
|
502
|
+
/** Why, in their own words. */
|
|
503
|
+
reason: z4.string()
|
|
504
|
+
}).strict()
|
|
505
|
+
]);
|
|
506
|
+
var acceptPhaseRequestSchema = z4.object({
|
|
507
|
+
phase: z4.string(),
|
|
508
|
+
acceptanceOutcome: acceptanceOutcomeSchema,
|
|
509
|
+
close: phaseCloseDecisionSchema.optional()
|
|
510
|
+
}).strict();
|
|
511
|
+
var coeFindingRequestSchema = z4.object({
|
|
512
|
+
phase: z4.string(),
|
|
513
|
+
description: z4.string(),
|
|
514
|
+
addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
|
|
515
|
+
mappedPatternRef: z4.string().nullable()
|
|
516
|
+
}).strict();
|
|
517
|
+
var coeFindingResponseSchema = z4.object({
|
|
518
|
+
findingId: z4.string(),
|
|
519
|
+
engagementId: z4.string(),
|
|
520
|
+
phase: z4.string(),
|
|
521
|
+
description: z4.string(),
|
|
522
|
+
addRateTag: z4.enum(["seam-new", "seam-repeat", "model-limitation"]),
|
|
523
|
+
mappedPatternRef: z4.string().nullable(),
|
|
524
|
+
createdAt: utcIso86012
|
|
525
|
+
}).strict();
|
|
526
|
+
var phaseEntryDecisionSchema = z4.discriminatedUnion("decision", [
|
|
527
|
+
z4.object({
|
|
528
|
+
decision: z4.literal("entry-condition-met"),
|
|
529
|
+
/** Who attests. */
|
|
530
|
+
actor: z4.string(),
|
|
531
|
+
/** What makes the entry condition hold, in the attester's own words. */
|
|
532
|
+
evidence: z4.string()
|
|
533
|
+
}).strict(),
|
|
534
|
+
z4.object({
|
|
535
|
+
decision: z4.literal("override"),
|
|
536
|
+
/** Who is opening it anyway. */
|
|
537
|
+
actor: z4.string(),
|
|
538
|
+
/** Why, in the operator's own words. */
|
|
539
|
+
reason: z4.string()
|
|
540
|
+
}).strict()
|
|
541
|
+
]);
|
|
542
|
+
var openPhaseRequestSchema = z4.object({
|
|
543
|
+
phase: z4.string().nullable(),
|
|
544
|
+
entry: phaseEntryDecisionSchema
|
|
545
|
+
}).strict();
|
|
546
|
+
var phaseEntryDecisionRecordSchema = z4.object({
|
|
547
|
+
decisionId: z4.string(),
|
|
548
|
+
decision: z4.enum(["entry-condition-met", "override"]),
|
|
549
|
+
actor: z4.string(),
|
|
550
|
+
/** The evidence (attestation) or the reason (override) — one basis per row. */
|
|
551
|
+
justification: z4.string(),
|
|
552
|
+
blockingFinding: z4.string().nullable(),
|
|
553
|
+
decidedAt: utcIso86012
|
|
554
|
+
}).strict();
|
|
555
|
+
var openPhaseResponseSchema = z4.object({
|
|
556
|
+
engagement: engagementStatusResponseSchema,
|
|
557
|
+
/** What is open now. `null` is project scope. */
|
|
558
|
+
phase: z4.string().nullable(),
|
|
559
|
+
/** What was open before this decision. `null` is project scope. */
|
|
560
|
+
previousPhase: z4.string().nullable(),
|
|
561
|
+
decision: phaseEntryDecisionRecordSchema
|
|
562
|
+
}).strict();
|
|
563
|
+
|
|
564
|
+
// ../events/dist/device-auth.js
|
|
565
|
+
import { z as z5 } from "zod";
|
|
566
|
+
var utcIso86013 = z5.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/, "must be a UTC ISO-8601 timestamp ending in Z (INV-015)");
|
|
567
|
+
var DEVICE_AUTH_STATUS = {
|
|
568
|
+
/** The poll: minted, nobody has acted yet. Keep polling. 200. */
|
|
569
|
+
PENDING: "pending",
|
|
570
|
+
/** The poll: approved, and the credential is in this body. Once only. 200. */
|
|
571
|
+
APPROVED: "approved",
|
|
572
|
+
/** Confirm: the decision was recorded (approve or decline). 200. */
|
|
573
|
+
RECORDED: "recorded",
|
|
574
|
+
/** The human refused at the confirmation page. 400 on the poll. */
|
|
575
|
+
DECLINED: "declined",
|
|
576
|
+
/** The code's absolute expiry has passed. 400. */
|
|
577
|
+
EXPIRED: "expired",
|
|
578
|
+
/** Never minted, or already spent. 400. */
|
|
579
|
+
UNKNOWN: "unknown",
|
|
580
|
+
/** Confirm: neither `approve` nor `decline` was named. Nothing was bound. 400. */
|
|
581
|
+
INVALID_DECISION: "invalid-decision",
|
|
582
|
+
/** Confirm: an approval arrived with no signed-in identity. 400. */
|
|
583
|
+
IDENTITY_REQUIRED: "identity-required",
|
|
584
|
+
/** This deployment has no `SIGNIN_BASE_URL` / `DEVICE_AUTH_CONFIRM_SECRET`. 503. */
|
|
585
|
+
NOT_CONFIGURED: "not-configured",
|
|
586
|
+
/** Confirm: the shared confirmation secret was missing or wrong. 401. */
|
|
587
|
+
UNAUTHORIZED: "unauthorized",
|
|
588
|
+
/** Confirm: the code was already approved, declined or claimed. 409. */
|
|
589
|
+
ALREADY_DECIDED: "already-decided"
|
|
590
|
+
};
|
|
591
|
+
var DEVICE_POLL_REFUSALS = [
|
|
592
|
+
DEVICE_AUTH_STATUS.DECLINED,
|
|
593
|
+
DEVICE_AUTH_STATUS.EXPIRED,
|
|
594
|
+
DEVICE_AUTH_STATUS.UNKNOWN
|
|
595
|
+
];
|
|
596
|
+
var deviceAuthStartResponseSchema = z5.object({
|
|
597
|
+
deviceCode: z5.string().min(1),
|
|
598
|
+
userCode: z5.string().min(1),
|
|
599
|
+
verificationUrl: z5.string().min(1),
|
|
600
|
+
expiresAt: utcIso86013,
|
|
601
|
+
pollIntervalMs: z5.number().int().positive()
|
|
602
|
+
}).strict();
|
|
603
|
+
var devicePollPendingSchema = z5.object({
|
|
604
|
+
status: z5.literal(DEVICE_AUTH_STATUS.PENDING),
|
|
605
|
+
pollIntervalMs: z5.number().int().positive()
|
|
606
|
+
}).strict();
|
|
607
|
+
var devicePollApprovedSchema = z5.object({
|
|
608
|
+
status: z5.literal(DEVICE_AUTH_STATUS.APPROVED),
|
|
609
|
+
accountId: z5.string().min(1),
|
|
610
|
+
credential: z5.string().min(1),
|
|
611
|
+
expiresAt: utcIso86013
|
|
612
|
+
}).strict();
|
|
613
|
+
var deviceAuthRefusalSchema = z5.object({
|
|
614
|
+
status: z5.enum([
|
|
615
|
+
DEVICE_AUTH_STATUS.DECLINED,
|
|
616
|
+
DEVICE_AUTH_STATUS.EXPIRED,
|
|
617
|
+
DEVICE_AUTH_STATUS.UNKNOWN,
|
|
618
|
+
DEVICE_AUTH_STATUS.INVALID_DECISION,
|
|
619
|
+
DEVICE_AUTH_STATUS.IDENTITY_REQUIRED,
|
|
620
|
+
DEVICE_AUTH_STATUS.NOT_CONFIGURED,
|
|
621
|
+
DEVICE_AUTH_STATUS.UNAUTHORIZED,
|
|
622
|
+
DEVICE_AUTH_STATUS.ALREADY_DECIDED
|
|
623
|
+
]),
|
|
624
|
+
message: z5.string().min(1)
|
|
625
|
+
}).strict();
|
|
626
|
+
var devicePollResponseSchema = z5.union([
|
|
627
|
+
devicePollPendingSchema,
|
|
628
|
+
devicePollApprovedSchema,
|
|
629
|
+
deviceAuthRefusalSchema
|
|
630
|
+
]);
|
|
631
|
+
var deviceConfirmRequestSchema = z5.object({
|
|
632
|
+
userCode: z5.string().min(1),
|
|
633
|
+
decision: z5.enum(["approve", "decline"]),
|
|
634
|
+
externalAuthId: z5.string().min(1).optional(),
|
|
635
|
+
email: z5.string().optional()
|
|
636
|
+
}).strict();
|
|
637
|
+
var deviceConfirmRecordedSchema = z5.object({
|
|
638
|
+
status: z5.literal(DEVICE_AUTH_STATUS.RECORDED),
|
|
639
|
+
accountId: z5.string().min(1).optional()
|
|
640
|
+
}).strict();
|
|
641
|
+
var deviceConfirmResponseSchema = z5.union([
|
|
642
|
+
deviceConfirmRecordedSchema,
|
|
643
|
+
deviceAuthRefusalSchema
|
|
644
|
+
]);
|
|
645
|
+
var LOOPBACK_REDIRECT_HOST = "127.0.0.1";
|
|
646
|
+
var LOOPBACK_PORT_MIN = 1024;
|
|
647
|
+
var LOOPBACK_PORT_MAX = 65535;
|
|
648
|
+
var LOOPBACK_CALLBACK_PATH = "/halfcycle-cli-callback";
|
|
649
|
+
var LOOPBACK_QUERY_PORT = "loopback_port";
|
|
650
|
+
var LOOPBACK_QUERY_STATE = "loopback_state";
|
|
651
|
+
var LOOPBACK_QUERY_RESULT = "result";
|
|
652
|
+
var LOOPBACK_RESULT = {
|
|
653
|
+
APPROVED: "approved",
|
|
654
|
+
DECLINED: "declined"
|
|
655
|
+
};
|
|
656
|
+
function loopbackVerificationUrl(verificationUrl, target) {
|
|
657
|
+
let url;
|
|
658
|
+
try {
|
|
659
|
+
url = new URL(verificationUrl);
|
|
660
|
+
} catch {
|
|
661
|
+
return verificationUrl;
|
|
662
|
+
}
|
|
663
|
+
url.searchParams.set(LOOPBACK_QUERY_PORT, String(target.port));
|
|
664
|
+
url.searchParams.set(LOOPBACK_QUERY_STATE, target.state);
|
|
665
|
+
return url.toString();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// dist/scan.js
|
|
669
|
+
var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
|
|
670
|
+
var HALFCYCLE_STATE_NOTE = [
|
|
671
|
+
"This file is a local cache written ONCE when Halfcycle was installed. It is not a",
|
|
672
|
+
"record of what this project has done, and it is never updated as work happens.",
|
|
673
|
+
'An empty or missing "layers" means the install-time scan found no pre-existing',
|
|
674
|
+
"artefacts on disk \u2014 it does NOT mean nothing has been done since, and it is not",
|
|
675
|
+
"evidence that any step is unfinished or any approval is outstanding.",
|
|
676
|
+
"The authoritative position lives in the Halfcycle service: call the",
|
|
677
|
+
"halfcycle_resolve tool to ask where this project stands. Do not answer that",
|
|
678
|
+
"question from this file."
|
|
679
|
+
].join(" ");
|
|
680
|
+
var LAYER_ARTEFACT_PATHS = {
|
|
681
|
+
l0: ["ENGAGEMENT.md"],
|
|
682
|
+
l1: ["docs/product-spec.md"],
|
|
683
|
+
l2: ["docs/architecture-overview.md"],
|
|
684
|
+
l3: ["docs/invariants.md"],
|
|
685
|
+
l4: ["docs/features"],
|
|
686
|
+
l5: ["docs/phases"]
|
|
687
|
+
};
|
|
688
|
+
function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
689
|
+
const layers = {};
|
|
690
|
+
for (const [layer, paths] of Object.entries(LAYER_ARTEFACT_PATHS)) {
|
|
691
|
+
const found = paths.filter((p) => existsSync2(join4(targetRepo, p)));
|
|
692
|
+
if (found.length > 0) {
|
|
693
|
+
layers[layer] = {
|
|
694
|
+
value: "asserted",
|
|
695
|
+
source: "bootstrap-scan",
|
|
696
|
+
foundPaths: found,
|
|
697
|
+
scannedAt
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
|
|
702
|
+
}
|
|
703
|
+
function runBootstrapScan(targetRepo) {
|
|
704
|
+
const statePath = join4(targetRepo, ".halfcycle", "state.json");
|
|
705
|
+
if (existsSync2(statePath)) {
|
|
706
|
+
const existing = JSON.parse(readFileSync4(statePath, "utf-8"));
|
|
707
|
+
return { state: existing, ran: false };
|
|
708
|
+
}
|
|
709
|
+
const state = scanLayers(targetRepo);
|
|
710
|
+
mkdirSync3(join4(targetRepo, ".halfcycle"), { recursive: true });
|
|
711
|
+
writeFileSync3(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
712
|
+
return { state, ran: true };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// dist/install.js
|
|
716
|
+
var ZONE_B_DIR = ".workbench";
|
|
717
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
718
|
+
var __dirname = dirname2(__filename);
|
|
719
|
+
function findBundleRoot(startDir) {
|
|
720
|
+
let candidate = startDir;
|
|
721
|
+
for (let i = 0; i < 4; i++) {
|
|
722
|
+
const manifest = join5(candidate, ".claude-plugin", "plugin.json");
|
|
723
|
+
try {
|
|
724
|
+
if (existsSync3(manifest))
|
|
725
|
+
return candidate;
|
|
726
|
+
} catch {
|
|
727
|
+
}
|
|
728
|
+
candidate = dirname2(candidate);
|
|
729
|
+
}
|
|
730
|
+
return join5(startDir, "..");
|
|
731
|
+
}
|
|
732
|
+
var BUNDLE_ROOT = findBundleRoot(__dirname);
|
|
733
|
+
function resolveVendoredBinary() {
|
|
734
|
+
const candidates = [
|
|
735
|
+
join5(BUNDLE_ROOT, "bin", "bin.bundle.mjs"),
|
|
736
|
+
join5(BUNDLE_ROOT, "..", "runner", "dist", "bin.bundle.mjs")
|
|
737
|
+
];
|
|
738
|
+
for (const candidate of candidates) {
|
|
739
|
+
if (existsSync3(candidate))
|
|
740
|
+
return candidate;
|
|
741
|
+
}
|
|
742
|
+
throw new Error(`[bundle install] Cannot find the self-contained guard binary bin.bundle.mjs. Run the runner's "bundle" script (pnpm --filter @halfcycle/runner run build) so the vendored artefact exists before installing. Looked in: ${candidates.join(", ")}.`);
|
|
743
|
+
}
|
|
744
|
+
var WRITE_ALLOWLIST = [
|
|
745
|
+
".claude/commands",
|
|
746
|
+
".claude/hooks",
|
|
747
|
+
".claude/settings.json",
|
|
748
|
+
".halfcycle",
|
|
749
|
+
".halfcycle/bundle.json",
|
|
750
|
+
".halfcycle/bin",
|
|
751
|
+
"docs/build-records",
|
|
752
|
+
"test/fixtures/captured",
|
|
753
|
+
".env.example",
|
|
754
|
+
".mcp.json",
|
|
755
|
+
ZONE_B_DIR
|
|
756
|
+
];
|
|
757
|
+
function isAllowlisted(targetRelPath) {
|
|
758
|
+
const normalised = targetRelPath.replace(/\\/g, "/");
|
|
759
|
+
return WRITE_ALLOWLIST.some((prefix) => {
|
|
760
|
+
const p = prefix.replace(/\\/g, "/");
|
|
761
|
+
return normalised === p || normalised.startsWith(p + "/");
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
function readPluginManifest() {
|
|
765
|
+
const manifestPath = join5(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
|
|
766
|
+
const raw = readFileSync5(manifestPath, "utf-8");
|
|
767
|
+
return JSON.parse(raw);
|
|
768
|
+
}
|
|
769
|
+
function copyManifestCommands(manifest, targetRepo, report) {
|
|
770
|
+
const commandsTargetDir = join5(targetRepo, ".claude", "commands");
|
|
771
|
+
for (const cmd of manifest.commands ?? []) {
|
|
772
|
+
const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
|
|
773
|
+
if (!existsSync3(srcFile)) {
|
|
774
|
+
throw new Error(`[bundle install] plugin.json declares command "${cmd.name}" at "${cmd.path}", but no file exists there (${srcFile}). The manifest and commands/ must agree.`);
|
|
775
|
+
}
|
|
776
|
+
const destFile = join5(commandsTargetDir, `${cmd.name}.md`);
|
|
777
|
+
const content = readFileSync5(srcFile, "utf-8");
|
|
778
|
+
const rel = relative(targetRepo, destFile).replace(/\\/g, "/");
|
|
779
|
+
record(report, writeCollisionSafe(destFile, targetRepo, content), rel);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
|
|
783
|
+
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
784
|
+
if (!isAllowlisted(rel)) {
|
|
785
|
+
throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
786
|
+
}
|
|
787
|
+
mkdirSync4(dirname2(targetAbsPath), { recursive: true });
|
|
788
|
+
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
789
|
+
writtenPaths.push(rel);
|
|
790
|
+
}
|
|
791
|
+
function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
|
|
792
|
+
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
793
|
+
if (!isAllowlisted(rel)) {
|
|
794
|
+
throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
795
|
+
}
|
|
796
|
+
if (existsSync3(targetAbsPath)) {
|
|
797
|
+
const current = readFileSync5(targetAbsPath, "utf-8");
|
|
798
|
+
return current === content ? "skipped" : "collided";
|
|
799
|
+
}
|
|
800
|
+
mkdirSync4(dirname2(targetAbsPath), { recursive: true });
|
|
801
|
+
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
802
|
+
return "written";
|
|
803
|
+
}
|
|
804
|
+
function record(report, outcome, rel) {
|
|
805
|
+
const bucket = {
|
|
806
|
+
written: report.writtenPaths,
|
|
807
|
+
skipped: report.skippedPaths,
|
|
808
|
+
collided: report.collidedPaths
|
|
809
|
+
};
|
|
810
|
+
bucket[outcome].push(rel);
|
|
811
|
+
}
|
|
812
|
+
var DENY_PATTERNS = [
|
|
813
|
+
"Bash(rm -rf:*)",
|
|
814
|
+
"Bash(git push --force:*)",
|
|
815
|
+
"Bash(git push -f:*)",
|
|
816
|
+
"Bash(git reset --hard:*)",
|
|
817
|
+
"Bash(pnpm publish:*)",
|
|
818
|
+
"Bash(npm publish:*)",
|
|
819
|
+
"Bash(gh repo delete:*)",
|
|
820
|
+
"Bash(git push --no-verify:*)",
|
|
821
|
+
"Bash(git commit --no-verify:*)"
|
|
822
|
+
];
|
|
823
|
+
var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
|
|
824
|
+
var USER_PROMPT_REMINDER_SENTENCE = "Where this project stands, and what order to work in, is recorded in the Halfcycle service and nowhere in this repository \u2014 call halfcycle_resolve before you answer or plan, because the files here will look complete and current and still be wrong about position.";
|
|
825
|
+
function generateSettingsJson() {
|
|
826
|
+
const settings = {
|
|
827
|
+
$schema: "https://json.schemastore.org/claude-code-settings.json",
|
|
828
|
+
permissions: {
|
|
829
|
+
// deny ONLY. See DENY_PATTERNS — an allow list evaporates under bypass mode
|
|
830
|
+
// and is the machine owner's grant to make, not ours.
|
|
831
|
+
deny: [...DENY_PATTERNS]
|
|
832
|
+
},
|
|
833
|
+
hooks: {
|
|
834
|
+
SessionStart: [
|
|
835
|
+
{
|
|
836
|
+
hooks: [
|
|
837
|
+
{
|
|
838
|
+
type: "command",
|
|
839
|
+
command: "bash ./.claude/hooks/session-start-marker.sh",
|
|
840
|
+
timeout: 5
|
|
841
|
+
}
|
|
842
|
+
]
|
|
843
|
+
}
|
|
844
|
+
],
|
|
845
|
+
UserPromptSubmit: [
|
|
846
|
+
{
|
|
847
|
+
hooks: [
|
|
848
|
+
{
|
|
849
|
+
type: "command",
|
|
850
|
+
command: "bash ./.claude/hooks/user-prompt-reminder.sh",
|
|
851
|
+
timeout: 5
|
|
852
|
+
}
|
|
853
|
+
]
|
|
854
|
+
}
|
|
855
|
+
],
|
|
856
|
+
PostToolUse: [
|
|
857
|
+
{
|
|
858
|
+
matcher: WRITE_CAPABLE_TOOLS.join("|"),
|
|
859
|
+
hooks: [
|
|
860
|
+
{
|
|
861
|
+
type: "command",
|
|
862
|
+
command: "bash ./.claude/hooks/guard-runner.sh hook post-tool-use",
|
|
863
|
+
timeout: 60
|
|
864
|
+
}
|
|
865
|
+
]
|
|
866
|
+
}
|
|
867
|
+
],
|
|
868
|
+
SubagentStop: [
|
|
869
|
+
{
|
|
870
|
+
hooks: [
|
|
871
|
+
{
|
|
872
|
+
type: "command",
|
|
873
|
+
command: "bash ./.claude/hooks/guard-runner.sh hook subagent-stop",
|
|
874
|
+
timeout: 60
|
|
875
|
+
}
|
|
876
|
+
]
|
|
877
|
+
}
|
|
878
|
+
]
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
return JSON.stringify(settings, null, 2) + "\n";
|
|
882
|
+
}
|
|
883
|
+
var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
|
|
884
|
+
function generateGuardRunnerWrapper() {
|
|
885
|
+
return `#!/usr/bin/env bash
|
|
886
|
+
# Governance hook wrapper \u2014 generated by the Halfcycle installer.
|
|
887
|
+
#
|
|
888
|
+
# Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
|
|
889
|
+
# values when Claude Code fires PostToolUse / SubagentStop, then exec's the
|
|
890
|
+
# SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path inside this
|
|
891
|
+
# project, so the hook resolves in any checkout of it.
|
|
892
|
+
#
|
|
893
|
+
# The runner itself reads only process.env, so env loading lives here in the hook
|
|
894
|
+
# wiring rather than inside the binary.
|
|
895
|
+
#
|
|
896
|
+
# NO CREDENTIAL LIVES IN THIS REPOSITORY. See the resolution block below.
|
|
897
|
+
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-.}"
|
|
898
|
+
|
|
899
|
+
${engagementResolutionShell()}
|
|
900
|
+
|
|
901
|
+
# A missing store is NOT an error here: on a CI runner there is no per-user store
|
|
902
|
+
# and the credentials arrive in the environment instead. Exec either way and let
|
|
903
|
+
# the runner say what it found \u2014 its never-configured branch is loud and non-zero
|
|
904
|
+
# on purpose, and a wrapper that exited quietly here would hide it.
|
|
905
|
+
if halfcycle_env_file "$PROJECT_DIR"; then
|
|
906
|
+
set -a
|
|
907
|
+
. "$HALFCYCLE_ENV_FILE"
|
|
908
|
+
set +a
|
|
909
|
+
fi
|
|
910
|
+
|
|
911
|
+
exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
912
|
+
`;
|
|
913
|
+
}
|
|
914
|
+
function generateSessionStartMarker() {
|
|
915
|
+
return `#!/usr/bin/env bash
|
|
916
|
+
# Session-start marker \u2014 generated by the Halfcycle installer.
|
|
917
|
+
# Writes the current git HEAD SHA to a temp file so SubagentStop can
|
|
918
|
+
# diff against session-start rather than just HEAD at stop time.
|
|
919
|
+
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
920
|
+
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
921
|
+
HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
|
|
922
|
+
MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
|
|
923
|
+
git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
|
|
924
|
+
exit 0
|
|
925
|
+
`;
|
|
926
|
+
}
|
|
927
|
+
function generateUserPromptReminderHook() {
|
|
928
|
+
return `#!/usr/bin/env bash
|
|
929
|
+
# UserPromptSubmit house-rule reminder \u2014 generated by the Halfcycle installer.
|
|
930
|
+
#
|
|
931
|
+
# Injects ONE plain-text sentence into context on every prompt (F-2(c),
|
|
932
|
+
# W8-T-05b) \u2014 the per-turn twin of the house rule W8-T-05a put in the client's
|
|
933
|
+
# CLAUDE.md, in its "Top rules (read first)" block. Byte-identical sentence,
|
|
934
|
+
# one home: RULE_SENTENCE_PLAIN in the method repo's own position-house-rule
|
|
935
|
+
# test governs it \u2014 reword there and here together, in the same commit.
|
|
936
|
+
#
|
|
937
|
+
# FAILS OPEN. This hook's stdout is always plain text, never JSON, so it
|
|
938
|
+
# cannot form a {"decision":"block",...} body (UserPromptSubmit's only way to
|
|
939
|
+
# hold the turn). But a write failure here (a closed stdout, say) must still
|
|
940
|
+
# never wedge or stall a session, so any error exits 0 with no output rather
|
|
941
|
+
# than propagate.
|
|
942
|
+
trap 'exit 0' ERR
|
|
943
|
+
set -e
|
|
944
|
+
printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
|
|
945
|
+
exit 0
|
|
946
|
+
`;
|
|
947
|
+
}
|
|
948
|
+
function generateCiStanza() {
|
|
949
|
+
return `# Halfcycle guard CI job \u2014 generated by the Halfcycle installer.
|
|
950
|
+
# Paste this job into your CI workflow. It assumes no package manager and no
|
|
951
|
+
# monorepo tooling: it runs the self-contained guard binary vendored at
|
|
952
|
+
# .halfcycle/bin/, so it works in a Python or Go repository as well as a Node
|
|
953
|
+
# one \u2014 the only prerequisite is Node 20 to run the bundled binary.
|
|
954
|
+
#
|
|
955
|
+
# WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
|
|
956
|
+
# commits pushed straight to the default branch, which is the shape most
|
|
957
|
+
# Halfcycle engagements settle on. \`fetch-depth\` is what makes that true: the
|
|
958
|
+
# check needs the commit BEFORE the one it is evaluating, and the default
|
|
959
|
+
# shallow checkout does not have it. With \`fetch-depth: 0\` the check fails loudly
|
|
960
|
+
# if it cannot work out what to evaluate \u2014 it will not pass quietly having
|
|
961
|
+
# evaluated nothing.
|
|
962
|
+
#
|
|
963
|
+
# halfcycle-guard-ci:
|
|
964
|
+
# runs-on: ubuntu-latest
|
|
965
|
+
# steps:
|
|
966
|
+
# - uses: actions/checkout@v4
|
|
967
|
+
# with:
|
|
968
|
+
# # REQUIRED. 0 = full history. The check diffs against the commit before
|
|
969
|
+
# # HEAD (or the fork point on a branch); the default depth of 1 has
|
|
970
|
+
# # neither. Do not lower this.
|
|
971
|
+
# fetch-depth: 0
|
|
972
|
+
# - uses: actions/setup-node@v4
|
|
973
|
+
# with:
|
|
974
|
+
# node-version: '20'
|
|
975
|
+
# - name: Halfcycle guard CI check
|
|
976
|
+
# env:
|
|
977
|
+
# GUARD_SERVICE_URL: \${{ secrets.GUARD_SERVICE_URL }}
|
|
978
|
+
# GUARD_SERVICE_TOKEN: \${{ secrets.GUARD_SERVICE_TOKEN }}
|
|
979
|
+
# GUARD_ENGAGEMENT_ID: \${{ secrets.GUARD_ENGAGEMENT_ID }}
|
|
980
|
+
# run: node ./.halfcycle/bin/bin.bundle.mjs ci
|
|
981
|
+
#
|
|
982
|
+
# The job prints the diff base it used and how many files it evaluated, on every
|
|
983
|
+
# run. If that line says 0 files on a commit that changed something, the base is
|
|
984
|
+
# wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block above to name it explicitly
|
|
985
|
+
# (on a GitHub push event, \${{ github.event.before }} is the right value).
|
|
986
|
+
`;
|
|
987
|
+
}
|
|
988
|
+
var ENV_EXAMPLE_NAMES = [
|
|
989
|
+
"GUARD_SERVICE_URL",
|
|
990
|
+
"GUARD_SERVICE_TOKEN",
|
|
991
|
+
"GUARD_ENGAGEMENT_ID",
|
|
992
|
+
"HALFCYCLE_SERVICE_URL",
|
|
993
|
+
"HALFCYCLE_MCP_URL",
|
|
994
|
+
"HALFCYCLE_TOKEN",
|
|
995
|
+
// T-10 — declared because it is now WRITTEN into the credential store, and
|
|
996
|
+
// `install-guard-config.test.ts`'s INV-004 check requires every written name to
|
|
997
|
+
// be named here too (`HALFCYCLE_ENGAGEMENT_ID` is the one deliberate exception,
|
|
998
|
+
// recorded in the spec). Optional at runtime — the runner's own telemetry
|
|
999
|
+
// emission is fail-open when it is absent — but a name that is written and never
|
|
1000
|
+
// declared is exactly what this generated section exists to prevent.
|
|
1001
|
+
"CONTROL_TELEMETRY_URL"
|
|
1002
|
+
];
|
|
1003
|
+
function generateEnvExampleSection() {
|
|
1004
|
+
return `
|
|
1005
|
+
# ---------------------------------------------------------------------------
|
|
1006
|
+
# Halfcycle runner (generated by the Halfcycle installer \u2014 names only, no values)
|
|
1007
|
+
# ---------------------------------------------------------------------------
|
|
1008
|
+
# YOU DO NOT NEED TO SET ANY OF THESE ON YOUR OWN MACHINE, and there is nowhere in
|
|
1009
|
+
# this repository to put them. \`npx halfcycle\` writes this engagement's credential
|
|
1010
|
+
# to $HOME/.halfcycle, owner-only, outside every checkout; the guard hook and the
|
|
1011
|
+
# MCP headers helper read it from there. Nothing Halfcycle writes a secret into
|
|
1012
|
+
# this tree, so there is no file here to leak, ignore or clean up.
|
|
1013
|
+
#
|
|
1014
|
+
# These names are declared because they ARE read from the environment in one
|
|
1015
|
+
# place: a CI job, which has no browser and no per-user store and supplies them as
|
|
1016
|
+
# workflow secrets (see .halfcycle/ci-stanza.yml). HALFCYCLE_TOKEN set in the
|
|
1017
|
+
# environment also takes precedence over the stored credential everywhere, which is
|
|
1018
|
+
# the escape hatch for running as a specific identity.
|
|
1019
|
+
GUARD_SERVICE_URL=<argType:runtime>
|
|
1020
|
+
GUARD_SERVICE_TOKEN=<argType:runtime>
|
|
1021
|
+
GUARD_ENGAGEMENT_ID=<argType:runtime>
|
|
1022
|
+
# TWO ORIGINS, TWO SERVICES (T-22). HALFCYCLE_SERVICE_URL is the Halfcycle
|
|
1023
|
+
# CONTROL plane \u2014 it serves engagement creation and the board's first-visit
|
|
1024
|
+
# code. HALFCYCLE_MCP_URL is the method-delivery service, which serves /mcp and
|
|
1025
|
+
# nothing else here. You configure the first; the platform reports the second on
|
|
1026
|
+
# create, so it is written for you and .mcp.json carries the composed address.
|
|
1027
|
+
HALFCYCLE_SERVICE_URL=<argType:runtime>
|
|
1028
|
+
HALFCYCLE_MCP_URL=<argType:runtime>
|
|
1029
|
+
HALFCYCLE_TOKEN=<argType:runtime>
|
|
1030
|
+
# CONTROL_TELEMETRY_URL (T-10) \u2014 the platform reports this on create/join, same as
|
|
1031
|
+
# HALFCYCLE_MCP_URL. OPTIONAL: absent, guard-eval telemetry is silently disabled
|
|
1032
|
+
# and the guard loop itself is unaffected \u2014 this name is declared only because it
|
|
1033
|
+
# is one this installer now writes to the machine-level store, same as every name
|
|
1034
|
+
# above it.
|
|
1035
|
+
CONTROL_TELEMETRY_URL=<argType:runtime>
|
|
1036
|
+
`;
|
|
1037
|
+
}
|
|
1038
|
+
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
1039
|
+
var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
1040
|
+
var MCP_HEADERS_HELPER_COMMAND = `/bin/sh -c 'd=$(pwd); while [ ! -f "$d/${MCP_HEADERS_HELPER_REL}" ] && [ "$d" != / ]; do d=$(dirname "$d"); done; [ -f "$d/${MCP_HEADERS_HELPER_REL}" ] || { echo "halfcycle: ${MCP_HEADERS_HELPER_REL} not found at or above $(pwd); re-run npx halfcycle" >&2; exit 1; }; exec /bin/sh "$d/${MCP_HEADERS_HELPER_REL}"'`;
|
|
1041
|
+
function generateMcpHeadersHelper() {
|
|
1042
|
+
return `#!/bin/sh
|
|
1043
|
+
# Halfcycle MCP connection headers \u2014 generated by the Halfcycle installer.
|
|
1044
|
+
#
|
|
1045
|
+
# Claude Code runs this at MCP connection time, from the session's cwd, and merges
|
|
1046
|
+
# its stdout into the connection headers for the "halfcycle" server in .mcp.json.
|
|
1047
|
+
#
|
|
1048
|
+
# It exists because \${VAR} expansion in .mcp.json reads the PROCESS environment
|
|
1049
|
+
# only: nothing loads a credential into a session, so a referenced
|
|
1050
|
+
# \${HALFCYCLE_TOKEN} expanded to nothing and the server was silently absent from
|
|
1051
|
+
# the tool list.
|
|
1052
|
+
#
|
|
1053
|
+
# This file is tracked and holds NO secret. NEITHER DOES ANY OTHER FILE IN THIS
|
|
1054
|
+
# REPOSITORY (T-09): the credential lives at machine level, outside every checkout,
|
|
1055
|
+
# and this script READS one line out of it (never sources \u2014 a credential store is
|
|
1056
|
+
# not code).
|
|
1057
|
+
set -eu
|
|
1058
|
+
|
|
1059
|
+
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
1060
|
+
# caller's working directory. Claude Code runs the helper from the SESSION's cwd,
|
|
1061
|
+
# and a session started in a subdirectory still finds the root .mcp.json \u2014 so a
|
|
1062
|
+
# relative read here silently produced no credential for anyone who started the
|
|
1063
|
+
# session in any subdirectory of a monorepo.
|
|
1064
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
1065
|
+
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
|
1066
|
+
|
|
1067
|
+
${engagementResolutionShell()}
|
|
1068
|
+
|
|
1069
|
+
# HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
|
|
1070
|
+
# browser and no per-user store exports the credential, and a stale store on a
|
|
1071
|
+
# long-lived runner must not silently win over it. Same order, and the same reason,
|
|
1072
|
+
# as the CLI's own resolver (\`resolve-credential.ts\`).
|
|
1073
|
+
TOKEN=\${HALFCYCLE_TOKEN:-}
|
|
1074
|
+
|
|
1075
|
+
if [ -z "$TOKEN" ]; then
|
|
1076
|
+
if ! halfcycle_env_file "$PROJECT_ROOT"; then
|
|
1077
|
+
case $HALFCYCLE_ENV_PROBLEM in
|
|
1078
|
+
no-pin)
|
|
1079
|
+
echo "halfcycle: no .halfcycle/bundle.json in $PROJECT_ROOT, so this repository has no Halfcycle engagement." >&2
|
|
1080
|
+
echo "halfcycle: run \\"npx halfcycle\\" here to install one." >&2 ;;
|
|
1081
|
+
no-id)
|
|
1082
|
+
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
|
|
1083
|
+
echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
|
|
1084
|
+
no-home)
|
|
1085
|
+
echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
|
|
1086
|
+
*)
|
|
1087
|
+
echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
|
|
1088
|
+
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 ;;
|
|
1089
|
+
esac
|
|
1090
|
+
exit 1
|
|
1091
|
+
fi
|
|
1092
|
+
ENV_FILE="$HALFCYCLE_ENV_FILE"
|
|
1093
|
+
|
|
1094
|
+
# Last assignment wins, matching the shell's own \`.\` semantics. An optional
|
|
1095
|
+
# leading \`export \` is tolerated because a hand-edited store may use one.
|
|
1096
|
+
LINE=$(grep '^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}HALFCYCLE_TOKEN=' "$ENV_FILE" | tail -n 1)
|
|
1097
|
+
TOKEN=\${LINE#*HALFCYCLE_TOKEN=}
|
|
1098
|
+
TOKEN=$(printf '%s' "$TOKEN" | tr -d '\\r')
|
|
1099
|
+
|
|
1100
|
+
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
1101
|
+
# writers use the same shq: this installer and Studio's provider), because
|
|
1102
|
+
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
1103
|
+
# would execute its own remainder. This reader greps rather than sources, so it
|
|
1104
|
+
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
1105
|
+
# sourcing consumer gets, or one file has two answers.
|
|
1106
|
+
case $TOKEN in
|
|
1107
|
+
'"'*'"') TOKEN=\${TOKEN#'"'}; TOKEN=\${TOKEN%'"'} ;;
|
|
1108
|
+
"'"*"'")
|
|
1109
|
+
TOKEN=\${TOKEN#"'"}; TOKEN=\${TOKEN%"'"}
|
|
1110
|
+
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
1111
|
+
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
1112
|
+
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
1113
|
+
# no shell quoting is in the way:
|
|
1114
|
+
#
|
|
1115
|
+
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
1116
|
+
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
1117
|
+
#
|
|
1118
|
+
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
1119
|
+
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
1120
|
+
# and the token then travels with four stray characters in it. The bracket
|
|
1121
|
+
# expression makes the backslash literal by a construction POSIX does define.
|
|
1122
|
+
TOKEN=$(printf '%s' "$TOKEN" | sed "s/'[\\]''/'/g") ;;
|
|
1123
|
+
esac
|
|
1124
|
+
|
|
1125
|
+
if [ -z "$TOKEN" ]; then
|
|
1126
|
+
echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
|
|
1127
|
+
echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
|
|
1128
|
+
exit 1
|
|
1129
|
+
fi
|
|
1130
|
+
fi
|
|
1131
|
+
|
|
1132
|
+
# JSON-escape: backslash first, then double quote. A token carrying either would
|
|
1133
|
+
# otherwise emit invalid JSON, and invalid JSON is dropped SILENTLY \u2014 the
|
|
1134
|
+
# connection succeeds carrying no Authorization header at all.
|
|
1135
|
+
ESCAPED=$(printf '%s' "$TOKEN" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
|
|
1136
|
+
|
|
1137
|
+
printf '{"Authorization":"Bearer %s"}\\n' "$ESCAPED"
|
|
1138
|
+
`;
|
|
1139
|
+
}
|
|
1140
|
+
function generateMcpRegistration(existing, mcpOrigin) {
|
|
1141
|
+
let base = { mcpServers: {} };
|
|
1142
|
+
if (existing !== null) {
|
|
1143
|
+
const parsed = JSON.parse(existing);
|
|
1144
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
1145
|
+
base = { mcpServers: {}, ...parsed };
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
|
|
1149
|
+
base.mcpServers = {};
|
|
1150
|
+
}
|
|
1151
|
+
base.mcpServers["halfcycle"] = {
|
|
1152
|
+
type: "http",
|
|
1153
|
+
url: mcpEndpointUrl(mcpOrigin),
|
|
1154
|
+
headersHelper: MCP_HEADERS_HELPER_COMMAND
|
|
1155
|
+
};
|
|
1156
|
+
return JSON.stringify(base, null, 2) + "\n";
|
|
1157
|
+
}
|
|
1158
|
+
function reconcileEnvExample(targetRepo) {
|
|
1159
|
+
const envExamplePath = join5(targetRepo, ".env.example");
|
|
1160
|
+
const section = generateEnvExampleSection();
|
|
1161
|
+
if (!existsSync3(envExamplePath)) {
|
|
1162
|
+
mkdirSync4(dirname2(envExamplePath), { recursive: true });
|
|
1163
|
+
writeFileSync4(envExamplePath, section.trimStart(), "utf-8");
|
|
1164
|
+
return "written";
|
|
1165
|
+
}
|
|
1166
|
+
const existing = readFileSync5(envExamplePath, "utf-8");
|
|
1167
|
+
const missing = ENV_EXAMPLE_NAMES.filter((name) => !existing.includes(name));
|
|
1168
|
+
if (missing.length === 0)
|
|
1169
|
+
return "skipped";
|
|
1170
|
+
writeFileSync4(envExamplePath, existing + section, "utf-8");
|
|
1171
|
+
return "written";
|
|
1172
|
+
}
|
|
1173
|
+
var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
|
|
1174
|
+
function gitignoreCovers(content) {
|
|
1175
|
+
const lines = new Set(content.split("\n").map((l) => l.trim()));
|
|
1176
|
+
return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
|
|
1177
|
+
}
|
|
1178
|
+
function reconcileGitignore(targetRepo) {
|
|
1179
|
+
const gitignorePath = join5(targetRepo, ".gitignore");
|
|
1180
|
+
const header = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
|
|
1181
|
+
try {
|
|
1182
|
+
if (!existsSync3(gitignorePath)) {
|
|
1183
|
+
const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
|
|
1184
|
+
writeFileSync4(gitignorePath, body, "utf-8");
|
|
1185
|
+
return "written";
|
|
1186
|
+
}
|
|
1187
|
+
const existing = readFileSync5(gitignorePath, "utf-8");
|
|
1188
|
+
if (gitignoreCovers(existing))
|
|
1189
|
+
return "skipped";
|
|
1190
|
+
const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
1191
|
+
const toAdd = REQUIRED_GITIGNORE_ENTRIES.filter((entry) => !existingLines.has(entry));
|
|
1192
|
+
const suffix = (existing.endsWith("\n") ? "" : "\n") + `
|
|
1193
|
+
${header}
|
|
1194
|
+
` + toAdd.join("\n") + "\n";
|
|
1195
|
+
writeFileSync4(gitignorePath, existing + suffix, "utf-8");
|
|
1196
|
+
return "written";
|
|
1197
|
+
} catch {
|
|
1198
|
+
return "failed";
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, writtenPaths) {
|
|
1202
|
+
const pin = {
|
|
1203
|
+
version,
|
|
1204
|
+
engagementId,
|
|
1205
|
+
engagementType,
|
|
1206
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1207
|
+
};
|
|
1208
|
+
const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
|
|
1209
|
+
writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
|
|
1210
|
+
}
|
|
1211
|
+
function readBundlePin(targetRepoRoot) {
|
|
1212
|
+
const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
|
|
1213
|
+
if (!existsSync3(pinPath))
|
|
1214
|
+
return null;
|
|
1215
|
+
return JSON.parse(readFileSync5(pinPath, "utf-8"));
|
|
1216
|
+
}
|
|
1217
|
+
function readPinnedEngagement(targetRepoRoot, home) {
|
|
1218
|
+
const pin = readBundlePin(targetRepoRoot);
|
|
1219
|
+
const engagementId = pin?.engagementId;
|
|
1220
|
+
if (engagementId === void 0 || engagementId === "")
|
|
1221
|
+
return null;
|
|
1222
|
+
const stored = readEngagementEnv(engagementId, home);
|
|
1223
|
+
const fromStore = stored === null ? void 0 : toCredential(stored);
|
|
1224
|
+
if (fromStore !== void 0) {
|
|
1225
|
+
return { engagementId, credential: fromStore, fromLegacyEnvLocal: false };
|
|
1226
|
+
}
|
|
1227
|
+
const legacy = readLegacyEnvLocal(targetRepoRoot);
|
|
1228
|
+
const fromLegacy = legacy === null ? void 0 : toCredential(legacy);
|
|
1229
|
+
return {
|
|
1230
|
+
engagementId,
|
|
1231
|
+
credential: fromLegacy,
|
|
1232
|
+
fromLegacyEnvLocal: fromLegacy !== void 0
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
function readLegacyEnvLocal(targetRepoRoot) {
|
|
1236
|
+
try {
|
|
1237
|
+
return parseEnvText(readFileSync5(join5(targetRepoRoot, LEGACY_ENV_LOCAL_REL), "utf-8"));
|
|
1238
|
+
} catch {
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
var CREDENTIAL_REQUIRED_KEYS = [
|
|
1243
|
+
"HALFCYCLE_SERVICE_URL",
|
|
1244
|
+
"HALFCYCLE_TOKEN",
|
|
1245
|
+
"HALFCYCLE_MCP_URL",
|
|
1246
|
+
"GUARD_SERVICE_URL"
|
|
1247
|
+
];
|
|
1248
|
+
function missingRequiredKeys(found) {
|
|
1249
|
+
const serviceUrl = (process.env["HALFCYCLE_SERVICE_URL"]?.trim() ?? "") || (found["HALFCYCLE_SERVICE_URL"] ?? "");
|
|
1250
|
+
const resolved = { ...found, HALFCYCLE_SERVICE_URL: serviceUrl };
|
|
1251
|
+
return CREDENTIAL_REQUIRED_KEYS.filter((key) => (resolved[key] ?? "") === "");
|
|
1252
|
+
}
|
|
1253
|
+
function toCredential(found) {
|
|
1254
|
+
if (missingRequiredKeys(found).length > 0)
|
|
1255
|
+
return void 0;
|
|
1256
|
+
const controlTelemetryUrl = found["CONTROL_TELEMETRY_URL"];
|
|
1257
|
+
return {
|
|
1258
|
+
serviceUrl: (process.env["HALFCYCLE_SERVICE_URL"]?.trim() ?? "") || (found["HALFCYCLE_SERVICE_URL"] ?? ""),
|
|
1259
|
+
token: found["HALFCYCLE_TOKEN"] ?? "",
|
|
1260
|
+
mcpUrl: found["HALFCYCLE_MCP_URL"] ?? "",
|
|
1261
|
+
guardUrl: found["GUARD_SERVICE_URL"] ?? "",
|
|
1262
|
+
...controlTelemetryUrl ? { controlTelemetryUrl } : {}
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
function checkDrift(targetRepoRoot) {
|
|
1266
|
+
const manifest = readPluginManifest();
|
|
1267
|
+
const pin = readBundlePin(targetRepoRoot);
|
|
1268
|
+
const installed = pin?.version ?? null;
|
|
1269
|
+
const current = manifest.version;
|
|
1270
|
+
return { drifted: installed !== current, installed, current };
|
|
1271
|
+
}
|
|
1272
|
+
function writeProjectIdentity(targetRepo) {
|
|
1273
|
+
const path = join5(targetRepo, ".halfcycle", "project.json");
|
|
1274
|
+
const { identity } = mintOrReadIdentity(targetRepo);
|
|
1275
|
+
const serialized = JSON.stringify(identity, null, 2) + "\n";
|
|
1276
|
+
if (existsSync3(path) && readFileSync5(path, "utf-8") === serialized)
|
|
1277
|
+
return "skipped";
|
|
1278
|
+
writeFileSync4(path, serialized, "utf-8");
|
|
1279
|
+
return "written";
|
|
1280
|
+
}
|
|
1281
|
+
function writeEngagementCredential(credential, engagementId, home) {
|
|
1282
|
+
const values = {
|
|
1283
|
+
HALFCYCLE_SERVICE_URL: credential.serviceUrl,
|
|
1284
|
+
// T-22 — the second origin, persisted for the developer and for any tool that
|
|
1285
|
+
// wants it by name. NOTHING EXPANDS IT ANY MORE: since T-24 the installer
|
|
1286
|
+
// composes the endpoint and writes it into `.mcp.json` as a literal, so this
|
|
1287
|
+
// key no longer feeds the registration. (It read "the registration expands to
|
|
1288
|
+
// `/mcp` against an empty string" until T-24 removed the expansion; a comment
|
|
1289
|
+
// describing a mechanism that no longer exists is how the next reader builds
|
|
1290
|
+
// on a claim that is not true.)
|
|
1291
|
+
HALFCYCLE_MCP_URL: credential.mcpUrl,
|
|
1292
|
+
HALFCYCLE_TOKEN: credential.token,
|
|
1293
|
+
HALFCYCLE_ENGAGEMENT_ID: engagementId,
|
|
1294
|
+
// FW-4 (F-29) — the three the hook this same module generates requires. The
|
|
1295
|
+
// guard origin is a third deployed service the platform reports on create;
|
|
1296
|
+
// the token is the one per-engagement credential under the name the runner
|
|
1297
|
+
// reads; the id is the engagement the guard service binds the body to.
|
|
1298
|
+
GUARD_SERVICE_URL: credential.guardUrl,
|
|
1299
|
+
GUARD_SERVICE_TOKEN: credential.token,
|
|
1300
|
+
GUARD_ENGAGEMENT_ID: engagementId,
|
|
1301
|
+
// T-10 — the eighth key. Written as '' when the plane has not reported one
|
|
1302
|
+
// (never `undefined`, for the exact reason the comment above this function
|
|
1303
|
+
// measures: a loose `Record<string, string>` let a missing key write the
|
|
1304
|
+
// literal string "undefined" with a clean typecheck. An empty value here reads
|
|
1305
|
+
// back as absent — `toCredential` treats a blank CONTROL_TELEMETRY_URL as no
|
|
1306
|
+
// telemetry origin, same as every other optional field on `DeviceCredential`.
|
|
1307
|
+
CONTROL_TELEMETRY_URL: credential.controlTelemetryUrl ?? ""
|
|
1308
|
+
};
|
|
1309
|
+
return writeEngagementEnv(engagementId, values, home);
|
|
1310
|
+
}
|
|
1311
|
+
var LEGACY_ENV_LOCAL_REL = ".env.local";
|
|
1312
|
+
function migrateLegacyEnvLocal(targetRepo, stored) {
|
|
1313
|
+
const path = join5(targetRepo, LEGACY_ENV_LOCAL_REL);
|
|
1314
|
+
if (!existsSync3(path))
|
|
1315
|
+
return { outcome: "absent", unbacked: [], missingForAdoption: [] };
|
|
1316
|
+
let raw;
|
|
1317
|
+
try {
|
|
1318
|
+
raw = readFileSync5(path, "utf-8");
|
|
1319
|
+
} catch {
|
|
1320
|
+
return { outcome: "failed", unbacked: [], missingForAdoption: [] };
|
|
1321
|
+
}
|
|
1322
|
+
const inFile = parseEnvText(raw);
|
|
1323
|
+
const present = ENGAGEMENT_ENV_KEYS.filter((key) => key in inFile);
|
|
1324
|
+
if (present.length === 0)
|
|
1325
|
+
return { outcome: "absent", unbacked: [], missingForAdoption: [] };
|
|
1326
|
+
const storeUnusable = missingRequiredKeys(stored).length > 0;
|
|
1327
|
+
const unbacked = present.filter((key) => (inFile[key] ?? "") !== "" && (storeUnusable || (stored[key] ?? "") === ""));
|
|
1328
|
+
if (unbacked.length > 0) {
|
|
1329
|
+
return { outcome: "kept", unbacked, missingForAdoption: missingRequiredKeys(inFile) };
|
|
1330
|
+
}
|
|
1331
|
+
const ours = new Set(ENGAGEMENT_ENV_KEYS);
|
|
1332
|
+
const kept = raw.split("\n").filter((line) => {
|
|
1333
|
+
const eq = line.indexOf("=");
|
|
1334
|
+
if (eq === -1)
|
|
1335
|
+
return true;
|
|
1336
|
+
return !ours.has(line.slice(0, eq).replace(/^\s*export\s+/, "").trim());
|
|
1337
|
+
});
|
|
1338
|
+
const substantive = kept.filter((line) => line.trim() !== "" && !line.trim().startsWith("# Halfcycle"));
|
|
1339
|
+
try {
|
|
1340
|
+
if (substantive.length === 0) {
|
|
1341
|
+
rmSync(path);
|
|
1342
|
+
return { outcome: "removed", unbacked: [], missingForAdoption: [] };
|
|
1343
|
+
}
|
|
1344
|
+
writeFileSync4(path, kept.join("\n"), "utf-8");
|
|
1345
|
+
return { outcome: "stripped", unbacked: [], missingForAdoption: [] };
|
|
1346
|
+
} catch {
|
|
1347
|
+
return { outcome: "failed", unbacked: [], missingForAdoption: [] };
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
async function install(options) {
|
|
1351
|
+
const { targetRepo, engagementId, engagementType, credential, home } = options;
|
|
1352
|
+
if (!existsSync3(targetRepo)) {
|
|
1353
|
+
throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
|
|
1354
|
+
}
|
|
1355
|
+
const manifest = readPluginManifest();
|
|
1356
|
+
const report = {
|
|
1357
|
+
writtenPaths: [],
|
|
1358
|
+
skippedPaths: [],
|
|
1359
|
+
mergedPaths: [],
|
|
1360
|
+
collidedPaths: []
|
|
1361
|
+
};
|
|
1362
|
+
copyManifestCommands(manifest, targetRepo, report);
|
|
1363
|
+
const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
|
|
1364
|
+
const capturedManifest = join5(capturedDir, "manifest.json");
|
|
1365
|
+
if (!existsSync3(capturedManifest)) {
|
|
1366
|
+
const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
|
|
1367
|
+
writeAllowlisted(capturedManifest, targetRepo, readFileSync5(srcManifest, "utf-8"), report.writtenPaths);
|
|
1368
|
+
} else {
|
|
1369
|
+
report.skippedPaths.push("test/fixtures/captured/manifest.json");
|
|
1370
|
+
}
|
|
1371
|
+
const vendoredBinSrc = resolveVendoredBinary();
|
|
1372
|
+
const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
|
|
1373
|
+
record(report, writeCollisionSafe(vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
|
|
1374
|
+
const settingsPath = join5(targetRepo, ".claude", "settings.json");
|
|
1375
|
+
const settingsPreexisted = existsSync3(settingsPath);
|
|
1376
|
+
const generatedSettings = JSON.parse(generateSettingsJson());
|
|
1377
|
+
const existingSettings = settingsPreexisted ? JSON.parse(readFileSync5(settingsPath, "utf-8")) : {};
|
|
1378
|
+
const mergedSettings = mergeSettings(existingSettings, generatedSettings);
|
|
1379
|
+
const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
|
|
1380
|
+
mkdirSync4(dirname2(settingsPath), { recursive: true });
|
|
1381
|
+
writeFileSync4(settingsPath, mergedSettingsText, "utf-8");
|
|
1382
|
+
(settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
|
|
1383
|
+
for (const [name, content] of [
|
|
1384
|
+
["guard-runner.sh", generateGuardRunnerWrapper()],
|
|
1385
|
+
["session-start-marker.sh", generateSessionStartMarker()],
|
|
1386
|
+
["user-prompt-reminder.sh", generateUserPromptReminderHook()]
|
|
1387
|
+
]) {
|
|
1388
|
+
const hookPath = join5(targetRepo, ".claude", "hooks", name);
|
|
1389
|
+
record(report, writeCollisionSafe(hookPath, targetRepo, content), `.claude/hooks/${name}`);
|
|
1390
|
+
}
|
|
1391
|
+
const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
|
|
1392
|
+
record(report, writeCollisionSafe(ciStanzaPath, targetRepo, generateCiStanza()), ".halfcycle/ci-stanza.yml");
|
|
1393
|
+
record(report, reconcileEnvExample(targetRepo), ".env.example");
|
|
1394
|
+
if (credential) {
|
|
1395
|
+
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
1396
|
+
record(report, writeCollisionSafe(helperPath, targetRepo, generateMcpHeadersHelper()), MCP_HEADERS_HELPER_REL);
|
|
1397
|
+
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
1398
|
+
const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
|
|
1399
|
+
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
1400
|
+
if (existingMcp === null) {
|
|
1401
|
+
writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
|
|
1402
|
+
} else if (existingMcp !== mcpContent) {
|
|
1403
|
+
writeAllowlisted(mcpPath, targetRepo, mcpContent, report.mergedPaths);
|
|
1404
|
+
} else {
|
|
1405
|
+
report.skippedPaths.push(MCP_REGISTRATION_REL);
|
|
1406
|
+
}
|
|
1407
|
+
} else {
|
|
1408
|
+
report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
|
|
1409
|
+
}
|
|
1410
|
+
const gitignoreOutcome = reconcileGitignore(targetRepo);
|
|
1411
|
+
if (gitignoreOutcome === "failed") {
|
|
1412
|
+
report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
|
|
1413
|
+
} else {
|
|
1414
|
+
record(report, gitignoreOutcome, ".gitignore");
|
|
1415
|
+
}
|
|
1416
|
+
record(report, writeProjectIdentity(targetRepo), ".halfcycle/project.json");
|
|
1417
|
+
const credentialPath = engagementEnvPath(engagementId, home);
|
|
1418
|
+
if (credential) {
|
|
1419
|
+
record(report, writeEngagementCredential(credential, engagementId, home), credentialPath);
|
|
1420
|
+
} else {
|
|
1421
|
+
report.skippedPaths.push(`${credentialPath} (no credential supplied)`);
|
|
1422
|
+
}
|
|
1423
|
+
const stored = readEngagementEnv(engagementId, home) ?? {};
|
|
1424
|
+
const migration = migrateLegacyEnvLocal(targetRepo, stored);
|
|
1425
|
+
switch (migration.outcome) {
|
|
1426
|
+
case "removed":
|
|
1427
|
+
report.writtenPaths.push(".env.local (REMOVED \u2014 the credential now lives outside this repository)");
|
|
1428
|
+
break;
|
|
1429
|
+
case "stripped":
|
|
1430
|
+
report.writtenPaths.push(".env.local (Halfcycle keys REMOVED \u2014 your own entries are untouched; the credential now lives outside this repository)");
|
|
1431
|
+
break;
|
|
1432
|
+
case "kept":
|
|
1433
|
+
report.skippedPaths.push(`.env.local (KEPT \u2014 nothing removed: this repository still holds the only copy of ${migration.unbacked.join(", ")}, and the store at ${credentialPath} does not)`);
|
|
1434
|
+
break;
|
|
1435
|
+
case "failed":
|
|
1436
|
+
report.skippedPaths.push(".env.local (could not be rewritten \u2014 DELETE IT BY HAND: it holds a credential this repository no longer needs)");
|
|
1437
|
+
break;
|
|
1438
|
+
case "absent":
|
|
1439
|
+
break;
|
|
1440
|
+
}
|
|
1441
|
+
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
|
|
1442
|
+
const scanResult = runBootstrapScan(targetRepo);
|
|
1443
|
+
return {
|
|
1444
|
+
version: manifest.version,
|
|
1445
|
+
writtenPaths: report.writtenPaths,
|
|
1446
|
+
skippedPaths: report.skippedPaths,
|
|
1447
|
+
mergedPaths: report.mergedPaths,
|
|
1448
|
+
collidedPaths: report.collidedPaths,
|
|
1449
|
+
bootstrapScanRan: scanResult.ran,
|
|
1450
|
+
legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// dist/create-engagement.js
|
|
1455
|
+
var CreateEngagementRefused = class extends Error {
|
|
1456
|
+
status;
|
|
1457
|
+
serverMessage;
|
|
1458
|
+
errorCode;
|
|
1459
|
+
constructor(status, serverMessage, errorCode, message) {
|
|
1460
|
+
super(message);
|
|
1461
|
+
this.status = status;
|
|
1462
|
+
this.serverMessage = serverMessage;
|
|
1463
|
+
this.errorCode = errorCode;
|
|
1464
|
+
this.name = "CreateEngagementRefused";
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Is this a refusal of the CALLER's credential — the arm signing in again can fix?
|
|
1468
|
+
*
|
|
1469
|
+
* `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
|
|
1470
|
+
* the whole reason this is a method rather than a status comparison at the call
|
|
1471
|
+
* site: the flag gates the DEPLOYMENT, identically for every caller, so no
|
|
1472
|
+
* credential and no sign-in changes the answer. Treating it as an auth refusal
|
|
1473
|
+
* would send a developer through a browser handoff to arrive at the same 403.
|
|
1474
|
+
*
|
|
1475
|
+
* **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
|
|
1476
|
+
* (T-24).** On the join route a 403 has two causes that share a status AND a
|
|
1477
|
+
* discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
|
|
1478
|
+
* apart: the credential may be a live engagement token carrying no account, or the
|
|
1479
|
+
* engagement id may not resolve. Signing in again cannot fix the second, and
|
|
1480
|
+
* treating it as though it could would discard a good credential, walk the
|
|
1481
|
+
* developer through a browser, and arrive at the identical 403 — a remedy that
|
|
1482
|
+
* loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
|
|
1483
|
+
* where the reason can be written down. See `joinPinnedEngagement` there.
|
|
1484
|
+
*/
|
|
1485
|
+
get authRefused() {
|
|
1486
|
+
if (this.errorCode === "PublicStartDisabled")
|
|
1487
|
+
return false;
|
|
1488
|
+
return this.status === 401 || this.status === 403;
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
function planeRefusal(detail) {
|
|
1492
|
+
let body;
|
|
1493
|
+
try {
|
|
1494
|
+
body = JSON.parse(detail);
|
|
1495
|
+
} catch {
|
|
1496
|
+
return void 0;
|
|
1497
|
+
}
|
|
1498
|
+
if (typeof body !== "object" || body === null)
|
|
1499
|
+
return void 0;
|
|
1500
|
+
const candidate = body;
|
|
1501
|
+
if (typeof candidate.message !== "string" || candidate.message.trim() === "")
|
|
1502
|
+
return void 0;
|
|
1503
|
+
const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
|
|
1504
|
+
if (!hasDiscriminant)
|
|
1505
|
+
return void 0;
|
|
1506
|
+
return {
|
|
1507
|
+
message: candidate.message,
|
|
1508
|
+
error: typeof candidate.error === "string" ? candidate.error : void 0
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
var CONTROL_ORIGIN_HINT = "HALFCYCLE_SERVICE_URL must be the Halfcycle 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 do not configure it.";
|
|
1512
|
+
async function createEngagement(baseUrl, name, credential) {
|
|
1513
|
+
return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
|
|
1514
|
+
}
|
|
1515
|
+
async function joinEngagement(baseUrl, engagementId, credential) {
|
|
1516
|
+
return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
|
|
1517
|
+
action: "Join-engagement",
|
|
1518
|
+
nothingHappened: "No credential was minted",
|
|
1519
|
+
route: "POST /engagements/:engagementId/join"
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
1523
|
+
const bearer = credential?.trim();
|
|
1524
|
+
let res;
|
|
1525
|
+
try {
|
|
1526
|
+
res = await fetch(url, {
|
|
1527
|
+
method: "POST",
|
|
1528
|
+
headers: {
|
|
1529
|
+
"content-type": "application/json",
|
|
1530
|
+
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
1531
|
+
},
|
|
1532
|
+
body: JSON.stringify(requestBody)
|
|
1533
|
+
});
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
throw new Error(`[bundle install] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
1536
|
+
}
|
|
1537
|
+
if (!res.ok) {
|
|
1538
|
+
const detail = await res.text().catch(() => "");
|
|
1539
|
+
const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
|
|
1540
|
+
if (refusal) {
|
|
1541
|
+
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
|
|
1542
|
+
}
|
|
1543
|
+
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
|
|
1544
|
+
throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
1545
|
+
}
|
|
1546
|
+
const body = await res.json().catch(() => null);
|
|
1547
|
+
if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
|
|
1548
|
+
throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
1549
|
+
}
|
|
1550
|
+
for (const [field, value] of [
|
|
1551
|
+
["engagementId", body.engagementId],
|
|
1552
|
+
["sessionToken", body.sessionToken]
|
|
1553
|
+
]) {
|
|
1554
|
+
if (value.trim() === "") {
|
|
1555
|
+
throw new Error(`[bundle install] The Halfcycle plane at ${url} answered with a blank ${field} (the field is present but empty). An install cannot proceed on it: it would be written into this engagement's credential store, read back as a configured value, and every guard evaluation would fail against it for the life of the engagement. ${shape.nothingHappened} and nothing was written. ` + CONTROL_ORIGIN_HINT);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
|
|
1559
|
+
throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its MCP server lives (no mcpUrl on the response). Without that address the install would write an MCP registration pointing nowhere, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
|
|
1560
|
+
}
|
|
1561
|
+
if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
|
|
1562
|
+
throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
|
|
1563
|
+
}
|
|
1564
|
+
const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
|
|
1565
|
+
return {
|
|
1566
|
+
engagementId: body.engagementId,
|
|
1567
|
+
sessionToken: body.sessionToken,
|
|
1568
|
+
mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
|
|
1569
|
+
guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
|
|
1570
|
+
...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// dist/device-signin.js
|
|
1575
|
+
import { spawn } from "node:child_process";
|
|
1576
|
+
import { platform as platform3 } from "node:os";
|
|
1577
|
+
|
|
1578
|
+
// dist/loopback-signin.js
|
|
1579
|
+
import { createServer } from "node:http";
|
|
1580
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
1581
|
+
var LOOPBACK_PORT_ENV = "HALFCYCLE_LOOPBACK_PORT";
|
|
1582
|
+
var DEFAULT_STILL_WAITING_MS = 15e3;
|
|
1583
|
+
function completionPage(result) {
|
|
1584
|
+
const headline = result === LOOPBACK_RESULT.APPROVED ? "Signed in. You can close this tab and return to your terminal." : "Sign-in declined. Nothing was created. You can close this tab.";
|
|
1585
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Halfcycle</title></head><body style="font:16px/1.5 system-ui;padding:3rem"><p>${headline}</p></body></html>`;
|
|
1586
|
+
}
|
|
1587
|
+
function isLoopbackPeer(remoteAddress) {
|
|
1588
|
+
if (remoteAddress === void 0)
|
|
1589
|
+
return false;
|
|
1590
|
+
const addr = remoteAddress.startsWith("::ffff:") ? remoteAddress.slice("::ffff:".length) : remoteAddress;
|
|
1591
|
+
return addr === "::1" || /^127\./.test(addr);
|
|
1592
|
+
}
|
|
1593
|
+
function statesMatch(presented, expected) {
|
|
1594
|
+
const a = Buffer.from(presented, "utf8");
|
|
1595
|
+
const b = Buffer.from(expected, "utf8");
|
|
1596
|
+
if (a.length !== b.length)
|
|
1597
|
+
return false;
|
|
1598
|
+
return timingSafeEqual(a, b);
|
|
1599
|
+
}
|
|
1600
|
+
function createCallbackHandler(state, onCallback) {
|
|
1601
|
+
return (req, res) => {
|
|
1602
|
+
if (!isLoopbackPeer(req.socket.remoteAddress)) {
|
|
1603
|
+
res.writeHead(403).end();
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (req.method !== "GET") {
|
|
1607
|
+
res.writeHead(405).end();
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
const url = new URL(req.url ?? "/", `http://${LOOPBACK_REDIRECT_HOST}`);
|
|
1611
|
+
if (url.pathname !== LOOPBACK_CALLBACK_PATH) {
|
|
1612
|
+
res.writeHead(404).end();
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const presented = url.searchParams.get(LOOPBACK_QUERY_STATE) ?? "";
|
|
1616
|
+
if (!statesMatch(presented, state)) {
|
|
1617
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
1618
|
+
res.end("This is not the sign-in this terminal is waiting for.\n");
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
const declared = url.searchParams.get(LOOPBACK_QUERY_RESULT);
|
|
1622
|
+
const result = declared === LOOPBACK_RESULT.DECLINED ? LOOPBACK_RESULT.DECLINED : LOOPBACK_RESULT.APPROVED;
|
|
1623
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
1624
|
+
res.end(completionPage(result));
|
|
1625
|
+
onCallback({ result });
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
function requestedPort(env) {
|
|
1629
|
+
const raw = env[LOOPBACK_PORT_ENV]?.trim();
|
|
1630
|
+
if (raw === void 0 || raw === "")
|
|
1631
|
+
return { port: 0 };
|
|
1632
|
+
if (!/^[0-9]+$/.test(raw)) {
|
|
1633
|
+
return { problem: `${LOOPBACK_PORT_ENV} is set to "${raw}", which is not a port number` };
|
|
1634
|
+
}
|
|
1635
|
+
const port = Number.parseInt(raw, 10);
|
|
1636
|
+
if (port < LOOPBACK_PORT_MIN || port > LOOPBACK_PORT_MAX) {
|
|
1637
|
+
return {
|
|
1638
|
+
problem: `${LOOPBACK_PORT_ENV} is set to ${port}, outside the range this flow allows (${LOOPBACK_PORT_MIN}-${LOOPBACK_PORT_MAX})`
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
return { port };
|
|
1642
|
+
}
|
|
1643
|
+
async function bindLoopback(env = process.env) {
|
|
1644
|
+
const wanted = requestedPort(env);
|
|
1645
|
+
if ("problem" in wanted)
|
|
1646
|
+
return { bound: false, reason: wanted.problem };
|
|
1647
|
+
const state = randomBytes(32).toString("hex");
|
|
1648
|
+
let pending = null;
|
|
1649
|
+
let deliver = null;
|
|
1650
|
+
const server = createServer(createCallbackHandler(state, (callback) => {
|
|
1651
|
+
if (deliver)
|
|
1652
|
+
deliver(callback);
|
|
1653
|
+
else
|
|
1654
|
+
pending = callback;
|
|
1655
|
+
}));
|
|
1656
|
+
const bindProblem = await new Promise((resolve2) => {
|
|
1657
|
+
const onError = (err) => {
|
|
1658
|
+
resolve2(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
|
|
1659
|
+
};
|
|
1660
|
+
server.once("error", onError);
|
|
1661
|
+
server.listen({ host: LOOPBACK_REDIRECT_HOST, port: wanted.port }, () => {
|
|
1662
|
+
server.removeListener("error", onError);
|
|
1663
|
+
resolve2(null);
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1666
|
+
if (bindProblem !== null) {
|
|
1667
|
+
server.close();
|
|
1668
|
+
return { bound: false, reason: `the local callback port could not be opened (${bindProblem})` };
|
|
1669
|
+
}
|
|
1670
|
+
const address = server.address();
|
|
1671
|
+
if (address === null || typeof address === "string") {
|
|
1672
|
+
await closeServer(server);
|
|
1673
|
+
return { bound: false, reason: "the local callback port could not be read back after binding" };
|
|
1674
|
+
}
|
|
1675
|
+
return {
|
|
1676
|
+
bound: true,
|
|
1677
|
+
target: { port: address.port, state },
|
|
1678
|
+
boundAddress: address.address,
|
|
1679
|
+
waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve2) => {
|
|
1680
|
+
if (pending !== null) {
|
|
1681
|
+
resolve2(pending);
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const startedAt = Date.now();
|
|
1685
|
+
const ticker = onStillWaiting === void 0 ? null : setInterval(() => onStillWaiting(Date.now() - startedAt), stillWaitingMs);
|
|
1686
|
+
const finish = (value) => {
|
|
1687
|
+
if (ticker !== null)
|
|
1688
|
+
clearInterval(ticker);
|
|
1689
|
+
clearTimeout(timer);
|
|
1690
|
+
deliver = null;
|
|
1691
|
+
resolve2(value);
|
|
1692
|
+
};
|
|
1693
|
+
const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
|
|
1694
|
+
deliver = finish;
|
|
1695
|
+
}),
|
|
1696
|
+
close: () => closeServer(server)
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
function closeServer(server) {
|
|
1700
|
+
return new Promise((resolve2) => {
|
|
1701
|
+
server.closeAllConnections();
|
|
1702
|
+
server.close(() => resolve2());
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// dist/device-signin.js
|
|
1707
|
+
var SignInRefused = class extends Error {
|
|
1708
|
+
status;
|
|
1709
|
+
constructor(status, message) {
|
|
1710
|
+
super(message);
|
|
1711
|
+
this.status = status;
|
|
1712
|
+
this.name = "SignInRefused";
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
var SIGN_IN_TIMED_OUT = "timed-out";
|
|
1716
|
+
var SIGN_IN_TIMEOUT_MESSAGE = "Sign-in timed out: the Halfcycle plane stopped answering while this terminal was waiting for the browser. Nothing was created. Check your connection and run the command again.";
|
|
1717
|
+
var SIGN_IN_NON_INTERACTIVE_MESSAGE = "Halfcycle needs a signed-in account and this looks like a non-interactive run (CI is set), so there is no browser to sign in with. Set HALFCYCLE_TOKEN in the environment to a Halfcycle credential and run this again. Nothing was created.";
|
|
1718
|
+
var DEADLINE_SLACK_INTERVALS = 1;
|
|
1719
|
+
var STILL_WAITING_MS = 15e3;
|
|
1720
|
+
function defaultWrite(text) {
|
|
1721
|
+
process.stdout.write(text);
|
|
1722
|
+
}
|
|
1723
|
+
function defaultSleep(ms) {
|
|
1724
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1725
|
+
}
|
|
1726
|
+
function browserOpenCommand(os, url) {
|
|
1727
|
+
if (os === "darwin")
|
|
1728
|
+
return { command: "open", args: [url] };
|
|
1729
|
+
if (os === "win32")
|
|
1730
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
1731
|
+
return { command: "xdg-open", args: [url] };
|
|
1732
|
+
}
|
|
1733
|
+
function isOpenableUrl(url) {
|
|
1734
|
+
try {
|
|
1735
|
+
const parsed = new URL(url);
|
|
1736
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
1737
|
+
} catch {
|
|
1738
|
+
return false;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
var BROWSER_OPEN_GRACE_MS = 3e3;
|
|
1742
|
+
async function openBrowser(url, env = process.env) {
|
|
1743
|
+
if ((env["HALFCYCLE_NO_BROWSER"] ?? "").trim() !== "") {
|
|
1744
|
+
return { opened: false, reason: "HALFCYCLE_NO_BROWSER is set in this environment" };
|
|
1745
|
+
}
|
|
1746
|
+
if (!isOpenableUrl(url)) {
|
|
1747
|
+
return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
|
|
1748
|
+
}
|
|
1749
|
+
const { command, args: args2 } = browserOpenCommand(platform3(), url);
|
|
1750
|
+
return new Promise((resolve2) => {
|
|
1751
|
+
let child;
|
|
1752
|
+
try {
|
|
1753
|
+
child = spawn(command, args2, { stdio: "ignore", env });
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
resolve2({
|
|
1756
|
+
opened: false,
|
|
1757
|
+
reason: `${command} could not be run (${err instanceof Error ? err.message : String(err)})`
|
|
1758
|
+
});
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
let settled = false;
|
|
1762
|
+
const settle = (attempt) => {
|
|
1763
|
+
if (settled)
|
|
1764
|
+
return;
|
|
1765
|
+
settled = true;
|
|
1766
|
+
clearTimeout(timer);
|
|
1767
|
+
resolve2(attempt);
|
|
1768
|
+
};
|
|
1769
|
+
const timer = setTimeout(() => {
|
|
1770
|
+
child.unref();
|
|
1771
|
+
settle({ opened: true });
|
|
1772
|
+
}, BROWSER_OPEN_GRACE_MS);
|
|
1773
|
+
timer.unref?.();
|
|
1774
|
+
child.on("error", (err) => {
|
|
1775
|
+
settle({ opened: false, reason: `${command} could not be run (${err.code ?? err.message})` });
|
|
1776
|
+
});
|
|
1777
|
+
child.on("exit", (code, signal) => {
|
|
1778
|
+
if (code === 0) {
|
|
1779
|
+
settle({ opened: true });
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
settle({
|
|
1783
|
+
opened: false,
|
|
1784
|
+
reason: `${command} exited ${signal !== null ? `on ${signal}` : String(code)}`
|
|
1785
|
+
});
|
|
1786
|
+
});
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
function isNonInteractive(env = process.env) {
|
|
1790
|
+
const ci = (env["CI"] ?? "").trim().toLowerCase();
|
|
1791
|
+
return ci !== "" && ci !== "0" && ci !== "false";
|
|
1792
|
+
}
|
|
1793
|
+
async function startDeviceAuth(serviceUrl) {
|
|
1794
|
+
const url = `${serviceUrl.replace(/\/+$/, "")}/auth/device/start`;
|
|
1795
|
+
let res;
|
|
1796
|
+
try {
|
|
1797
|
+
res = await fetch(url, {
|
|
1798
|
+
method: "POST",
|
|
1799
|
+
headers: { "content-type": "application/json" },
|
|
1800
|
+
body: "{}"
|
|
1801
|
+
});
|
|
1802
|
+
} catch (err) {
|
|
1803
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, `Could not reach the Halfcycle plane at ${url} to start a sign-in: ${err instanceof Error ? err.message : String(err)}. Nothing was created.`);
|
|
1804
|
+
}
|
|
1805
|
+
const body = await res.json().catch(() => null);
|
|
1806
|
+
if (res.status !== 201) {
|
|
1807
|
+
const refusal = tryRefusal(body);
|
|
1808
|
+
throw new SignInRefused(refusal?.status ?? String(res.status), refusal?.message ?? `Sign-in could not be started: ${url} answered ${res.status}. Nothing was created.`);
|
|
1809
|
+
}
|
|
1810
|
+
const parsed = deviceAuthStartResponseSchema.safeParse(body);
|
|
1811
|
+
if (!parsed.success) {
|
|
1812
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, `The Halfcycle plane at ${url} answered a sign-in start this client cannot read. Either that plane is older than this installer, or the address is not a Halfcycle control plane. Nothing was created.`);
|
|
1813
|
+
}
|
|
1814
|
+
return parsed.data;
|
|
1815
|
+
}
|
|
1816
|
+
function tryRefusal(body) {
|
|
1817
|
+
if (typeof body !== "object" || body === null)
|
|
1818
|
+
return void 0;
|
|
1819
|
+
const candidate = body;
|
|
1820
|
+
if (typeof candidate.status !== "string" || typeof candidate.message !== "string") {
|
|
1821
|
+
return void 0;
|
|
1822
|
+
}
|
|
1823
|
+
return { status: candidate.status, message: candidate.message };
|
|
1824
|
+
}
|
|
1825
|
+
async function pollForCredential(serviceUrl, started, deps = {}) {
|
|
1826
|
+
const write = deps.write ?? defaultWrite;
|
|
1827
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
1828
|
+
const now = deps.now ?? Date.now;
|
|
1829
|
+
const url = `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
|
|
1830
|
+
const interval = started.pollIntervalMs;
|
|
1831
|
+
const deadline = new Date(started.expiresAt).getTime() + interval * DEADLINE_SLACK_INTERVALS;
|
|
1832
|
+
const startedAt = now();
|
|
1833
|
+
let announcedAt = startedAt;
|
|
1834
|
+
for (; ; ) {
|
|
1835
|
+
let body;
|
|
1836
|
+
let reachable = true;
|
|
1837
|
+
try {
|
|
1838
|
+
const res = await fetch(url, {
|
|
1839
|
+
method: "POST",
|
|
1840
|
+
headers: { "content-type": "application/json" },
|
|
1841
|
+
body: JSON.stringify({ deviceCode: started.deviceCode })
|
|
1842
|
+
});
|
|
1843
|
+
body = await res.json().catch(() => null);
|
|
1844
|
+
} catch {
|
|
1845
|
+
reachable = false;
|
|
1846
|
+
}
|
|
1847
|
+
if (reachable) {
|
|
1848
|
+
const parsed = devicePollResponseSchema.safeParse(body);
|
|
1849
|
+
if (parsed.success) {
|
|
1850
|
+
const answer = parsed.data;
|
|
1851
|
+
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
1852
|
+
return answer;
|
|
1853
|
+
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
1854
|
+
throw new SignInRefused(answer.status, answer.message);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (now() >= deadline) {
|
|
1859
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_TIMEOUT_MESSAGE);
|
|
1860
|
+
}
|
|
1861
|
+
if (now() - announcedAt >= STILL_WAITING_MS) {
|
|
1862
|
+
announcedAt = now();
|
|
1863
|
+
write(`[halfcycle] Still waiting for the browser (${Math.round((announcedAt - startedAt) / 1e3)}s). Code: ${started.userCode}
|
|
1864
|
+
`);
|
|
1865
|
+
}
|
|
1866
|
+
await sleep(interval);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
var LOOPBACK_TIMEOUT_MESSAGE = "Sign-in timed out: the browser never came back to this terminal. Nothing was created. Run the command again \u2014 or, if this machine cannot hand the browser back (a remote session, a container), run it with HALFCYCLE_NO_BROWSER=1 to use the code-based sign-in instead.";
|
|
1870
|
+
var LOOPBACK_COLLECT_ATTEMPTS = 3;
|
|
1871
|
+
async function collectDecidedCredential(serviceUrl, started, deps) {
|
|
1872
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
1873
|
+
const url = `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
|
|
1874
|
+
let lastProblem = LOOPBACK_TIMEOUT_MESSAGE;
|
|
1875
|
+
for (let attempt = 0; attempt < LOOPBACK_COLLECT_ATTEMPTS; attempt++) {
|
|
1876
|
+
if (attempt > 0)
|
|
1877
|
+
await sleep(started.pollIntervalMs);
|
|
1878
|
+
let body;
|
|
1879
|
+
try {
|
|
1880
|
+
const res = await fetch(url, {
|
|
1881
|
+
method: "POST",
|
|
1882
|
+
headers: { "content-type": "application/json" },
|
|
1883
|
+
body: JSON.stringify({ deviceCode: started.deviceCode })
|
|
1884
|
+
});
|
|
1885
|
+
body = await res.json().catch(() => null);
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
lastProblem = `The browser finished signing you in, but this terminal could not reach the Halfcycle plane to collect the credential: ${err instanceof Error ? err.message : String(err)}. Run the command again.`;
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
const parsed = devicePollResponseSchema.safeParse(body);
|
|
1891
|
+
if (!parsed.success)
|
|
1892
|
+
continue;
|
|
1893
|
+
const answer = parsed.data;
|
|
1894
|
+
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
1895
|
+
return answer;
|
|
1896
|
+
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
1897
|
+
throw new SignInRefused(answer.status, answer.message);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, lastProblem);
|
|
1901
|
+
}
|
|
1902
|
+
async function signIn(serviceUrl, deps = {}) {
|
|
1903
|
+
const write = deps.write ?? defaultWrite;
|
|
1904
|
+
const now = deps.now ?? Date.now;
|
|
1905
|
+
const env = deps.env ?? process.env;
|
|
1906
|
+
if (isNonInteractive(env)) {
|
|
1907
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_NON_INTERACTIVE_MESSAGE);
|
|
1908
|
+
}
|
|
1909
|
+
write(`[halfcycle] Halfcycle needs an account before it can create an engagement \u2014 signing you in through your browser.
|
|
1910
|
+
`);
|
|
1911
|
+
const bind = await bindLoopback(env);
|
|
1912
|
+
let closed = false;
|
|
1913
|
+
const closeListener = async () => {
|
|
1914
|
+
if (bind.bound && !closed) {
|
|
1915
|
+
closed = true;
|
|
1916
|
+
await bind.close();
|
|
1917
|
+
}
|
|
1918
|
+
};
|
|
1919
|
+
try {
|
|
1920
|
+
const started = await startDeviceAuth(serviceUrl);
|
|
1921
|
+
if (bind.bound) {
|
|
1922
|
+
const url = loopbackVerificationUrl(started.verificationUrl, bind.target);
|
|
1923
|
+
const attempt = await openBrowser(url, env);
|
|
1924
|
+
if (attempt.opened) {
|
|
1925
|
+
write(`[halfcycle] Opened your browser to finish signing in. This terminal is waiting.
|
|
1926
|
+
`);
|
|
1927
|
+
const deadline = new Date(started.expiresAt).getTime() + started.pollIntervalMs * DEADLINE_SLACK_INTERVALS;
|
|
1928
|
+
const callback = await bind.waitForCallback({
|
|
1929
|
+
timeoutMs: deadline - now(),
|
|
1930
|
+
onStillWaiting: (elapsed) => (
|
|
1931
|
+
// NO CODE IN THIS LINE. The device-code path prints the code here
|
|
1932
|
+
// because a human is comparing it; on this path there is nothing to
|
|
1933
|
+
// compare and criterion 1 is that none is displayed AT ALL.
|
|
1934
|
+
write(`[halfcycle] Still waiting for the browser (${Math.round(elapsed / 1e3)}s).
|
|
1935
|
+
`)
|
|
1936
|
+
)
|
|
1937
|
+
});
|
|
1938
|
+
await closeListener();
|
|
1939
|
+
if (callback === null) {
|
|
1940
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
|
|
1941
|
+
}
|
|
1942
|
+
const approved = await collectDecidedCredential(serviceUrl, started, deps);
|
|
1943
|
+
write(`[halfcycle] Signed in as account ${approved.accountId} \u2014 continuing.
|
|
1944
|
+
`);
|
|
1945
|
+
return {
|
|
1946
|
+
accountId: approved.accountId,
|
|
1947
|
+
credential: approved.credential,
|
|
1948
|
+
expiresAt: approved.expiresAt
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
await closeListener();
|
|
1952
|
+
write(`[halfcycle] Falling back to the code-based sign-in: ${attempt.reason}.
|
|
1953
|
+
`);
|
|
1954
|
+
} else {
|
|
1955
|
+
write(`[halfcycle] Falling back to the code-based sign-in: ${bind.reason}.
|
|
1956
|
+
`);
|
|
1957
|
+
}
|
|
1958
|
+
return await deviceCodeSignIn(serviceUrl, started, env, deps);
|
|
1959
|
+
} finally {
|
|
1960
|
+
await closeListener();
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
async function deviceCodeSignIn(serviceUrl, started, env, deps) {
|
|
1964
|
+
const write = deps.write ?? defaultWrite;
|
|
1965
|
+
write(`[halfcycle] Your sign-in code: ${started.userCode}
|
|
1966
|
+
`);
|
|
1967
|
+
const attempt = await openBrowser(started.verificationUrl, env);
|
|
1968
|
+
if (attempt.opened) {
|
|
1969
|
+
write(`[halfcycle] Opened your browser at ${started.verificationUrl}
|
|
1970
|
+
`);
|
|
1971
|
+
} else {
|
|
1972
|
+
write(`[halfcycle] Open this address to sign in: ${started.verificationUrl}
|
|
1973
|
+
`);
|
|
1974
|
+
}
|
|
1975
|
+
write(`[halfcycle] Check the code on that page matches the one above, then sign in. This terminal is waiting.
|
|
1976
|
+
`);
|
|
1977
|
+
const approved = await pollForCredential(serviceUrl, started, deps);
|
|
1978
|
+
write(`[halfcycle] Signed in as account ${approved.accountId} \u2014 continuing.
|
|
1979
|
+
`);
|
|
1980
|
+
return {
|
|
1981
|
+
accountId: approved.accountId,
|
|
1982
|
+
credential: approved.credential,
|
|
1983
|
+
expiresAt: approved.expiresAt
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// dist/resolve-credential.js
|
|
1988
|
+
async function obtainCredential(serviceUrl, opts = {}) {
|
|
1989
|
+
const env = opts.env ?? process.env;
|
|
1990
|
+
const fromEnv = env["HALFCYCLE_TOKEN"]?.trim();
|
|
1991
|
+
if (fromEnv)
|
|
1992
|
+
return { credential: fromEnv, source: "environment" };
|
|
1993
|
+
const stored = readStoredCredential(serviceUrl, opts.home);
|
|
1994
|
+
if (stored) {
|
|
1995
|
+
return { credential: stored.credential, source: "stored", accountId: stored.accountId };
|
|
1996
|
+
}
|
|
1997
|
+
const signedIn = await signIn(serviceUrl, opts);
|
|
1998
|
+
writeStoredCredential(serviceUrl, {
|
|
1999
|
+
accountId: signedIn.accountId,
|
|
2000
|
+
credential: signedIn.credential,
|
|
2001
|
+
expiresAt: signedIn.expiresAt
|
|
2002
|
+
}, opts.home);
|
|
2003
|
+
return {
|
|
2004
|
+
credential: signedIn.credential,
|
|
2005
|
+
source: "browser-handoff",
|
|
2006
|
+
accountId: signedIn.accountId
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
async function replaceRefusedCredential(serviceUrl, refused, opts = {}) {
|
|
2010
|
+
if (refused.source !== "stored")
|
|
2011
|
+
return null;
|
|
2012
|
+
forgetStoredCredential(serviceUrl, opts.home);
|
|
2013
|
+
const signedIn = await signIn(serviceUrl, opts);
|
|
2014
|
+
writeStoredCredential(serviceUrl, {
|
|
2015
|
+
accountId: signedIn.accountId,
|
|
2016
|
+
credential: signedIn.credential,
|
|
2017
|
+
expiresAt: signedIn.expiresAt
|
|
2018
|
+
}, opts.home);
|
|
2019
|
+
return {
|
|
2020
|
+
credential: signedIn.credential,
|
|
2021
|
+
source: "browser-handoff",
|
|
2022
|
+
accountId: signedIn.accountId
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
function refusedCredentialRemedy(source) {
|
|
2026
|
+
switch (source) {
|
|
2027
|
+
case "environment":
|
|
2028
|
+
return "That credential came from HALFCYCLE_TOKEN in this environment, and it takes precedence over any saved sign-in \u2014 so running this again will present the same value and get the same answer. Replace it with a live credential, or unset it to sign in through the browser instead.";
|
|
2029
|
+
case "stored":
|
|
2030
|
+
return "That was this machine's saved sign-in and it has been discarded. Run the command again to sign in through your browser.";
|
|
2031
|
+
case "browser-handoff":
|
|
2032
|
+
return "That credential was issued by this sign-in moments ago, so signing in again would produce the same result. This is a problem on the Halfcycle plane rather than with your account.";
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// dist/mint-board-code.js
|
|
2037
|
+
async function mintBoardEnterCode(baseUrl, sessionToken) {
|
|
2038
|
+
const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
|
|
2039
|
+
let res;
|
|
2040
|
+
try {
|
|
2041
|
+
res = await fetch(url, {
|
|
2042
|
+
method: "POST",
|
|
2043
|
+
headers: {
|
|
2044
|
+
"content-type": "application/json",
|
|
2045
|
+
authorization: `Bearer ${sessionToken}`
|
|
2046
|
+
},
|
|
2047
|
+
body: "{}"
|
|
2048
|
+
});
|
|
2049
|
+
} catch (err) {
|
|
2050
|
+
throw new Error(`[bundle install] Could not reach the Halfcycle board mint endpoint at ${url}: ${err instanceof Error ? err.message : String(err)}.`);
|
|
2051
|
+
}
|
|
2052
|
+
if (!res.ok) {
|
|
2053
|
+
throw new Error(`[bundle install] Board code mint failed: ${url} returned ${res.status}.`);
|
|
2054
|
+
}
|
|
2055
|
+
const body = await res.json().catch(() => null);
|
|
2056
|
+
if (!body || typeof body.enterCode !== "string" || typeof body.engagementId !== "string" || typeof body.boardUrl !== "string" || typeof body.expiresAt !== "string") {
|
|
2057
|
+
throw new Error(`[bundle install] Board code mint response from ${url} did not carry {enterCode, engagementId, boardUrl, expiresAt}.`);
|
|
2058
|
+
}
|
|
2059
|
+
return {
|
|
2060
|
+
enterCode: body.enterCode,
|
|
2061
|
+
engagementId: body.engagementId,
|
|
2062
|
+
boardUrl: body.boardUrl,
|
|
2063
|
+
expiresAt: body.expiresAt
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// dist/probe-mcp.js
|
|
2068
|
+
var PROBE_TIMEOUT_MS = 5e3;
|
|
2069
|
+
async function probeMcpOrigin(mcpUrl) {
|
|
2070
|
+
const url = mcpEndpointUrl(mcpUrl);
|
|
2071
|
+
const controller = new AbortController();
|
|
2072
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
2073
|
+
let res;
|
|
2074
|
+
try {
|
|
2075
|
+
res = await fetch(url, {
|
|
2076
|
+
method: "POST",
|
|
2077
|
+
headers: { "content-type": "application/json" },
|
|
2078
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
2079
|
+
signal: controller.signal
|
|
2080
|
+
});
|
|
2081
|
+
} catch (err) {
|
|
2082
|
+
return {
|
|
2083
|
+
reached: false,
|
|
2084
|
+
problem: controller.signal.aborted ? `it did not answer within ${PROBE_TIMEOUT_MS / 1e3} seconds` : `it could not be reached (${err instanceof Error ? err.message : String(err)})`
|
|
2085
|
+
};
|
|
2086
|
+
} finally {
|
|
2087
|
+
clearTimeout(timer);
|
|
2088
|
+
}
|
|
2089
|
+
if (res.status === 404) {
|
|
2090
|
+
return { reached: false, problem: "that address answered, but it serves no /mcp endpoint" };
|
|
2091
|
+
}
|
|
2092
|
+
if (!res.ok) {
|
|
2093
|
+
return { reached: false, problem: `it answered ${res.status}` };
|
|
2094
|
+
}
|
|
2095
|
+
const body = await res.json().catch(() => null);
|
|
2096
|
+
const name = body?.result?.serverInfo?.name;
|
|
2097
|
+
if (typeof name !== "string" || name === "") {
|
|
2098
|
+
return { reached: false, problem: "it answered, but not as an MCP server" };
|
|
2099
|
+
}
|
|
2100
|
+
return { reached: true, serverName: name };
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// dist/build-record/types.js
|
|
2104
|
+
var BUILD_RECORD_FORMAT = "halfcycle-build-record/v1";
|
|
2105
|
+
function projectSeedGuard(fired, includeExplanation) {
|
|
2106
|
+
const seed = {
|
|
2107
|
+
patternRef: fired.patternRef,
|
|
2108
|
+
severity: fired.severity
|
|
2109
|
+
};
|
|
2110
|
+
if (includeExplanation && fired.explanation)
|
|
2111
|
+
seed.explanation = fired.explanation;
|
|
2112
|
+
return seed;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// dist/build-record/sources.js
|
|
2116
|
+
import { readFileSync as readFileSync6, existsSync as existsSync4 } from "node:fs";
|
|
2117
|
+
import { createHash } from "node:crypto";
|
|
2118
|
+
import { join as join6 } from "node:path";
|
|
2119
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2120
|
+
var MissingSourceError = class extends Error {
|
|
2121
|
+
constructor(sourceName, detail) {
|
|
2122
|
+
super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
|
|
2123
|
+
this.name = "MissingSourceError";
|
|
2124
|
+
}
|
|
2125
|
+
};
|
|
2126
|
+
function projectSeedGuards(runs) {
|
|
2127
|
+
const byRef = /* @__PURE__ */ new Map();
|
|
2128
|
+
for (const run of runs) {
|
|
2129
|
+
if (!run.result)
|
|
2130
|
+
continue;
|
|
2131
|
+
for (const fired of run.result.guardsFired) {
|
|
2132
|
+
const seed = projectSeedGuard(
|
|
2133
|
+
fired,
|
|
2134
|
+
/* includeExplanation */
|
|
2135
|
+
false
|
|
2136
|
+
);
|
|
2137
|
+
const existing = byRef.get(seed.patternRef);
|
|
2138
|
+
if (!existing) {
|
|
2139
|
+
byRef.set(seed.patternRef, seed);
|
|
2140
|
+
} else if (existing.severity !== seed.severity) {
|
|
2141
|
+
byRef.set(seed.patternRef, pickHigherSeverity(existing, seed));
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
return [...byRef.values()].sort((a, b) => a.patternRef.localeCompare(b.patternRef));
|
|
2146
|
+
}
|
|
2147
|
+
var SEVERITY_ORDINAL = { info: 0, warn: 1, block: 2 };
|
|
2148
|
+
function pickHigherSeverity(a, b) {
|
|
2149
|
+
return (SEVERITY_ORDINAL[a.severity] ?? -1) >= (SEVERITY_ORDINAL[b.severity] ?? -1) ? a : b;
|
|
2150
|
+
}
|
|
2151
|
+
function normalizeLegacyLine(parsed) {
|
|
2152
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2153
|
+
return parsed;
|
|
2154
|
+
}
|
|
2155
|
+
const record2 = parsed;
|
|
2156
|
+
if (record2["outcome"] !== void 0)
|
|
2157
|
+
return parsed;
|
|
2158
|
+
return {
|
|
2159
|
+
...record2,
|
|
2160
|
+
runId: record2["runId"] ?? syntheticRunId(record2),
|
|
2161
|
+
outcome: "evaluated"
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
function syntheticRunId(record2) {
|
|
2165
|
+
const key = [
|
|
2166
|
+
record2["engagementId"],
|
|
2167
|
+
record2["occurredAt"],
|
|
2168
|
+
record2["runType"],
|
|
2169
|
+
record2["phase"]
|
|
2170
|
+
].join("|");
|
|
2171
|
+
const hex = createHash("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
|
|
2172
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
2173
|
+
}
|
|
2174
|
+
function readGuardEvalLog(logDir, phaseId) {
|
|
2175
|
+
const logPath = join6(logDir, `phase-${phaseId}.jsonl`);
|
|
2176
|
+
if (!existsSync4(logPath)) {
|
|
2177
|
+
throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
|
|
2178
|
+
}
|
|
2179
|
+
const raw = readFileSync6(logPath, "utf-8");
|
|
2180
|
+
const runs = [];
|
|
2181
|
+
for (const line of raw.split("\n")) {
|
|
2182
|
+
const trimmed = line.trim();
|
|
2183
|
+
if (trimmed === "")
|
|
2184
|
+
continue;
|
|
2185
|
+
const parsed = JSON.parse(trimmed);
|
|
2186
|
+
runs.push(guardEvalRunSchema.parse(normalizeLegacyLine(parsed)));
|
|
2187
|
+
}
|
|
2188
|
+
return runs;
|
|
2189
|
+
}
|
|
2190
|
+
function readOrchestrationState(phasesDir, phaseId) {
|
|
2191
|
+
const statePath = join6(phasesDir, `phase-${phaseId}-orchestration-state.json`);
|
|
2192
|
+
if (!existsSync4(statePath)) {
|
|
2193
|
+
throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
|
|
2194
|
+
}
|
|
2195
|
+
const parsed = JSON.parse(readFileSync6(statePath, "utf-8"));
|
|
2196
|
+
if (!parsed.tasks || typeof parsed.tasks !== "object") {
|
|
2197
|
+
throw new MissingSourceError("orchestration state", `${statePath} has no tasks map`);
|
|
2198
|
+
}
|
|
2199
|
+
const tasks = {};
|
|
2200
|
+
for (const [id, t] of Object.entries(parsed.tasks)) {
|
|
2201
|
+
if (!t || typeof t.status !== "string") {
|
|
2202
|
+
throw new MissingSourceError("orchestration state", `task ${id} has no status`);
|
|
2203
|
+
}
|
|
2204
|
+
tasks[id] = { status: t.status };
|
|
2205
|
+
}
|
|
2206
|
+
return { tasks };
|
|
2207
|
+
}
|
|
2208
|
+
function readGitClose(repoRoot) {
|
|
2209
|
+
let closeCommit;
|
|
2210
|
+
let closedDate;
|
|
2211
|
+
try {
|
|
2212
|
+
closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
|
|
2213
|
+
encoding: "utf-8"
|
|
2214
|
+
}).trim();
|
|
2215
|
+
closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], { encoding: "utf-8" }).trim();
|
|
2216
|
+
} catch (err) {
|
|
2217
|
+
throw new MissingSourceError("git HEAD", `cannot resolve close commit/date in ${repoRoot}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2218
|
+
}
|
|
2219
|
+
if (!closeCommit)
|
|
2220
|
+
throw new MissingSourceError("git HEAD", `empty commit sha in ${repoRoot}`);
|
|
2221
|
+
if (!closedDate)
|
|
2222
|
+
throw new MissingSourceError("git HEAD", `empty commit date in ${repoRoot}`);
|
|
2223
|
+
return { closeCommit, closedDate };
|
|
2224
|
+
}
|
|
2225
|
+
function validateNarrated(narrated) {
|
|
2226
|
+
const req = (v, name) => {
|
|
2227
|
+
if (v === void 0 || v === null || v === "") {
|
|
2228
|
+
throw new MissingSourceError("narrated artefact", `${name} is missing`);
|
|
2229
|
+
}
|
|
2230
|
+
};
|
|
2231
|
+
req(narrated.engagement, "engagement");
|
|
2232
|
+
req(narrated.engagementType, "engagementType");
|
|
2233
|
+
req(narrated.phase?.name, "phase.name");
|
|
2234
|
+
req(narrated.status, "status");
|
|
2235
|
+
req(narrated.thesis, "thesis");
|
|
2236
|
+
req(narrated.delivered, "delivered");
|
|
2237
|
+
req(narrated.acceptance, "acceptance");
|
|
2238
|
+
req(narrated.acceptance?.verdict, "acceptance.verdict");
|
|
2239
|
+
req(narrated.gatesEarnedKeep, "gatesEarnedKeep");
|
|
2240
|
+
req(narrated.instruments, "instruments");
|
|
2241
|
+
if (!Array.isArray(narrated.deviations)) {
|
|
2242
|
+
throw new MissingSourceError("narrated artefact", "deviations must be an array");
|
|
2243
|
+
}
|
|
2244
|
+
return narrated;
|
|
2245
|
+
}
|
|
2246
|
+
function loadSources(opts) {
|
|
2247
|
+
const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
|
|
2248
|
+
const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
|
|
2249
|
+
const git2 = readGitClose(opts.repoRoot);
|
|
2250
|
+
const narrated = validateNarrated(opts.narrated);
|
|
2251
|
+
if (!Array.isArray(opts.touchedInvariants)) {
|
|
2252
|
+
throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
|
|
2253
|
+
}
|
|
2254
|
+
return {
|
|
2255
|
+
orchestration,
|
|
2256
|
+
narrated,
|
|
2257
|
+
git: git2,
|
|
2258
|
+
guardEvalRuns,
|
|
2259
|
+
touchedInvariants: opts.touchedInvariants
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// dist/build-record/assemble.js
|
|
2264
|
+
var LANDED_STATES = /* @__PURE__ */ new Set(["merged", "done"]);
|
|
2265
|
+
var CANCELLED_STATE = "cancelled";
|
|
2266
|
+
function deriveTasks(orchestration) {
|
|
2267
|
+
const ids = Object.keys(orchestration.tasks);
|
|
2268
|
+
const cancelled = ids.filter((id) => orchestration.tasks[id].status === CANCELLED_STATE).sort((a, b) => a.localeCompare(b));
|
|
2269
|
+
const landed = ids.filter((id) => LANDED_STATES.has(orchestration.tasks[id].status)).length;
|
|
2270
|
+
return { planned: ids.length, landed, cancelled };
|
|
2271
|
+
}
|
|
2272
|
+
function deriveThesisHeld(acceptance) {
|
|
2273
|
+
if (acceptance.verdict !== "clean")
|
|
2274
|
+
return false;
|
|
2275
|
+
for (const dw of Object.values(acceptance.doneWhen)) {
|
|
2276
|
+
if (dw.result === "dropped")
|
|
2277
|
+
continue;
|
|
2278
|
+
if (dw.result !== "pass")
|
|
2279
|
+
return false;
|
|
2280
|
+
}
|
|
2281
|
+
return true;
|
|
2282
|
+
}
|
|
2283
|
+
function deriveInvariantsExercised(touched) {
|
|
2284
|
+
return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
|
|
2285
|
+
}
|
|
2286
|
+
function assembleBuildRecord(sources) {
|
|
2287
|
+
const { narrated, git: git2, orchestration } = sources;
|
|
2288
|
+
return {
|
|
2289
|
+
format: BUILD_RECORD_FORMAT,
|
|
2290
|
+
engagement: narrated.engagement,
|
|
2291
|
+
engagementType: narrated.engagementType,
|
|
2292
|
+
phase: { id: narrated.phase.id, name: narrated.phase.name },
|
|
2293
|
+
status: narrated.status,
|
|
2294
|
+
closedDate: git2.closedDate,
|
|
2295
|
+
closeCommit: git2.closeCommit,
|
|
2296
|
+
thesis: narrated.thesis,
|
|
2297
|
+
thesisHeld: deriveThesisHeld(narrated.acceptance),
|
|
2298
|
+
delivered: narrated.delivered,
|
|
2299
|
+
seedGuards: projectSeedGuards(sources.guardEvalRuns),
|
|
2300
|
+
acceptance: narrated.acceptance,
|
|
2301
|
+
gatesEarnedKeep: narrated.gatesEarnedKeep,
|
|
2302
|
+
instruments: narrated.instruments,
|
|
2303
|
+
invariantsExercised: deriveInvariantsExercised(sources.touchedInvariants),
|
|
2304
|
+
deviations: narrated.deviations,
|
|
2305
|
+
tasks: deriveTasks(orchestration)
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// dist/build-record/template.js
|
|
2310
|
+
function severityLabel(seed) {
|
|
2311
|
+
return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
|
|
2312
|
+
}
|
|
2313
|
+
function renderDoneWhenRows(record2) {
|
|
2314
|
+
const rows = [];
|
|
2315
|
+
for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
|
|
2316
|
+
const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
|
|
2317
|
+
rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
|
|
2318
|
+
}
|
|
2319
|
+
return rows.join("\n");
|
|
2320
|
+
}
|
|
2321
|
+
function renderBuildRecordMarkdown(record2) {
|
|
2322
|
+
const { phase } = record2;
|
|
2323
|
+
const guardsList = record2.seedGuards.map(severityLabel).join(", ");
|
|
2324
|
+
const lines = [
|
|
2325
|
+
// Fixed prose (vault-canonical template) + projected title slots.
|
|
2326
|
+
`# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
|
|
2327
|
+
"",
|
|
2328
|
+
`**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
|
|
2329
|
+
"",
|
|
2330
|
+
"> 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.",
|
|
2331
|
+
"",
|
|
2332
|
+
`**Engagement:** ${record2.engagement} (${record2.engagementType})`,
|
|
2333
|
+
`**Phase:** ${phase.id} \u2014 ${phase.name}`,
|
|
2334
|
+
`**Status:** ${record2.status.toUpperCase()} \xB7 **Closed:** ${record2.closedDate} \xB7 **Close commit:** \`${record2.closeCommit}\``,
|
|
2335
|
+
`**Companion:** [\`phase-${phase.id}.json\`](phase-${phase.id}.json)`,
|
|
2336
|
+
"",
|
|
2337
|
+
"## What this phase proved",
|
|
2338
|
+
"",
|
|
2339
|
+
`${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
|
|
2340
|
+
"",
|
|
2341
|
+
"## Delivered",
|
|
2342
|
+
"",
|
|
2343
|
+
`- **Packages:** ${record2.delivered.packages.join(", ")}`,
|
|
2344
|
+
`- **Services:** ${record2.delivered.services.join(", ")}`,
|
|
2345
|
+
`- **Tools:** ${record2.delivered.tools.join(", ")}`,
|
|
2346
|
+
`- **Dogfood:** ${record2.delivered.dogfood}`,
|
|
2347
|
+
"",
|
|
2348
|
+
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.",
|
|
2349
|
+
"",
|
|
2350
|
+
`## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
|
|
2351
|
+
"",
|
|
2352
|
+
`**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
|
|
2353
|
+
"",
|
|
2354
|
+
"| Done-when | Result | Evidence |",
|
|
2355
|
+
"|---|---|---|",
|
|
2356
|
+
renderDoneWhenRows(record2),
|
|
2357
|
+
""
|
|
2358
|
+
];
|
|
2359
|
+
if (record2.acceptance.inv002ProdBypassProbe) {
|
|
2360
|
+
lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
|
|
2361
|
+
}
|
|
2362
|
+
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"}`, "");
|
|
2363
|
+
return lines.join("\n");
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// dist/build-record/write.js
|
|
2367
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2368
|
+
import { join as join7, resolve, relative as relative2, isAbsolute } from "node:path";
|
|
2369
|
+
var BUILD_RECORD_DIR = join7("docs", "build-records");
|
|
2370
|
+
function serialiseRecordJson(record2) {
|
|
2371
|
+
return JSON.stringify(record2, null, 2) + "\n";
|
|
2372
|
+
}
|
|
2373
|
+
function writeBuildRecord(repoRoot, record2) {
|
|
2374
|
+
const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
|
|
2375
|
+
const expected = resolve(repoRoot, BUILD_RECORD_DIR);
|
|
2376
|
+
const rel = relative2(expected, outDir);
|
|
2377
|
+
if (rel !== "" || isAbsolute(rel)) {
|
|
2378
|
+
throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
|
|
2379
|
+
}
|
|
2380
|
+
mkdirSync5(outDir, { recursive: true });
|
|
2381
|
+
const jsonPath = join7(outDir, `phase-${record2.phase.id}.json`);
|
|
2382
|
+
const mdPath = join7(outDir, `phase-${record2.phase.id}.md`);
|
|
2383
|
+
writeFileSync5(jsonPath, serialiseRecordJson(record2), "utf-8");
|
|
2384
|
+
writeFileSync5(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
|
|
2385
|
+
return { jsonPath, mdPath };
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// dist/build-record/index.js
|
|
2389
|
+
function assemblePhaseBuildRecord(opts) {
|
|
2390
|
+
const sources = loadSources({
|
|
2391
|
+
repoRoot: opts.repoRoot,
|
|
2392
|
+
phasesDir: opts.phasesDir,
|
|
2393
|
+
guardEvalLogDir: opts.guardEvalLogDir,
|
|
2394
|
+
phaseId: opts.phaseId,
|
|
2395
|
+
narrated: opts.narrated,
|
|
2396
|
+
touchedInvariants: opts.touchedInvariants
|
|
2397
|
+
});
|
|
2398
|
+
const record2 = assembleBuildRecord(sources);
|
|
2399
|
+
return writeBuildRecord(opts.repoRoot, record2);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// dist/open-phase.js
|
|
2403
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
2404
|
+
import { join as join8 } from "node:path";
|
|
2405
|
+
var OpenPhaseRefused = class extends Error {
|
|
2406
|
+
status;
|
|
2407
|
+
finding;
|
|
2408
|
+
constructor(status, message, finding) {
|
|
2409
|
+
super(message);
|
|
2410
|
+
this.status = status;
|
|
2411
|
+
this.finding = finding;
|
|
2412
|
+
this.name = "OpenPhaseRefused";
|
|
2413
|
+
}
|
|
2414
|
+
};
|
|
2415
|
+
var OPEN_PHASE_ENV_KEYS = [
|
|
2416
|
+
"HALFCYCLE_SERVICE_URL",
|
|
2417
|
+
"HALFCYCLE_TOKEN",
|
|
2418
|
+
"HALFCYCLE_ENGAGEMENT_ID"
|
|
2419
|
+
];
|
|
2420
|
+
function readRepoCredential(repoRoot, env = process.env, home) {
|
|
2421
|
+
const pinned = readPinnedEngagementId(repoRoot);
|
|
2422
|
+
const fromStore = pinned === null ? null : readEngagementEnv(pinned, home);
|
|
2423
|
+
const looked = pinned === null ? `${join8(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
|
|
2424
|
+
const values = {};
|
|
2425
|
+
for (const key of OPEN_PHASE_ENV_KEYS) {
|
|
2426
|
+
const value = (env[key] ?? fromStore?.[key] ?? "").trim();
|
|
2427
|
+
if (value === "") {
|
|
2428
|
+
throw new Error(`[halfcycle] ${key} is not set \u2014 it is neither in this shell's environment nor in ${looked}. That file is written by \`npx halfcycle\`; if this repo has no Halfcycle install yet, or this machine has never installed this engagement, run the installer here first.`);
|
|
2429
|
+
}
|
|
2430
|
+
values[key] = value;
|
|
2431
|
+
}
|
|
2432
|
+
return {
|
|
2433
|
+
serviceUrl: values["HALFCYCLE_SERVICE_URL"].replace(/\/+$/, ""),
|
|
2434
|
+
token: values["HALFCYCLE_TOKEN"],
|
|
2435
|
+
engagementId: values["HALFCYCLE_ENGAGEMENT_ID"]
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
function readPinnedEngagementId(repoRoot) {
|
|
2439
|
+
try {
|
|
2440
|
+
const pin = JSON.parse(readFileSync7(join8(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
|
|
2441
|
+
const id = pin[PIN_ENGAGEMENT_ID_FIELD];
|
|
2442
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
2443
|
+
} catch {
|
|
2444
|
+
return null;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
async function openPhase(credential, phase, entry) {
|
|
2448
|
+
const parsed = phaseEntryDecisionSchema.safeParse(entry);
|
|
2449
|
+
if (!parsed.success) {
|
|
2450
|
+
throw new Error(`[halfcycle] The entry decision is not a decision this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
|
|
2451
|
+
}
|
|
2452
|
+
const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phase`;
|
|
2453
|
+
let res;
|
|
2454
|
+
try {
|
|
2455
|
+
res = await fetch(url, {
|
|
2456
|
+
method: "POST",
|
|
2457
|
+
headers: {
|
|
2458
|
+
"content-type": "application/json",
|
|
2459
|
+
authorization: `Bearer ${credential.token}`
|
|
2460
|
+
},
|
|
2461
|
+
body: JSON.stringify({ phase, entry })
|
|
2462
|
+
});
|
|
2463
|
+
} catch (err) {
|
|
2464
|
+
throw new Error(`[halfcycle] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. No phase was opened.`);
|
|
2465
|
+
}
|
|
2466
|
+
if (!res.ok) {
|
|
2467
|
+
const body2 = await res.json().catch(() => null);
|
|
2468
|
+
const message = typeof body2?.message === "string" ? body2.message : `${url} returned ${res.status}.`;
|
|
2469
|
+
const finding = typeof body2?.finding === "string" ? body2.finding : null;
|
|
2470
|
+
throw new OpenPhaseRefused(res.status, message, finding);
|
|
2471
|
+
}
|
|
2472
|
+
const body = await res.json().catch(() => null);
|
|
2473
|
+
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") {
|
|
2474
|
+
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.`);
|
|
2475
|
+
}
|
|
2476
|
+
return {
|
|
2477
|
+
phase: body.phase,
|
|
2478
|
+
previousPhase: body.previousPhase,
|
|
2479
|
+
decision: body.decision
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// dist/close-phase.js
|
|
2484
|
+
var ClosePhaseRefused = class extends Error {
|
|
2485
|
+
status;
|
|
2486
|
+
finding;
|
|
2487
|
+
code;
|
|
2488
|
+
constructor(status, message, finding, code) {
|
|
2489
|
+
super(message);
|
|
2490
|
+
this.status = status;
|
|
2491
|
+
this.finding = finding;
|
|
2492
|
+
this.code = code;
|
|
2493
|
+
this.name = "ClosePhaseRefused";
|
|
2494
|
+
}
|
|
2495
|
+
};
|
|
2496
|
+
async function closePhase(credential, phase, outcome, close) {
|
|
2497
|
+
const parsed = acceptanceOutcomeSchema.safeParse(outcome);
|
|
2498
|
+
if (!parsed.success) {
|
|
2499
|
+
throw new Error(`[halfcycle] That is not an acceptance outcome this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
|
|
2500
|
+
}
|
|
2501
|
+
const parsedClose = phaseCloseDecisionSchema.safeParse(close);
|
|
2502
|
+
if (!parsedClose.success) {
|
|
2503
|
+
throw new Error(`[halfcycle] That is not a phase-close decision this platform accepts: ${parsedClose.error.issues.map((i) => i.message).join("; ")}.`);
|
|
2504
|
+
}
|
|
2505
|
+
const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/accept`;
|
|
2506
|
+
let res;
|
|
2507
|
+
try {
|
|
2508
|
+
res = await fetch(url, {
|
|
2509
|
+
method: "POST",
|
|
2510
|
+
headers: {
|
|
2511
|
+
"content-type": "application/json",
|
|
2512
|
+
authorization: `Bearer ${credential.token}`
|
|
2513
|
+
},
|
|
2514
|
+
body: JSON.stringify({ phase, acceptanceOutcome: outcome, close })
|
|
2515
|
+
});
|
|
2516
|
+
} catch (err) {
|
|
2517
|
+
throw new Error(`[halfcycle] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. The phase was not closed.`);
|
|
2518
|
+
}
|
|
2519
|
+
if (!res.ok) {
|
|
2520
|
+
const body2 = await res.json().catch(() => null);
|
|
2521
|
+
const message = typeof body2?.message === "string" ? body2.message : `${url} returned ${res.status}.`;
|
|
2522
|
+
const finding = typeof body2?.finding === "string" ? body2.finding : void 0;
|
|
2523
|
+
const code = typeof body2?.error === "string" ? body2.error : void 0;
|
|
2524
|
+
throw new ClosePhaseRefused(res.status, message, finding, code);
|
|
2525
|
+
}
|
|
2526
|
+
const body = await res.json().catch(() => null);
|
|
2527
|
+
if (!body || typeof body.engagementId !== "string" || typeof body.status !== "string") {
|
|
2528
|
+
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.`);
|
|
2529
|
+
}
|
|
2530
|
+
return { engagementId: body.engagementId, status: body.status };
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// dist/cli-contract.js
|
|
2534
|
+
var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase"];
|
|
2535
|
+
var CONTRACTS = [
|
|
2536
|
+
// `install`, `check-drift` and `build-record` take positionals and no required
|
|
2537
|
+
// flags. They are declared so the verb set has ONE home — a remedy naming a verb
|
|
2538
|
+
// this CLI does not have is the same defect as one missing a flag.
|
|
2539
|
+
{ verb: "install", required: [], conditional: [] },
|
|
2540
|
+
{ verb: "check-drift", required: [], conditional: [] },
|
|
2541
|
+
{ verb: "build-record", required: [], conditional: [] },
|
|
2542
|
+
{
|
|
2543
|
+
verb: "open-phase",
|
|
2544
|
+
required: [
|
|
2545
|
+
{
|
|
2546
|
+
flag: "--actor",
|
|
2547
|
+
takesValue: true,
|
|
2548
|
+
missingMessage: "halfcycle open-phase: --actor is required \u2014 the decision is recorded, and an unattributed decision is what that record exists to prevent."
|
|
2549
|
+
},
|
|
2550
|
+
{
|
|
2551
|
+
flag: "--evidence",
|
|
2552
|
+
orFlags: ["--override"],
|
|
2553
|
+
takesValue: true,
|
|
2554
|
+
missingMessage: 'halfcycle open-phase: --evidence is required (or --override --reason "\u2026") \u2014 an entry condition asserted without evidence asserts nothing.'
|
|
2555
|
+
}
|
|
2556
|
+
],
|
|
2557
|
+
conditional: [
|
|
2558
|
+
{
|
|
2559
|
+
whenPresent: "--override",
|
|
2560
|
+
thenRequires: "--reason",
|
|
2561
|
+
takesValue: true,
|
|
2562
|
+
missingMessage: "halfcycle open-phase: --override requires --reason \u2014 an override with no reason is a silent skip wearing a decision."
|
|
2563
|
+
}
|
|
2564
|
+
]
|
|
2565
|
+
},
|
|
2566
|
+
{
|
|
2567
|
+
verb: "close-phase",
|
|
2568
|
+
// Declaration order is the order a rendered invocation names them, and the
|
|
2569
|
+
// order refusals fire in. `--verdict` leads because that is how the usage line
|
|
2570
|
+
// and every printed remedy already read.
|
|
2571
|
+
required: [
|
|
2572
|
+
{
|
|
2573
|
+
flag: "--verdict",
|
|
2574
|
+
takesValue: true,
|
|
2575
|
+
missingMessage: `halfcycle close-phase: --verdict must be "clean" or "defects" \u2014 it is the acceptance walk's own verdict (INV-009) and nothing derives it for you.`
|
|
2576
|
+
},
|
|
2577
|
+
{
|
|
2578
|
+
flag: "--actor",
|
|
2579
|
+
takesValue: true,
|
|
2580
|
+
missingMessage: "halfcycle close-phase: --actor is required \u2014 closing a phase decides whether the method re-serves at the next one, and that decision is recorded. An unattributed close is what the record exists to prevent."
|
|
2581
|
+
}
|
|
2582
|
+
],
|
|
2583
|
+
conditional: [
|
|
2584
|
+
{
|
|
2585
|
+
whenPresent: "--override",
|
|
2586
|
+
thenRequires: "--reason",
|
|
2587
|
+
takesValue: true,
|
|
2588
|
+
missingMessage: "halfcycle close-phase: --override requires --reason \u2014 an override with no reason is a silent skip wearing a decision."
|
|
2589
|
+
}
|
|
2590
|
+
]
|
|
2591
|
+
}
|
|
2592
|
+
];
|
|
2593
|
+
function contractFor(verb) {
|
|
2594
|
+
return CONTRACTS.find((c) => c.verb === verb);
|
|
2595
|
+
}
|
|
2596
|
+
function carries(tokens, flag, takesValue) {
|
|
2597
|
+
const i = tokens.indexOf(flag);
|
|
2598
|
+
if (i < 0)
|
|
2599
|
+
return false;
|
|
2600
|
+
if (!takesValue)
|
|
2601
|
+
return true;
|
|
2602
|
+
const value = tokens[i + 1];
|
|
2603
|
+
if (value === void 0 || value.startsWith("--"))
|
|
2604
|
+
return false;
|
|
2605
|
+
return value.trim().length > 0;
|
|
2606
|
+
}
|
|
2607
|
+
function missingRequirements(verb, tokens) {
|
|
2608
|
+
const contract = contractFor(verb);
|
|
2609
|
+
if (!contract) {
|
|
2610
|
+
return [
|
|
2611
|
+
{
|
|
2612
|
+
flag: verb,
|
|
2613
|
+
message: `halfcycle: unknown command "${verb}" \u2014 the verbs are ${CLI_VERBS.join(", ")}.`
|
|
2614
|
+
}
|
|
2615
|
+
];
|
|
2616
|
+
}
|
|
2617
|
+
const shortfalls = [];
|
|
2618
|
+
for (const req of contract.required) {
|
|
2619
|
+
const satisfied = carries(tokens, req.flag, req.takesValue) || (req.orFlags ?? []).some((alt) => tokens.includes(alt));
|
|
2620
|
+
if (!satisfied)
|
|
2621
|
+
shortfalls.push({ flag: req.flag, message: req.missingMessage });
|
|
2622
|
+
}
|
|
2623
|
+
for (const cond of contract.conditional) {
|
|
2624
|
+
if (!tokens.includes(cond.whenPresent))
|
|
2625
|
+
continue;
|
|
2626
|
+
if (!carries(tokens, cond.thenRequires, cond.takesValue)) {
|
|
2627
|
+
shortfalls.push({ flag: cond.thenRequires, message: cond.missingMessage });
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
return shortfalls;
|
|
2631
|
+
}
|
|
2632
|
+
function requirementMessage(verb, flag) {
|
|
2633
|
+
const contract = contractFor(verb);
|
|
2634
|
+
const req = contract?.required.find((r) => r.flag === flag);
|
|
2635
|
+
return req?.missingMessage ?? `halfcycle ${verb}: ${flag} is required.`;
|
|
2636
|
+
}
|
|
2637
|
+
function runnableInvocation(verb, phase) {
|
|
2638
|
+
const contract = contractFor(verb);
|
|
2639
|
+
const flags = (contract?.required ?? []).map((req) => `${req.flag} ${PLACEHOLDERS[req.flag] ?? '"\u2026"'}`).join(" ");
|
|
2640
|
+
return `halfcycle ${verb} ${phase}${flags ? ` ${flags}` : ""}`;
|
|
2641
|
+
}
|
|
2642
|
+
var PLACEHOLDERS = {
|
|
2643
|
+
"--verdict": "<clean|defects>",
|
|
2644
|
+
"--actor": '"you"',
|
|
2645
|
+
"--evidence": '"\u2026"'
|
|
2646
|
+
};
|
|
2647
|
+
|
|
2648
|
+
// dist/bin.js
|
|
2649
|
+
var args = process.argv.slice(2);
|
|
2650
|
+
var MIN_HEADERS_HELPER_VERSION = "2.1.118";
|
|
2651
|
+
var MIN_HEADER_ROTATION_VERSION = "2.1.193";
|
|
2652
|
+
function compareVersions(a, b) {
|
|
2653
|
+
const pa = a.split(".").map((p) => Number.parseInt(p, 10));
|
|
2654
|
+
const pb = b.split(".").map((p) => Number.parseInt(p, 10));
|
|
2655
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
2656
|
+
const x = Number.isFinite(pa[i]) ? pa[i] : 0;
|
|
2657
|
+
const y = Number.isFinite(pb[i]) ? pb[i] : 0;
|
|
2658
|
+
if (x !== y)
|
|
2659
|
+
return x - y;
|
|
2660
|
+
}
|
|
2661
|
+
return 0;
|
|
2662
|
+
}
|
|
2663
|
+
function reportClaudeCodeVersion() {
|
|
2664
|
+
let raw;
|
|
2665
|
+
try {
|
|
2666
|
+
raw = execFileSync3("claude", ["--version"], {
|
|
2667
|
+
encoding: "utf-8",
|
|
2668
|
+
timeout: 5e3,
|
|
2669
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2670
|
+
});
|
|
2671
|
+
} catch {
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
const found = /(\d+\.\d+\.\d+)/.exec(raw);
|
|
2675
|
+
if (!found)
|
|
2676
|
+
return;
|
|
2677
|
+
const version = found[1];
|
|
2678
|
+
if (compareVersions(version, MIN_HEADERS_HELPER_VERSION) >= 0) {
|
|
2679
|
+
process.stdout.write(`[halfcycle] Claude Code ${version} detected \u2014 new enough for the credential helper.
|
|
2680
|
+
`);
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
process.stdout.write(`[halfcycle] WARNING: Claude Code ${version} is older than ${MIN_HEADERS_HELPER_VERSION}, which ignores the credential helper this install wrote. The Halfcycle server will connect with no credential and every call will fail \u2014 upgrade Claude Code, then re-open this folder.
|
|
2684
|
+
`);
|
|
2685
|
+
}
|
|
2686
|
+
var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|client]
|
|
2687
|
+
install into target (default: the current directory)
|
|
2688
|
+
halfcycle check-drift <target-repo>
|
|
2689
|
+
halfcycle build-record <phase-id> [--repo <root>]
|
|
2690
|
+
halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
|
|
2691
|
+
halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
|
|
2692
|
+
`;
|
|
2693
|
+
function isHalfcycleMonorepo(dir) {
|
|
2694
|
+
try {
|
|
2695
|
+
const pkg = JSON.parse(readFileSync8(join9(dir, "package.json"), "utf-8"));
|
|
2696
|
+
return pkg.name === "halfcycle-monorepo";
|
|
2697
|
+
} catch {
|
|
2698
|
+
return false;
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
async function createOwnedEngagement(serviceUrl) {
|
|
2702
|
+
let credential = await obtainCredential(serviceUrl);
|
|
2703
|
+
for (; ; ) {
|
|
2704
|
+
try {
|
|
2705
|
+
return await createEngagement(serviceUrl, void 0, credential.credential);
|
|
2706
|
+
} catch (err) {
|
|
2707
|
+
if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
|
|
2708
|
+
throw err;
|
|
2709
|
+
const replacement = await replaceRefusedCredential(serviceUrl, credential);
|
|
2710
|
+
if (replacement === null) {
|
|
2711
|
+
throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
|
|
2712
|
+
}
|
|
2713
|
+
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
2714
|
+
`);
|
|
2715
|
+
credential = replacement;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
var JOIN_REFUSED_LOCAL_REMEDY = "No credential was minted and nothing was written. This repository is pinned to that engagement by .halfcycle/bundle.json \u2014 check that the id in it is the one you meant. To start a NEW engagement here instead, remove that file and re-run.";
|
|
2720
|
+
async function joinPinnedEngagement(serviceUrl, engagementId) {
|
|
2721
|
+
let credential = await obtainCredential(serviceUrl);
|
|
2722
|
+
for (; ; ) {
|
|
2723
|
+
try {
|
|
2724
|
+
return await joinEngagement(serviceUrl, engagementId, credential.credential);
|
|
2725
|
+
} catch (err) {
|
|
2726
|
+
if (!(err instanceof CreateEngagementRefused))
|
|
2727
|
+
throw err;
|
|
2728
|
+
if (err.status === 403) {
|
|
2729
|
+
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
|
|
2730
|
+
}
|
|
2731
|
+
if (err.status !== 401) {
|
|
2732
|
+
throw err;
|
|
2733
|
+
}
|
|
2734
|
+
const replacement = await replaceRefusedCredential(serviceUrl, credential);
|
|
2735
|
+
if (replacement === null) {
|
|
2736
|
+
throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
|
|
2737
|
+
}
|
|
2738
|
+
process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
|
|
2739
|
+
`);
|
|
2740
|
+
credential = replacement;
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
async function main() {
|
|
2745
|
+
const [cmd, ...rest] = args;
|
|
2746
|
+
if (cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
2747
|
+
process.stdout.write(`halfcycle \u2014 install and drive the Halfcycle method in a repository.
|
|
2748
|
+
|
|
2749
|
+
Usage:
|
|
2750
|
+
${USAGE}`);
|
|
2751
|
+
process.exit(0);
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase)$/.test(cmd);
|
|
2755
|
+
if (cmd === "install" || cmd === void 0 || bareTarget) {
|
|
2756
|
+
const positionals = cmd === "install" ? rest : args;
|
|
2757
|
+
const targetRepo = positionals[0] ?? process.cwd();
|
|
2758
|
+
const engagementIdArg = positionals[1];
|
|
2759
|
+
const engagementTypeRaw = positionals[2] ?? "client";
|
|
2760
|
+
if (isHalfcycleMonorepo(targetRepo)) {
|
|
2761
|
+
process.stderr.write(`halfcycle: refusing to install into the Halfcycle monorepo itself (${targetRepo}).
|
|
2762
|
+
The bare form installs into the CURRENT directory, and this directory is the
|
|
2763
|
+
product's own source tree \u2014 installing here would write over tracked files.
|
|
2764
|
+
To see what this CLI does: halfcycle --help
|
|
2765
|
+
To install into a real project: halfcycle <path-to-that-repo>
|
|
2766
|
+
`);
|
|
2767
|
+
process.exit(1);
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
if (engagementTypeRaw !== "self-build" && engagementTypeRaw !== "client") {
|
|
2771
|
+
process.stderr.write(`halfcycle: engagement type must be "self-build" or "client", got "${engagementTypeRaw}"
|
|
2772
|
+
`);
|
|
2773
|
+
process.exit(1);
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
try {
|
|
2777
|
+
const serviceUrl = process.env.HALFCYCLE_SERVICE_URL?.trim();
|
|
2778
|
+
const pinned = engagementIdArg === void 0 ? readPinnedEngagement(targetRepo) : null;
|
|
2779
|
+
let engagementId = engagementIdArg ?? pinned?.engagementId ?? "engagement-001";
|
|
2780
|
+
let credential;
|
|
2781
|
+
if (pinned) {
|
|
2782
|
+
credential = pinned.credential;
|
|
2783
|
+
process.stdout.write(`[halfcycle] Re-using the engagement already pinned in this repo: ${pinned.engagementId}
|
|
2784
|
+
`);
|
|
2785
|
+
if (credential === void 0 && serviceUrl) {
|
|
2786
|
+
const joined = await joinPinnedEngagement(serviceUrl, pinned.engagementId);
|
|
2787
|
+
credential = {
|
|
2788
|
+
serviceUrl,
|
|
2789
|
+
token: joined.sessionToken,
|
|
2790
|
+
mcpUrl: joined.mcpUrl,
|
|
2791
|
+
guardUrl: joined.guardUrl,
|
|
2792
|
+
// T-10 — optional; absent on a plane that has not configured
|
|
2793
|
+
// CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
|
|
2794
|
+
// an absent value, never `undefined` verbatim.
|
|
2795
|
+
controlTelemetryUrl: joined.controlTelemetryUrl
|
|
2796
|
+
};
|
|
2797
|
+
process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
|
|
2798
|
+
`);
|
|
2799
|
+
} else if (credential === void 0) {
|
|
2800
|
+
process.stdout.write(`[halfcycle] This machine holds no credential for it, and HALFCYCLE_SERVICE_URL is not
|
|
2801
|
+
[halfcycle] set \u2014 so there is no plane to ask for one. Nothing will be minted, and the
|
|
2802
|
+
[halfcycle] board link and MCP calls will not work until it is. Looked in
|
|
2803
|
+
[halfcycle] ${engagementEnvPath(pinned.engagementId)}.
|
|
2804
|
+
[halfcycle] Set that variable and re-run to get a credential of your own for this
|
|
2805
|
+
[halfcycle] engagement. To start a NEW engagement instead, remove
|
|
2806
|
+
[halfcycle] .halfcycle/bundle.json first.
|
|
2807
|
+
`);
|
|
2808
|
+
} else if (pinned.fromLegacyEnvLocal) {
|
|
2809
|
+
process.stdout.write(`[halfcycle] Its credential is in this repository's .env.local \u2014 an install from before
|
|
2810
|
+
[halfcycle] credentials moved out of the tree. It is being copied to
|
|
2811
|
+
[halfcycle] ${engagementEnvPath(pinned.engagementId)} and taken back out of .env.local.
|
|
2812
|
+
[halfcycle] Anything else that file holds is untouched.
|
|
2813
|
+
`);
|
|
2814
|
+
}
|
|
2815
|
+
} else if (serviceUrl && engagementIdArg === void 0) {
|
|
2816
|
+
const created = await createOwnedEngagement(serviceUrl);
|
|
2817
|
+
engagementId = created.engagementId;
|
|
2818
|
+
credential = {
|
|
2819
|
+
serviceUrl,
|
|
2820
|
+
token: created.sessionToken,
|
|
2821
|
+
mcpUrl: created.mcpUrl,
|
|
2822
|
+
// FW-4 (F-29) — the THIRD origin. Without it the install writes the
|
|
2823
|
+
// guard hook and none of the three variables that hook requires, so
|
|
2824
|
+
// every edit fails open and no guard ever runs.
|
|
2825
|
+
guardUrl: created.guardUrl,
|
|
2826
|
+
// T-10 — the FOURTH origin, optional. Absent on a plane that has not
|
|
2827
|
+
// configured CONTROL_TELEMETRY_URL yet; the runner's own telemetry
|
|
2828
|
+
// emission is fail-open on that absence.
|
|
2829
|
+
controlTelemetryUrl: created.controlTelemetryUrl
|
|
2830
|
+
};
|
|
2831
|
+
process.stdout.write(`[halfcycle] Created engagement ${created.engagementId}
|
|
2832
|
+
`);
|
|
2833
|
+
}
|
|
2834
|
+
const result = await install({
|
|
2835
|
+
targetRepo,
|
|
2836
|
+
engagementId,
|
|
2837
|
+
engagementType: engagementTypeRaw,
|
|
2838
|
+
credential
|
|
2839
|
+
});
|
|
2840
|
+
process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
|
|
2841
|
+
`);
|
|
2842
|
+
process.stdout.write(`[halfcycle] Written: ${result.writtenPaths.length} paths
|
|
2843
|
+
`);
|
|
2844
|
+
if (result.mergedPaths.length > 0) {
|
|
2845
|
+
process.stdout.write(`[halfcycle] Merged: ${result.mergedPaths.join(", ")}
|
|
2846
|
+
`);
|
|
2847
|
+
}
|
|
2848
|
+
if (result.skippedPaths.length > 0) {
|
|
2849
|
+
process.stdout.write(`[halfcycle] Skipped (already present): ${result.skippedPaths.join(", ")}
|
|
2850
|
+
`);
|
|
2851
|
+
}
|
|
2852
|
+
if (result.collidedPaths.length > 0) {
|
|
2853
|
+
process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${result.collidedPaths.join(", ")}
|
|
2854
|
+
`);
|
|
2855
|
+
}
|
|
2856
|
+
if (result.legacyCredentialKept !== null) {
|
|
2857
|
+
const { keys, missing } = result.legacyCredentialKept;
|
|
2858
|
+
process.stdout.write(`[halfcycle] .env.local was KEPT, not migrated \u2014 nothing was removed from it.
|
|
2859
|
+
[halfcycle] This repository still holds the only copy of ${keys.join(", ")},
|
|
2860
|
+
[halfcycle] and ${engagementEnvPath(engagementId)} does not.
|
|
2861
|
+
[halfcycle] The move needs ${missing.join(" and ")}, which this repository does not have:
|
|
2862
|
+
[halfcycle] it was installed before ${missing.length === 1 ? "that key" : "those keys"} existed. Add ${missing.length === 1 ? "it" : "them"} to .env.local (or export
|
|
2863
|
+
[halfcycle] ${missing.length === 1 ? "it" : "them"}) and re-run, and the credential moves out of the tree.
|
|
2864
|
+
`);
|
|
2865
|
+
}
|
|
2866
|
+
if (credential) {
|
|
2867
|
+
const probe = await probeMcpOrigin(credential.mcpUrl);
|
|
2868
|
+
if (probe.reached) {
|
|
2869
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
|
|
2870
|
+
`);
|
|
2871
|
+
} else {
|
|
2872
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
|
|
2873
|
+
[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 your HALFCYCLE_SERVICE_URL (${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.
|
|
2874
|
+
`);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
if (credential) {
|
|
2878
|
+
process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_ENV_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.
|
|
2879
|
+
`);
|
|
2880
|
+
} else {
|
|
2881
|
+
const remedy = pinned ? `Set HALFCYCLE_SERVICE_URL to the Halfcycle CONTROL origin and re-run \u2014 this repo is pinned to engagement ${pinned.engagementId}, and with a plane to ask, the installer mints you a credential of your own for it, at ${engagementEnvPath(pinned.engagementId)}. To start a NEW engagement here instead, remove .halfcycle/bundle.json and re-run.` : engagementIdArg !== void 0 ? `This install named engagement ${engagementIdArg} explicitly, which skips the create that mints a credential. This repo is now pinned to it, so re-run \`npx halfcycle\` with no id and the installer will mint you a credential of your own for it. To start a NEW engagement instead, remove .halfcycle/bundle.json and re-run.` : `Set HALFCYCLE_SERVICE_URL to the Halfcycle CONTROL origin and re-run this installer.`;
|
|
2882
|
+
process.stdout.write(`[halfcycle] Guard evaluation: OFF \u2014 this install had no credential, so no guard will run in this repository and edits are unguarded. ${remedy}
|
|
2883
|
+
`);
|
|
2884
|
+
}
|
|
2885
|
+
if (credential) {
|
|
2886
|
+
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.
|
|
2887
|
+
`);
|
|
2888
|
+
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).
|
|
2889
|
+
`);
|
|
2890
|
+
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.
|
|
2891
|
+
`);
|
|
2892
|
+
reportClaudeCodeVersion();
|
|
2893
|
+
}
|
|
2894
|
+
if (credential) {
|
|
2895
|
+
try {
|
|
2896
|
+
const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
|
|
2897
|
+
process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
|
|
2898
|
+
`);
|
|
2899
|
+
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}
|
|
2900
|
+
`);
|
|
2901
|
+
} catch {
|
|
2902
|
+
process.stdout.write("[halfcycle] Your board: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
process.exit(0);
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
if (err instanceof SignInRefused) {
|
|
2908
|
+
process.stderr.write(`halfcycle: ${err.message}
|
|
2909
|
+
`);
|
|
2910
|
+
process.exit(1);
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
process.stderr.write(`halfcycle install failed: ${err instanceof Error ? err.message : String(err)}
|
|
2914
|
+
`);
|
|
2915
|
+
process.exit(1);
|
|
2916
|
+
}
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
if (cmd === "check-drift") {
|
|
2920
|
+
const targetRepo = rest[0];
|
|
2921
|
+
if (!targetRepo) {
|
|
2922
|
+
process.stderr.write("halfcycle-bundle: usage: halfcycle-bundle check-drift <target-repo>\n");
|
|
2923
|
+
process.exit(1);
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2926
|
+
try {
|
|
2927
|
+
const { drifted, installed, current } = checkDrift(targetRepo);
|
|
2928
|
+
if (drifted) {
|
|
2929
|
+
process.stdout.write(`[halfcycle-bundle] DRIFT DETECTED: installed=${installed ?? "none"} current=${current}
|
|
2930
|
+
[halfcycle-bundle] Re-run "halfcycle-bundle install ${targetRepo}" to sync.
|
|
2931
|
+
`);
|
|
2932
|
+
process.exit(2);
|
|
2933
|
+
} else {
|
|
2934
|
+
process.stdout.write(`[halfcycle-bundle] Bundle in sync: v${current}
|
|
2935
|
+
`);
|
|
2936
|
+
process.exit(0);
|
|
2937
|
+
}
|
|
2938
|
+
} catch (err) {
|
|
2939
|
+
process.stderr.write(`halfcycle-bundle check-drift failed: ${err instanceof Error ? err.message : String(err)}
|
|
2940
|
+
`);
|
|
2941
|
+
process.exit(1);
|
|
2942
|
+
}
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
if (cmd === "build-record") {
|
|
2946
|
+
const phaseArg = rest[0];
|
|
2947
|
+
const repoRoot = rest.includes("--repo") ? rest[rest.indexOf("--repo") + 1] : process.cwd();
|
|
2948
|
+
if (!phaseArg || !/^\d+$/.test(phaseArg)) {
|
|
2949
|
+
process.stderr.write("halfcycle-bundle: usage: halfcycle-bundle build-record <phase-id> [--repo <root>]\n");
|
|
2950
|
+
process.exit(1);
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
const phaseId = Number(phaseArg);
|
|
2954
|
+
try {
|
|
2955
|
+
const inputPath = join9(repoRoot, ".workbench", "build-record", `phase-${phaseId}.input.json`);
|
|
2956
|
+
const input = JSON.parse(readFileSync8(inputPath, "utf-8"));
|
|
2957
|
+
const result = assemblePhaseBuildRecord({
|
|
2958
|
+
repoRoot,
|
|
2959
|
+
phasesDir: join9(repoRoot, "docs", "phases"),
|
|
2960
|
+
guardEvalLogDir: input.guardEvalLogDir ?? join9(repoRoot, ".workbench", "guard-eval-log"),
|
|
2961
|
+
phaseId,
|
|
2962
|
+
narrated: input.narrated,
|
|
2963
|
+
touchedInvariants: input.touchedInvariants
|
|
2964
|
+
});
|
|
2965
|
+
process.stdout.write(`[halfcycle-bundle] Build Record written:
|
|
2966
|
+
${result.jsonPath}
|
|
2967
|
+
${result.mdPath}
|
|
2968
|
+
`);
|
|
2969
|
+
process.exit(0);
|
|
2970
|
+
} catch (err) {
|
|
2971
|
+
process.stderr.write(`halfcycle-bundle build-record failed: ${err instanceof Error ? err.message : String(err)}
|
|
2972
|
+
`);
|
|
2973
|
+
process.exit(1);
|
|
2974
|
+
}
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
if (cmd === "open-phase") {
|
|
2978
|
+
const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
|
|
2979
|
+
const toProjectScope = rest.includes("--none");
|
|
2980
|
+
const phaseArg = firstPositional(rest);
|
|
2981
|
+
if (!toProjectScope && !phaseArg) {
|
|
2982
|
+
process.stderr.write('halfcycle open-phase: name the phase to open, or pass --none to return to project scope.\n halfcycle open-phase <phase> --actor "you" --evidence "what makes the entry condition hold"\n halfcycle open-phase <phase> --actor "you" --override --reason "why you are opening it anyway"\n');
|
|
2983
|
+
process.exit(2);
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
const openShortfalls = missingRequirements("open-phase", rest);
|
|
2987
|
+
if (openShortfalls.length > 0) {
|
|
2988
|
+
process.stderr.write(`${openShortfalls[0].message}
|
|
2989
|
+
`);
|
|
2990
|
+
process.exit(2);
|
|
2991
|
+
return;
|
|
2992
|
+
}
|
|
2993
|
+
const actor = flagValue(rest, "--actor") ?? "";
|
|
2994
|
+
let entry;
|
|
2995
|
+
if (rest.includes("--override")) {
|
|
2996
|
+
entry = { decision: "override", actor, reason: flagValue(rest, "--reason") ?? "" };
|
|
2997
|
+
} else {
|
|
2998
|
+
entry = { decision: "entry-condition-met", actor, evidence: flagValue(rest, "--evidence") ?? "" };
|
|
2999
|
+
}
|
|
3000
|
+
try {
|
|
3001
|
+
const credential = readRepoCredential(repoRoot);
|
|
3002
|
+
const opened = await openPhase(credential, toProjectScope ? null : phaseArg, entry);
|
|
3003
|
+
const what = opened.phase === null ? "project scope" : `phase "${opened.phase}"`;
|
|
3004
|
+
const from = opened.previousPhase === null ? "project scope" : `phase "${opened.previousPhase}"`;
|
|
3005
|
+
process.stdout.write(`[halfcycle] Moved from ${from} to ${what}.
|
|
3006
|
+
`);
|
|
3007
|
+
process.stdout.write(`[halfcycle] Recorded as ${opened.decision.decision} by ${opened.decision.actor} at ${opened.decision.decidedAt}.
|
|
3008
|
+
`);
|
|
3009
|
+
if (opened.decision.blockingFinding) {
|
|
3010
|
+
process.stdout.write(`[halfcycle] This decision CROSSED: ${opened.decision.blockingFinding}
|
|
3011
|
+
`);
|
|
3012
|
+
}
|
|
3013
|
+
process.exit(0);
|
|
3014
|
+
} catch (err) {
|
|
3015
|
+
if (err instanceof OpenPhaseRefused) {
|
|
3016
|
+
process.stderr.write(`halfcycle open-phase: refused (${err.status}). ${err.message}
|
|
3017
|
+
`);
|
|
3018
|
+
if (err.finding) {
|
|
3019
|
+
process.stderr.write(`halfcycle open-phase: blocking finding \u2014 ${err.finding}
|
|
3020
|
+
`);
|
|
3021
|
+
}
|
|
3022
|
+
if (err.status === 409) {
|
|
3023
|
+
const stuck = err.finding?.match(/^phase '([^']+)' is still open/)?.[1];
|
|
3024
|
+
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.
|
|
3025
|
+
`);
|
|
3026
|
+
}
|
|
3027
|
+
process.exit(1);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
process.stderr.write(`halfcycle open-phase failed: ${err instanceof Error ? err.message : String(err)}
|
|
3031
|
+
`);
|
|
3032
|
+
process.exit(1);
|
|
3033
|
+
}
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
if (cmd === "close-phase") {
|
|
3037
|
+
const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
|
|
3038
|
+
const phaseArg = firstPositional(rest);
|
|
3039
|
+
if (!phaseArg) {
|
|
3040
|
+
process.stderr.write('halfcycle close-phase: name the phase you are closing.\n halfcycle close-phase <phase> --verdict clean --actor "you"\n halfcycle close-phase <phase> --verdict defects --actor "you" --finding "what is still wrong"\n');
|
|
3041
|
+
process.exit(2);
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
const closeShortfalls = missingRequirements("close-phase", rest);
|
|
3045
|
+
if (closeShortfalls.length > 0) {
|
|
3046
|
+
process.stderr.write(`${closeShortfalls[0].message}
|
|
3047
|
+
`);
|
|
3048
|
+
process.exit(2);
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
const closeActor = flagValue(rest, "--actor") ?? "";
|
|
3052
|
+
let closeDecision;
|
|
3053
|
+
if (rest.includes("--override")) {
|
|
3054
|
+
closeDecision = { decision: "override", actor: closeActor, reason: flagValue(rest, "--reason") ?? "" };
|
|
3055
|
+
} else {
|
|
3056
|
+
closeDecision = { decision: "done-when-met", actor: closeActor };
|
|
3057
|
+
}
|
|
3058
|
+
const verdict = flagValue(rest, "--verdict");
|
|
3059
|
+
if (verdict !== "clean" && verdict !== "defects") {
|
|
3060
|
+
process.stderr.write(`${requirementMessage("close-phase", "--verdict")}
|
|
3061
|
+
`);
|
|
3062
|
+
process.exit(2);
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
const findingsDetail = [];
|
|
3066
|
+
for (let i = 0; i < rest.length; i++) {
|
|
3067
|
+
if (rest[i] !== "--finding")
|
|
3068
|
+
continue;
|
|
3069
|
+
const summary = rest[i + 1];
|
|
3070
|
+
if (summary === void 0 || summary.startsWith("--")) {
|
|
3071
|
+
process.stderr.write("halfcycle close-phase: --finding needs a summary after it.\n");
|
|
3072
|
+
process.exit(2);
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
findingsDetail.push({ summary });
|
|
3076
|
+
i++;
|
|
3077
|
+
}
|
|
3078
|
+
if (verdict === "defects" && findingsDetail.length === 0) {
|
|
3079
|
+
process.stderr.write('halfcycle close-phase: --verdict defects with no --finding says the phase failed and declines to say how. Give at least one --finding "\u2026", or close it clean.\n');
|
|
3080
|
+
process.exit(2);
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
if (verdict === "clean" && findingsDetail.length > 0) {
|
|
3084
|
+
process.stderr.write("halfcycle close-phase: --verdict clean was passed alongside --finding. A clean verdict means the walk found nothing outstanding; pick one.\n");
|
|
3085
|
+
process.exit(2);
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
try {
|
|
3089
|
+
const credential = readRepoCredential(repoRoot);
|
|
3090
|
+
const closed = await closePhase(credential, phaseArg, {
|
|
3091
|
+
verdict,
|
|
3092
|
+
findings: findingsDetail.length,
|
|
3093
|
+
findingsDetail
|
|
3094
|
+
}, closeDecision);
|
|
3095
|
+
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}.
|
|
3096
|
+
`);
|
|
3097
|
+
if (closeDecision.decision === "override") {
|
|
3098
|
+
process.stdout.write(`[halfcycle] This close was an OVERRIDE: ${closeDecision.reason}
|
|
3099
|
+
`);
|
|
3100
|
+
}
|
|
3101
|
+
process.stdout.write(`[halfcycle] Open the next phase with: halfcycle open-phase <phase> --actor "you" --evidence "\u2026"
|
|
3102
|
+
`);
|
|
3103
|
+
process.exit(0);
|
|
3104
|
+
} catch (err) {
|
|
3105
|
+
if (err instanceof ClosePhaseRefused) {
|
|
3106
|
+
process.stderr.write(`halfcycle close-phase: refused (${err.status}). ${err.message}
|
|
3107
|
+
`);
|
|
3108
|
+
if (err.finding) {
|
|
3109
|
+
process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${err.finding}
|
|
3110
|
+
`);
|
|
3111
|
+
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.
|
|
3112
|
+
`);
|
|
3113
|
+
} else if (err.code === "PhaseAlreadyClosed") {
|
|
3114
|
+
process.stderr.write("halfcycle close-phase: this phase is already closed \u2014 open the next one instead of accepting it twice.\n");
|
|
3115
|
+
}
|
|
3116
|
+
process.exit(1);
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
process.stderr.write(`halfcycle close-phase failed: ${err instanceof Error ? err.message : String(err)}
|
|
3120
|
+
`);
|
|
3121
|
+
process.exit(1);
|
|
3122
|
+
}
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
process.stderr.write(`halfcycle: unknown command "${cmd ?? ""}"
|
|
3126
|
+
|
|
3127
|
+
Usage:
|
|
3128
|
+
${USAGE}`);
|
|
3129
|
+
process.exit(1);
|
|
3130
|
+
}
|
|
3131
|
+
function flagValue(argv, name) {
|
|
3132
|
+
const i = argv.indexOf(name);
|
|
3133
|
+
if (i < 0 || i + 1 >= argv.length)
|
|
3134
|
+
return void 0;
|
|
3135
|
+
const value = argv[i + 1];
|
|
3136
|
+
return value === void 0 || value.startsWith("--") ? void 0 : value;
|
|
3137
|
+
}
|
|
3138
|
+
function firstPositional(argv) {
|
|
3139
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3140
|
+
const arg = argv[i];
|
|
3141
|
+
if (arg.startsWith("--")) {
|
|
3142
|
+
const next = argv[i + 1];
|
|
3143
|
+
if (next !== void 0 && !next.startsWith("--"))
|
|
3144
|
+
i++;
|
|
3145
|
+
continue;
|
|
3146
|
+
}
|
|
3147
|
+
return arg;
|
|
3148
|
+
}
|
|
3149
|
+
return void 0;
|
|
3150
|
+
}
|
|
3151
|
+
main().catch((err) => {
|
|
3152
|
+
process.stderr.write(`halfcycle: unexpected error: ${err instanceof Error ? err.message : String(err)}
|
|
3153
|
+
`);
|
|
3154
|
+
process.exit(1);
|
|
3155
|
+
});
|
|
3156
|
+
//# sourceMappingURL=bin.js.map
|