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/index.js
ADDED
|
@@ -0,0 +1,2083 @@
|
|
|
1
|
+
// dist/install.js
|
|
2
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3
|
+
import { dirname as dirname2, join as join5, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// dist/engagement-credential.js
|
|
7
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { platform as platform2 } from "node:os";
|
|
9
|
+
import { join as join2 } from "node:path";
|
|
10
|
+
|
|
11
|
+
// dist/account-credential.js
|
|
12
|
+
import { homedir, platform } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
function halfcycleHome(home) {
|
|
15
|
+
return join(home ?? homedir(), ".halfcycle");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// dist/engagement-credential.js
|
|
19
|
+
var ENGAGEMENTS_DIR = "engagements";
|
|
20
|
+
var ENV_FILENAME = "env";
|
|
21
|
+
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
22
|
+
function engagementStateDir(engagementId, home) {
|
|
23
|
+
return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
|
|
24
|
+
}
|
|
25
|
+
function engagementEnvPath(engagementId, home) {
|
|
26
|
+
return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
|
|
27
|
+
}
|
|
28
|
+
var GUARD_ENV_KEYS = [
|
|
29
|
+
"GUARD_SERVICE_URL",
|
|
30
|
+
"GUARD_SERVICE_TOKEN",
|
|
31
|
+
"GUARD_ENGAGEMENT_ID"
|
|
32
|
+
];
|
|
33
|
+
var ENGAGEMENT_ENV_KEYS = [
|
|
34
|
+
"HALFCYCLE_SERVICE_URL",
|
|
35
|
+
"HALFCYCLE_MCP_URL",
|
|
36
|
+
"HALFCYCLE_TOKEN",
|
|
37
|
+
"HALFCYCLE_ENGAGEMENT_ID",
|
|
38
|
+
...GUARD_ENV_KEYS,
|
|
39
|
+
"CONTROL_TELEMETRY_URL"
|
|
40
|
+
];
|
|
41
|
+
var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
|
|
42
|
+
function shq(value) {
|
|
43
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
44
|
+
}
|
|
45
|
+
function unquote(value) {
|
|
46
|
+
const v = value.trim();
|
|
47
|
+
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
|
|
48
|
+
return v.slice(1, -1).split(`'\\''`).join(`'`);
|
|
49
|
+
}
|
|
50
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
|
|
51
|
+
return v.slice(1, -1);
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
function parseEnvText(raw) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const line of raw.split("\n")) {
|
|
57
|
+
const eq = line.indexOf("=");
|
|
58
|
+
if (eq === -1)
|
|
59
|
+
continue;
|
|
60
|
+
const key = line.slice(0, eq).replace(/^\s*export\s+/, "").trim();
|
|
61
|
+
if (key === "" || key.startsWith("#"))
|
|
62
|
+
continue;
|
|
63
|
+
out[key] = unquote(line.slice(eq + 1));
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
|
|
68
|
+
const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
|
|
69
|
+
const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
|
|
70
|
+
if (existing === null) {
|
|
71
|
+
return `${ENV_HEADER}
|
|
72
|
+
` + keys.map(line).join("\n") + "\n";
|
|
73
|
+
}
|
|
74
|
+
const seen = /* @__PURE__ */ new Set();
|
|
75
|
+
const out = existing.split("\n").map((existingLine) => {
|
|
76
|
+
const eq = existingLine.indexOf("=");
|
|
77
|
+
if (eq === -1)
|
|
78
|
+
return existingLine;
|
|
79
|
+
const lhs = existingLine.slice(0, eq);
|
|
80
|
+
const exported = /^\s*export\s+/.exec(lhs);
|
|
81
|
+
const prefix = exported === null ? "" : exported[0];
|
|
82
|
+
const key = lhs.slice(prefix.length).trim();
|
|
83
|
+
if (desired.has(key)) {
|
|
84
|
+
seen.add(key);
|
|
85
|
+
return `${prefix}${line(key)}`;
|
|
86
|
+
}
|
|
87
|
+
return existingLine;
|
|
88
|
+
});
|
|
89
|
+
const missing = keys.filter((k) => !seen.has(k));
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
const trailingBlank = out.length > 0 && out[out.length - 1] === "";
|
|
92
|
+
const prefix = trailingBlank ? "" : existing.endsWith("\n") ? "" : "\n";
|
|
93
|
+
out.push(`${prefix}${ENV_HEADER}
|
|
94
|
+
` + missing.map(line).join("\n"));
|
|
95
|
+
}
|
|
96
|
+
let result = out.join("\n");
|
|
97
|
+
if (existing.endsWith("\n") && !result.endsWith("\n"))
|
|
98
|
+
result += "\n";
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
function readEngagementEnv(engagementId, home) {
|
|
102
|
+
try {
|
|
103
|
+
return parseEnvText(readFileSync(engagementEnvPath(engagementId, home), "utf-8"));
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function writeEngagementEnv(engagementId, values, home) {
|
|
109
|
+
const path = engagementEnvPath(engagementId, home);
|
|
110
|
+
const dir = engagementStateDir(engagementId, home);
|
|
111
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
112
|
+
let existing;
|
|
113
|
+
try {
|
|
114
|
+
existing = readFileSync(path, "utf-8");
|
|
115
|
+
} catch {
|
|
116
|
+
existing = null;
|
|
117
|
+
}
|
|
118
|
+
const reconciled = reconcileEnvText(existing, values);
|
|
119
|
+
const outcome = existing === reconciled ? "skipped" : "written";
|
|
120
|
+
if (outcome === "written") {
|
|
121
|
+
writeFileSync(path, reconciled, { mode: 384 });
|
|
122
|
+
}
|
|
123
|
+
applyOwnerOnly(path, dir);
|
|
124
|
+
return outcome;
|
|
125
|
+
}
|
|
126
|
+
function applyOwnerOnly(path, dir) {
|
|
127
|
+
if (platform2() === "win32")
|
|
128
|
+
return;
|
|
129
|
+
try {
|
|
130
|
+
chmodSync(path, 384);
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
chmodSync(dir, 448);
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function engagementResolutionShell() {
|
|
139
|
+
return `# --- Halfcycle credential resolution (generated; do not edit) ----------------
|
|
140
|
+
# THE CREDENTIAL IS NOT IN THIS REPOSITORY. It lives at
|
|
141
|
+
# $HOME/.halfcycle/${ENGAGEMENTS_DIR}/<engagement-id>/${ENV_FILENAME}
|
|
142
|
+
# written owner-only by \`npx halfcycle\`. What this repository holds is the
|
|
143
|
+
# engagement id, in the committed .halfcycle/bundle.json. So: read the id out of
|
|
144
|
+
# the pin, then name the file. There is no jq here and node is not dependable on a
|
|
145
|
+
# desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
|
|
146
|
+
# file, because sed is line-oriented and the pin's line breaks are not a contract.
|
|
147
|
+
#
|
|
148
|
+
# Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT and HALFCYCLE_ENV_PROBLEM in
|
|
149
|
+
# the CALLER's shell and returns a status. Call it as \`if halfcycle_env_file \u2026\`,
|
|
150
|
+
# never as \`$(halfcycle_env_file \u2026)\` \u2014 a command substitution is a subshell and
|
|
151
|
+
# would discard everything it set.
|
|
152
|
+
halfcycle_env_file() {
|
|
153
|
+
HALFCYCLE_ENV_FILE=""
|
|
154
|
+
HALFCYCLE_ENV_ENGAGEMENT=""
|
|
155
|
+
HALFCYCLE_ENV_PROBLEM=""
|
|
156
|
+
hc_pin="$1/.halfcycle/bundle.json"
|
|
157
|
+
if [ ! -f "$hc_pin" ]; then
|
|
158
|
+
HALFCYCLE_ENV_PROBLEM="no-pin"
|
|
159
|
+
return 1
|
|
160
|
+
fi
|
|
161
|
+
hc_id=$(tr -d '\\n' < "$hc_pin" | sed -n 's/.*"${PIN_ENGAGEMENT_ID_FIELD}"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
|
|
162
|
+
if [ -z "$hc_id" ]; then
|
|
163
|
+
HALFCYCLE_ENV_PROBLEM="no-id"
|
|
164
|
+
return 1
|
|
165
|
+
fi
|
|
166
|
+
HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
|
|
167
|
+
if [ -z "\${HOME:-}" ]; then
|
|
168
|
+
HALFCYCLE_ENV_PROBLEM="no-home"
|
|
169
|
+
return 1
|
|
170
|
+
fi
|
|
171
|
+
hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
|
|
172
|
+
if [ ! -f "$hc_env" ]; then
|
|
173
|
+
HALFCYCLE_ENV_PROBLEM="no-credential"
|
|
174
|
+
return 1
|
|
175
|
+
fi
|
|
176
|
+
HALFCYCLE_ENV_FILE="$hc_env"
|
|
177
|
+
return 0
|
|
178
|
+
}
|
|
179
|
+
# --- end Halfcycle credential resolution ------------------------------------`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// dist/identity.js
|
|
183
|
+
import { execFileSync } from "node:child_process";
|
|
184
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
185
|
+
import { join as join3 } from "node:path";
|
|
186
|
+
import { randomUUID } from "node:crypto";
|
|
187
|
+
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.';
|
|
188
|
+
function git(targetRepoRoot, args) {
|
|
189
|
+
try {
|
|
190
|
+
const out = execFileSync("git", ["-C", targetRepoRoot, ...args], {
|
|
191
|
+
encoding: "utf-8",
|
|
192
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
193
|
+
});
|
|
194
|
+
const trimmed = out.trim();
|
|
195
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function readRootCommit(targetRepoRoot) {
|
|
201
|
+
const roots = git(targetRepoRoot, ["rev-list", "--max-parents=0", "HEAD"]);
|
|
202
|
+
if (!roots)
|
|
203
|
+
return null;
|
|
204
|
+
const lines = roots.split("\n").filter((l) => l.length > 0);
|
|
205
|
+
return lines.length > 0 ? lines[lines.length - 1] : null;
|
|
206
|
+
}
|
|
207
|
+
function readRemote(targetRepoRoot) {
|
|
208
|
+
return git(targetRepoRoot, ["remote", "get-url", "origin"]);
|
|
209
|
+
}
|
|
210
|
+
function mintOrReadIdentity(targetRepoRoot) {
|
|
211
|
+
const path = join3(targetRepoRoot, ".halfcycle", "project.json");
|
|
212
|
+
if (existsSync(path)) {
|
|
213
|
+
const onDisk = JSON.parse(readFileSync2(path, "utf-8"));
|
|
214
|
+
const identity2 = {
|
|
215
|
+
projectId: onDisk.projectId ?? "",
|
|
216
|
+
rootCommit: onDisk.rootCommit ?? null,
|
|
217
|
+
remote: onDisk.remote ?? null,
|
|
218
|
+
note: PROJECT_IDENTITY_NOTE
|
|
219
|
+
};
|
|
220
|
+
return { identity: identity2, minted: false };
|
|
221
|
+
}
|
|
222
|
+
const identity = {
|
|
223
|
+
projectId: randomUUID(),
|
|
224
|
+
rootCommit: readRootCommit(targetRepoRoot),
|
|
225
|
+
remote: readRemote(targetRepoRoot),
|
|
226
|
+
note: PROJECT_IDENTITY_NOTE
|
|
227
|
+
};
|
|
228
|
+
return { identity, minted: true };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// dist/mcp-endpoint.js
|
|
232
|
+
var MCP_ENDPOINT_PATH = "/mcp";
|
|
233
|
+
function mcpEndpointUrl(origin) {
|
|
234
|
+
return `${origin.trim().replace(/\/+$/, "")}${MCP_ENDPOINT_PATH}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// dist/merge-settings.js
|
|
238
|
+
function isHalfcycleEntry(entry, halfcycleCommands) {
|
|
239
|
+
return entry.hooks.some((h) => halfcycleCommands.has(h.command));
|
|
240
|
+
}
|
|
241
|
+
function mergeSettings(existing, generated) {
|
|
242
|
+
const merged = { ...existing };
|
|
243
|
+
if (generated.$schema !== void 0) {
|
|
244
|
+
merged.$schema = generated.$schema;
|
|
245
|
+
}
|
|
246
|
+
const generatedHooks = generated.hooks ?? {};
|
|
247
|
+
const halfcycleCommands = /* @__PURE__ */ new Set();
|
|
248
|
+
for (const entries of Object.values(generatedHooks)) {
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
for (const h of entry.hooks)
|
|
251
|
+
halfcycleCommands.add(h.command);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const existingHooks = existing.hooks ?? {};
|
|
255
|
+
const mergedHooks = {};
|
|
256
|
+
const events = /* @__PURE__ */ new Set([...Object.keys(existingHooks), ...Object.keys(generatedHooks)]);
|
|
257
|
+
for (const event of events) {
|
|
258
|
+
const developerEntries = (existingHooks[event] ?? []).filter((entry) => !isHalfcycleEntry(entry, halfcycleCommands));
|
|
259
|
+
const halfcycleEntries = generatedHooks[event] ?? [];
|
|
260
|
+
mergedHooks[event] = [...developerEntries, ...halfcycleEntries];
|
|
261
|
+
}
|
|
262
|
+
merged.hooks = mergedHooks;
|
|
263
|
+
merged.permissions = mergePermissions(existing.permissions, generated.permissions);
|
|
264
|
+
return merged;
|
|
265
|
+
}
|
|
266
|
+
function mergePermissions(existing, generated) {
|
|
267
|
+
if (existing === void 0 && generated === void 0)
|
|
268
|
+
return void 0;
|
|
269
|
+
const result = { ...existing ?? {} };
|
|
270
|
+
const generatedDeny = generated?.deny ?? [];
|
|
271
|
+
if (generatedDeny.length > 0) {
|
|
272
|
+
const theirs = existing?.deny ?? [];
|
|
273
|
+
const seen = new Set(theirs);
|
|
274
|
+
result.deny = [...theirs, ...generatedDeny.filter((p) => !seen.has(p))];
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// dist/scan.js
|
|
280
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
281
|
+
import { join as join4 } from "node:path";
|
|
282
|
+
|
|
283
|
+
// ../events/dist/result.js
|
|
284
|
+
import { z as z2 } from "zod";
|
|
285
|
+
|
|
286
|
+
// ../core/dist/guard-record.js
|
|
287
|
+
import { z } from "zod";
|
|
288
|
+
var severitySchema = z.enum(["info", "warn", "block"]);
|
|
289
|
+
var channelSchema = z.enum(["stable", "candidate", "rented"]);
|
|
290
|
+
var matcherKindSchema = z.enum(["grep", "ast", "integrationTest", "llm"]);
|
|
291
|
+
var matcherConfigSchema = z.record(z.string(), z.unknown());
|
|
292
|
+
var grepMatcherSchema = z.object({ kind: z.literal("grep"), config: matcherConfigSchema }).strict();
|
|
293
|
+
var astMatcherSchema = z.object({ kind: z.literal("ast"), config: matcherConfigSchema }).strict();
|
|
294
|
+
var integrationTestMatcherSchema = z.object({ kind: z.literal("integrationTest"), config: matcherConfigSchema }).strict();
|
|
295
|
+
var llmMatcherSchema = z.object({ kind: z.literal("llm"), config: matcherConfigSchema }).strict();
|
|
296
|
+
var matcherSchema = z.discriminatedUnion("kind", [
|
|
297
|
+
grepMatcherSchema,
|
|
298
|
+
astMatcherSchema,
|
|
299
|
+
integrationTestMatcherSchema,
|
|
300
|
+
llmMatcherSchema
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
// ../events/dist/result.js
|
|
304
|
+
var firedGuardSchema = z2.object({
|
|
305
|
+
guardId: z2.string(),
|
|
306
|
+
patternRef: z2.string(),
|
|
307
|
+
severity: severitySchema,
|
|
308
|
+
explanation: z2.string()
|
|
309
|
+
}).strict();
|
|
310
|
+
var resultEnvelopeSchema = z2.object({
|
|
311
|
+
guardsFired: z2.array(firedGuardSchema),
|
|
312
|
+
severity: severitySchema.nullable(),
|
|
313
|
+
blocking: z2.boolean(),
|
|
314
|
+
explanation: z2.string()
|
|
315
|
+
}).strict();
|
|
316
|
+
var wireErrorSchema = z2.object({
|
|
317
|
+
statusCode: z2.number(),
|
|
318
|
+
error: z2.string(),
|
|
319
|
+
message: z2.string()
|
|
320
|
+
}).strict();
|
|
321
|
+
|
|
322
|
+
// ../events/dist/telemetry.js
|
|
323
|
+
import { z as z3 } from "zod";
|
|
324
|
+
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)");
|
|
325
|
+
var guardEvalOutcomeSchema = z3.enum([
|
|
326
|
+
"evaluated",
|
|
327
|
+
"infra-error",
|
|
328
|
+
"contract-error",
|
|
329
|
+
"unconfigured"
|
|
330
|
+
]);
|
|
331
|
+
var guardEvalRunSchema = z3.object({
|
|
332
|
+
// `runId`/`outcome` are the E2 run-observability additions, declared
|
|
333
|
+
// field-level-OPTIONAL as the FIRST (expand) step of an expand-contract
|
|
334
|
+
// migration. Every new record populated by the migrated writer (T-07)
|
|
335
|
+
// carries both; a legacy line predating the amendment carries neither and
|
|
336
|
+
// must still parse (the mixed-era guard-eval log the Build-Record reader
|
|
337
|
+
// assembles from, and the runner emit not yet migrated). So the presence
|
|
338
|
+
// refinement below is skipped for a legacy line (no `outcome`), and the
|
|
339
|
+
// per-writer guarantee lives with the writer + reader-normalisation (T-07),
|
|
340
|
+
// where the schema may then be tightened to required. runId is uuid-checked
|
|
341
|
+
// WHEN present; outcome is enum-checked when present.
|
|
342
|
+
runId: z3.string().uuid().optional(),
|
|
343
|
+
engagementId: z3.string().optional(),
|
|
344
|
+
runType: z3.enum(["hook", "ci"]),
|
|
345
|
+
occurredAt: utcIso8601,
|
|
346
|
+
outcome: guardEvalOutcomeSchema.optional(),
|
|
347
|
+
failureReason: z3.string().optional(),
|
|
348
|
+
phase: z3.string().optional(),
|
|
349
|
+
scope: z3.record(z3.string(), z3.unknown()),
|
|
350
|
+
cost: z3.record(z3.string(), z3.unknown()).optional(),
|
|
351
|
+
result: resultEnvelopeSchema.optional()
|
|
352
|
+
}).strict().superRefine((run, ctx) => {
|
|
353
|
+
if (run.outcome === void 0)
|
|
354
|
+
return;
|
|
355
|
+
if (run.outcome === "evaluated" && run.result === void 0) {
|
|
356
|
+
ctx.addIssue({
|
|
357
|
+
code: z3.ZodIssueCode.custom,
|
|
358
|
+
path: ["result"],
|
|
359
|
+
message: "result is required when outcome is 'evaluated'"
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (run.outcome !== "evaluated" && run.result !== void 0) {
|
|
363
|
+
ctx.addIssue({
|
|
364
|
+
code: z3.ZodIssueCode.custom,
|
|
365
|
+
path: ["result"],
|
|
366
|
+
message: "result is only present when outcome is 'evaluated'"
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
if (run.outcome !== "unconfigured" && run.engagementId === void 0) {
|
|
370
|
+
ctx.addIssue({
|
|
371
|
+
code: z3.ZodIssueCode.custom,
|
|
372
|
+
path: ["engagementId"],
|
|
373
|
+
message: "engagementId is required unless outcome is 'unconfigured'"
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// dist/scan.js
|
|
379
|
+
var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
|
|
380
|
+
var HALFCYCLE_STATE_NOTE = [
|
|
381
|
+
"This file is a local cache written ONCE when Halfcycle was installed. It is not a",
|
|
382
|
+
"record of what this project has done, and it is never updated as work happens.",
|
|
383
|
+
'An empty or missing "layers" means the install-time scan found no pre-existing',
|
|
384
|
+
"artefacts on disk \u2014 it does NOT mean nothing has been done since, and it is not",
|
|
385
|
+
"evidence that any step is unfinished or any approval is outstanding.",
|
|
386
|
+
"The authoritative position lives in the Halfcycle service: call the",
|
|
387
|
+
"halfcycle_resolve tool to ask where this project stands. Do not answer that",
|
|
388
|
+
"question from this file."
|
|
389
|
+
].join(" ");
|
|
390
|
+
var LAYER_ARTEFACT_PATHS = {
|
|
391
|
+
l0: ["ENGAGEMENT.md"],
|
|
392
|
+
l1: ["docs/product-spec.md"],
|
|
393
|
+
l2: ["docs/architecture-overview.md"],
|
|
394
|
+
l3: ["docs/invariants.md"],
|
|
395
|
+
l4: ["docs/features"],
|
|
396
|
+
l5: ["docs/phases"]
|
|
397
|
+
};
|
|
398
|
+
function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
399
|
+
const layers = {};
|
|
400
|
+
for (const [layer, paths] of Object.entries(LAYER_ARTEFACT_PATHS)) {
|
|
401
|
+
const found = paths.filter((p) => existsSync2(join4(targetRepo, p)));
|
|
402
|
+
if (found.length > 0) {
|
|
403
|
+
layers[layer] = {
|
|
404
|
+
value: "asserted",
|
|
405
|
+
source: "bootstrap-scan",
|
|
406
|
+
foundPaths: found,
|
|
407
|
+
scannedAt
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
|
|
412
|
+
}
|
|
413
|
+
function runBootstrapScan(targetRepo) {
|
|
414
|
+
const statePath = join4(targetRepo, ".halfcycle", "state.json");
|
|
415
|
+
if (existsSync2(statePath)) {
|
|
416
|
+
const existing = JSON.parse(readFileSync3(statePath, "utf-8"));
|
|
417
|
+
return { state: existing, ran: false };
|
|
418
|
+
}
|
|
419
|
+
const state = scanLayers(targetRepo);
|
|
420
|
+
mkdirSync2(join4(targetRepo, ".halfcycle"), { recursive: true });
|
|
421
|
+
writeFileSync2(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
422
|
+
return { state, ran: true };
|
|
423
|
+
}
|
|
424
|
+
function mergeWithRecords(state, layersWithRealRecords) {
|
|
425
|
+
const merged = {};
|
|
426
|
+
const allLayers = /* @__PURE__ */ new Set([
|
|
427
|
+
...Object.keys(state.layers),
|
|
428
|
+
...layersWithRealRecords
|
|
429
|
+
]);
|
|
430
|
+
for (const layer of allLayers) {
|
|
431
|
+
if (layersWithRealRecords.has(layer)) {
|
|
432
|
+
merged[layer] = "complete";
|
|
433
|
+
} else if (state.layers[layer]) {
|
|
434
|
+
merged[layer] = "asserted";
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return merged;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// dist/install.js
|
|
441
|
+
var ZONE_B_DIR = ".workbench";
|
|
442
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
443
|
+
var __dirname = dirname2(__filename);
|
|
444
|
+
function findBundleRoot(startDir) {
|
|
445
|
+
let candidate = startDir;
|
|
446
|
+
for (let i = 0; i < 4; i++) {
|
|
447
|
+
const manifest = join5(candidate, ".claude-plugin", "plugin.json");
|
|
448
|
+
try {
|
|
449
|
+
if (existsSync3(manifest))
|
|
450
|
+
return candidate;
|
|
451
|
+
} catch {
|
|
452
|
+
}
|
|
453
|
+
candidate = dirname2(candidate);
|
|
454
|
+
}
|
|
455
|
+
return join5(startDir, "..");
|
|
456
|
+
}
|
|
457
|
+
var BUNDLE_ROOT = findBundleRoot(__dirname);
|
|
458
|
+
function resolveVendoredBinary() {
|
|
459
|
+
const candidates = [
|
|
460
|
+
join5(BUNDLE_ROOT, "bin", "bin.bundle.mjs"),
|
|
461
|
+
join5(BUNDLE_ROOT, "..", "runner", "dist", "bin.bundle.mjs")
|
|
462
|
+
];
|
|
463
|
+
for (const candidate of candidates) {
|
|
464
|
+
if (existsSync3(candidate))
|
|
465
|
+
return candidate;
|
|
466
|
+
}
|
|
467
|
+
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(", ")}.`);
|
|
468
|
+
}
|
|
469
|
+
var WRITE_ALLOWLIST = [
|
|
470
|
+
".claude/commands",
|
|
471
|
+
".claude/hooks",
|
|
472
|
+
".claude/settings.json",
|
|
473
|
+
".halfcycle",
|
|
474
|
+
".halfcycle/bundle.json",
|
|
475
|
+
".halfcycle/bin",
|
|
476
|
+
"docs/build-records",
|
|
477
|
+
"test/fixtures/captured",
|
|
478
|
+
".env.example",
|
|
479
|
+
".mcp.json",
|
|
480
|
+
ZONE_B_DIR
|
|
481
|
+
];
|
|
482
|
+
function isAllowlisted(targetRelPath) {
|
|
483
|
+
const normalised = targetRelPath.replace(/\\/g, "/");
|
|
484
|
+
return WRITE_ALLOWLIST.some((prefix) => {
|
|
485
|
+
const p = prefix.replace(/\\/g, "/");
|
|
486
|
+
return normalised === p || normalised.startsWith(p + "/");
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
function readPluginManifest() {
|
|
490
|
+
const manifestPath = join5(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
|
|
491
|
+
const raw = readFileSync4(manifestPath, "utf-8");
|
|
492
|
+
return JSON.parse(raw);
|
|
493
|
+
}
|
|
494
|
+
function copyManifestCommands(manifest, targetRepo, report) {
|
|
495
|
+
const commandsTargetDir = join5(targetRepo, ".claude", "commands");
|
|
496
|
+
for (const cmd of manifest.commands ?? []) {
|
|
497
|
+
const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
|
|
498
|
+
if (!existsSync3(srcFile)) {
|
|
499
|
+
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.`);
|
|
500
|
+
}
|
|
501
|
+
const destFile = join5(commandsTargetDir, `${cmd.name}.md`);
|
|
502
|
+
const content = readFileSync4(srcFile, "utf-8");
|
|
503
|
+
const rel = relative(targetRepo, destFile).replace(/\\/g, "/");
|
|
504
|
+
record(report, writeCollisionSafe(destFile, targetRepo, content), rel);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
|
|
508
|
+
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
509
|
+
if (!isAllowlisted(rel)) {
|
|
510
|
+
throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
511
|
+
}
|
|
512
|
+
mkdirSync3(dirname2(targetAbsPath), { recursive: true });
|
|
513
|
+
writeFileSync3(targetAbsPath, content, "utf-8");
|
|
514
|
+
writtenPaths.push(rel);
|
|
515
|
+
}
|
|
516
|
+
function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
|
|
517
|
+
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
518
|
+
if (!isAllowlisted(rel)) {
|
|
519
|
+
throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
520
|
+
}
|
|
521
|
+
if (existsSync3(targetAbsPath)) {
|
|
522
|
+
const current = readFileSync4(targetAbsPath, "utf-8");
|
|
523
|
+
return current === content ? "skipped" : "collided";
|
|
524
|
+
}
|
|
525
|
+
mkdirSync3(dirname2(targetAbsPath), { recursive: true });
|
|
526
|
+
writeFileSync3(targetAbsPath, content, "utf-8");
|
|
527
|
+
return "written";
|
|
528
|
+
}
|
|
529
|
+
function record(report, outcome, rel) {
|
|
530
|
+
const bucket = {
|
|
531
|
+
written: report.writtenPaths,
|
|
532
|
+
skipped: report.skippedPaths,
|
|
533
|
+
collided: report.collidedPaths
|
|
534
|
+
};
|
|
535
|
+
bucket[outcome].push(rel);
|
|
536
|
+
}
|
|
537
|
+
var DENY_PATTERNS = [
|
|
538
|
+
"Bash(rm -rf:*)",
|
|
539
|
+
"Bash(git push --force:*)",
|
|
540
|
+
"Bash(git push -f:*)",
|
|
541
|
+
"Bash(git reset --hard:*)",
|
|
542
|
+
"Bash(pnpm publish:*)",
|
|
543
|
+
"Bash(npm publish:*)",
|
|
544
|
+
"Bash(gh repo delete:*)",
|
|
545
|
+
"Bash(git push --no-verify:*)",
|
|
546
|
+
"Bash(git commit --no-verify:*)"
|
|
547
|
+
];
|
|
548
|
+
var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
|
|
549
|
+
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.";
|
|
550
|
+
function generateSettingsJson() {
|
|
551
|
+
const settings = {
|
|
552
|
+
$schema: "https://json.schemastore.org/claude-code-settings.json",
|
|
553
|
+
permissions: {
|
|
554
|
+
// deny ONLY. See DENY_PATTERNS — an allow list evaporates under bypass mode
|
|
555
|
+
// and is the machine owner's grant to make, not ours.
|
|
556
|
+
deny: [...DENY_PATTERNS]
|
|
557
|
+
},
|
|
558
|
+
hooks: {
|
|
559
|
+
SessionStart: [
|
|
560
|
+
{
|
|
561
|
+
hooks: [
|
|
562
|
+
{
|
|
563
|
+
type: "command",
|
|
564
|
+
command: "bash ./.claude/hooks/session-start-marker.sh",
|
|
565
|
+
timeout: 5
|
|
566
|
+
}
|
|
567
|
+
]
|
|
568
|
+
}
|
|
569
|
+
],
|
|
570
|
+
UserPromptSubmit: [
|
|
571
|
+
{
|
|
572
|
+
hooks: [
|
|
573
|
+
{
|
|
574
|
+
type: "command",
|
|
575
|
+
command: "bash ./.claude/hooks/user-prompt-reminder.sh",
|
|
576
|
+
timeout: 5
|
|
577
|
+
}
|
|
578
|
+
]
|
|
579
|
+
}
|
|
580
|
+
],
|
|
581
|
+
PostToolUse: [
|
|
582
|
+
{
|
|
583
|
+
matcher: WRITE_CAPABLE_TOOLS.join("|"),
|
|
584
|
+
hooks: [
|
|
585
|
+
{
|
|
586
|
+
type: "command",
|
|
587
|
+
command: "bash ./.claude/hooks/guard-runner.sh hook post-tool-use",
|
|
588
|
+
timeout: 60
|
|
589
|
+
}
|
|
590
|
+
]
|
|
591
|
+
}
|
|
592
|
+
],
|
|
593
|
+
SubagentStop: [
|
|
594
|
+
{
|
|
595
|
+
hooks: [
|
|
596
|
+
{
|
|
597
|
+
type: "command",
|
|
598
|
+
command: "bash ./.claude/hooks/guard-runner.sh hook subagent-stop",
|
|
599
|
+
timeout: 60
|
|
600
|
+
}
|
|
601
|
+
]
|
|
602
|
+
}
|
|
603
|
+
]
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
return JSON.stringify(settings, null, 2) + "\n";
|
|
607
|
+
}
|
|
608
|
+
var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
|
|
609
|
+
function generateGuardRunnerWrapper() {
|
|
610
|
+
return `#!/usr/bin/env bash
|
|
611
|
+
# Governance hook wrapper \u2014 generated by the Halfcycle installer.
|
|
612
|
+
#
|
|
613
|
+
# Loads this engagement's credentials so the guard runner has its GUARD_SERVICE_*
|
|
614
|
+
# values when Claude Code fires PostToolUse / SubagentStop, then exec's the
|
|
615
|
+
# SELF-CONTAINED binary vendored at ${VENDORED_BIN_REL} \u2014 a path inside this
|
|
616
|
+
# project, so the hook resolves in any checkout of it.
|
|
617
|
+
#
|
|
618
|
+
# The runner itself reads only process.env, so env loading lives here in the hook
|
|
619
|
+
# wiring rather than inside the binary.
|
|
620
|
+
#
|
|
621
|
+
# NO CREDENTIAL LIVES IN THIS REPOSITORY. See the resolution block below.
|
|
622
|
+
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-.}"
|
|
623
|
+
|
|
624
|
+
${engagementResolutionShell()}
|
|
625
|
+
|
|
626
|
+
# A missing store is NOT an error here: on a CI runner there is no per-user store
|
|
627
|
+
# and the credentials arrive in the environment instead. Exec either way and let
|
|
628
|
+
# the runner say what it found \u2014 its never-configured branch is loud and non-zero
|
|
629
|
+
# on purpose, and a wrapper that exited quietly here would hide it.
|
|
630
|
+
if halfcycle_env_file "$PROJECT_DIR"; then
|
|
631
|
+
set -a
|
|
632
|
+
. "$HALFCYCLE_ENV_FILE"
|
|
633
|
+
set +a
|
|
634
|
+
fi
|
|
635
|
+
|
|
636
|
+
exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
637
|
+
`;
|
|
638
|
+
}
|
|
639
|
+
function generateSessionStartMarker() {
|
|
640
|
+
return `#!/usr/bin/env bash
|
|
641
|
+
# Session-start marker \u2014 generated by the Halfcycle installer.
|
|
642
|
+
# Writes the current git HEAD SHA to a temp file so SubagentStop can
|
|
643
|
+
# diff against session-start rather than just HEAD at stop time.
|
|
644
|
+
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
645
|
+
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
646
|
+
HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
|
|
647
|
+
MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
|
|
648
|
+
git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
|
|
649
|
+
exit 0
|
|
650
|
+
`;
|
|
651
|
+
}
|
|
652
|
+
function generateUserPromptReminderHook() {
|
|
653
|
+
return `#!/usr/bin/env bash
|
|
654
|
+
# UserPromptSubmit house-rule reminder \u2014 generated by the Halfcycle installer.
|
|
655
|
+
#
|
|
656
|
+
# Injects ONE plain-text sentence into context on every prompt (F-2(c),
|
|
657
|
+
# W8-T-05b) \u2014 the per-turn twin of the house rule W8-T-05a put in the client's
|
|
658
|
+
# CLAUDE.md, in its "Top rules (read first)" block. Byte-identical sentence,
|
|
659
|
+
# one home: RULE_SENTENCE_PLAIN in the method repo's own position-house-rule
|
|
660
|
+
# test governs it \u2014 reword there and here together, in the same commit.
|
|
661
|
+
#
|
|
662
|
+
# FAILS OPEN. This hook's stdout is always plain text, never JSON, so it
|
|
663
|
+
# cannot form a {"decision":"block",...} body (UserPromptSubmit's only way to
|
|
664
|
+
# hold the turn). But a write failure here (a closed stdout, say) must still
|
|
665
|
+
# never wedge or stall a session, so any error exits 0 with no output rather
|
|
666
|
+
# than propagate.
|
|
667
|
+
trap 'exit 0' ERR
|
|
668
|
+
set -e
|
|
669
|
+
printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
|
|
670
|
+
exit 0
|
|
671
|
+
`;
|
|
672
|
+
}
|
|
673
|
+
function generateCiStanza() {
|
|
674
|
+
return `# Halfcycle guard CI job \u2014 generated by the Halfcycle installer.
|
|
675
|
+
# Paste this job into your CI workflow. It assumes no package manager and no
|
|
676
|
+
# monorepo tooling: it runs the self-contained guard binary vendored at
|
|
677
|
+
# .halfcycle/bin/, so it works in a Python or Go repository as well as a Node
|
|
678
|
+
# one \u2014 the only prerequisite is Node 20 to run the bundled binary.
|
|
679
|
+
#
|
|
680
|
+
# WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
|
|
681
|
+
# commits pushed straight to the default branch, which is the shape most
|
|
682
|
+
# Halfcycle engagements settle on. \`fetch-depth\` is what makes that true: the
|
|
683
|
+
# check needs the commit BEFORE the one it is evaluating, and the default
|
|
684
|
+
# shallow checkout does not have it. With \`fetch-depth: 0\` the check fails loudly
|
|
685
|
+
# if it cannot work out what to evaluate \u2014 it will not pass quietly having
|
|
686
|
+
# evaluated nothing.
|
|
687
|
+
#
|
|
688
|
+
# halfcycle-guard-ci:
|
|
689
|
+
# runs-on: ubuntu-latest
|
|
690
|
+
# steps:
|
|
691
|
+
# - uses: actions/checkout@v4
|
|
692
|
+
# with:
|
|
693
|
+
# # REQUIRED. 0 = full history. The check diffs against the commit before
|
|
694
|
+
# # HEAD (or the fork point on a branch); the default depth of 1 has
|
|
695
|
+
# # neither. Do not lower this.
|
|
696
|
+
# fetch-depth: 0
|
|
697
|
+
# - uses: actions/setup-node@v4
|
|
698
|
+
# with:
|
|
699
|
+
# node-version: '20'
|
|
700
|
+
# - name: Halfcycle guard CI check
|
|
701
|
+
# env:
|
|
702
|
+
# GUARD_SERVICE_URL: \${{ secrets.GUARD_SERVICE_URL }}
|
|
703
|
+
# GUARD_SERVICE_TOKEN: \${{ secrets.GUARD_SERVICE_TOKEN }}
|
|
704
|
+
# GUARD_ENGAGEMENT_ID: \${{ secrets.GUARD_ENGAGEMENT_ID }}
|
|
705
|
+
# run: node ./.halfcycle/bin/bin.bundle.mjs ci
|
|
706
|
+
#
|
|
707
|
+
# The job prints the diff base it used and how many files it evaluated, on every
|
|
708
|
+
# run. If that line says 0 files on a commit that changed something, the base is
|
|
709
|
+
# wrong \u2014 set HALFCYCLE_DIFF_BASE in the env block above to name it explicitly
|
|
710
|
+
# (on a GitHub push event, \${{ github.event.before }} is the right value).
|
|
711
|
+
`;
|
|
712
|
+
}
|
|
713
|
+
var ENV_EXAMPLE_NAMES = [
|
|
714
|
+
"GUARD_SERVICE_URL",
|
|
715
|
+
"GUARD_SERVICE_TOKEN",
|
|
716
|
+
"GUARD_ENGAGEMENT_ID",
|
|
717
|
+
"HALFCYCLE_SERVICE_URL",
|
|
718
|
+
"HALFCYCLE_MCP_URL",
|
|
719
|
+
"HALFCYCLE_TOKEN",
|
|
720
|
+
// T-10 — declared because it is now WRITTEN into the credential store, and
|
|
721
|
+
// `install-guard-config.test.ts`'s INV-004 check requires every written name to
|
|
722
|
+
// be named here too (`HALFCYCLE_ENGAGEMENT_ID` is the one deliberate exception,
|
|
723
|
+
// recorded in the spec). Optional at runtime — the runner's own telemetry
|
|
724
|
+
// emission is fail-open when it is absent — but a name that is written and never
|
|
725
|
+
// declared is exactly what this generated section exists to prevent.
|
|
726
|
+
"CONTROL_TELEMETRY_URL"
|
|
727
|
+
];
|
|
728
|
+
function generateEnvExampleSection() {
|
|
729
|
+
return `
|
|
730
|
+
# ---------------------------------------------------------------------------
|
|
731
|
+
# Halfcycle runner (generated by the Halfcycle installer \u2014 names only, no values)
|
|
732
|
+
# ---------------------------------------------------------------------------
|
|
733
|
+
# YOU DO NOT NEED TO SET ANY OF THESE ON YOUR OWN MACHINE, and there is nowhere in
|
|
734
|
+
# this repository to put them. \`npx halfcycle\` writes this engagement's credential
|
|
735
|
+
# to $HOME/.halfcycle, owner-only, outside every checkout; the guard hook and the
|
|
736
|
+
# MCP headers helper read it from there. Nothing Halfcycle writes a secret into
|
|
737
|
+
# this tree, so there is no file here to leak, ignore or clean up.
|
|
738
|
+
#
|
|
739
|
+
# These names are declared because they ARE read from the environment in one
|
|
740
|
+
# place: a CI job, which has no browser and no per-user store and supplies them as
|
|
741
|
+
# workflow secrets (see .halfcycle/ci-stanza.yml). HALFCYCLE_TOKEN set in the
|
|
742
|
+
# environment also takes precedence over the stored credential everywhere, which is
|
|
743
|
+
# the escape hatch for running as a specific identity.
|
|
744
|
+
GUARD_SERVICE_URL=<argType:runtime>
|
|
745
|
+
GUARD_SERVICE_TOKEN=<argType:runtime>
|
|
746
|
+
GUARD_ENGAGEMENT_ID=<argType:runtime>
|
|
747
|
+
# TWO ORIGINS, TWO SERVICES (T-22). HALFCYCLE_SERVICE_URL is the Halfcycle
|
|
748
|
+
# CONTROL plane \u2014 it serves engagement creation and the board's first-visit
|
|
749
|
+
# code. HALFCYCLE_MCP_URL is the method-delivery service, which serves /mcp and
|
|
750
|
+
# nothing else here. You configure the first; the platform reports the second on
|
|
751
|
+
# create, so it is written for you and .mcp.json carries the composed address.
|
|
752
|
+
HALFCYCLE_SERVICE_URL=<argType:runtime>
|
|
753
|
+
HALFCYCLE_MCP_URL=<argType:runtime>
|
|
754
|
+
HALFCYCLE_TOKEN=<argType:runtime>
|
|
755
|
+
# CONTROL_TELEMETRY_URL (T-10) \u2014 the platform reports this on create/join, same as
|
|
756
|
+
# HALFCYCLE_MCP_URL. OPTIONAL: absent, guard-eval telemetry is silently disabled
|
|
757
|
+
# and the guard loop itself is unaffected \u2014 this name is declared only because it
|
|
758
|
+
# is one this installer now writes to the machine-level store, same as every name
|
|
759
|
+
# above it.
|
|
760
|
+
CONTROL_TELEMETRY_URL=<argType:runtime>
|
|
761
|
+
`;
|
|
762
|
+
}
|
|
763
|
+
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
764
|
+
var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
765
|
+
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}"'`;
|
|
766
|
+
function generateMcpHeadersHelper() {
|
|
767
|
+
return `#!/bin/sh
|
|
768
|
+
# Halfcycle MCP connection headers \u2014 generated by the Halfcycle installer.
|
|
769
|
+
#
|
|
770
|
+
# Claude Code runs this at MCP connection time, from the session's cwd, and merges
|
|
771
|
+
# its stdout into the connection headers for the "halfcycle" server in .mcp.json.
|
|
772
|
+
#
|
|
773
|
+
# It exists because \${VAR} expansion in .mcp.json reads the PROCESS environment
|
|
774
|
+
# only: nothing loads a credential into a session, so a referenced
|
|
775
|
+
# \${HALFCYCLE_TOKEN} expanded to nothing and the server was silently absent from
|
|
776
|
+
# the tool list.
|
|
777
|
+
#
|
|
778
|
+
# This file is tracked and holds NO secret. NEITHER DOES ANY OTHER FILE IN THIS
|
|
779
|
+
# REPOSITORY (T-09): the credential lives at machine level, outside every checkout,
|
|
780
|
+
# and this script READS one line out of it (never sources \u2014 a credential store is
|
|
781
|
+
# not code).
|
|
782
|
+
set -eu
|
|
783
|
+
|
|
784
|
+
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
785
|
+
# caller's working directory. Claude Code runs the helper from the SESSION's cwd,
|
|
786
|
+
# and a session started in a subdirectory still finds the root .mcp.json \u2014 so a
|
|
787
|
+
# relative read here silently produced no credential for anyone who started the
|
|
788
|
+
# session in any subdirectory of a monorepo.
|
|
789
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
790
|
+
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
|
791
|
+
|
|
792
|
+
${engagementResolutionShell()}
|
|
793
|
+
|
|
794
|
+
# HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
|
|
795
|
+
# browser and no per-user store exports the credential, and a stale store on a
|
|
796
|
+
# long-lived runner must not silently win over it. Same order, and the same reason,
|
|
797
|
+
# as the CLI's own resolver (\`resolve-credential.ts\`).
|
|
798
|
+
TOKEN=\${HALFCYCLE_TOKEN:-}
|
|
799
|
+
|
|
800
|
+
if [ -z "$TOKEN" ]; then
|
|
801
|
+
if ! halfcycle_env_file "$PROJECT_ROOT"; then
|
|
802
|
+
case $HALFCYCLE_ENV_PROBLEM in
|
|
803
|
+
no-pin)
|
|
804
|
+
echo "halfcycle: no .halfcycle/bundle.json in $PROJECT_ROOT, so this repository has no Halfcycle engagement." >&2
|
|
805
|
+
echo "halfcycle: run \\"npx halfcycle\\" here to install one." >&2 ;;
|
|
806
|
+
no-id)
|
|
807
|
+
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
|
|
808
|
+
echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
|
|
809
|
+
no-home)
|
|
810
|
+
echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
|
|
811
|
+
*)
|
|
812
|
+
echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
|
|
813
|
+
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 ;;
|
|
814
|
+
esac
|
|
815
|
+
exit 1
|
|
816
|
+
fi
|
|
817
|
+
ENV_FILE="$HALFCYCLE_ENV_FILE"
|
|
818
|
+
|
|
819
|
+
# Last assignment wins, matching the shell's own \`.\` semantics. An optional
|
|
820
|
+
# leading \`export \` is tolerated because a hand-edited store may use one.
|
|
821
|
+
LINE=$(grep '^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}HALFCYCLE_TOKEN=' "$ENV_FILE" | tail -n 1)
|
|
822
|
+
TOKEN=\${LINE#*HALFCYCLE_TOKEN=}
|
|
823
|
+
TOKEN=$(printf '%s' "$TOKEN" | tr -d '\\r')
|
|
824
|
+
|
|
825
|
+
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
826
|
+
# writers use the same shq: this installer and Studio's provider), because
|
|
827
|
+
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
828
|
+
# would execute its own remainder. This reader greps rather than sources, so it
|
|
829
|
+
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
830
|
+
# sourcing consumer gets, or one file has two answers.
|
|
831
|
+
case $TOKEN in
|
|
832
|
+
'"'*'"') TOKEN=\${TOKEN#'"'}; TOKEN=\${TOKEN%'"'} ;;
|
|
833
|
+
"'"*"'")
|
|
834
|
+
TOKEN=\${TOKEN#"'"}; TOKEN=\${TOKEN%"'"}
|
|
835
|
+
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
836
|
+
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
837
|
+
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
838
|
+
# no shell quoting is in the way:
|
|
839
|
+
#
|
|
840
|
+
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
841
|
+
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
842
|
+
#
|
|
843
|
+
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
844
|
+
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
845
|
+
# and the token then travels with four stray characters in it. The bracket
|
|
846
|
+
# expression makes the backslash literal by a construction POSIX does define.
|
|
847
|
+
TOKEN=$(printf '%s' "$TOKEN" | sed "s/'[\\]''/'/g") ;;
|
|
848
|
+
esac
|
|
849
|
+
|
|
850
|
+
if [ -z "$TOKEN" ]; then
|
|
851
|
+
echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
|
|
852
|
+
echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
|
|
853
|
+
exit 1
|
|
854
|
+
fi
|
|
855
|
+
fi
|
|
856
|
+
|
|
857
|
+
# JSON-escape: backslash first, then double quote. A token carrying either would
|
|
858
|
+
# otherwise emit invalid JSON, and invalid JSON is dropped SILENTLY \u2014 the
|
|
859
|
+
# connection succeeds carrying no Authorization header at all.
|
|
860
|
+
ESCAPED=$(printf '%s' "$TOKEN" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
|
|
861
|
+
|
|
862
|
+
printf '{"Authorization":"Bearer %s"}\\n' "$ESCAPED"
|
|
863
|
+
`;
|
|
864
|
+
}
|
|
865
|
+
function generateMcpRegistration(existing, mcpOrigin) {
|
|
866
|
+
let base = { mcpServers: {} };
|
|
867
|
+
if (existing !== null) {
|
|
868
|
+
const parsed = JSON.parse(existing);
|
|
869
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
870
|
+
base = { mcpServers: {}, ...parsed };
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
|
|
874
|
+
base.mcpServers = {};
|
|
875
|
+
}
|
|
876
|
+
base.mcpServers["halfcycle"] = {
|
|
877
|
+
type: "http",
|
|
878
|
+
url: mcpEndpointUrl(mcpOrigin),
|
|
879
|
+
headersHelper: MCP_HEADERS_HELPER_COMMAND
|
|
880
|
+
};
|
|
881
|
+
return JSON.stringify(base, null, 2) + "\n";
|
|
882
|
+
}
|
|
883
|
+
function reconcileEnvExample(targetRepo) {
|
|
884
|
+
const envExamplePath = join5(targetRepo, ".env.example");
|
|
885
|
+
const section = generateEnvExampleSection();
|
|
886
|
+
if (!existsSync3(envExamplePath)) {
|
|
887
|
+
mkdirSync3(dirname2(envExamplePath), { recursive: true });
|
|
888
|
+
writeFileSync3(envExamplePath, section.trimStart(), "utf-8");
|
|
889
|
+
return "written";
|
|
890
|
+
}
|
|
891
|
+
const existing = readFileSync4(envExamplePath, "utf-8");
|
|
892
|
+
const missing = ENV_EXAMPLE_NAMES.filter((name) => !existing.includes(name));
|
|
893
|
+
if (missing.length === 0)
|
|
894
|
+
return "skipped";
|
|
895
|
+
writeFileSync3(envExamplePath, existing + section, "utf-8");
|
|
896
|
+
return "written";
|
|
897
|
+
}
|
|
898
|
+
var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
|
|
899
|
+
function gitignoreCovers(content) {
|
|
900
|
+
const lines = new Set(content.split("\n").map((l) => l.trim()));
|
|
901
|
+
return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
|
|
902
|
+
}
|
|
903
|
+
function reconcileGitignore(targetRepo) {
|
|
904
|
+
const gitignorePath = join5(targetRepo, ".gitignore");
|
|
905
|
+
const header = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
|
|
906
|
+
try {
|
|
907
|
+
if (!existsSync3(gitignorePath)) {
|
|
908
|
+
const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
|
|
909
|
+
writeFileSync3(gitignorePath, body, "utf-8");
|
|
910
|
+
return "written";
|
|
911
|
+
}
|
|
912
|
+
const existing = readFileSync4(gitignorePath, "utf-8");
|
|
913
|
+
if (gitignoreCovers(existing))
|
|
914
|
+
return "skipped";
|
|
915
|
+
const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
916
|
+
const toAdd = REQUIRED_GITIGNORE_ENTRIES.filter((entry) => !existingLines.has(entry));
|
|
917
|
+
const suffix = (existing.endsWith("\n") ? "" : "\n") + `
|
|
918
|
+
${header}
|
|
919
|
+
` + toAdd.join("\n") + "\n";
|
|
920
|
+
writeFileSync3(gitignorePath, existing + suffix, "utf-8");
|
|
921
|
+
return "written";
|
|
922
|
+
} catch {
|
|
923
|
+
return "failed";
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, writtenPaths) {
|
|
927
|
+
const pin = {
|
|
928
|
+
version,
|
|
929
|
+
engagementId,
|
|
930
|
+
engagementType,
|
|
931
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
932
|
+
};
|
|
933
|
+
const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
|
|
934
|
+
writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
|
|
935
|
+
}
|
|
936
|
+
function readBundlePin(targetRepoRoot) {
|
|
937
|
+
const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
|
|
938
|
+
if (!existsSync3(pinPath))
|
|
939
|
+
return null;
|
|
940
|
+
return JSON.parse(readFileSync4(pinPath, "utf-8"));
|
|
941
|
+
}
|
|
942
|
+
var CREDENTIAL_REQUIRED_KEYS = [
|
|
943
|
+
"HALFCYCLE_SERVICE_URL",
|
|
944
|
+
"HALFCYCLE_TOKEN",
|
|
945
|
+
"HALFCYCLE_MCP_URL",
|
|
946
|
+
"GUARD_SERVICE_URL"
|
|
947
|
+
];
|
|
948
|
+
function missingRequiredKeys(found) {
|
|
949
|
+
const serviceUrl = (process.env["HALFCYCLE_SERVICE_URL"]?.trim() ?? "") || (found["HALFCYCLE_SERVICE_URL"] ?? "");
|
|
950
|
+
const resolved = { ...found, HALFCYCLE_SERVICE_URL: serviceUrl };
|
|
951
|
+
return CREDENTIAL_REQUIRED_KEYS.filter((key) => (resolved[key] ?? "") === "");
|
|
952
|
+
}
|
|
953
|
+
function checkDrift(targetRepoRoot) {
|
|
954
|
+
const manifest = readPluginManifest();
|
|
955
|
+
const pin = readBundlePin(targetRepoRoot);
|
|
956
|
+
const installed = pin?.version ?? null;
|
|
957
|
+
const current = manifest.version;
|
|
958
|
+
return { drifted: installed !== current, installed, current };
|
|
959
|
+
}
|
|
960
|
+
function writeProjectIdentity(targetRepo) {
|
|
961
|
+
const path = join5(targetRepo, ".halfcycle", "project.json");
|
|
962
|
+
const { identity } = mintOrReadIdentity(targetRepo);
|
|
963
|
+
const serialized = JSON.stringify(identity, null, 2) + "\n";
|
|
964
|
+
if (existsSync3(path) && readFileSync4(path, "utf-8") === serialized)
|
|
965
|
+
return "skipped";
|
|
966
|
+
writeFileSync3(path, serialized, "utf-8");
|
|
967
|
+
return "written";
|
|
968
|
+
}
|
|
969
|
+
function writeEngagementCredential(credential, engagementId, home) {
|
|
970
|
+
const values = {
|
|
971
|
+
HALFCYCLE_SERVICE_URL: credential.serviceUrl,
|
|
972
|
+
// T-22 — the second origin, persisted for the developer and for any tool that
|
|
973
|
+
// wants it by name. NOTHING EXPANDS IT ANY MORE: since T-24 the installer
|
|
974
|
+
// composes the endpoint and writes it into `.mcp.json` as a literal, so this
|
|
975
|
+
// key no longer feeds the registration. (It read "the registration expands to
|
|
976
|
+
// `/mcp` against an empty string" until T-24 removed the expansion; a comment
|
|
977
|
+
// describing a mechanism that no longer exists is how the next reader builds
|
|
978
|
+
// on a claim that is not true.)
|
|
979
|
+
HALFCYCLE_MCP_URL: credential.mcpUrl,
|
|
980
|
+
HALFCYCLE_TOKEN: credential.token,
|
|
981
|
+
HALFCYCLE_ENGAGEMENT_ID: engagementId,
|
|
982
|
+
// FW-4 (F-29) — the three the hook this same module generates requires. The
|
|
983
|
+
// guard origin is a third deployed service the platform reports on create;
|
|
984
|
+
// the token is the one per-engagement credential under the name the runner
|
|
985
|
+
// reads; the id is the engagement the guard service binds the body to.
|
|
986
|
+
GUARD_SERVICE_URL: credential.guardUrl,
|
|
987
|
+
GUARD_SERVICE_TOKEN: credential.token,
|
|
988
|
+
GUARD_ENGAGEMENT_ID: engagementId,
|
|
989
|
+
// T-10 — the eighth key. Written as '' when the plane has not reported one
|
|
990
|
+
// (never `undefined`, for the exact reason the comment above this function
|
|
991
|
+
// measures: a loose `Record<string, string>` let a missing key write the
|
|
992
|
+
// literal string "undefined" with a clean typecheck. An empty value here reads
|
|
993
|
+
// back as absent — `toCredential` treats a blank CONTROL_TELEMETRY_URL as no
|
|
994
|
+
// telemetry origin, same as every other optional field on `DeviceCredential`.
|
|
995
|
+
CONTROL_TELEMETRY_URL: credential.controlTelemetryUrl ?? ""
|
|
996
|
+
};
|
|
997
|
+
return writeEngagementEnv(engagementId, values, home);
|
|
998
|
+
}
|
|
999
|
+
var LEGACY_ENV_LOCAL_REL = ".env.local";
|
|
1000
|
+
function migrateLegacyEnvLocal(targetRepo, stored) {
|
|
1001
|
+
const path = join5(targetRepo, LEGACY_ENV_LOCAL_REL);
|
|
1002
|
+
if (!existsSync3(path))
|
|
1003
|
+
return { outcome: "absent", unbacked: [], missingForAdoption: [] };
|
|
1004
|
+
let raw;
|
|
1005
|
+
try {
|
|
1006
|
+
raw = readFileSync4(path, "utf-8");
|
|
1007
|
+
} catch {
|
|
1008
|
+
return { outcome: "failed", unbacked: [], missingForAdoption: [] };
|
|
1009
|
+
}
|
|
1010
|
+
const inFile = parseEnvText(raw);
|
|
1011
|
+
const present = ENGAGEMENT_ENV_KEYS.filter((key) => key in inFile);
|
|
1012
|
+
if (present.length === 0)
|
|
1013
|
+
return { outcome: "absent", unbacked: [], missingForAdoption: [] };
|
|
1014
|
+
const storeUnusable = missingRequiredKeys(stored).length > 0;
|
|
1015
|
+
const unbacked = present.filter((key) => (inFile[key] ?? "") !== "" && (storeUnusable || (stored[key] ?? "") === ""));
|
|
1016
|
+
if (unbacked.length > 0) {
|
|
1017
|
+
return { outcome: "kept", unbacked, missingForAdoption: missingRequiredKeys(inFile) };
|
|
1018
|
+
}
|
|
1019
|
+
const ours = new Set(ENGAGEMENT_ENV_KEYS);
|
|
1020
|
+
const kept = raw.split("\n").filter((line) => {
|
|
1021
|
+
const eq = line.indexOf("=");
|
|
1022
|
+
if (eq === -1)
|
|
1023
|
+
return true;
|
|
1024
|
+
return !ours.has(line.slice(0, eq).replace(/^\s*export\s+/, "").trim());
|
|
1025
|
+
});
|
|
1026
|
+
const substantive = kept.filter((line) => line.trim() !== "" && !line.trim().startsWith("# Halfcycle"));
|
|
1027
|
+
try {
|
|
1028
|
+
if (substantive.length === 0) {
|
|
1029
|
+
rmSync(path);
|
|
1030
|
+
return { outcome: "removed", unbacked: [], missingForAdoption: [] };
|
|
1031
|
+
}
|
|
1032
|
+
writeFileSync3(path, kept.join("\n"), "utf-8");
|
|
1033
|
+
return { outcome: "stripped", unbacked: [], missingForAdoption: [] };
|
|
1034
|
+
} catch {
|
|
1035
|
+
return { outcome: "failed", unbacked: [], missingForAdoption: [] };
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
async function install(options) {
|
|
1039
|
+
const { targetRepo, engagementId, engagementType, credential, home } = options;
|
|
1040
|
+
if (!existsSync3(targetRepo)) {
|
|
1041
|
+
throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
|
|
1042
|
+
}
|
|
1043
|
+
const manifest = readPluginManifest();
|
|
1044
|
+
const report = {
|
|
1045
|
+
writtenPaths: [],
|
|
1046
|
+
skippedPaths: [],
|
|
1047
|
+
mergedPaths: [],
|
|
1048
|
+
collidedPaths: []
|
|
1049
|
+
};
|
|
1050
|
+
copyManifestCommands(manifest, targetRepo, report);
|
|
1051
|
+
const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
|
|
1052
|
+
const capturedManifest = join5(capturedDir, "manifest.json");
|
|
1053
|
+
if (!existsSync3(capturedManifest)) {
|
|
1054
|
+
const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
|
|
1055
|
+
writeAllowlisted(capturedManifest, targetRepo, readFileSync4(srcManifest, "utf-8"), report.writtenPaths);
|
|
1056
|
+
} else {
|
|
1057
|
+
report.skippedPaths.push("test/fixtures/captured/manifest.json");
|
|
1058
|
+
}
|
|
1059
|
+
const vendoredBinSrc = resolveVendoredBinary();
|
|
1060
|
+
const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
|
|
1061
|
+
record(report, writeCollisionSafe(vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
|
|
1062
|
+
const settingsPath = join5(targetRepo, ".claude", "settings.json");
|
|
1063
|
+
const settingsPreexisted = existsSync3(settingsPath);
|
|
1064
|
+
const generatedSettings = JSON.parse(generateSettingsJson());
|
|
1065
|
+
const existingSettings = settingsPreexisted ? JSON.parse(readFileSync4(settingsPath, "utf-8")) : {};
|
|
1066
|
+
const mergedSettings = mergeSettings(existingSettings, generatedSettings);
|
|
1067
|
+
const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
|
|
1068
|
+
mkdirSync3(dirname2(settingsPath), { recursive: true });
|
|
1069
|
+
writeFileSync3(settingsPath, mergedSettingsText, "utf-8");
|
|
1070
|
+
(settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
|
|
1071
|
+
for (const [name, content] of [
|
|
1072
|
+
["guard-runner.sh", generateGuardRunnerWrapper()],
|
|
1073
|
+
["session-start-marker.sh", generateSessionStartMarker()],
|
|
1074
|
+
["user-prompt-reminder.sh", generateUserPromptReminderHook()]
|
|
1075
|
+
]) {
|
|
1076
|
+
const hookPath = join5(targetRepo, ".claude", "hooks", name);
|
|
1077
|
+
record(report, writeCollisionSafe(hookPath, targetRepo, content), `.claude/hooks/${name}`);
|
|
1078
|
+
}
|
|
1079
|
+
const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
|
|
1080
|
+
record(report, writeCollisionSafe(ciStanzaPath, targetRepo, generateCiStanza()), ".halfcycle/ci-stanza.yml");
|
|
1081
|
+
record(report, reconcileEnvExample(targetRepo), ".env.example");
|
|
1082
|
+
if (credential) {
|
|
1083
|
+
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
1084
|
+
record(report, writeCollisionSafe(helperPath, targetRepo, generateMcpHeadersHelper()), MCP_HEADERS_HELPER_REL);
|
|
1085
|
+
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
1086
|
+
const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
|
|
1087
|
+
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
1088
|
+
if (existingMcp === null) {
|
|
1089
|
+
writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
|
|
1090
|
+
} else if (existingMcp !== mcpContent) {
|
|
1091
|
+
writeAllowlisted(mcpPath, targetRepo, mcpContent, report.mergedPaths);
|
|
1092
|
+
} else {
|
|
1093
|
+
report.skippedPaths.push(MCP_REGISTRATION_REL);
|
|
1094
|
+
}
|
|
1095
|
+
} else {
|
|
1096
|
+
report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
|
|
1097
|
+
}
|
|
1098
|
+
const gitignoreOutcome = reconcileGitignore(targetRepo);
|
|
1099
|
+
if (gitignoreOutcome === "failed") {
|
|
1100
|
+
report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
|
|
1101
|
+
} else {
|
|
1102
|
+
record(report, gitignoreOutcome, ".gitignore");
|
|
1103
|
+
}
|
|
1104
|
+
record(report, writeProjectIdentity(targetRepo), ".halfcycle/project.json");
|
|
1105
|
+
const credentialPath = engagementEnvPath(engagementId, home);
|
|
1106
|
+
if (credential) {
|
|
1107
|
+
record(report, writeEngagementCredential(credential, engagementId, home), credentialPath);
|
|
1108
|
+
} else {
|
|
1109
|
+
report.skippedPaths.push(`${credentialPath} (no credential supplied)`);
|
|
1110
|
+
}
|
|
1111
|
+
const stored = readEngagementEnv(engagementId, home) ?? {};
|
|
1112
|
+
const migration = migrateLegacyEnvLocal(targetRepo, stored);
|
|
1113
|
+
switch (migration.outcome) {
|
|
1114
|
+
case "removed":
|
|
1115
|
+
report.writtenPaths.push(".env.local (REMOVED \u2014 the credential now lives outside this repository)");
|
|
1116
|
+
break;
|
|
1117
|
+
case "stripped":
|
|
1118
|
+
report.writtenPaths.push(".env.local (Halfcycle keys REMOVED \u2014 your own entries are untouched; the credential now lives outside this repository)");
|
|
1119
|
+
break;
|
|
1120
|
+
case "kept":
|
|
1121
|
+
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)`);
|
|
1122
|
+
break;
|
|
1123
|
+
case "failed":
|
|
1124
|
+
report.skippedPaths.push(".env.local (could not be rewritten \u2014 DELETE IT BY HAND: it holds a credential this repository no longer needs)");
|
|
1125
|
+
break;
|
|
1126
|
+
case "absent":
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
|
|
1130
|
+
const scanResult = runBootstrapScan(targetRepo);
|
|
1131
|
+
return {
|
|
1132
|
+
version: manifest.version,
|
|
1133
|
+
writtenPaths: report.writtenPaths,
|
|
1134
|
+
skippedPaths: report.skippedPaths,
|
|
1135
|
+
mergedPaths: report.mergedPaths,
|
|
1136
|
+
collidedPaths: report.collidedPaths,
|
|
1137
|
+
bootstrapScanRan: scanResult.ran,
|
|
1138
|
+
legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// dist/create-engagement.js
|
|
1143
|
+
var CreateEngagementRefused = class extends Error {
|
|
1144
|
+
status;
|
|
1145
|
+
serverMessage;
|
|
1146
|
+
errorCode;
|
|
1147
|
+
constructor(status, serverMessage, errorCode, message) {
|
|
1148
|
+
super(message);
|
|
1149
|
+
this.status = status;
|
|
1150
|
+
this.serverMessage = serverMessage;
|
|
1151
|
+
this.errorCode = errorCode;
|
|
1152
|
+
this.name = "CreateEngagementRefused";
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Is this a refusal of the CALLER's credential — the arm signing in again can fix?
|
|
1156
|
+
*
|
|
1157
|
+
* `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
|
|
1158
|
+
* the whole reason this is a method rather than a status comparison at the call
|
|
1159
|
+
* site: the flag gates the DEPLOYMENT, identically for every caller, so no
|
|
1160
|
+
* credential and no sign-in changes the answer. Treating it as an auth refusal
|
|
1161
|
+
* would send a developer through a browser handoff to arrive at the same 403.
|
|
1162
|
+
*
|
|
1163
|
+
* **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
|
|
1164
|
+
* (T-24).** On the join route a 403 has two causes that share a status AND a
|
|
1165
|
+
* discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
|
|
1166
|
+
* apart: the credential may be a live engagement token carrying no account, or the
|
|
1167
|
+
* engagement id may not resolve. Signing in again cannot fix the second, and
|
|
1168
|
+
* treating it as though it could would discard a good credential, walk the
|
|
1169
|
+
* developer through a browser, and arrive at the identical 403 — a remedy that
|
|
1170
|
+
* loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
|
|
1171
|
+
* where the reason can be written down. See `joinPinnedEngagement` there.
|
|
1172
|
+
*/
|
|
1173
|
+
get authRefused() {
|
|
1174
|
+
if (this.errorCode === "PublicStartDisabled")
|
|
1175
|
+
return false;
|
|
1176
|
+
return this.status === 401 || this.status === 403;
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
function planeRefusal(detail) {
|
|
1180
|
+
let body;
|
|
1181
|
+
try {
|
|
1182
|
+
body = JSON.parse(detail);
|
|
1183
|
+
} catch {
|
|
1184
|
+
return void 0;
|
|
1185
|
+
}
|
|
1186
|
+
if (typeof body !== "object" || body === null)
|
|
1187
|
+
return void 0;
|
|
1188
|
+
const candidate = body;
|
|
1189
|
+
if (typeof candidate.message !== "string" || candidate.message.trim() === "")
|
|
1190
|
+
return void 0;
|
|
1191
|
+
const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
|
|
1192
|
+
if (!hasDiscriminant)
|
|
1193
|
+
return void 0;
|
|
1194
|
+
return {
|
|
1195
|
+
message: candidate.message,
|
|
1196
|
+
error: typeof candidate.error === "string" ? candidate.error : void 0
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
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.";
|
|
1200
|
+
async function createEngagement(baseUrl, name, credential) {
|
|
1201
|
+
return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
|
|
1202
|
+
}
|
|
1203
|
+
async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
1204
|
+
const bearer = credential?.trim();
|
|
1205
|
+
let res;
|
|
1206
|
+
try {
|
|
1207
|
+
res = await fetch(url, {
|
|
1208
|
+
method: "POST",
|
|
1209
|
+
headers: {
|
|
1210
|
+
"content-type": "application/json",
|
|
1211
|
+
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
1212
|
+
},
|
|
1213
|
+
body: JSON.stringify(requestBody)
|
|
1214
|
+
});
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
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);
|
|
1217
|
+
}
|
|
1218
|
+
if (!res.ok) {
|
|
1219
|
+
const detail = await res.text().catch(() => "");
|
|
1220
|
+
const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
|
|
1221
|
+
if (refusal) {
|
|
1222
|
+
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
|
|
1223
|
+
}
|
|
1224
|
+
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
|
|
1225
|
+
throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
1226
|
+
}
|
|
1227
|
+
const body = await res.json().catch(() => null);
|
|
1228
|
+
if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
|
|
1229
|
+
throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
1230
|
+
}
|
|
1231
|
+
for (const [field, value] of [
|
|
1232
|
+
["engagementId", body.engagementId],
|
|
1233
|
+
["sessionToken", body.sessionToken]
|
|
1234
|
+
]) {
|
|
1235
|
+
if (value.trim() === "") {
|
|
1236
|
+
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);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
|
|
1240
|
+
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.`);
|
|
1241
|
+
}
|
|
1242
|
+
if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
|
|
1243
|
+
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.`);
|
|
1244
|
+
}
|
|
1245
|
+
const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
|
|
1246
|
+
return {
|
|
1247
|
+
engagementId: body.engagementId,
|
|
1248
|
+
sessionToken: body.sessionToken,
|
|
1249
|
+
mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
|
|
1250
|
+
guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
|
|
1251
|
+
...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// dist/mint-board-code.js
|
|
1256
|
+
async function mintBoardEnterCode(baseUrl, sessionToken) {
|
|
1257
|
+
const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
|
|
1258
|
+
let res;
|
|
1259
|
+
try {
|
|
1260
|
+
res = await fetch(url, {
|
|
1261
|
+
method: "POST",
|
|
1262
|
+
headers: {
|
|
1263
|
+
"content-type": "application/json",
|
|
1264
|
+
authorization: `Bearer ${sessionToken}`
|
|
1265
|
+
},
|
|
1266
|
+
body: "{}"
|
|
1267
|
+
});
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
throw new Error(`[bundle install] Could not reach the Halfcycle board mint endpoint at ${url}: ${err instanceof Error ? err.message : String(err)}.`);
|
|
1270
|
+
}
|
|
1271
|
+
if (!res.ok) {
|
|
1272
|
+
throw new Error(`[bundle install] Board code mint failed: ${url} returned ${res.status}.`);
|
|
1273
|
+
}
|
|
1274
|
+
const body = await res.json().catch(() => null);
|
|
1275
|
+
if (!body || typeof body.enterCode !== "string" || typeof body.engagementId !== "string" || typeof body.boardUrl !== "string" || typeof body.expiresAt !== "string") {
|
|
1276
|
+
throw new Error(`[bundle install] Board code mint response from ${url} did not carry {enterCode, engagementId, boardUrl, expiresAt}.`);
|
|
1277
|
+
}
|
|
1278
|
+
return {
|
|
1279
|
+
enterCode: body.enterCode,
|
|
1280
|
+
engagementId: body.engagementId,
|
|
1281
|
+
boardUrl: body.boardUrl,
|
|
1282
|
+
expiresAt: body.expiresAt
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// dist/setup/manifest.js
|
|
1287
|
+
import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1288
|
+
import { dirname as dirname3, join as join6, posix } from "node:path";
|
|
1289
|
+
var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
|
|
1290
|
+
var DECLARED_DIR_PREFIX = "docs";
|
|
1291
|
+
var RESERVED_MIRROR_PATH = "docs/method";
|
|
1292
|
+
var STEP_ID_GRAMMAR = /\b(?:l[0-5](?:-l[0-5])?|xl)\.[a-z0-9-]+/i;
|
|
1293
|
+
var STEP_ANNOTATION = /<!--\s*step:/i;
|
|
1294
|
+
var ANCHOR_DECLARATION = /<!--\s*anchor:/i;
|
|
1295
|
+
function normalise(path) {
|
|
1296
|
+
const slashed = path.replace(/\\/g, "/");
|
|
1297
|
+
if (slashed.trim() === "")
|
|
1298
|
+
return "";
|
|
1299
|
+
return posix.normalize(slashed).replace(/\/+$/, "");
|
|
1300
|
+
}
|
|
1301
|
+
function isUnder(candidate, prefix) {
|
|
1302
|
+
return candidate === prefix || candidate.startsWith(prefix + "/");
|
|
1303
|
+
}
|
|
1304
|
+
function isUnderIgnoringCase(candidate, prefix) {
|
|
1305
|
+
return isUnder(candidate.toLowerCase(), prefix.toLowerCase());
|
|
1306
|
+
}
|
|
1307
|
+
function foldWin32Canonicalisation(path) {
|
|
1308
|
+
return path.split("/").map((segment) => segment.replace(/[. ]+$/, "")).join("/");
|
|
1309
|
+
}
|
|
1310
|
+
function assertWritable(relPath) {
|
|
1311
|
+
const path = normalise(relPath);
|
|
1312
|
+
if (path === "" || path === "." || path.startsWith("/") || /^[A-Za-z]:/.test(path)) {
|
|
1313
|
+
throw new Error(`[halfcycle setup] Refusing to write "${relPath}": the manifest writes repository-relative paths only.`);
|
|
1314
|
+
}
|
|
1315
|
+
if (path.split("/").includes("..")) {
|
|
1316
|
+
throw new Error(`[halfcycle setup] Refusing to write "${relPath}": a path may not climb out of the repository.`);
|
|
1317
|
+
}
|
|
1318
|
+
if (isUnderIgnoringCase(foldWin32Canonicalisation(path), RESERVED_MIRROR_PATH)) {
|
|
1319
|
+
throw new Error(`[halfcycle setup] Refusing to write "${relPath}": ${RESERVED_MIRROR_PATH}/ is reserved and is never created in a project repository. The method is served per step and never installed (INV-016).`);
|
|
1320
|
+
}
|
|
1321
|
+
const declared = DECLARED_ROOT_FILES.includes(path) || isUnder(path, DECLARED_DIR_PREFIX);
|
|
1322
|
+
if (!declared) {
|
|
1323
|
+
throw new Error(`[halfcycle setup] Refusing to write "${relPath}": setup writes only ${DECLARED_ROOT_FILES.join(", ")} and paths under ${DECLARED_DIR_PREFIX}/.`);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
function assertContentCarriesNoMethodStructure(relPath, content) {
|
|
1327
|
+
const carried = STEP_ID_GRAMMAR.test(content) && "a method step identifier" || STEP_ANNOTATION.test(content) && "a method step annotation" || ANCHOR_DECLARATION.test(content) && "a method anchor declaration, so this is corpus text";
|
|
1328
|
+
if (carried) {
|
|
1329
|
+
throw new Error(`[halfcycle setup] Refusing to write "${relPath}": the content carries ${carried}. What a step directs is executed, never transcribed into the repository (INV-016).`);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
function assertUsableHeading(relPath, heading) {
|
|
1333
|
+
if (heading.trim().length === 0) {
|
|
1334
|
+
throw new Error(`[halfcycle setup] Refusing to write "${relPath}": a section needs a heading, and an empty one cannot be found again on the next run.`);
|
|
1335
|
+
}
|
|
1336
|
+
if (/[\r\n\u2028\u2029]/.test(heading)) {
|
|
1337
|
+
throw new Error(`[halfcycle setup] Refusing to write "${relPath}": a section heading must be a single line. A heading that spans lines can never be found again, so the section would be appended on every run.`);
|
|
1338
|
+
}
|
|
1339
|
+
if (heading !== heading.trim()) {
|
|
1340
|
+
throw new Error(`[halfcycle setup] Refusing to write "${relPath}": a section heading may not begin or end with whitespace \u2014 it would not match itself on the next run.`);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
function assertNoConflictingDuplicates(rows) {
|
|
1344
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1345
|
+
for (const row of rows) {
|
|
1346
|
+
const key = row.kind === "section" ? `${normalise(row.path)}##${row.heading}` : normalise(row.path);
|
|
1347
|
+
if (seen.has(key)) {
|
|
1348
|
+
throw new Error(`[halfcycle setup] Refusing this batch: "${row.path}" is named twice in one step's writes with the same "${row.kind === "section" ? row.heading : ""}" section. The second would be reported as a path you already own, which would be false.`);
|
|
1349
|
+
}
|
|
1350
|
+
seen.add(key);
|
|
1351
|
+
if (row.kind !== "section") {
|
|
1352
|
+
for (const other of rows) {
|
|
1353
|
+
if (other !== row && normalise(other.path) === normalise(row.path)) {
|
|
1354
|
+
throw new Error(`[halfcycle setup] Refusing this batch: "${row.path}" is named twice in one step's writes, once as a whole ${row.kind}. Those cannot both be honoured.`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
function renderSection(heading, body) {
|
|
1361
|
+
return `## ${heading}
|
|
1362
|
+
|
|
1363
|
+
${body.replace(/\s+$/, "")}
|
|
1364
|
+
`;
|
|
1365
|
+
}
|
|
1366
|
+
function hasSection(text, heading) {
|
|
1367
|
+
return text.split("\n").some((line) => line.trim() === `## ${heading}`);
|
|
1368
|
+
}
|
|
1369
|
+
function firstSymlinkOnPath(targetRepo, path) {
|
|
1370
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
1371
|
+
let walked = targetRepo;
|
|
1372
|
+
for (let i = 0; i < segments.length; i++) {
|
|
1373
|
+
walked = join6(walked, segments[i]);
|
|
1374
|
+
try {
|
|
1375
|
+
if (lstatSync(walked).isSymbolicLink())
|
|
1376
|
+
return segments.slice(0, i + 1).join("/");
|
|
1377
|
+
} catch {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
function symlinkEntry(path, link, isDirectoryRow) {
|
|
1384
|
+
return {
|
|
1385
|
+
path: isDirectoryRow ? `${path}/` : path,
|
|
1386
|
+
outcome: "left-alone",
|
|
1387
|
+
note: `"${link}" is a symbolic link \u2014 left exactly as it was, and not followed, because writing through it would put this file outside your repository`
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
function applyFileRow(targetRepo, path, content) {
|
|
1391
|
+
const abs = join6(targetRepo, path);
|
|
1392
|
+
const link = firstSymlinkOnPath(targetRepo, path);
|
|
1393
|
+
if (link !== null)
|
|
1394
|
+
return symlinkEntry(path, link, false);
|
|
1395
|
+
if (existsSync4(abs)) {
|
|
1396
|
+
const current = readFileSync5(abs, "utf-8");
|
|
1397
|
+
return current === content ? { path, outcome: "already-present", note: "already present and correct \u2014 nothing written" } : { path, outcome: "left-alone", note: "you already own this file \u2014 left exactly as it was" };
|
|
1398
|
+
}
|
|
1399
|
+
mkdirSync4(dirname3(abs), { recursive: true });
|
|
1400
|
+
writeFileSync4(abs, content, "utf-8");
|
|
1401
|
+
return { path, outcome: "created", note: "created" };
|
|
1402
|
+
}
|
|
1403
|
+
function applySectionRow(targetRepo, path, heading, body) {
|
|
1404
|
+
const abs = join6(targetRepo, path);
|
|
1405
|
+
const link = firstSymlinkOnPath(targetRepo, path);
|
|
1406
|
+
if (link !== null)
|
|
1407
|
+
return symlinkEntry(path, link, false);
|
|
1408
|
+
const section = renderSection(heading, body);
|
|
1409
|
+
if (!existsSync4(abs)) {
|
|
1410
|
+
mkdirSync4(dirname3(abs), { recursive: true });
|
|
1411
|
+
writeFileSync4(abs, section, "utf-8");
|
|
1412
|
+
return { path, outcome: "created", note: `created, with a "${heading}" section` };
|
|
1413
|
+
}
|
|
1414
|
+
const existing = readFileSync5(abs, "utf-8");
|
|
1415
|
+
if (hasSection(existing, heading)) {
|
|
1416
|
+
return {
|
|
1417
|
+
path,
|
|
1418
|
+
outcome: "already-present",
|
|
1419
|
+
note: `a "${heading}" section is already there \u2014 nothing written`
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
1423
|
+
writeFileSync4(abs, existing + separator + section, "utf-8");
|
|
1424
|
+
return {
|
|
1425
|
+
path,
|
|
1426
|
+
outcome: "section-added",
|
|
1427
|
+
note: `gained a "${heading}" section; everything you had is unchanged`
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
function applyDirectoryRow(targetRepo, path) {
|
|
1431
|
+
const abs = join6(targetRepo, path);
|
|
1432
|
+
const link = firstSymlinkOnPath(targetRepo, path);
|
|
1433
|
+
if (link !== null)
|
|
1434
|
+
return symlinkEntry(path, link, true);
|
|
1435
|
+
if (existsSync4(abs)) {
|
|
1436
|
+
const kind = statSync(abs).isDirectory() ? "directory" : "file";
|
|
1437
|
+
return kind === "directory" ? { path: `${path}/`, outcome: "already-present", note: "already there \u2014 nothing written" } : { path, outcome: "left-alone", note: "you already own this path \u2014 left exactly as it was" };
|
|
1438
|
+
}
|
|
1439
|
+
mkdirSync4(abs, { recursive: true });
|
|
1440
|
+
return { path: `${path}/`, outcome: "created", note: "created" };
|
|
1441
|
+
}
|
|
1442
|
+
function applySetupWrites(targetRepo, rows) {
|
|
1443
|
+
if (!existsSync4(targetRepo)) {
|
|
1444
|
+
throw new Error(`[halfcycle setup] The project directory does not exist: ${targetRepo}`);
|
|
1445
|
+
}
|
|
1446
|
+
assertNoConflictingDuplicates(rows);
|
|
1447
|
+
for (const row of rows) {
|
|
1448
|
+
assertWritable(row.path);
|
|
1449
|
+
if (row.kind === "file")
|
|
1450
|
+
assertContentCarriesNoMethodStructure(row.path, row.content);
|
|
1451
|
+
if (row.kind === "section") {
|
|
1452
|
+
assertUsableHeading(row.path, row.heading);
|
|
1453
|
+
assertContentCarriesNoMethodStructure(row.path, `${row.heading}
|
|
1454
|
+
${row.body}`);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const entries = [];
|
|
1458
|
+
for (const row of rows) {
|
|
1459
|
+
const path = normalise(row.path);
|
|
1460
|
+
if (row.kind === "file")
|
|
1461
|
+
entries.push(applyFileRow(targetRepo, path, row.content));
|
|
1462
|
+
else if (row.kind === "section") {
|
|
1463
|
+
entries.push(applySectionRow(targetRepo, path, row.heading, row.body));
|
|
1464
|
+
} else
|
|
1465
|
+
entries.push(applyDirectoryRow(targetRepo, path));
|
|
1466
|
+
}
|
|
1467
|
+
return { entries };
|
|
1468
|
+
}
|
|
1469
|
+
var SetupManifest = class {
|
|
1470
|
+
rows = [];
|
|
1471
|
+
/** Fold one resolve's report into the run's manifest. */
|
|
1472
|
+
add(report) {
|
|
1473
|
+
this.rows.push(...report.entries);
|
|
1474
|
+
}
|
|
1475
|
+
/** Every row, in the order the resolves produced them. */
|
|
1476
|
+
entries() {
|
|
1477
|
+
return this.rows;
|
|
1478
|
+
}
|
|
1479
|
+
/** The paths this run actually changed — created or section-added. */
|
|
1480
|
+
changedPaths() {
|
|
1481
|
+
return this.rows.filter((r) => r.outcome === "created" || r.outcome === "section-added").map((r) => r.path);
|
|
1482
|
+
}
|
|
1483
|
+
/** True iff the run changed nothing — the honest output of a second run. */
|
|
1484
|
+
nothingToDo() {
|
|
1485
|
+
return this.changedPaths().length === 0;
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* The paths this run wanted and did not get: a name the developer already uses, or
|
|
1489
|
+
* a symlink it would not follow. **Not the same as "already set up"** — a run where
|
|
1490
|
+
* every row is `left-alone` changed nothing *and* set nothing up, and reporting
|
|
1491
|
+
* that as done is the failure the report exists to prevent.
|
|
1492
|
+
*/
|
|
1493
|
+
leftAlonePaths() {
|
|
1494
|
+
return this.rows.filter((r) => r.outcome === "left-alone").map((r) => r.path);
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* The report, in plain words. Names every path touched, including the ones left
|
|
1498
|
+
* alone — a path the command decided not to write is exactly what a developer
|
|
1499
|
+
* needs told.
|
|
1500
|
+
*
|
|
1501
|
+
* **The header describes the run's actual composition, and says nothing universal
|
|
1502
|
+
* unless it holds.** Two earlier forms got this wrong in the same way, one round
|
|
1503
|
+
* apart: the first printed "already set up" for a run that wrote nothing *because
|
|
1504
|
+
* every path was already the developer's*, and the second printed "every path this
|
|
1505
|
+
* needed is already yours" for the **mixed** state — some rows already correct
|
|
1506
|
+
* from an earlier run, some blocked — which is the ordinary second-run case and is
|
|
1507
|
+
* simply false. Counts are what the header is built from now, and *every* appears
|
|
1508
|
+
* only when the count says every.
|
|
1509
|
+
*/
|
|
1510
|
+
render() {
|
|
1511
|
+
if (this.rows.length === 0)
|
|
1512
|
+
return "Nothing to do \u2014 nothing was asked for.";
|
|
1513
|
+
const lines = this.rows.map((r) => `${r.path} \u2014 ${r.note}`);
|
|
1514
|
+
if (!this.nothingToDo())
|
|
1515
|
+
return ["Set up this project. Here is every path touched:", ...lines].join("\n");
|
|
1516
|
+
const skipped = this.leftAlonePaths().length;
|
|
1517
|
+
const already = this.rows.filter((r) => r.outcome === "already-present").length;
|
|
1518
|
+
let header;
|
|
1519
|
+
if (skipped === 0)
|
|
1520
|
+
header = "Nothing to do \u2014 this project is already set up.";
|
|
1521
|
+
else if (already === 0) {
|
|
1522
|
+
header = `Nothing was written: ${skipped === 1 ? "the one path" : `all ${skipped} paths`} this needed ${skipped === 1 ? "is" : "are"} already yours, so ${skipped === 1 ? "it was" : "they were"} left alone. Here is what that was:`;
|
|
1523
|
+
} else {
|
|
1524
|
+
header = `Nothing was written this time: ${already} ${already === 1 ? "path was" : "paths were"} already set up, and ${skipped} ${skipped === 1 ? "is" : "are"} already yours and ${skipped === 1 ? "was" : "were"} left alone. Here is every path:`;
|
|
1525
|
+
}
|
|
1526
|
+
return [header, ...lines].join("\n");
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
// dist/setup/rows.js
|
|
1531
|
+
var FLAT_FILE_TRACKER_HOME = "docs/tasks";
|
|
1532
|
+
function contextIndexRow(heading, body) {
|
|
1533
|
+
return { path: "CLAUDE.md", kind: "section", heading, body };
|
|
1534
|
+
}
|
|
1535
|
+
function agentsPointerRow(content) {
|
|
1536
|
+
return { path: "AGENTS.md", kind: "file", content };
|
|
1537
|
+
}
|
|
1538
|
+
function layerDocumentRow(path, content) {
|
|
1539
|
+
return { path, kind: "file", content };
|
|
1540
|
+
}
|
|
1541
|
+
function documentSectionRow(path, heading, body) {
|
|
1542
|
+
return { path, kind: "section", heading, body };
|
|
1543
|
+
}
|
|
1544
|
+
function engagementRecordRow(content) {
|
|
1545
|
+
return { path: "ENGAGEMENT.md", kind: "file", content };
|
|
1546
|
+
}
|
|
1547
|
+
function trackerHomeRow(home = FLAT_FILE_TRACKER_HOME) {
|
|
1548
|
+
return { path: home, kind: "directory" };
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// dist/setup/discovery.js
|
|
1552
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6, readdirSync, statSync as statSync2 } from "node:fs";
|
|
1553
|
+
import { join as join7 } from "node:path";
|
|
1554
|
+
function renderAnchor(anchor) {
|
|
1555
|
+
return anchor.kind === "file" ? `${anchor.path}:${anchor.line}` : `$ ${anchor.command}`;
|
|
1556
|
+
}
|
|
1557
|
+
function settleOrAsk(key, candidate, question, findings, questions) {
|
|
1558
|
+
if (candidate === null) {
|
|
1559
|
+
questions.push({
|
|
1560
|
+
key,
|
|
1561
|
+
question,
|
|
1562
|
+
why: "nothing in this repository answers it, so it is a decision rather than a finding"
|
|
1563
|
+
});
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
findings.push({ key, value: candidate.value, anchor: candidate.anchor });
|
|
1567
|
+
}
|
|
1568
|
+
function lineOf(text, needle) {
|
|
1569
|
+
const lines = text.split("\n");
|
|
1570
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1571
|
+
if (lines[i].includes(needle))
|
|
1572
|
+
return i + 1;
|
|
1573
|
+
}
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
function readOrNull(repo, rel) {
|
|
1577
|
+
const abs = join7(repo, rel);
|
|
1578
|
+
return existsSync5(abs) && statSync2(abs).isFile() ? readFileSync6(abs, "utf-8") : null;
|
|
1579
|
+
}
|
|
1580
|
+
function anchoredIn(repo, rel, needle, value) {
|
|
1581
|
+
const text = readOrNull(repo, rel);
|
|
1582
|
+
if (text === null)
|
|
1583
|
+
return null;
|
|
1584
|
+
const line = lineOf(text, needle);
|
|
1585
|
+
if (line === null)
|
|
1586
|
+
return null;
|
|
1587
|
+
return { value, anchor: { kind: "file", path: rel, line } };
|
|
1588
|
+
}
|
|
1589
|
+
function presenceAnchor(repo, rel) {
|
|
1590
|
+
const abs = join7(repo, rel);
|
|
1591
|
+
if (!existsSync5(abs))
|
|
1592
|
+
return null;
|
|
1593
|
+
return statSync2(abs).isDirectory() ? { kind: "command", command: `ls -d ${rel}`, output: `${rel}/` } : { kind: "file", path: rel, line: 1 };
|
|
1594
|
+
}
|
|
1595
|
+
function anchoredByPresence(repo, rel, value) {
|
|
1596
|
+
const anchor = presenceAnchor(repo, rel);
|
|
1597
|
+
return anchor === null ? null : { value, anchor };
|
|
1598
|
+
}
|
|
1599
|
+
var LOCKFILES = [
|
|
1600
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
1601
|
+
["package-lock.json", "npm"],
|
|
1602
|
+
["yarn.lock", "yarn"],
|
|
1603
|
+
["bun.lockb", "bun"],
|
|
1604
|
+
["poetry.lock", "poetry"],
|
|
1605
|
+
["uv.lock", "uv"],
|
|
1606
|
+
["Cargo.lock", "cargo"],
|
|
1607
|
+
["go.sum", "go modules"]
|
|
1608
|
+
];
|
|
1609
|
+
var MANIFESTS = [
|
|
1610
|
+
["package.json", '"name"', "JavaScript/TypeScript"],
|
|
1611
|
+
["pyproject.toml", "name", "Python"],
|
|
1612
|
+
["go.mod", "module", "Go"],
|
|
1613
|
+
["Cargo.toml", "name", "Rust"],
|
|
1614
|
+
["pom.xml", "<artifactId>", "Java"]
|
|
1615
|
+
];
|
|
1616
|
+
var WORKSPACE_MARKERS = [
|
|
1617
|
+
["pnpm-workspace.yaml", "packages:", "a pnpm workspace"],
|
|
1618
|
+
["package.json", '"workspaces"', "an npm/yarn workspace"],
|
|
1619
|
+
["Cargo.toml", "[workspace]", "a cargo workspace"],
|
|
1620
|
+
["go.work", "use", "a Go multi-module workspace"]
|
|
1621
|
+
];
|
|
1622
|
+
function firstMatch(items, f) {
|
|
1623
|
+
for (const item of items) {
|
|
1624
|
+
const hit = f(item);
|
|
1625
|
+
if (hit !== null)
|
|
1626
|
+
return hit;
|
|
1627
|
+
}
|
|
1628
|
+
return null;
|
|
1629
|
+
}
|
|
1630
|
+
function ciWorkflows(repo) {
|
|
1631
|
+
const dir = join7(repo, ".github", "workflows");
|
|
1632
|
+
if (!existsSync5(dir) || !statSync2(dir).isDirectory())
|
|
1633
|
+
return [];
|
|
1634
|
+
return readdirSync(dir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")).sort();
|
|
1635
|
+
}
|
|
1636
|
+
function existingLayerDocuments(repo) {
|
|
1637
|
+
const found = [];
|
|
1638
|
+
for (const paths of Object.values(LAYER_ARTEFACT_PATHS)) {
|
|
1639
|
+
for (const p of paths ?? []) {
|
|
1640
|
+
if (existsSync5(join7(repo, p)))
|
|
1641
|
+
found.push(p);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return found.sort();
|
|
1645
|
+
}
|
|
1646
|
+
function discoverRepository(targetRepo) {
|
|
1647
|
+
if (!existsSync5(targetRepo)) {
|
|
1648
|
+
throw new Error(`[halfcycle setup] The project directory does not exist: ${targetRepo}`);
|
|
1649
|
+
}
|
|
1650
|
+
const findings = [];
|
|
1651
|
+
const questions = [];
|
|
1652
|
+
settleOrAsk("project-language", firstMatch(MANIFESTS, ([file, needle, language]) => anchoredIn(targetRepo, file, needle, language)), "What is this project written in?", findings, questions);
|
|
1653
|
+
settleOrAsk("package-manager", firstMatch(LOCKFILES, ([file, manager]) => anchoredByPresence(targetRepo, file, manager)), "Which package manager does this project use?", findings, questions);
|
|
1654
|
+
settleOrAsk("repo-structure", firstMatch(WORKSPACE_MARKERS, ([file, needle, shape]) => anchoredIn(targetRepo, file, needle, shape)) ?? (existsSync5(join7(targetRepo, "package.json")) || existsSync5(join7(targetRepo, "go.mod")) || existsSync5(join7(targetRepo, "pyproject.toml")) ? anchoredByPresence(targetRepo, "package.json", "a single-package repository") ?? anchoredByPresence(targetRepo, "go.mod", "a single-package repository") ?? anchoredByPresence(targetRepo, "pyproject.toml", "a single-package repository") : null), "Is this one repository for one product, or one of several?", findings, questions);
|
|
1655
|
+
const workflows = ciWorkflows(targetRepo);
|
|
1656
|
+
settleOrAsk("ci-strategy", workflows.length > 0 ? {
|
|
1657
|
+
value: `GitHub Actions \u2014 ${workflows.length === 1 ? "one workflow" : `${workflows.length} workflows`}: ${workflows.join(", ")}`,
|
|
1658
|
+
anchor: { kind: "file", path: `.github/workflows/${workflows[0]}`, line: 1 }
|
|
1659
|
+
} : null, "How does this project run CI?", findings, questions);
|
|
1660
|
+
const layerDocs = existingLayerDocuments(targetRepo);
|
|
1661
|
+
settleOrAsk("existing-layer-documents", layerDocs.length > 0 ? anchoredByPresence(targetRepo, layerDocs[0], `already written: ${layerDocs.join(", ")}`) : null, "Which of the project documents already exist?", findings, questions);
|
|
1662
|
+
settleOrAsk("context-index", anchoredByPresence(targetRepo, "CLAUDE.md", "a context index is already in place"), "Where should the project context index live?", findings, questions);
|
|
1663
|
+
settleOrAsk("tracker", anchoredByPresence(targetRepo, "docs/tasks", "a flat-file task directory is already in place") ?? anchoredByPresence(targetRepo, ".github/ISSUE_TEMPLATE", "GitHub Issues is already in use"), "Where should tasks live?", findings, questions);
|
|
1664
|
+
settleOrAsk("push-policy", null, "How does work reach the default branch?", findings, questions);
|
|
1665
|
+
settleOrAsk("branch-policy", null, "How are branches organised for a piece of work?", findings, questions);
|
|
1666
|
+
return { findings, questions };
|
|
1667
|
+
}
|
|
1668
|
+
function questionsToPut(result) {
|
|
1669
|
+
const settled = new Set(result.findings.map((f) => f.key));
|
|
1670
|
+
return result.questions.filter((q) => !settled.has(q.key));
|
|
1671
|
+
}
|
|
1672
|
+
function renderDiscovery(result) {
|
|
1673
|
+
const lines = result.findings.map((f) => `${f.value} (${renderAnchor(f.anchor)})`);
|
|
1674
|
+
const asks = questionsToPut(result).map((q) => `? ${q.question}`);
|
|
1675
|
+
return [...lines, ...asks].join("\n");
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// dist/setup/engagement.js
|
|
1679
|
+
function flatFileTrackerDefault() {
|
|
1680
|
+
return {
|
|
1681
|
+
chosen: "flat-file, in this repository",
|
|
1682
|
+
alternativeOffered: "your existing issue tracker",
|
|
1683
|
+
rationale: "tasks stay beside the code and in the same review, so there is one place to look; an issue tracker is offered instead when the team already lives in one",
|
|
1684
|
+
home: FLAT_FILE_TRACKER_HOME
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
function isSettled(d) {
|
|
1688
|
+
return d.answer.trim().length > 0 && d.rationale.trim().length > 0;
|
|
1689
|
+
}
|
|
1690
|
+
function renderEngagementDocument(input) {
|
|
1691
|
+
const lines = [
|
|
1692
|
+
`# Engagement defaults \u2014 ${input.projectName}`,
|
|
1693
|
+
"",
|
|
1694
|
+
`Settled ${input.settledAt}.`,
|
|
1695
|
+
"",
|
|
1696
|
+
"## Decisions",
|
|
1697
|
+
""
|
|
1698
|
+
];
|
|
1699
|
+
for (const d of input.decisions) {
|
|
1700
|
+
lines.push(`### ${d.question}`, "", `**Decided:** ${d.answer}`, "", `**Why:** ${d.rationale}`, "");
|
|
1701
|
+
}
|
|
1702
|
+
lines.push("## Tracker", "", `**Chosen:** ${input.tracker.chosen}`, "", `**Offered instead:** ${input.tracker.alternativeOffered}`, "", `**Why:** ${input.tracker.rationale}`, "");
|
|
1703
|
+
if (input.tracker.home !== null) {
|
|
1704
|
+
lines.push(`**Where tasks live:** \`${input.tracker.home}/\``, "");
|
|
1705
|
+
}
|
|
1706
|
+
return lines.join("\n");
|
|
1707
|
+
}
|
|
1708
|
+
function buildEngagementRecord(input) {
|
|
1709
|
+
const unsettled = input.decisions.filter((d) => !isSettled(d)).map((d) => d.key);
|
|
1710
|
+
const missingCount = Math.max(0, input.requiredDecisions - input.decisions.length);
|
|
1711
|
+
if (unsettled.length > 0 || missingCount > 0) {
|
|
1712
|
+
const parts = [];
|
|
1713
|
+
if (unsettled.length > 0) {
|
|
1714
|
+
parts.push(`${unsettled.length} of the decisions has no answer or no reason recorded (${unsettled.join(", ")})`);
|
|
1715
|
+
}
|
|
1716
|
+
if (missingCount > 0)
|
|
1717
|
+
parts.push(`${missingCount} were never put`);
|
|
1718
|
+
return {
|
|
1719
|
+
ok: false,
|
|
1720
|
+
halt: {
|
|
1721
|
+
halted: true,
|
|
1722
|
+
unsettled,
|
|
1723
|
+
reason: `Stopping before writing anything: ${parts.join("; ")}. This file records what was decided and why, so an unanswered question cannot be written down as an answer. Answer it and run this again.`,
|
|
1724
|
+
emits: Object.freeze([])
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
const document = renderEngagementDocument(input);
|
|
1729
|
+
const rows = [engagementRecordRow(document)];
|
|
1730
|
+
if (input.tracker.home !== null)
|
|
1731
|
+
rows.push(trackerHomeRow(input.tracker.home));
|
|
1732
|
+
return { ok: true, document, rows };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// dist/build-record/types.js
|
|
1736
|
+
var BUILD_RECORD_FORMAT = "halfcycle-build-record/v1";
|
|
1737
|
+
function projectSeedGuard(fired, includeExplanation) {
|
|
1738
|
+
const seed = {
|
|
1739
|
+
patternRef: fired.patternRef,
|
|
1740
|
+
severity: fired.severity
|
|
1741
|
+
};
|
|
1742
|
+
if (includeExplanation && fired.explanation)
|
|
1743
|
+
seed.explanation = fired.explanation;
|
|
1744
|
+
return seed;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// dist/build-record/sources.js
|
|
1748
|
+
import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
|
|
1749
|
+
import { createHash } from "node:crypto";
|
|
1750
|
+
import { join as join8 } from "node:path";
|
|
1751
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1752
|
+
var MissingSourceError = class extends Error {
|
|
1753
|
+
constructor(sourceName, detail) {
|
|
1754
|
+
super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
|
|
1755
|
+
this.name = "MissingSourceError";
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
function projectSeedGuards(runs) {
|
|
1759
|
+
const byRef = /* @__PURE__ */ new Map();
|
|
1760
|
+
for (const run of runs) {
|
|
1761
|
+
if (!run.result)
|
|
1762
|
+
continue;
|
|
1763
|
+
for (const fired of run.result.guardsFired) {
|
|
1764
|
+
const seed = projectSeedGuard(
|
|
1765
|
+
fired,
|
|
1766
|
+
/* includeExplanation */
|
|
1767
|
+
false
|
|
1768
|
+
);
|
|
1769
|
+
const existing = byRef.get(seed.patternRef);
|
|
1770
|
+
if (!existing) {
|
|
1771
|
+
byRef.set(seed.patternRef, seed);
|
|
1772
|
+
} else if (existing.severity !== seed.severity) {
|
|
1773
|
+
byRef.set(seed.patternRef, pickHigherSeverity(existing, seed));
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return [...byRef.values()].sort((a, b) => a.patternRef.localeCompare(b.patternRef));
|
|
1778
|
+
}
|
|
1779
|
+
var SEVERITY_ORDINAL = { info: 0, warn: 1, block: 2 };
|
|
1780
|
+
function pickHigherSeverity(a, b) {
|
|
1781
|
+
return (SEVERITY_ORDINAL[a.severity] ?? -1) >= (SEVERITY_ORDINAL[b.severity] ?? -1) ? a : b;
|
|
1782
|
+
}
|
|
1783
|
+
function normalizeLegacyLine(parsed) {
|
|
1784
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1785
|
+
return parsed;
|
|
1786
|
+
}
|
|
1787
|
+
const record2 = parsed;
|
|
1788
|
+
if (record2["outcome"] !== void 0)
|
|
1789
|
+
return parsed;
|
|
1790
|
+
return {
|
|
1791
|
+
...record2,
|
|
1792
|
+
runId: record2["runId"] ?? syntheticRunId(record2),
|
|
1793
|
+
outcome: "evaluated"
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
function syntheticRunId(record2) {
|
|
1797
|
+
const key = [
|
|
1798
|
+
record2["engagementId"],
|
|
1799
|
+
record2["occurredAt"],
|
|
1800
|
+
record2["runType"],
|
|
1801
|
+
record2["phase"]
|
|
1802
|
+
].join("|");
|
|
1803
|
+
const hex = createHash("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
|
|
1804
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
1805
|
+
}
|
|
1806
|
+
function readGuardEvalLog(logDir, phaseId) {
|
|
1807
|
+
const logPath = join8(logDir, `phase-${phaseId}.jsonl`);
|
|
1808
|
+
if (!existsSync6(logPath)) {
|
|
1809
|
+
throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
|
|
1810
|
+
}
|
|
1811
|
+
const raw = readFileSync7(logPath, "utf-8");
|
|
1812
|
+
const runs = [];
|
|
1813
|
+
for (const line of raw.split("\n")) {
|
|
1814
|
+
const trimmed = line.trim();
|
|
1815
|
+
if (trimmed === "")
|
|
1816
|
+
continue;
|
|
1817
|
+
const parsed = JSON.parse(trimmed);
|
|
1818
|
+
runs.push(guardEvalRunSchema.parse(normalizeLegacyLine(parsed)));
|
|
1819
|
+
}
|
|
1820
|
+
return runs;
|
|
1821
|
+
}
|
|
1822
|
+
function readOrchestrationState(phasesDir, phaseId) {
|
|
1823
|
+
const statePath = join8(phasesDir, `phase-${phaseId}-orchestration-state.json`);
|
|
1824
|
+
if (!existsSync6(statePath)) {
|
|
1825
|
+
throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
|
|
1826
|
+
}
|
|
1827
|
+
const parsed = JSON.parse(readFileSync7(statePath, "utf-8"));
|
|
1828
|
+
if (!parsed.tasks || typeof parsed.tasks !== "object") {
|
|
1829
|
+
throw new MissingSourceError("orchestration state", `${statePath} has no tasks map`);
|
|
1830
|
+
}
|
|
1831
|
+
const tasks = {};
|
|
1832
|
+
for (const [id, t] of Object.entries(parsed.tasks)) {
|
|
1833
|
+
if (!t || typeof t.status !== "string") {
|
|
1834
|
+
throw new MissingSourceError("orchestration state", `task ${id} has no status`);
|
|
1835
|
+
}
|
|
1836
|
+
tasks[id] = { status: t.status };
|
|
1837
|
+
}
|
|
1838
|
+
return { tasks };
|
|
1839
|
+
}
|
|
1840
|
+
function readGitClose(repoRoot) {
|
|
1841
|
+
let closeCommit;
|
|
1842
|
+
let closedDate;
|
|
1843
|
+
try {
|
|
1844
|
+
closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
|
|
1845
|
+
encoding: "utf-8"
|
|
1846
|
+
}).trim();
|
|
1847
|
+
closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], { encoding: "utf-8" }).trim();
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
throw new MissingSourceError("git HEAD", `cannot resolve close commit/date in ${repoRoot}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1850
|
+
}
|
|
1851
|
+
if (!closeCommit)
|
|
1852
|
+
throw new MissingSourceError("git HEAD", `empty commit sha in ${repoRoot}`);
|
|
1853
|
+
if (!closedDate)
|
|
1854
|
+
throw new MissingSourceError("git HEAD", `empty commit date in ${repoRoot}`);
|
|
1855
|
+
return { closeCommit, closedDate };
|
|
1856
|
+
}
|
|
1857
|
+
function validateNarrated(narrated) {
|
|
1858
|
+
const req = (v, name) => {
|
|
1859
|
+
if (v === void 0 || v === null || v === "") {
|
|
1860
|
+
throw new MissingSourceError("narrated artefact", `${name} is missing`);
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
req(narrated.engagement, "engagement");
|
|
1864
|
+
req(narrated.engagementType, "engagementType");
|
|
1865
|
+
req(narrated.phase?.name, "phase.name");
|
|
1866
|
+
req(narrated.status, "status");
|
|
1867
|
+
req(narrated.thesis, "thesis");
|
|
1868
|
+
req(narrated.delivered, "delivered");
|
|
1869
|
+
req(narrated.acceptance, "acceptance");
|
|
1870
|
+
req(narrated.acceptance?.verdict, "acceptance.verdict");
|
|
1871
|
+
req(narrated.gatesEarnedKeep, "gatesEarnedKeep");
|
|
1872
|
+
req(narrated.instruments, "instruments");
|
|
1873
|
+
if (!Array.isArray(narrated.deviations)) {
|
|
1874
|
+
throw new MissingSourceError("narrated artefact", "deviations must be an array");
|
|
1875
|
+
}
|
|
1876
|
+
return narrated;
|
|
1877
|
+
}
|
|
1878
|
+
function loadSources(opts) {
|
|
1879
|
+
const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
|
|
1880
|
+
const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
|
|
1881
|
+
const git2 = readGitClose(opts.repoRoot);
|
|
1882
|
+
const narrated = validateNarrated(opts.narrated);
|
|
1883
|
+
if (!Array.isArray(opts.touchedInvariants)) {
|
|
1884
|
+
throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
|
|
1885
|
+
}
|
|
1886
|
+
return {
|
|
1887
|
+
orchestration,
|
|
1888
|
+
narrated,
|
|
1889
|
+
git: git2,
|
|
1890
|
+
guardEvalRuns,
|
|
1891
|
+
touchedInvariants: opts.touchedInvariants
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// dist/build-record/assemble.js
|
|
1896
|
+
var LANDED_STATES = /* @__PURE__ */ new Set(["merged", "done"]);
|
|
1897
|
+
var CANCELLED_STATE = "cancelled";
|
|
1898
|
+
function deriveTasks(orchestration) {
|
|
1899
|
+
const ids = Object.keys(orchestration.tasks);
|
|
1900
|
+
const cancelled = ids.filter((id) => orchestration.tasks[id].status === CANCELLED_STATE).sort((a, b) => a.localeCompare(b));
|
|
1901
|
+
const landed = ids.filter((id) => LANDED_STATES.has(orchestration.tasks[id].status)).length;
|
|
1902
|
+
return { planned: ids.length, landed, cancelled };
|
|
1903
|
+
}
|
|
1904
|
+
function deriveThesisHeld(acceptance) {
|
|
1905
|
+
if (acceptance.verdict !== "clean")
|
|
1906
|
+
return false;
|
|
1907
|
+
for (const dw of Object.values(acceptance.doneWhen)) {
|
|
1908
|
+
if (dw.result === "dropped")
|
|
1909
|
+
continue;
|
|
1910
|
+
if (dw.result !== "pass")
|
|
1911
|
+
return false;
|
|
1912
|
+
}
|
|
1913
|
+
return true;
|
|
1914
|
+
}
|
|
1915
|
+
function deriveInvariantsExercised(touched) {
|
|
1916
|
+
return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
|
|
1917
|
+
}
|
|
1918
|
+
function assembleBuildRecord(sources) {
|
|
1919
|
+
const { narrated, git: git2, orchestration } = sources;
|
|
1920
|
+
return {
|
|
1921
|
+
format: BUILD_RECORD_FORMAT,
|
|
1922
|
+
engagement: narrated.engagement,
|
|
1923
|
+
engagementType: narrated.engagementType,
|
|
1924
|
+
phase: { id: narrated.phase.id, name: narrated.phase.name },
|
|
1925
|
+
status: narrated.status,
|
|
1926
|
+
closedDate: git2.closedDate,
|
|
1927
|
+
closeCommit: git2.closeCommit,
|
|
1928
|
+
thesis: narrated.thesis,
|
|
1929
|
+
thesisHeld: deriveThesisHeld(narrated.acceptance),
|
|
1930
|
+
delivered: narrated.delivered,
|
|
1931
|
+
seedGuards: projectSeedGuards(sources.guardEvalRuns),
|
|
1932
|
+
acceptance: narrated.acceptance,
|
|
1933
|
+
gatesEarnedKeep: narrated.gatesEarnedKeep,
|
|
1934
|
+
instruments: narrated.instruments,
|
|
1935
|
+
invariantsExercised: deriveInvariantsExercised(sources.touchedInvariants),
|
|
1936
|
+
deviations: narrated.deviations,
|
|
1937
|
+
tasks: deriveTasks(orchestration)
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// dist/build-record/template.js
|
|
1942
|
+
function severityLabel(seed) {
|
|
1943
|
+
return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
|
|
1944
|
+
}
|
|
1945
|
+
function renderDoneWhenRows(record2) {
|
|
1946
|
+
const rows = [];
|
|
1947
|
+
for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
|
|
1948
|
+
const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
|
|
1949
|
+
rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
|
|
1950
|
+
}
|
|
1951
|
+
return rows.join("\n");
|
|
1952
|
+
}
|
|
1953
|
+
function renderBuildRecordMarkdown(record2) {
|
|
1954
|
+
const { phase } = record2;
|
|
1955
|
+
const guardsList = record2.seedGuards.map(severityLabel).join(", ");
|
|
1956
|
+
const lines = [
|
|
1957
|
+
// Fixed prose (vault-canonical template) + projected title slots.
|
|
1958
|
+
`# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
|
|
1959
|
+
"",
|
|
1960
|
+
`**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
|
|
1961
|
+
"",
|
|
1962
|
+
"> 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.",
|
|
1963
|
+
"",
|
|
1964
|
+
`**Engagement:** ${record2.engagement} (${record2.engagementType})`,
|
|
1965
|
+
`**Phase:** ${phase.id} \u2014 ${phase.name}`,
|
|
1966
|
+
`**Status:** ${record2.status.toUpperCase()} \xB7 **Closed:** ${record2.closedDate} \xB7 **Close commit:** \`${record2.closeCommit}\``,
|
|
1967
|
+
`**Companion:** [\`phase-${phase.id}.json\`](phase-${phase.id}.json)`,
|
|
1968
|
+
"",
|
|
1969
|
+
"## What this phase proved",
|
|
1970
|
+
"",
|
|
1971
|
+
`${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
|
|
1972
|
+
"",
|
|
1973
|
+
"## Delivered",
|
|
1974
|
+
"",
|
|
1975
|
+
`- **Packages:** ${record2.delivered.packages.join(", ")}`,
|
|
1976
|
+
`- **Services:** ${record2.delivered.services.join(", ")}`,
|
|
1977
|
+
`- **Tools:** ${record2.delivered.tools.join(", ")}`,
|
|
1978
|
+
`- **Dogfood:** ${record2.delivered.dogfood}`,
|
|
1979
|
+
"",
|
|
1980
|
+
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.",
|
|
1981
|
+
"",
|
|
1982
|
+
`## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
|
|
1983
|
+
"",
|
|
1984
|
+
`**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
|
|
1985
|
+
"",
|
|
1986
|
+
"| Done-when | Result | Evidence |",
|
|
1987
|
+
"|---|---|---|",
|
|
1988
|
+
renderDoneWhenRows(record2),
|
|
1989
|
+
""
|
|
1990
|
+
];
|
|
1991
|
+
if (record2.acceptance.inv002ProdBypassProbe) {
|
|
1992
|
+
lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
|
|
1993
|
+
}
|
|
1994
|
+
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"}`, "");
|
|
1995
|
+
return lines.join("\n");
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// dist/build-record/write.js
|
|
1999
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2000
|
+
import { join as join9, resolve, relative as relative2, isAbsolute } from "node:path";
|
|
2001
|
+
var BUILD_RECORD_DIR = join9("docs", "build-records");
|
|
2002
|
+
function serialiseRecordJson(record2) {
|
|
2003
|
+
return JSON.stringify(record2, null, 2) + "\n";
|
|
2004
|
+
}
|
|
2005
|
+
function writeBuildRecord(repoRoot, record2) {
|
|
2006
|
+
const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
|
|
2007
|
+
const expected = resolve(repoRoot, BUILD_RECORD_DIR);
|
|
2008
|
+
const rel = relative2(expected, outDir);
|
|
2009
|
+
if (rel !== "" || isAbsolute(rel)) {
|
|
2010
|
+
throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
|
|
2011
|
+
}
|
|
2012
|
+
mkdirSync5(outDir, { recursive: true });
|
|
2013
|
+
const jsonPath = join9(outDir, `phase-${record2.phase.id}.json`);
|
|
2014
|
+
const mdPath = join9(outDir, `phase-${record2.phase.id}.md`);
|
|
2015
|
+
writeFileSync5(jsonPath, serialiseRecordJson(record2), "utf-8");
|
|
2016
|
+
writeFileSync5(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
|
|
2017
|
+
return { jsonPath, mdPath };
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// dist/build-record/index.js
|
|
2021
|
+
function assemblePhaseBuildRecord(opts) {
|
|
2022
|
+
const sources = loadSources({
|
|
2023
|
+
repoRoot: opts.repoRoot,
|
|
2024
|
+
phasesDir: opts.phasesDir,
|
|
2025
|
+
guardEvalLogDir: opts.guardEvalLogDir,
|
|
2026
|
+
phaseId: opts.phaseId,
|
|
2027
|
+
narrated: opts.narrated,
|
|
2028
|
+
touchedInvariants: opts.touchedInvariants
|
|
2029
|
+
});
|
|
2030
|
+
const record2 = assembleBuildRecord(sources);
|
|
2031
|
+
return writeBuildRecord(opts.repoRoot, record2);
|
|
2032
|
+
}
|
|
2033
|
+
export {
|
|
2034
|
+
BUILD_RECORD_DIR,
|
|
2035
|
+
BUILD_RECORD_FORMAT,
|
|
2036
|
+
CreateEngagementRefused,
|
|
2037
|
+
FLAT_FILE_TRACKER_HOME,
|
|
2038
|
+
HALFCYCLE_STATE_FORMAT,
|
|
2039
|
+
MissingSourceError,
|
|
2040
|
+
SetupManifest,
|
|
2041
|
+
ZONE_B_DIR,
|
|
2042
|
+
agentsPointerRow,
|
|
2043
|
+
applySetupWrites,
|
|
2044
|
+
assembleBuildRecord,
|
|
2045
|
+
assemblePhaseBuildRecord,
|
|
2046
|
+
buildEngagementRecord,
|
|
2047
|
+
checkDrift,
|
|
2048
|
+
contextIndexRow,
|
|
2049
|
+
createEngagement,
|
|
2050
|
+
deriveInvariantsExercised,
|
|
2051
|
+
deriveTasks,
|
|
2052
|
+
deriveThesisHeld,
|
|
2053
|
+
discoverRepository,
|
|
2054
|
+
documentSectionRow,
|
|
2055
|
+
engagementRecordRow,
|
|
2056
|
+
flatFileTrackerDefault,
|
|
2057
|
+
install,
|
|
2058
|
+
layerDocumentRow,
|
|
2059
|
+
loadSources,
|
|
2060
|
+
mergeSettings,
|
|
2061
|
+
mergeWithRecords,
|
|
2062
|
+
mintBoardEnterCode,
|
|
2063
|
+
mintOrReadIdentity,
|
|
2064
|
+
projectSeedGuard,
|
|
2065
|
+
projectSeedGuards,
|
|
2066
|
+
questionsToPut,
|
|
2067
|
+
readBundlePin,
|
|
2068
|
+
readGitClose,
|
|
2069
|
+
readGuardEvalLog,
|
|
2070
|
+
readOrchestrationState,
|
|
2071
|
+
readRemote,
|
|
2072
|
+
readRootCommit,
|
|
2073
|
+
renderAnchor,
|
|
2074
|
+
renderBuildRecordMarkdown,
|
|
2075
|
+
renderDiscovery,
|
|
2076
|
+
runBootstrapScan,
|
|
2077
|
+
scanLayers,
|
|
2078
|
+
serialiseRecordJson,
|
|
2079
|
+
trackerHomeRow,
|
|
2080
|
+
validateNarrated,
|
|
2081
|
+
writeBuildRecord
|
|
2082
|
+
};
|
|
2083
|
+
//# sourceMappingURL=index.js.map
|