halfcycle 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -9
- package/bin/bin.bundle.mjs +188 -148
- package/commands/halfcycle-setup.md +3 -3
- package/dist/bin.js +1 -6823
- package/dist/claude-version-probe.d.ts +32 -0
- package/dist/claude-version-probe.d.ts.map +1 -1
- package/dist/cli.js +7188 -0
- package/dist/cli.js.map +6 -0
- package/dist/confirm-identity.d.ts +28 -18
- package/dist/confirm-identity.d.ts.map +1 -1
- package/dist/create-engagement.d.ts +32 -0
- package/dist/create-engagement.d.ts.map +1 -1
- package/dist/device-signin.d.ts +35 -2
- package/dist/device-signin.d.ts.map +1 -1
- package/dist/identity.d.ts +57 -13
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.js +228 -123
- package/dist/index.js.map +3 -3
- package/dist/install.d.ts +78 -2
- package/dist/install.d.ts.map +1 -1
- package/dist/merge-settings.d.ts +11 -2
- package/dist/merge-settings.d.ts.map +1 -1
- package/dist/node-floor.d.ts +54 -0
- package/dist/node-floor.d.ts.map +1 -0
- package/dist/own-engagement.d.ts +25 -5
- package/dist/own-engagement.d.ts.map +1 -1
- package/dist/pre-create-checks.d.ts +88 -0
- package/dist/pre-create-checks.d.ts.map +1 -0
- package/dist/resolve-credential.d.ts.map +1 -1
- package/dist/uninstall.d.ts +31 -9
- package/dist/uninstall.d.ts.map +1 -1
- package/package.json +4 -1
- package/dist/bin.js.map +0 -6
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// dist/install.js
|
|
2
2
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
3
3
|
import { createHash as createHash2 } from "node:crypto";
|
|
4
|
-
import { existsSync as existsSync3, lstatSync, mkdirSync as
|
|
5
|
-
import { dirname as
|
|
4
|
+
import { existsSync as existsSync3, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "node:fs";
|
|
5
|
+
import { dirname as dirname3, join as join5, posix, relative } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
8
|
// ../core/dist/guard-record.js
|
|
@@ -120,58 +120,70 @@ var GUARD_RUNNER_REL = ".claude/hooks/guard-runner.sh";
|
|
|
120
120
|
var USER_PROMPT_REMINDER_REL = ".claude/hooks/user-prompt-reminder.sh";
|
|
121
121
|
var SESSION_END_MARKER_REL = ".claude/hooks/session-end-marker.sh";
|
|
122
122
|
var WRITE_CAPABLE_TOOLS = ["Edit", "MultiEdit", "NotebookEdit", "Write"];
|
|
123
|
+
function hookCommand(script, verb) {
|
|
124
|
+
const invocation = `bash "$CLAUDE_PROJECT_DIR"/${script}`;
|
|
125
|
+
return verb === void 0 ? invocation : `${invocation} ${verb}`;
|
|
126
|
+
}
|
|
123
127
|
var HALFCYCLE_HOOK_WIRING = [
|
|
124
128
|
{
|
|
125
129
|
event: "SessionStart",
|
|
126
130
|
matcher: null,
|
|
127
131
|
timeout: 5,
|
|
128
|
-
command:
|
|
132
|
+
command: hookCommand(SESSION_START_MARKER_REL),
|
|
129
133
|
script: SESSION_START_MARKER_REL
|
|
130
134
|
},
|
|
131
135
|
{
|
|
132
136
|
event: "SessionStart",
|
|
133
137
|
matcher: null,
|
|
134
138
|
timeout: 10,
|
|
135
|
-
command:
|
|
139
|
+
command: hookCommand(GUARD_RUNNER_REL, "refresh-credential"),
|
|
136
140
|
script: GUARD_RUNNER_REL
|
|
137
141
|
},
|
|
138
142
|
{
|
|
139
143
|
event: "UserPromptSubmit",
|
|
140
144
|
matcher: null,
|
|
141
145
|
timeout: 5,
|
|
142
|
-
command:
|
|
146
|
+
command: hookCommand(USER_PROMPT_REMINDER_REL),
|
|
143
147
|
script: USER_PROMPT_REMINDER_REL
|
|
144
148
|
},
|
|
145
149
|
{
|
|
146
150
|
event: "PostToolUse",
|
|
147
151
|
matcher: WRITE_CAPABLE_TOOLS.join("|"),
|
|
148
152
|
timeout: 60,
|
|
149
|
-
command:
|
|
153
|
+
command: hookCommand(GUARD_RUNNER_REL, "hook post-tool-use"),
|
|
150
154
|
script: GUARD_RUNNER_REL
|
|
151
155
|
},
|
|
152
156
|
{
|
|
153
157
|
event: "Stop",
|
|
154
158
|
matcher: null,
|
|
155
159
|
timeout: 60,
|
|
156
|
-
command:
|
|
160
|
+
command: hookCommand(GUARD_RUNNER_REL, "hook stop"),
|
|
157
161
|
script: GUARD_RUNNER_REL
|
|
158
162
|
},
|
|
159
163
|
{
|
|
160
164
|
event: "SubagentStop",
|
|
161
165
|
matcher: null,
|
|
162
166
|
timeout: 60,
|
|
163
|
-
command:
|
|
167
|
+
command: hookCommand(GUARD_RUNNER_REL, "hook subagent-stop"),
|
|
164
168
|
script: GUARD_RUNNER_REL
|
|
165
169
|
},
|
|
166
170
|
{
|
|
167
171
|
event: "SessionEnd",
|
|
168
172
|
matcher: null,
|
|
169
173
|
timeout: 5,
|
|
170
|
-
command:
|
|
174
|
+
command: hookCommand(SESSION_END_MARKER_REL),
|
|
171
175
|
script: SESSION_END_MARKER_REL
|
|
172
176
|
}
|
|
173
177
|
];
|
|
174
|
-
var RETIRED_HOOK_COMMANDS = [
|
|
178
|
+
var RETIRED_HOOK_COMMANDS = [
|
|
179
|
+
"bash ./.claude/hooks/session-start-marker.sh",
|
|
180
|
+
"bash ./.claude/hooks/guard-runner.sh refresh-credential",
|
|
181
|
+
"bash ./.claude/hooks/user-prompt-reminder.sh",
|
|
182
|
+
"bash ./.claude/hooks/guard-runner.sh hook post-tool-use",
|
|
183
|
+
"bash ./.claude/hooks/guard-runner.sh hook stop",
|
|
184
|
+
"bash ./.claude/hooks/guard-runner.sh hook subagent-stop",
|
|
185
|
+
"bash ./.claude/hooks/session-end-marker.sh"
|
|
186
|
+
];
|
|
175
187
|
var HALFCYCLE_HOOK_COMMANDS = /* @__PURE__ */ new Set([
|
|
176
188
|
...HALFCYCLE_HOOK_WIRING.map((entry) => entry.command),
|
|
177
189
|
...RETIRED_HOOK_COMMANDS
|
|
@@ -1217,10 +1229,11 @@ halfcycle_env_file() {
|
|
|
1217
1229
|
|
|
1218
1230
|
// dist/identity.js
|
|
1219
1231
|
import { execFileSync } from "node:child_process";
|
|
1220
|
-
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1221
|
-
import { isAbsolute, join as join3 } from "node:path";
|
|
1232
|
+
import { existsSync, linkSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1233
|
+
import { dirname as dirname2, isAbsolute, join as join3 } from "node:path";
|
|
1222
1234
|
import { randomUUID } from "node:crypto";
|
|
1223
1235
|
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.';
|
|
1236
|
+
var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
|
|
1224
1237
|
var REPOSITORY_LOCATING_GIT_VARS = [
|
|
1225
1238
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
1226
1239
|
"GIT_COMMON_DIR",
|
|
@@ -1301,21 +1314,45 @@ function mintOrReadIdentity(targetRepoRoot) {
|
|
|
1301
1314
|
const path = join3(targetRepoRoot, ".halfcycle", "project.json");
|
|
1302
1315
|
if (existsSync(path)) {
|
|
1303
1316
|
const onDisk = JSON.parse(readFileSync2(path, "utf-8"));
|
|
1304
|
-
const
|
|
1317
|
+
const identity = {
|
|
1305
1318
|
projectId: onDisk.projectId ?? "",
|
|
1306
1319
|
rootCommit: onDisk.rootCommit ?? null,
|
|
1307
1320
|
remote: onDisk.remote ?? null,
|
|
1308
1321
|
note: PROJECT_IDENTITY_NOTE
|
|
1309
1322
|
};
|
|
1310
|
-
return { identity
|
|
1323
|
+
return { identity, minted: false };
|
|
1311
1324
|
}
|
|
1312
|
-
|
|
1325
|
+
return { identity: mintIdentity(targetRepoRoot), minted: true };
|
|
1326
|
+
}
|
|
1327
|
+
function mintIdentity(targetRepoRoot) {
|
|
1328
|
+
return {
|
|
1313
1329
|
projectId: randomUUID(),
|
|
1314
1330
|
rootCommit: readRootCommit(targetRepoRoot),
|
|
1315
1331
|
remote: readRemote(targetRepoRoot),
|
|
1316
1332
|
note: PROJECT_IDENTITY_NOTE
|
|
1317
1333
|
};
|
|
1318
|
-
|
|
1334
|
+
}
|
|
1335
|
+
function serializeProjectIdentity(identity) {
|
|
1336
|
+
return JSON.stringify(identity, null, 2) + "\n";
|
|
1337
|
+
}
|
|
1338
|
+
function createProjectIdentityFile(targetRepoRoot) {
|
|
1339
|
+
const path = join3(targetRepoRoot, PROJECT_IDENTITY_REL);
|
|
1340
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
1341
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
1342
|
+
try {
|
|
1343
|
+
writeFileSync2(temporary, serializeProjectIdentity(mintIdentity(targetRepoRoot)), {
|
|
1344
|
+
encoding: "utf-8",
|
|
1345
|
+
flag: "wx"
|
|
1346
|
+
});
|
|
1347
|
+
try {
|
|
1348
|
+
linkSync(temporary, path);
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
if (err.code !== "EEXIST")
|
|
1351
|
+
throw err;
|
|
1352
|
+
}
|
|
1353
|
+
} finally {
|
|
1354
|
+
rmSync(temporary, { force: true });
|
|
1355
|
+
}
|
|
1319
1356
|
}
|
|
1320
1357
|
|
|
1321
1358
|
// dist/install-manifest.js
|
|
@@ -1600,6 +1637,13 @@ function mcpEndpointUrl(origin) {
|
|
|
1600
1637
|
return `${origin.trim().replace(/\/+$/, "")}${MCP_ENDPOINT_PATH}`;
|
|
1601
1638
|
}
|
|
1602
1639
|
|
|
1640
|
+
// dist/node-floor.js
|
|
1641
|
+
var NODE_FLOOR_MAJOR = 18;
|
|
1642
|
+
var NODE_FLOOR_SENTENCE_HEAD = `Halfcycle needs Node ${NODE_FLOOR_MAJOR} or newer; you have `;
|
|
1643
|
+
function nodeFloorProgram(guarded) {
|
|
1644
|
+
return `if(+process.versions.node.split('.')[0]<${NODE_FLOOR_MAJOR}){console.error('${NODE_FLOOR_SENTENCE_HEAD}'+process.versions.node);process.exitCode=1}else{${guarded}}`;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1603
1647
|
// dist/project-page.js
|
|
1604
1648
|
function projectPageUrl(engagementId) {
|
|
1605
1649
|
return isEngagementId(engagementId) ? `${DASHBOARD_URL}/${encodeURIComponent(engagementId)}` : void 0;
|
|
@@ -1615,7 +1659,7 @@ function mergeSettings(existing, generated) {
|
|
|
1615
1659
|
merged.$schema = generated.$schema;
|
|
1616
1660
|
}
|
|
1617
1661
|
const generatedHooks = generated.hooks ?? {};
|
|
1618
|
-
const halfcycleCommands =
|
|
1662
|
+
const halfcycleCommands = new Set(HALFCYCLE_HOOK_COMMANDS);
|
|
1619
1663
|
for (const entries of Object.values(generatedHooks)) {
|
|
1620
1664
|
for (const entry of entries) {
|
|
1621
1665
|
for (const h of entry.hooks)
|
|
@@ -1648,7 +1692,7 @@ function mergePermissions(existing, generated) {
|
|
|
1648
1692
|
}
|
|
1649
1693
|
|
|
1650
1694
|
// dist/scan.js
|
|
1651
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
1695
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1652
1696
|
import { join as join4 } from "node:path";
|
|
1653
1697
|
var HALFCYCLE_STATE_FORMAT = "halfcycle-state-file/v1";
|
|
1654
1698
|
var HALFCYCLE_STATE_NOTE = [
|
|
@@ -1692,8 +1736,8 @@ function runBootstrapScan(targetRepo) {
|
|
|
1692
1736
|
return { state: existing, ran: false };
|
|
1693
1737
|
}
|
|
1694
1738
|
const state = scanLayers(targetRepo);
|
|
1695
|
-
|
|
1696
|
-
|
|
1739
|
+
mkdirSync3(join4(targetRepo, ".halfcycle"), { recursive: true });
|
|
1740
|
+
writeFileSync3(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
1697
1741
|
return { state, ran: true };
|
|
1698
1742
|
}
|
|
1699
1743
|
function mergeWithRecords(state, layersWithRealRecords) {
|
|
@@ -1715,7 +1759,7 @@ function mergeWithRecords(state, layersWithRealRecords) {
|
|
|
1715
1759
|
// dist/install.js
|
|
1716
1760
|
var ZONE_B_DIR = ".workbench";
|
|
1717
1761
|
var __filename = fileURLToPath(import.meta.url);
|
|
1718
|
-
var __dirname =
|
|
1762
|
+
var __dirname = dirname3(__filename);
|
|
1719
1763
|
function findBundleRoot(startDir) {
|
|
1720
1764
|
let candidate = startDir;
|
|
1721
1765
|
for (let i = 0; i < 4; i++) {
|
|
@@ -1725,7 +1769,7 @@ function findBundleRoot(startDir) {
|
|
|
1725
1769
|
return candidate;
|
|
1726
1770
|
} catch {
|
|
1727
1771
|
}
|
|
1728
|
-
candidate =
|
|
1772
|
+
candidate = dirname3(candidate);
|
|
1729
1773
|
}
|
|
1730
1774
|
return join5(startDir, "..");
|
|
1731
1775
|
}
|
|
@@ -1739,7 +1783,7 @@ function resolveVendoredBinary() {
|
|
|
1739
1783
|
if (existsSync3(candidate))
|
|
1740
1784
|
return candidate;
|
|
1741
1785
|
}
|
|
1742
|
-
throw new Error(`[
|
|
1786
|
+
throw new Error(`[halfcycle] Cannot find the self-contained guard binary bin.bundle.mjs. This copy of the halfcycle package is incomplete \u2014 reinstall it, or rebuild it if you are working on it. Looked in: ${candidates.join(", ")}.`);
|
|
1743
1787
|
}
|
|
1744
1788
|
var WRITE_ALLOWLIST = [
|
|
1745
1789
|
".claude/commands",
|
|
@@ -1761,9 +1805,11 @@ function isAllowlisted(targetRelPath) {
|
|
|
1761
1805
|
return normalised === p || normalised.startsWith(p + "/");
|
|
1762
1806
|
});
|
|
1763
1807
|
}
|
|
1764
|
-
var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
|
|
1765
1808
|
var CREW_ROSTER_REL = ".halfcycle/crew.json";
|
|
1766
1809
|
var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
|
|
1810
|
+
function capturedIndexScaffold() {
|
|
1811
|
+
return join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
|
|
1812
|
+
}
|
|
1767
1813
|
var GITIGNORE_REL = ".gitignore";
|
|
1768
1814
|
var GITATTRIBUTES_REL = ".gitattributes";
|
|
1769
1815
|
function commandStubRel(name) {
|
|
@@ -1773,8 +1819,9 @@ var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
|
1773
1819
|
var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
|
|
1774
1820
|
var RETIRED_DENY_PATTERNS = [];
|
|
1775
1821
|
function closedWriteSet() {
|
|
1822
|
+
const commands = readPluginManifest().commands ?? [];
|
|
1776
1823
|
const files = /* @__PURE__ */ new Set([
|
|
1777
|
-
...
|
|
1824
|
+
...commands.map((cmd) => commandStubRel(cmd.name)),
|
|
1778
1825
|
...Object.keys(OWNED_GENERATED_HEADERS),
|
|
1779
1826
|
VENDORED_BIN_REL,
|
|
1780
1827
|
CREW_ROSTER_REL,
|
|
@@ -1803,8 +1850,27 @@ function closedWriteSet() {
|
|
|
1803
1850
|
gitattributesLines: /* @__PURE__ */ new Set([
|
|
1804
1851
|
GITATTRIBUTES_HEADER,
|
|
1805
1852
|
...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
|
|
1806
|
-
])
|
|
1853
|
+
]),
|
|
1854
|
+
generatedSha256: sha256OfWhatThisVersionWrites(commands)
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
function sha256OfWhatThisVersionWrites(commands) {
|
|
1858
|
+
const out = /* @__PURE__ */ new Map();
|
|
1859
|
+
const fromFile = (rel, src) => {
|
|
1860
|
+
if (existsSync3(src))
|
|
1861
|
+
out.set(rel, sha256Hex(readFileSync4(src)));
|
|
1807
1862
|
};
|
|
1863
|
+
for (const cmd of commands)
|
|
1864
|
+
fromFile(commandStubRel(cmd.name), join5(BUNDLE_ROOT, ".claude-plugin", cmd.path));
|
|
1865
|
+
for (const [rel, content] of hookScriptsThisVersionWrites())
|
|
1866
|
+
out.set(rel, sha256Hex(content));
|
|
1867
|
+
fromFile(CAPTURED_INDEX_REL, capturedIndexScaffold());
|
|
1868
|
+
return out;
|
|
1869
|
+
}
|
|
1870
|
+
function bytesAreOurs(path, bytes, recorded, writeSet) {
|
|
1871
|
+
const hash = sha256Hex(bytes);
|
|
1872
|
+
const recordedHash = recorded !== void 0 && "sha256" in recorded ? recorded.sha256 : void 0;
|
|
1873
|
+
return hash === recordedHash || hash === writeSet.generatedSha256.get(path);
|
|
1808
1874
|
}
|
|
1809
1875
|
function readPreviousManifest(targetRepo) {
|
|
1810
1876
|
try {
|
|
@@ -1823,7 +1889,7 @@ function noteFile(draft, targetRepo, rel, outcome) {
|
|
|
1823
1889
|
function recordSettingsMerge(draft, existing, generated, preexisted) {
|
|
1824
1890
|
const generatedHooks = generated.hooks ?? {};
|
|
1825
1891
|
const hookCommands = Object.values(generatedHooks).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
|
|
1826
|
-
const ours = new Set(hookCommands);
|
|
1892
|
+
const ours = /* @__PURE__ */ new Set([...hookCommands, ...HALFCYCLE_HOOK_COMMANDS]);
|
|
1827
1893
|
const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : void 0;
|
|
1828
1894
|
const heldOurs = Object.values(existingHooks ?? {}).some((entries) => Array.isArray(entries) && entries.some((entry) => isHalfcycleEntry(entry, ours)));
|
|
1829
1895
|
const existingDeny = Array.isArray(existing.permissions?.deny) ? existing.permissions.deny : [];
|
|
@@ -1865,13 +1931,13 @@ function recordMcpAdoptionOnly(draft, existingText) {
|
|
|
1865
1931
|
return;
|
|
1866
1932
|
recordMcp(draft, { created: false, adopted: true, mcpServersCreated: false });
|
|
1867
1933
|
}
|
|
1868
|
-
function recordGitignoreReconcile(draft, before, after) {
|
|
1869
|
-
const adopted = draft.gitignore === void 0 && before !== null ?
|
|
1870
|
-
|
|
1871
|
-
recordGitignore(draft, before === null && after !== null, [adopted, appended]);
|
|
1934
|
+
function recordGitignoreReconcile(draft, before, after, writeSet) {
|
|
1935
|
+
const adopted = draft.gitignore === void 0 && before !== null ? adoptBlockUnderHeader(before, GITIGNORE_HEADER, writeSet.gitignoreLines) : "";
|
|
1936
|
+
recordGitignore(draft, before === null && after !== null, [adopted, appendedText(before, after)]);
|
|
1872
1937
|
}
|
|
1873
|
-
function recordGitattributesReconcile(draft, before, after) {
|
|
1874
|
-
|
|
1938
|
+
function recordGitattributesReconcile(draft, before, after, writeSet) {
|
|
1939
|
+
const adopted = draft.gitattributes === void 0 && before !== null ? adoptBlockUnderHeader(before, GITATTRIBUTES_HEADER, writeSet.gitattributesLines) : "";
|
|
1940
|
+
recordGitattributes(draft, before === null && after !== null, [adopted, appendedText(before, after)]);
|
|
1875
1941
|
}
|
|
1876
1942
|
function appendedText(before, after) {
|
|
1877
1943
|
if (after === null || after === before)
|
|
@@ -1887,7 +1953,7 @@ function assertManifestInWriteSet(manifest, writeSet) {
|
|
|
1887
1953
|
...manifest.createdDirs.filter((p) => !writeSet.dirs.has(p))
|
|
1888
1954
|
];
|
|
1889
1955
|
if (strays.length > 0) {
|
|
1890
|
-
throw new Error(`[
|
|
1956
|
+
throw new Error(`[halfcycle] the install record names paths outside the write set: ${strays.join(", ")}`);
|
|
1891
1957
|
}
|
|
1892
1958
|
}
|
|
1893
1959
|
function findNotShared(targetRepo, manifest, collidedPaths) {
|
|
@@ -1939,6 +2005,13 @@ function lstatOrNull(path) {
|
|
|
1939
2005
|
return null;
|
|
1940
2006
|
}
|
|
1941
2007
|
}
|
|
2008
|
+
function readFileOrNull(path) {
|
|
2009
|
+
try {
|
|
2010
|
+
return readFileSync4(path, "utf-8");
|
|
2011
|
+
} catch {
|
|
2012
|
+
return null;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
1942
2015
|
function isPlainObject(value) {
|
|
1943
2016
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1944
2017
|
}
|
|
@@ -1947,63 +2020,73 @@ function readPluginManifest() {
|
|
|
1947
2020
|
const raw = readFileSync4(manifestPath, "utf-8");
|
|
1948
2021
|
return JSON.parse(raw);
|
|
1949
2022
|
}
|
|
1950
|
-
function copyManifestCommands(manifest, targetRepo, report, draft) {
|
|
2023
|
+
function copyManifestCommands(manifest, targetRepo, report, draft, writeSet) {
|
|
1951
2024
|
for (const cmd of manifest.commands ?? []) {
|
|
1952
2025
|
const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
|
|
1953
2026
|
if (!existsSync3(srcFile)) {
|
|
1954
|
-
throw new Error(`[
|
|
2027
|
+
throw new Error(`[halfcycle] plugin.json declares command "${cmd.name}" at "${cmd.path}", but no file exists there (${srcFile}). The manifest and commands/ must agree.`);
|
|
1955
2028
|
}
|
|
1956
2029
|
const rel = commandStubRel(cmd.name);
|
|
1957
2030
|
const destFile = join5(targetRepo, rel);
|
|
1958
2031
|
const content = readFileSync4(srcFile, "utf-8");
|
|
1959
|
-
const
|
|
2032
|
+
const recorded = draft.files.get(rel);
|
|
2033
|
+
const { outcome, replacedExisting } = writeCollisionSafe(destFile, targetRepo, content, (current) => bytesAreOurs(rel, current, recorded, writeSet));
|
|
1960
2034
|
record(report, outcome, rel);
|
|
2035
|
+
if (replacedExisting)
|
|
2036
|
+
report.replacedPaths.push(rel);
|
|
1961
2037
|
noteFile(draft, targetRepo, rel, outcome);
|
|
1962
2038
|
}
|
|
1963
2039
|
}
|
|
1964
2040
|
function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
|
|
1965
2041
|
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
1966
2042
|
if (!isAllowlisted(rel)) {
|
|
1967
|
-
throw new Error(`[
|
|
2043
|
+
throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
1968
2044
|
}
|
|
1969
|
-
|
|
1970
|
-
|
|
2045
|
+
if (readFileOrNull(targetAbsPath) === content)
|
|
2046
|
+
return;
|
|
2047
|
+
mkdirSync4(dirname3(targetAbsPath), { recursive: true });
|
|
2048
|
+
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
1971
2049
|
writtenPaths.push(rel);
|
|
1972
2050
|
}
|
|
1973
|
-
function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
|
|
2051
|
+
function writeCollisionSafe(targetAbsPath, targetRepoRoot, content, isOurs) {
|
|
1974
2052
|
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
1975
2053
|
if (!isAllowlisted(rel)) {
|
|
1976
|
-
throw new Error(`[
|
|
2054
|
+
throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
1977
2055
|
}
|
|
2056
|
+
let replacedExisting = false;
|
|
1978
2057
|
if (existsSync3(targetAbsPath)) {
|
|
1979
|
-
const current = readFileSync4(targetAbsPath
|
|
1980
|
-
|
|
2058
|
+
const current = readFileSync4(targetAbsPath);
|
|
2059
|
+
if (current.toString("utf-8") === content)
|
|
2060
|
+
return { outcome: "skipped", replacedExisting: false };
|
|
2061
|
+
if (!isOurs(current))
|
|
2062
|
+
return { outcome: "collided", replacedExisting: false };
|
|
2063
|
+
replacedExisting = true;
|
|
1981
2064
|
}
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
return "written";
|
|
2065
|
+
mkdirSync4(dirname3(targetAbsPath), { recursive: true });
|
|
2066
|
+
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
2067
|
+
return { outcome: "written", replacedExisting };
|
|
1985
2068
|
}
|
|
1986
2069
|
var INSTALLER_OWNED_DIR = ".halfcycle/";
|
|
1987
2070
|
function writeOwned(targetAbsPath, targetRepoRoot, content) {
|
|
1988
2071
|
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
1989
2072
|
if (!isAllowlisted(rel)) {
|
|
1990
|
-
throw new Error(`[
|
|
2073
|
+
throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
1991
2074
|
}
|
|
1992
2075
|
if (!rel.startsWith(INSTALLER_OWNED_DIR)) {
|
|
1993
|
-
throw new Error(`[
|
|
2076
|
+
throw new Error(`[halfcycle] Path-ownership violation: attempted to claim "${rel}" by path. Only ${INSTALLER_OWNED_DIR} is owned outright by this installer; everywhere else a differing file may be a developer's, so ownership must be proved from the file's own contents (writeOwnedGenerated) rather than assumed from where it sits.`);
|
|
1994
2077
|
}
|
|
1995
2078
|
if (existsSync3(targetAbsPath) && readFileSync4(targetAbsPath, "utf-8") === content) {
|
|
1996
2079
|
return "skipped";
|
|
1997
2080
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2081
|
+
mkdirSync4(dirname3(targetAbsPath), { recursive: true });
|
|
2082
|
+
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
2000
2083
|
return "written";
|
|
2001
2084
|
}
|
|
2002
2085
|
var GENERATED_HEADER_LINE_INDEX = 1;
|
|
2003
2086
|
function writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMarker) {
|
|
2004
2087
|
const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
|
|
2005
2088
|
if (!isAllowlisted(rel)) {
|
|
2006
|
-
throw new Error(`[
|
|
2089
|
+
throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
|
|
2007
2090
|
}
|
|
2008
2091
|
let replacedExisting = false;
|
|
2009
2092
|
if (existsSync3(targetAbsPath)) {
|
|
@@ -2015,8 +2098,8 @@ function writeOwnedGenerated(targetAbsPath, targetRepoRoot, content, generatedMa
|
|
|
2015
2098
|
return { outcome: "collided", replacedExisting: false };
|
|
2016
2099
|
replacedExisting = true;
|
|
2017
2100
|
}
|
|
2018
|
-
|
|
2019
|
-
|
|
2101
|
+
mkdirSync4(dirname3(targetAbsPath), { recursive: true });
|
|
2102
|
+
writeFileSync4(targetAbsPath, content, "utf-8");
|
|
2020
2103
|
return { outcome: "written", replacedExisting };
|
|
2021
2104
|
}
|
|
2022
2105
|
function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, generatedMarker, rel) {
|
|
@@ -2054,6 +2137,26 @@ var DENY_PATTERNS = [
|
|
|
2054
2137
|
"Bash(git commit --no-verify:*)"
|
|
2055
2138
|
];
|
|
2056
2139
|
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.";
|
|
2140
|
+
function readExistingSettings(targetRepo) {
|
|
2141
|
+
const settingsPath = join5(targetRepo, SETTINGS_REL);
|
|
2142
|
+
return existsSync3(settingsPath) ? JSON.parse(readFileSync4(settingsPath, "utf-8")) : void 0;
|
|
2143
|
+
}
|
|
2144
|
+
var SettingsParseRefused = class extends Error {
|
|
2145
|
+
constructor(message) {
|
|
2146
|
+
super(message);
|
|
2147
|
+
this.name = "SettingsParseRefused";
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
function settingsParseRefusal(targetRepo, err) {
|
|
2151
|
+
const file = join5(targetRepo, SETTINGS_REL);
|
|
2152
|
+
const offset = /position (\d+)/.exec(err instanceof Error ? err.message : "")?.[1];
|
|
2153
|
+
let where = "";
|
|
2154
|
+
if (offset !== void 0) {
|
|
2155
|
+
const before = readFileSync4(file, "utf-8").slice(0, Number(offset)).split("\n");
|
|
2156
|
+
where = ` (line ${before.length}, column ${before[before.length - 1].length + 1})`;
|
|
2157
|
+
}
|
|
2158
|
+
return `${file} could not be read as JSON${where} \u2014 fix it and run "npx halfcycle" again.`;
|
|
2159
|
+
}
|
|
2057
2160
|
function generateSettingsJson() {
|
|
2058
2161
|
const hooks = {};
|
|
2059
2162
|
for (const { event, matcher, timeout, command } of HALFCYCLE_HOOK_WIRING) {
|
|
@@ -2118,6 +2221,10 @@ if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
|
2118
2221
|
export HALFCYCLE_NOT_THIS_ACCOUNT=1
|
|
2119
2222
|
fi
|
|
2120
2223
|
|
|
2224
|
+
# On a Node older than Halfcycle supports, the runner cannot even be read, so
|
|
2225
|
+
# say that in one line instead of handing it a stack trace.
|
|
2226
|
+
node -e "${nodeFloorProgram("")}" || exit
|
|
2227
|
+
|
|
2121
2228
|
exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
2122
2229
|
`;
|
|
2123
2230
|
}
|
|
@@ -2130,7 +2237,7 @@ function noNodeReplyShell() {
|
|
|
2130
2237
|
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: NO_NODE_SENTENCE }
|
|
2131
2238
|
});
|
|
2132
2239
|
if (reply.includes("'")) {
|
|
2133
|
-
throw new Error("[
|
|
2240
|
+
throw new Error("[halfcycle] the no-Node notice cannot be single-quoted; refusing to write the session-start script.");
|
|
2134
2241
|
}
|
|
2135
2242
|
return `if ! command -v node >/dev/null 2>&1; then
|
|
2136
2243
|
printf '%s\\n' '${reply}'
|
|
@@ -2293,13 +2400,13 @@ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeade
|
|
|
2293
2400
|
if (!isTrackedAndClean(targetRepo, rel))
|
|
2294
2401
|
return "kept-uncommitted";
|
|
2295
2402
|
try {
|
|
2296
|
-
|
|
2403
|
+
rmSync2(path);
|
|
2297
2404
|
} catch {
|
|
2298
2405
|
return "failed";
|
|
2299
2406
|
}
|
|
2300
2407
|
return "removed";
|
|
2301
2408
|
}
|
|
2302
|
-
var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
|
|
2409
|
+
var MCP_HEADERS_HELPER_COMMAND = `node -e "${nodeFloorProgram([
|
|
2303
2410
|
`const p=require('path'),f=require('fs'),c=require('child_process');`,
|
|
2304
2411
|
`let d=process.cwd();`,
|
|
2305
2412
|
`for(;;){`,
|
|
@@ -2312,7 +2419,7 @@ var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
|
|
|
2312
2419
|
`d=u}`,
|
|
2313
2420
|
`console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
|
|
2314
2421
|
`process.exit(1)`
|
|
2315
|
-
].join("")}"`;
|
|
2422
|
+
].join(""))}"`;
|
|
2316
2423
|
var MCP_HEADERS_KNOWN_HEADER_HASHES = [
|
|
2317
2424
|
"37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
|
|
2318
2425
|
"6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
|
|
@@ -2326,6 +2433,14 @@ var OWNED_GENERATED_HEADERS = {
|
|
|
2326
2433
|
".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
|
|
2327
2434
|
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
|
|
2328
2435
|
};
|
|
2436
|
+
function hookScriptsThisVersionWrites() {
|
|
2437
|
+
return [
|
|
2438
|
+
[".claude/hooks/guard-runner.sh", generateGuardRunnerWrapper()],
|
|
2439
|
+
[".claude/hooks/session-end-marker.sh", generateSessionEndMarker()],
|
|
2440
|
+
[".claude/hooks/user-prompt-reminder.sh", generateUserPromptReminderHook()],
|
|
2441
|
+
[".claude/hooks/session-start-marker.sh", generateSessionStartMarker()]
|
|
2442
|
+
];
|
|
2443
|
+
}
|
|
2329
2444
|
function generateMcpRegistration(existing, mcpOrigin) {
|
|
2330
2445
|
let base = { mcpServers: {} };
|
|
2331
2446
|
if (existing !== null) {
|
|
@@ -2346,13 +2461,12 @@ function generateMcpRegistration(existing, mcpOrigin) {
|
|
|
2346
2461
|
}
|
|
2347
2462
|
var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
|
|
2348
2463
|
var GITIGNORE_HEADER = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
|
|
2349
|
-
function
|
|
2350
|
-
const claimable = /* @__PURE__ */ new Set([...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]);
|
|
2464
|
+
function adoptBlockUnderHeader(before, header, claimable) {
|
|
2351
2465
|
const claimed = [];
|
|
2352
2466
|
let underHeader = false;
|
|
2353
2467
|
const lines = before.split("\n");
|
|
2354
2468
|
for (const [i, line] of lines.entries()) {
|
|
2355
|
-
if (line ===
|
|
2469
|
+
if (line === header) {
|
|
2356
2470
|
if (i > 0 && lines[i - 1] === "")
|
|
2357
2471
|
claimed.push("");
|
|
2358
2472
|
claimed.push(line);
|
|
@@ -2375,7 +2489,7 @@ function reconcileGitignore(targetRepo) {
|
|
|
2375
2489
|
try {
|
|
2376
2490
|
if (!existsSync3(gitignorePath)) {
|
|
2377
2491
|
const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
|
|
2378
|
-
|
|
2492
|
+
writeFileSync4(gitignorePath, body, "utf-8");
|
|
2379
2493
|
return "written";
|
|
2380
2494
|
}
|
|
2381
2495
|
const existing = readFileSync4(gitignorePath, "utf-8");
|
|
@@ -2386,7 +2500,7 @@ function reconcileGitignore(targetRepo) {
|
|
|
2386
2500
|
const suffix = (existing.endsWith("\n") ? "" : "\n") + `
|
|
2387
2501
|
${header}
|
|
2388
2502
|
` + toAdd.join("\n") + "\n";
|
|
2389
|
-
|
|
2503
|
+
writeFileSync4(gitignorePath, existing + suffix, "utf-8");
|
|
2390
2504
|
return "written";
|
|
2391
2505
|
} catch {
|
|
2392
2506
|
return "failed";
|
|
@@ -2494,12 +2608,9 @@ function checkDrift(targetRepoRoot) {
|
|
|
2494
2608
|
return { drifted: installed !== current, installed, current, methodVersion: pin?.methodVersion ?? null };
|
|
2495
2609
|
}
|
|
2496
2610
|
function writeProjectIdentity(targetRepo) {
|
|
2497
|
-
|
|
2498
|
-
const { identity } = mintOrReadIdentity(targetRepo);
|
|
2499
|
-
const serialized = JSON.stringify(identity, null, 2) + "\n";
|
|
2500
|
-
if (existsSync3(path) && readFileSync4(path, "utf-8") === serialized)
|
|
2611
|
+
if (existsSync3(join5(targetRepo, PROJECT_IDENTITY_REL)))
|
|
2501
2612
|
return "skipped";
|
|
2502
|
-
|
|
2613
|
+
createProjectIdentityFile(targetRepo);
|
|
2503
2614
|
return "written";
|
|
2504
2615
|
}
|
|
2505
2616
|
function writeEngagementCredential(credential, engagementId, home) {
|
|
@@ -2562,10 +2673,10 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
|
|
|
2562
2673
|
const substantive = kept.filter((line) => line.trim() !== "" && !line.trim().startsWith("# Halfcycle"));
|
|
2563
2674
|
try {
|
|
2564
2675
|
if (substantive.length === 0) {
|
|
2565
|
-
|
|
2676
|
+
rmSync2(path);
|
|
2566
2677
|
return { outcome: "removed", unbacked: [], missingForAdoption: [] };
|
|
2567
2678
|
}
|
|
2568
|
-
|
|
2679
|
+
writeFileSync4(path, kept.join("\n"), "utf-8");
|
|
2569
2680
|
return { outcome: "stripped", unbacked: [], missingForAdoption: [] };
|
|
2570
2681
|
} catch {
|
|
2571
2682
|
return { outcome: "failed", unbacked: [], missingForAdoption: [] };
|
|
@@ -2575,7 +2686,7 @@ async function install(options) {
|
|
|
2575
2686
|
const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
|
|
2576
2687
|
assertEngagementId(engagementId);
|
|
2577
2688
|
if (!existsSync3(targetRepo)) {
|
|
2578
|
-
throw new Error(`[
|
|
2689
|
+
throw new Error(`[halfcycle] Target repo does not exist: ${targetRepo}`);
|
|
2579
2690
|
}
|
|
2580
2691
|
const manifest = readPluginManifest();
|
|
2581
2692
|
const report = {
|
|
@@ -2588,18 +2699,7 @@ async function install(options) {
|
|
|
2588
2699
|
const writeSet = closedWriteSet();
|
|
2589
2700
|
const draft = startManifest(readPreviousManifest(targetRepo), (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
|
|
2590
2701
|
const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
|
|
2591
|
-
copyManifestCommands(manifest, targetRepo, report, draft);
|
|
2592
|
-
const capturedManifest = join5(targetRepo, CAPTURED_INDEX_REL);
|
|
2593
|
-
const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
|
|
2594
|
-
if (!existsSync3(capturedManifest)) {
|
|
2595
|
-
writeAllowlisted(capturedManifest, targetRepo, readFileSync4(srcManifest, "utf-8"), report.writtenPaths);
|
|
2596
|
-
noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "written");
|
|
2597
|
-
} else {
|
|
2598
|
-
report.skippedPaths.push(CAPTURED_INDEX_REL);
|
|
2599
|
-
if (readFileSync4(capturedManifest).equals(readFileSync4(srcManifest))) {
|
|
2600
|
-
noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "skipped");
|
|
2601
|
-
}
|
|
2602
|
-
}
|
|
2702
|
+
copyManifestCommands(manifest, targetRepo, report, draft, writeSet);
|
|
2603
2703
|
const vendoredBinSrc = resolveVendoredBinary();
|
|
2604
2704
|
const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
|
|
2605
2705
|
const vendoredOutcome = recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"), VENDORED_BIN_REL);
|
|
@@ -2607,21 +2707,20 @@ async function install(options) {
|
|
|
2607
2707
|
const settingsPath = join5(targetRepo, SETTINGS_REL);
|
|
2608
2708
|
const settingsPreexisted = existsSync3(settingsPath);
|
|
2609
2709
|
const generatedSettings = JSON.parse(generateSettingsJson());
|
|
2610
|
-
|
|
2710
|
+
let existingSettings;
|
|
2711
|
+
try {
|
|
2712
|
+
existingSettings = readExistingSettings(targetRepo) ?? {};
|
|
2713
|
+
} catch (err) {
|
|
2714
|
+
throw new SettingsParseRefused(settingsParseRefusal(targetRepo, err));
|
|
2715
|
+
}
|
|
2611
2716
|
recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted);
|
|
2612
2717
|
const mergedSettings = mergeSettings(existingSettings, generatedSettings);
|
|
2613
2718
|
const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
|
|
2614
|
-
|
|
2615
|
-
|
|
2719
|
+
mkdirSync4(dirname3(settingsPath), { recursive: true });
|
|
2720
|
+
writeFileSync4(settingsPath, mergedSettingsText, "utf-8");
|
|
2616
2721
|
(settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
|
|
2617
|
-
for (const [
|
|
2618
|
-
|
|
2619
|
-
["session-end-marker.sh", generateSessionEndMarker()],
|
|
2620
|
-
["user-prompt-reminder.sh", generateUserPromptReminderHook()],
|
|
2621
|
-
["session-start-marker.sh", generateSessionStartMarker()]
|
|
2622
|
-
]) {
|
|
2623
|
-
const rel = `.claude/hooks/${name}`;
|
|
2624
|
-
const hookPath = join5(targetRepo, ".claude", "hooks", name);
|
|
2722
|
+
for (const [rel, content] of hookScriptsThisVersionWrites()) {
|
|
2723
|
+
const hookPath = join5(targetRepo, rel);
|
|
2625
2724
|
const outcome = recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
|
|
2626
2725
|
noteFile(draft, targetRepo, rel, outcome);
|
|
2627
2726
|
}
|
|
@@ -2680,7 +2779,7 @@ async function install(options) {
|
|
|
2680
2779
|
const gitignorePath = join5(targetRepo, GITIGNORE_REL);
|
|
2681
2780
|
const gitignoreBefore = existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null;
|
|
2682
2781
|
const gitignoreOutcome = reconcileGitignore(targetRepo);
|
|
2683
|
-
recordGitignoreReconcile(draft, gitignoreBefore, existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null);
|
|
2782
|
+
recordGitignoreReconcile(draft, gitignoreBefore, existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null, writeSet);
|
|
2684
2783
|
if (gitignoreOutcome === "failed") {
|
|
2685
2784
|
report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
|
|
2686
2785
|
} else {
|
|
@@ -2689,7 +2788,7 @@ async function install(options) {
|
|
|
2689
2788
|
const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
|
|
2690
2789
|
const gitattributesBefore = readTextOrNull(gitattributesPath);
|
|
2691
2790
|
const gitattributesOutcome = reconcileGitattributes(targetRepo);
|
|
2692
|
-
recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
|
|
2791
|
+
recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath), writeSet);
|
|
2693
2792
|
if (gitattributesOutcome === "failed") {
|
|
2694
2793
|
report.skippedPaths.push(`${GITATTRIBUTES_REL} (write failed \u2014 a clone made with core.autocrlf=true may check the hook scripts out with CRLF line endings)`);
|
|
2695
2794
|
} else {
|
|
@@ -2812,16 +2911,22 @@ function planeRefusal(detail) {
|
|
|
2812
2911
|
reason: typeof candidate.reason === "string" ? candidate.reason : void 0
|
|
2813
2912
|
};
|
|
2814
2913
|
}
|
|
2815
|
-
var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}
|
|
2914
|
+
var CONTROL_ORIGIN_HINT = `This CLI talks to the Halfcycle service at ${DEFAULT_CONTROL_ORIGIN} \u2014 the same address for every user, and not something you set. Halfcycle also uses a second, different address for editor commands, which is not yours to set either. If something else answered here, it is most likely a network problem or an outage rather than something to configure.`;
|
|
2816
2915
|
async function createEngagement(baseUrl, name, credential, derivedName) {
|
|
2817
2916
|
return requestEngagementValues(
|
|
2917
|
+
"POST",
|
|
2818
2918
|
`${baseUrl.replace(/\/+$/, "")}/engagements`,
|
|
2819
2919
|
// A stated name wins over a guess, and the body carries at most one of them —
|
|
2820
2920
|
// the plane applies the same ordering, so the two cannot disagree about which
|
|
2821
2921
|
// one a row was named from.
|
|
2822
2922
|
name ? { name } : derivedName ? { derivedName } : {},
|
|
2823
2923
|
credential,
|
|
2824
|
-
{
|
|
2924
|
+
{
|
|
2925
|
+
action: "Create-engagement",
|
|
2926
|
+
nothingHappened: "No engagement was created",
|
|
2927
|
+
route: "POST /engagements",
|
|
2928
|
+
alsoNothingWritten: true
|
|
2929
|
+
}
|
|
2825
2930
|
);
|
|
2826
2931
|
}
|
|
2827
2932
|
function withDeclaredTelemetryKey(raw) {
|
|
@@ -2830,12 +2935,12 @@ function withDeclaredTelemetryKey(raw) {
|
|
|
2830
2935
|
const obj = raw;
|
|
2831
2936
|
return "controlTelemetryUrl" in obj ? obj : { ...obj, controlTelemetryUrl: "" };
|
|
2832
2937
|
}
|
|
2833
|
-
async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
2938
|
+
async function requestEngagementValues(method, url, requestBody, credential, shape) {
|
|
2834
2939
|
const bearer = credential?.trim();
|
|
2835
2940
|
let res;
|
|
2836
2941
|
try {
|
|
2837
2942
|
res = await fetch(url, {
|
|
2838
|
-
method
|
|
2943
|
+
method,
|
|
2839
2944
|
headers: {
|
|
2840
2945
|
"content-type": "application/json",
|
|
2841
2946
|
...bearer ? { authorization: `Bearer ${bearer}` } : {}
|
|
@@ -2843,36 +2948,36 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
2843
2948
|
body: JSON.stringify(requestBody)
|
|
2844
2949
|
});
|
|
2845
2950
|
} catch (err) {
|
|
2846
|
-
throw new Error(`[
|
|
2951
|
+
throw new Error(`[halfcycle] Could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2847
2952
|
}
|
|
2848
2953
|
if (!res.ok) {
|
|
2849
2954
|
const detail = await res.text().catch(() => "");
|
|
2850
2955
|
const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
|
|
2851
2956
|
if (refusal) {
|
|
2852
|
-
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, refusal.reason, `[
|
|
2957
|
+
throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, refusal.reason, `[halfcycle] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
|
|
2853
2958
|
}
|
|
2854
2959
|
const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
|
|
2855
|
-
throw new Error(`[
|
|
2960
|
+
throw new Error(`[halfcycle] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2856
2961
|
}
|
|
2857
2962
|
const candidate = withDeclaredTelemetryKey(await res.json().catch(() => null));
|
|
2858
2963
|
const declared = engagementCredentialResponseSchema.safeParse(candidate);
|
|
2859
2964
|
const body = declared.success ? declared.data : candidate;
|
|
2860
2965
|
if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
|
|
2861
|
-
throw new Error(`[
|
|
2966
|
+
throw new Error(`[halfcycle] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
|
|
2862
2967
|
}
|
|
2863
2968
|
for (const [field, value] of [
|
|
2864
2969
|
["engagementId", body.engagementId],
|
|
2865
2970
|
["sessionToken", body.sessionToken]
|
|
2866
2971
|
]) {
|
|
2867
2972
|
if (value.trim() === "") {
|
|
2868
|
-
throw new Error(`[
|
|
2973
|
+
throw new Error(`[halfcycle] The Halfcycle service 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}${shape.alsoNothingWritten ? " and nothing was written" : ""}. ` + CONTROL_ORIGIN_HINT);
|
|
2869
2974
|
}
|
|
2870
2975
|
}
|
|
2871
2976
|
if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
|
|
2872
|
-
throw new Error(`[
|
|
2977
|
+
throw new Error(`[halfcycle] The Halfcycle service 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 it does not continue. Either that service is older than this installer, or ${url} is not a Halfcycle service.`);
|
|
2873
2978
|
}
|
|
2874
2979
|
if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
|
|
2875
|
-
throw new Error(`[
|
|
2980
|
+
throw new Error(`[halfcycle] The Halfcycle service 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 it does not continue. Either that service is older than this installer, or ${url} is not a Halfcycle service.`);
|
|
2876
2981
|
}
|
|
2877
2982
|
const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
|
|
2878
2983
|
if (!declared.success) {
|
|
@@ -2883,7 +2988,7 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
2883
2988
|
}
|
|
2884
2989
|
return issue.path.length > 0 ? issue.path.map(String).join(".") : "(the body itself)";
|
|
2885
2990
|
}).join(", ");
|
|
2886
|
-
throw new Error(`[
|
|
2991
|
+
throw new Error(`[halfcycle] ${shape.action} response from ${url} is not the credential response this CLI is built to read (${fields}). ${shape.nothingHappened}. Either that service is a different version from this installer, or ${url} is not a Halfcycle service.`);
|
|
2887
2992
|
}
|
|
2888
2993
|
const consoleUrl = typeof body.consoleUrl === "string" && body.consoleUrl.trim() !== "" ? body.consoleUrl.trim() : void 0;
|
|
2889
2994
|
const methodVersion = typeof body.methodVersion === "string" && body.methodVersion.trim() !== "" ? body.methodVersion.trim() : void 0;
|
|
@@ -2899,8 +3004,8 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
|
|
|
2899
3004
|
}
|
|
2900
3005
|
|
|
2901
3006
|
// dist/setup/manifest.js
|
|
2902
|
-
import { existsSync as existsSync4, lstatSync as lstatSync2, mkdirSync as
|
|
2903
|
-
import { dirname as
|
|
3007
|
+
import { existsSync as existsSync4, lstatSync as lstatSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync5 } from "node:fs";
|
|
3008
|
+
import { dirname as dirname4, join as join6, posix as posix2 } from "node:path";
|
|
2904
3009
|
var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
|
|
2905
3010
|
var DECLARED_DIR_PREFIX = "docs";
|
|
2906
3011
|
var RESERVED_METHOD_PATH = "docs/method";
|
|
@@ -3011,8 +3116,8 @@ function applyFileRow(targetRepo, path, content) {
|
|
|
3011
3116
|
const current = readFileSync5(abs, "utf-8");
|
|
3012
3117
|
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" };
|
|
3013
3118
|
}
|
|
3014
|
-
|
|
3015
|
-
|
|
3119
|
+
mkdirSync5(dirname4(abs), { recursive: true });
|
|
3120
|
+
writeFileSync5(abs, content, "utf-8");
|
|
3016
3121
|
return { path, outcome: "created", note: "created" };
|
|
3017
3122
|
}
|
|
3018
3123
|
function applySectionRow(targetRepo, path, heading, body) {
|
|
@@ -3022,8 +3127,8 @@ function applySectionRow(targetRepo, path, heading, body) {
|
|
|
3022
3127
|
return symlinkEntry(path, link, false);
|
|
3023
3128
|
const section = renderSection(heading, body);
|
|
3024
3129
|
if (!existsSync4(abs)) {
|
|
3025
|
-
|
|
3026
|
-
|
|
3130
|
+
mkdirSync5(dirname4(abs), { recursive: true });
|
|
3131
|
+
writeFileSync5(abs, section, "utf-8");
|
|
3027
3132
|
return { path, outcome: "created", note: `created, with a "${heading}" section` };
|
|
3028
3133
|
}
|
|
3029
3134
|
const existing = readFileSync5(abs, "utf-8");
|
|
@@ -3035,7 +3140,7 @@ function applySectionRow(targetRepo, path, heading, body) {
|
|
|
3035
3140
|
};
|
|
3036
3141
|
}
|
|
3037
3142
|
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
3038
|
-
|
|
3143
|
+
writeFileSync5(abs, existing + separator + section, "utf-8");
|
|
3039
3144
|
return {
|
|
3040
3145
|
path,
|
|
3041
3146
|
outcome: "section-added",
|
|
@@ -3051,7 +3156,7 @@ function applyDirectoryRow(targetRepo, path) {
|
|
|
3051
3156
|
const kind = statSync(abs).isDirectory() ? "directory" : "file";
|
|
3052
3157
|
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" };
|
|
3053
3158
|
}
|
|
3054
|
-
|
|
3159
|
+
mkdirSync5(abs, { recursive: true });
|
|
3055
3160
|
return { path: `${path}/`, outcome: "created", note: "created" };
|
|
3056
3161
|
}
|
|
3057
3162
|
function applySetupWrites(targetRepo, rows) {
|
|
@@ -3734,7 +3839,7 @@ function renderBuildRecordV2Markdown(record2) {
|
|
|
3734
3839
|
}
|
|
3735
3840
|
|
|
3736
3841
|
// dist/build-record/write.js
|
|
3737
|
-
import { closeSync, existsSync as existsSync6, mkdirSync as
|
|
3842
|
+
import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync6, openSync, readFileSync as readFileSync7, renameSync, rmSync as rmSync3, writeSync } from "node:fs";
|
|
3738
3843
|
import { basename, join as join8, resolve, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
|
|
3739
3844
|
var BUILD_RECORD_DIR = join8("docs", "build-records");
|
|
3740
3845
|
function assertInsideOutDir(outDir, candidate, identity) {
|
|
@@ -3750,7 +3855,7 @@ function createExclusiveOnDisk(path, text) {
|
|
|
3750
3855
|
writeSync(fd, text);
|
|
3751
3856
|
} catch (err) {
|
|
3752
3857
|
closeSync(fd);
|
|
3753
|
-
|
|
3858
|
+
rmSync3(path, { force: true });
|
|
3754
3859
|
throw err;
|
|
3755
3860
|
}
|
|
3756
3861
|
closeSync(fd);
|
|
@@ -3814,7 +3919,7 @@ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_P
|
|
|
3814
3919
|
}
|
|
3815
3920
|
const jsonText = serialiseServedRecord(rawRecord);
|
|
3816
3921
|
const mdText = renderBuildRecordV2Markdown(record2);
|
|
3817
|
-
|
|
3922
|
+
mkdirSync6(outDir, { recursive: true });
|
|
3818
3923
|
let createdJson = false;
|
|
3819
3924
|
try {
|
|
3820
3925
|
io.createExclusive(jsonPath, jsonText);
|
|
@@ -3823,7 +3928,7 @@ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_P
|
|
|
3823
3928
|
return { kind: "created", jsonPath, mdPath };
|
|
3824
3929
|
} catch (err) {
|
|
3825
3930
|
if (createdJson)
|
|
3826
|
-
|
|
3931
|
+
rmSync3(jsonPath, { force: true });
|
|
3827
3932
|
if (!isEexist(err))
|
|
3828
3933
|
throw err;
|
|
3829
3934
|
}
|
|
@@ -3845,19 +3950,19 @@ function writeRecordPair(repoRoot, phase, rawRecord, record2, io = NODE_RECORD_P
|
|
|
3845
3950
|
try {
|
|
3846
3951
|
replaceOne(io, jsonTmp, jsonPath, existingJson);
|
|
3847
3952
|
} catch {
|
|
3848
|
-
|
|
3953
|
+
rmSync3(jsonPath, { force: true });
|
|
3849
3954
|
}
|
|
3850
3955
|
throw err;
|
|
3851
3956
|
}
|
|
3852
3957
|
return { kind: "replaced", jsonPath, mdPath };
|
|
3853
3958
|
}
|
|
3854
3959
|
function replaceOne(io, tmp, finalPath, text) {
|
|
3855
|
-
|
|
3960
|
+
rmSync3(tmp, { force: true });
|
|
3856
3961
|
try {
|
|
3857
3962
|
io.createExclusive(tmp, text);
|
|
3858
3963
|
io.rename(tmp, finalPath);
|
|
3859
3964
|
} catch (err) {
|
|
3860
|
-
|
|
3965
|
+
rmSync3(tmp, { force: true });
|
|
3861
3966
|
throw err;
|
|
3862
3967
|
}
|
|
3863
3968
|
}
|