halfcycle 0.3.34 → 0.3.36
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 +13 -0
- package/bin/bin.bundle.mjs +274 -43
- package/dist/banner-facts.d.ts +6 -2
- package/dist/banner-facts.d.ts.map +1 -1
- package/dist/bin.js +282 -298
- package/dist/bin.js.map +3 -3
- package/dist/build-record/render-v2.d.ts.map +1 -1
- package/dist/claude-version-probe.d.ts +44 -0
- package/dist/claude-version-probe.d.ts.map +1 -0
- package/dist/confirm-identity.d.ts +7 -0
- package/dist/confirm-identity.d.ts.map +1 -1
- package/dist/engagement-credential.d.ts +31 -23
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/index.js +158 -233
- package/dist/index.js.map +1 -1
- package/dist/install-manifest.d.ts +32 -4
- package/dist/install-manifest.d.ts.map +1 -1
- package/dist/install.d.ts +52 -16
- package/dist/install.d.ts.map +1 -1
- package/dist/open-phase.d.ts +2 -1
- package/dist/open-phase.d.ts.map +1 -1
- package/dist/probe-mcp.d.ts +39 -7
- package/dist/probe-mcp.d.ts.map +1 -1
- package/dist/uninstall.d.ts +1 -1
- package/dist/uninstall.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -27,6 +27,8 @@ var HALFCYCLE_DIR_NAME = ".halfcycle";
|
|
|
27
27
|
var ENGAGEMENTS_DIR_NAME = "engagements";
|
|
28
28
|
var ENGAGEMENT_ENV_FILENAME = "env";
|
|
29
29
|
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
30
|
+
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
31
|
+
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
30
32
|
var ENGAGEMENT_ID_PATTERN = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
|
|
31
33
|
var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
|
|
32
34
|
function isEngagementId(value) {
|
|
@@ -1053,7 +1055,6 @@ function halfcycleHome(home) {
|
|
|
1053
1055
|
var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
|
|
1054
1056
|
var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
|
|
1055
1057
|
var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
|
|
1056
|
-
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
1057
1058
|
var InvalidEngagementIdError = class extends Error {
|
|
1058
1059
|
constructor(engagementId) {
|
|
1059
1060
|
const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
|
|
@@ -1352,6 +1353,15 @@ function bool(value, field) {
|
|
|
1352
1353
|
throw new UnreadableManifestError(`"${field}" is not true or false`);
|
|
1353
1354
|
return value;
|
|
1354
1355
|
}
|
|
1356
|
+
function appendedTextRecord(value, field) {
|
|
1357
|
+
if (!isRecord(value))
|
|
1358
|
+
throw new UnreadableManifestError(`"${field}" is not an object`);
|
|
1359
|
+
onlyKeys(value, ["created", "appended"], `"${field}"`);
|
|
1360
|
+
return {
|
|
1361
|
+
created: bool(value["created"], `${field}.created`),
|
|
1362
|
+
appended: stringArray(value["appended"], `${field}.appended`)
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1355
1365
|
function onlyKeys(value, allowed, where) {
|
|
1356
1366
|
const extra = Object.keys(value).filter((k) => !allowed.includes(k));
|
|
1357
1367
|
if (extra.length > 0)
|
|
@@ -1366,7 +1376,7 @@ function parseManifest(text) {
|
|
|
1366
1376
|
}
|
|
1367
1377
|
if (!isRecord(raw))
|
|
1368
1378
|
throw new UnreadableManifestError("it is not a JSON object");
|
|
1369
|
-
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
|
|
1379
|
+
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
|
|
1370
1380
|
if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
|
|
1371
1381
|
throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
|
|
1372
1382
|
}
|
|
@@ -1427,14 +1437,10 @@ function parseManifest(text) {
|
|
|
1427
1437
|
...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
|
|
1428
1438
|
};
|
|
1429
1439
|
}
|
|
1430
|
-
|
|
1431
|
-
if (!isRecord(g))
|
|
1440
|
+
if (raw["gitignore"] === void 0)
|
|
1432
1441
|
throw new UnreadableManifestError('"gitignore" is missing');
|
|
1433
|
-
|
|
1434
|
-
const
|
|
1435
|
-
created: bool(g["created"], "gitignore.created"),
|
|
1436
|
-
appended: stringArray(g["appended"], "gitignore.appended")
|
|
1437
|
-
};
|
|
1442
|
+
const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
|
|
1443
|
+
const gitattributes = raw["gitattributes"] === void 0 ? void 0 : appendedTextRecord(raw["gitattributes"], "gitattributes");
|
|
1438
1444
|
const paths = files.map((f) => f.path);
|
|
1439
1445
|
if (new Set(paths).size !== paths.length)
|
|
1440
1446
|
throw new UnreadableManifestError('"files" names a path twice');
|
|
@@ -1449,7 +1455,8 @@ function parseManifest(text) {
|
|
|
1449
1455
|
leftAlone,
|
|
1450
1456
|
settings,
|
|
1451
1457
|
...mcp !== void 0 ? { mcp } : {},
|
|
1452
|
-
gitignore
|
|
1458
|
+
gitignore,
|
|
1459
|
+
...gitattributes !== void 0 ? { gitattributes } : {}
|
|
1453
1460
|
};
|
|
1454
1461
|
}
|
|
1455
1462
|
function startManifest(previous, isMember, isMemberDir) {
|
|
@@ -1465,12 +1472,16 @@ function startManifest(previous, isMember, isMemberDir) {
|
|
|
1465
1472
|
createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
|
|
1466
1473
|
settings: previous?.settings,
|
|
1467
1474
|
mcp: previous?.mcp,
|
|
1468
|
-
gitignore: previous?.gitignore
|
|
1475
|
+
gitignore: previous?.gitignore,
|
|
1476
|
+
gitattributes: previous?.gitattributes
|
|
1469
1477
|
};
|
|
1470
1478
|
}
|
|
1471
1479
|
function recordInstalledFile(draft, path, bytes) {
|
|
1472
1480
|
draft.files.set(path, { path, sha256: sha256Hex(bytes) });
|
|
1473
1481
|
}
|
|
1482
|
+
function recordRemovedFile(draft, path) {
|
|
1483
|
+
draft.files.delete(path);
|
|
1484
|
+
}
|
|
1474
1485
|
function recordPerMachineFile(draft, path) {
|
|
1475
1486
|
draft.files.set(path, { path, perMachine: true });
|
|
1476
1487
|
}
|
|
@@ -1513,8 +1524,15 @@ function recordMcp(draft, observed) {
|
|
|
1513
1524
|
};
|
|
1514
1525
|
}
|
|
1515
1526
|
function recordGitignore(draft, created, appended) {
|
|
1516
|
-
|
|
1517
|
-
|
|
1527
|
+
draft.gitignore = carryAppended(draft.gitignore, created, appended);
|
|
1528
|
+
}
|
|
1529
|
+
function recordGitattributes(draft, created, appended) {
|
|
1530
|
+
if (draft.gitattributes === void 0 && !created && appended.every((a) => a === ""))
|
|
1531
|
+
return;
|
|
1532
|
+
draft.gitattributes = carryAppended(draft.gitattributes, created, appended);
|
|
1533
|
+
}
|
|
1534
|
+
function carryAppended(prev, created, appended) {
|
|
1535
|
+
return {
|
|
1518
1536
|
created: prev?.created ?? created,
|
|
1519
1537
|
appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
|
|
1520
1538
|
};
|
|
@@ -1538,7 +1556,8 @@ function finishManifest(draft) {
|
|
|
1538
1556
|
denyCreated: false
|
|
1539
1557
|
},
|
|
1540
1558
|
...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
|
|
1541
|
-
gitignore: draft.gitignore ?? { created: false, appended: [] }
|
|
1559
|
+
gitignore: draft.gitignore ?? { created: false, appended: [] },
|
|
1560
|
+
...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
|
|
1542
1561
|
};
|
|
1543
1562
|
}
|
|
1544
1563
|
function union(a, b) {
|
|
@@ -1697,11 +1716,12 @@ var WRITE_ALLOWLIST = [
|
|
|
1697
1716
|
".claude/hooks",
|
|
1698
1717
|
".claude/settings.json",
|
|
1699
1718
|
".halfcycle",
|
|
1700
|
-
|
|
1719
|
+
BUNDLE_PIN_REL,
|
|
1701
1720
|
".halfcycle/bin",
|
|
1702
1721
|
"docs/build-records",
|
|
1703
1722
|
"test/fixtures/captured",
|
|
1704
1723
|
".mcp.json",
|
|
1724
|
+
".gitattributes",
|
|
1705
1725
|
ZONE_B_DIR
|
|
1706
1726
|
];
|
|
1707
1727
|
function isAllowlisted(targetRelPath) {
|
|
@@ -1711,15 +1731,16 @@ function isAllowlisted(targetRelPath) {
|
|
|
1711
1731
|
return normalised === p || normalised.startsWith(p + "/");
|
|
1712
1732
|
});
|
|
1713
1733
|
}
|
|
1714
|
-
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
1715
1734
|
var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
|
|
1716
1735
|
var CREW_ROSTER_REL = ".halfcycle/crew.json";
|
|
1717
1736
|
var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
|
|
1718
1737
|
var GITIGNORE_REL = ".gitignore";
|
|
1738
|
+
var GITATTRIBUTES_REL = ".gitattributes";
|
|
1719
1739
|
function commandStubRel(name) {
|
|
1720
1740
|
return `.claude/commands/${name}.md`;
|
|
1721
1741
|
}
|
|
1722
|
-
var
|
|
1742
|
+
var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
1743
|
+
var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
|
|
1723
1744
|
var RETIRED_DENY_PATTERNS = [];
|
|
1724
1745
|
function closedWriteSet() {
|
|
1725
1746
|
const files = /* @__PURE__ */ new Set([
|
|
@@ -1734,7 +1755,7 @@ function closedWriteSet() {
|
|
|
1734
1755
|
INSTALL_MANIFEST_REL,
|
|
1735
1756
|
...RETIRED_WRITE_SET_FILES
|
|
1736
1757
|
]);
|
|
1737
|
-
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
|
|
1758
|
+
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
|
|
1738
1759
|
const dirs = /* @__PURE__ */ new Set();
|
|
1739
1760
|
for (const path of [...files, ...merged]) {
|
|
1740
1761
|
for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
|
|
@@ -1748,7 +1769,11 @@ function closedWriteSet() {
|
|
|
1748
1769
|
ownedDirs,
|
|
1749
1770
|
hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
|
|
1750
1771
|
denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
|
|
1751
|
-
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
|
|
1772
|
+
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
|
|
1773
|
+
gitattributesLines: /* @__PURE__ */ new Set([
|
|
1774
|
+
GITATTRIBUTES_HEADER,
|
|
1775
|
+
...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
|
|
1776
|
+
])
|
|
1752
1777
|
};
|
|
1753
1778
|
}
|
|
1754
1779
|
function readPreviousManifest(targetRepo) {
|
|
@@ -1812,15 +1837,19 @@ function recordMcpAdoptionOnly(draft, existingText) {
|
|
|
1812
1837
|
}
|
|
1813
1838
|
function recordGitignoreReconcile(draft, before, after) {
|
|
1814
1839
|
const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
|
|
1815
|
-
|
|
1816
|
-
if (after !== null && after !== before) {
|
|
1817
|
-
if (before === null)
|
|
1818
|
-
appended = after;
|
|
1819
|
-
else if (after.startsWith(before))
|
|
1820
|
-
appended = after.slice(before.length);
|
|
1821
|
-
}
|
|
1840
|
+
const appended = appendedText(before, after);
|
|
1822
1841
|
recordGitignore(draft, before === null && after !== null, [adopted, appended]);
|
|
1823
1842
|
}
|
|
1843
|
+
function recordGitattributesReconcile(draft, before, after) {
|
|
1844
|
+
recordGitattributes(draft, before === null && after !== null, [appendedText(before, after)]);
|
|
1845
|
+
}
|
|
1846
|
+
function appendedText(before, after) {
|
|
1847
|
+
if (after === null || after === before)
|
|
1848
|
+
return "";
|
|
1849
|
+
if (before === null)
|
|
1850
|
+
return after;
|
|
1851
|
+
return after.startsWith(before) ? after.slice(before.length) : "";
|
|
1852
|
+
}
|
|
1824
1853
|
function assertManifestInWriteSet(manifest, writeSet) {
|
|
1825
1854
|
const strays = [
|
|
1826
1855
|
...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
|
|
@@ -1835,7 +1864,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
|
|
|
1835
1864
|
const merged = [
|
|
1836
1865
|
SETTINGS_REL,
|
|
1837
1866
|
...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
|
|
1838
|
-
...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
|
|
1867
|
+
...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
|
|
1868
|
+
...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
|
|
1839
1869
|
];
|
|
1840
1870
|
const collided = new Set(collidedPaths);
|
|
1841
1871
|
const dirVerdicts = /* @__PURE__ */ new Map();
|
|
@@ -2204,7 +2234,8 @@ set -e
|
|
|
2204
2234
|
|
|
2205
2235
|
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
2206
2236
|
# session's cwd \u2014 a session started in a subdirectory must find the same root
|
|
2207
|
-
# .mcp.json the editor loaded, which is the bug
|
|
2237
|
+
# .mcp.json the editor loaded, which is the bug the editor's headers command
|
|
2238
|
+
# already paid for.
|
|
2208
2239
|
HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
2209
2240
|
HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
|
|
2210
2241
|
HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
|
|
@@ -2251,7 +2282,9 @@ function isTrackedAndClean(targetRepo, relPath) {
|
|
|
2251
2282
|
return true;
|
|
2252
2283
|
}
|
|
2253
2284
|
function removeLegacyCiStanza(targetRepo) {
|
|
2254
|
-
|
|
2285
|
+
return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
|
|
2286
|
+
}
|
|
2287
|
+
function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
|
|
2255
2288
|
const path = join5(targetRepo, rel);
|
|
2256
2289
|
if (!existsSync3(path))
|
|
2257
2290
|
return "absent";
|
|
@@ -2261,10 +2294,9 @@ function removeLegacyCiStanza(targetRepo) {
|
|
|
2261
2294
|
} catch {
|
|
2262
2295
|
return "failed";
|
|
2263
2296
|
}
|
|
2264
|
-
const headerLine = raw.split("\n")[
|
|
2297
|
+
const headerLine = raw.split("\n")[headerLineIndex] ?? "";
|
|
2265
2298
|
const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
|
|
2266
|
-
|
|
2267
|
-
if (!ours)
|
|
2299
|
+
if (!knownHeaderHashes.includes(digest))
|
|
2268
2300
|
return "kept-foreign";
|
|
2269
2301
|
if (!isTrackedAndClean(targetRepo, rel))
|
|
2270
2302
|
return "kept-uncommitted";
|
|
@@ -2277,203 +2309,33 @@ function removeLegacyCiStanza(targetRepo) {
|
|
|
2277
2309
|
}
|
|
2278
2310
|
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
2279
2311
|
var MCP_SERVER_KEY = "halfcycle";
|
|
2280
|
-
var
|
|
2281
|
-
|
|
2282
|
-
|
|
2312
|
+
var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
|
|
2313
|
+
`const p=require('path'),f=require('fs'),c=require('child_process');`,
|
|
2314
|
+
`let d=process.cwd();`,
|
|
2315
|
+
`for(;;){`,
|
|
2316
|
+
`const b=p.join(d,'${VENDORED_BIN_REL}');`,
|
|
2317
|
+
`if(f.existsSync(b)){`,
|
|
2318
|
+
`const r=c.spawnSync(process.execPath,[b,'mcp-headers'],{stdio:'inherit'});`,
|
|
2319
|
+
`process.exit(typeof r.status==='number'?r.status:1)}`,
|
|
2320
|
+
`const u=p.dirname(d);`,
|
|
2321
|
+
`if(u===d)break;`,
|
|
2322
|
+
`d=u}`,
|
|
2323
|
+
`console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
|
|
2324
|
+
`process.exit(1)`
|
|
2325
|
+
].join("")}"`;
|
|
2326
|
+
var MCP_HEADERS_KNOWN_HEADER_HASHES = [
|
|
2327
|
+
"37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
|
|
2328
|
+
"6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
|
|
2329
|
+
];
|
|
2330
|
+
function removeRetiredMcpHeadersHelper(targetRepo) {
|
|
2331
|
+
return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
|
|
2332
|
+
}
|
|
2283
2333
|
var OWNED_GENERATED_HEADERS = {
|
|
2284
2334
|
".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
|
|
2285
2335
|
".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
|
|
2286
2336
|
".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
|
|
2287
|
-
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
|
|
2288
|
-
".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
|
|
2337
|
+
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
|
|
2289
2338
|
};
|
|
2290
|
-
function generateMcpHeadersHelper() {
|
|
2291
|
-
return `#!/bin/sh
|
|
2292
|
-
${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
|
|
2293
|
-
#
|
|
2294
|
-
# Claude Code runs this at MCP connection time, from the session's cwd, and merges
|
|
2295
|
-
# its stdout into the connection headers for the "halfcycle" server in .mcp.json.
|
|
2296
|
-
#
|
|
2297
|
-
# It exists because \${VAR} expansion in .mcp.json reads the PROCESS environment
|
|
2298
|
-
# only: nothing loads a credential into a session, so a referenced
|
|
2299
|
-
# \${HALFCYCLE_TOKEN} expanded to nothing and the server was silently absent from
|
|
2300
|
-
# the tool list.
|
|
2301
|
-
#
|
|
2302
|
-
# This file is tracked and holds NO secret. NEITHER DOES ANY OTHER FILE IN THIS
|
|
2303
|
-
# REPOSITORY (T-09): the credential lives at machine level, outside every checkout,
|
|
2304
|
-
# and this script READS one line out of it (never sources \u2014 a credential store is
|
|
2305
|
-
# not code).
|
|
2306
|
-
set -eu
|
|
2307
|
-
|
|
2308
|
-
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
2309
|
-
# caller's working directory. Claude Code runs the helper from the SESSION's cwd,
|
|
2310
|
-
# and a session started in a subdirectory still finds the root .mcp.json \u2014 so a
|
|
2311
|
-
# relative read here silently produced no credential for anyone who started the
|
|
2312
|
-
# session in any subdirectory of a monorepo.
|
|
2313
|
-
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
2314
|
-
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
|
2315
|
-
|
|
2316
|
-
${engagementResolutionShell()}
|
|
2317
|
-
|
|
2318
|
-
# Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
|
|
2319
|
-
# shell (call it as a statement, never inside $(...)). Last assignment wins,
|
|
2320
|
-
# matching the shell's own \`.\` semantics. An optional leading \`export \` is
|
|
2321
|
-
# tolerated because a hand-edited store may use one.
|
|
2322
|
-
halfcycle_store_value() {
|
|
2323
|
-
HC_VALUE=""
|
|
2324
|
-
hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
|
|
2325
|
-
hc_v=\${hc_line#*"$2="}
|
|
2326
|
-
hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
|
|
2327
|
-
|
|
2328
|
-
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
2329
|
-
# writers use the same shq: this installer and Studio's provider), because
|
|
2330
|
-
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
2331
|
-
# would execute its own remainder. This reader greps rather than sources, so it
|
|
2332
|
-
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
2333
|
-
# sourcing consumer gets, or one file has two answers.
|
|
2334
|
-
case $hc_v in
|
|
2335
|
-
'"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
|
|
2336
|
-
"'"*"'")
|
|
2337
|
-
hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
|
|
2338
|
-
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
2339
|
-
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
2340
|
-
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
2341
|
-
# no shell quoting is in the way:
|
|
2342
|
-
#
|
|
2343
|
-
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
2344
|
-
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
2345
|
-
#
|
|
2346
|
-
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
2347
|
-
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
2348
|
-
# and the token then travels with four stray characters in it. The bracket
|
|
2349
|
-
# expression makes the backslash literal by a construction POSIX does define.
|
|
2350
|
-
hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
|
|
2351
|
-
esac
|
|
2352
|
-
HC_VALUE=$hc_v
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
# The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
|
|
2356
|
-
# a space or a control character anywhere. That refusal is the load-bearing part:
|
|
2357
|
-
# a URL parser drops tabs and newlines before it reads the host, so
|
|
2358
|
-
# "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
|
|
2359
|
-
# line-by-line reading here would see only "https://ours". The host ends at the
|
|
2360
|
-
# first / ? # or backslash, which is where a URL parser ends it for http(s).
|
|
2361
|
-
halfcycle_origin() {
|
|
2362
|
-
hc_u=$1
|
|
2363
|
-
if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
|
|
2364
|
-
return 0
|
|
2365
|
-
fi
|
|
2366
|
-
printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
# HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
|
|
2370
|
-
# browser and no per-user store exports the credential, and a stale store on a
|
|
2371
|
-
# long-lived runner must not silently win over it. Same order, and the same reason,
|
|
2372
|
-
# as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
|
|
2373
|
-
# to is NOT taken from the environment: it is read from this machine's store in
|
|
2374
|
-
# both arms, so a token supplied this way still goes only where this machine was
|
|
2375
|
-
# set up to send it.
|
|
2376
|
-
# The pin names an id that is not a project id. Re-running the installer against
|
|
2377
|
-
# that pin refuses it too, so the one remedy that works is a fresh pin.
|
|
2378
|
-
halfcycle_bad_id_message() {
|
|
2379
|
-
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
|
|
2380
|
-
echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
|
|
2381
|
-
echo "halfcycle: is then set up as a new Halfcycle project." >&2
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
TOKEN=\${HALFCYCLE_TOKEN:-}
|
|
2385
|
-
MCP_URL=""
|
|
2386
|
-
|
|
2387
|
-
if [ -n "$TOKEN" ]; then
|
|
2388
|
-
if halfcycle_env_file "$PROJECT_ROOT"; then
|
|
2389
|
-
halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
|
|
2390
|
-
MCP_URL=$HC_VALUE
|
|
2391
|
-
fi
|
|
2392
|
-
else
|
|
2393
|
-
if ! halfcycle_env_file "$PROJECT_ROOT"; then
|
|
2394
|
-
case $HALFCYCLE_ENV_PROBLEM in
|
|
2395
|
-
no-pin)
|
|
2396
|
-
echo "halfcycle: no .halfcycle/bundle.json in $PROJECT_ROOT, so this repository has no Halfcycle engagement." >&2
|
|
2397
|
-
echo "halfcycle: run \\"npx halfcycle\\" here to install one." >&2 ;;
|
|
2398
|
-
no-id)
|
|
2399
|
-
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
|
|
2400
|
-
echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
|
|
2401
|
-
bad-id)
|
|
2402
|
-
halfcycle_bad_id_message ;;
|
|
2403
|
-
no-home)
|
|
2404
|
-
echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
|
|
2405
|
-
*)
|
|
2406
|
-
# OUTCOME B (T-02b, solo-project-ownership) is a \`no-credential\` PROBLEM
|
|
2407
|
-
# with a REASON attached, checked here rather than as a fifth case value
|
|
2408
|
-
# \u2014 see \`engagementResolutionShell\`'s own docblock for why. Re-running
|
|
2409
|
-
# this installer can never obtain a credential for this reason, so it is
|
|
2410
|
-
# the one sentence in this file that must not say to.
|
|
2411
|
-
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
2412
|
-
echo "halfcycle: this project belongs to another Halfcycle account, so this machine has no" >&2
|
|
2413
|
-
echo "halfcycle: credential for it and the Halfcycle tools are not available here. Nothing is" >&2
|
|
2414
|
-
echo "halfcycle: broken \u2014 ask the project's owner for view access, or sign in as the account" >&2
|
|
2415
|
-
echo "halfcycle: that owns it and run \\"npx halfcycle\\" again." >&2
|
|
2416
|
-
else
|
|
2417
|
-
echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
|
|
2418
|
-
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
|
|
2419
|
-
fi ;;
|
|
2420
|
-
esac
|
|
2421
|
-
exit 1
|
|
2422
|
-
fi
|
|
2423
|
-
ENV_FILE="$HALFCYCLE_ENV_FILE"
|
|
2424
|
-
|
|
2425
|
-
halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
|
|
2426
|
-
TOKEN=$HC_VALUE
|
|
2427
|
-
if [ -z "$TOKEN" ]; then
|
|
2428
|
-
echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
|
|
2429
|
-
echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
|
|
2430
|
-
exit 1
|
|
2431
|
-
fi
|
|
2432
|
-
halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
|
|
2433
|
-
MCP_URL=$HC_VALUE
|
|
2434
|
-
fi
|
|
2435
|
-
|
|
2436
|
-
# THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
|
|
2437
|
-
# file: anyone who can land a commit can change the server's url, and this script
|
|
2438
|
-
# would then hand the credential to whatever it names. Claude Code tells the helper
|
|
2439
|
-
# which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
|
|
2440
|
-
# machine was set up against is recorded OUTSIDE the repository, beside the
|
|
2441
|
-
# credential. The two must share scheme, host and port, or nothing is printed.
|
|
2442
|
-
# No url at all is a refusal too: sending the credential without knowing where it
|
|
2443
|
-
# is going is the thing this check exists to stop.
|
|
2444
|
-
REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
|
|
2445
|
-
if [ -z "$REQUESTED" ]; then
|
|
2446
|
-
echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
|
|
2447
|
-
echo "halfcycle: update Claude Code, then reconnect." >&2
|
|
2448
|
-
exit 1
|
|
2449
|
-
fi
|
|
2450
|
-
if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
|
|
2451
|
-
halfcycle_bad_id_message
|
|
2452
|
-
exit 1
|
|
2453
|
-
fi
|
|
2454
|
-
if [ -z "$MCP_URL" ]; then
|
|
2455
|
-
echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
|
|
2456
|
-
echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
|
|
2457
|
-
exit 1
|
|
2458
|
-
fi
|
|
2459
|
-
WANT=$(halfcycle_origin "$MCP_URL")
|
|
2460
|
-
GOT=$(halfcycle_origin "$REQUESTED")
|
|
2461
|
-
if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
|
|
2462
|
-
SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
|
|
2463
|
-
echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
|
|
2464
|
-
echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
|
|
2465
|
-
echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
|
|
2466
|
-
exit 1
|
|
2467
|
-
fi
|
|
2468
|
-
|
|
2469
|
-
# JSON-escape: backslash first, then double quote. A token carrying either would
|
|
2470
|
-
# otherwise emit invalid JSON, and invalid JSON is dropped SILENTLY \u2014 the
|
|
2471
|
-
# connection succeeds carrying no Authorization header at all.
|
|
2472
|
-
ESCAPED=$(printf '%s' "$TOKEN" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
|
|
2473
|
-
|
|
2474
|
-
printf '{"Authorization":"Bearer %s"}\\n' "$ESCAPED"
|
|
2475
|
-
`;
|
|
2476
|
-
}
|
|
2477
2339
|
function generateMcpRegistration(existing, mcpOrigin) {
|
|
2478
2340
|
let base = { mcpServers: {} };
|
|
2479
2341
|
if (existing !== null) {
|
|
@@ -2540,6 +2402,42 @@ ${header}
|
|
|
2540
2402
|
return "failed";
|
|
2541
2403
|
}
|
|
2542
2404
|
}
|
|
2405
|
+
var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
|
|
2406
|
+
function readTextOrNull(path) {
|
|
2407
|
+
try {
|
|
2408
|
+
return readFileSync4(path, "utf-8");
|
|
2409
|
+
} catch {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
function lfRules(paths) {
|
|
2414
|
+
return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
|
|
2415
|
+
}
|
|
2416
|
+
var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
|
|
2417
|
+
function reconcileGitattributes(targetRepo) {
|
|
2418
|
+
const path = join5(targetRepo, GITATTRIBUTES_REL);
|
|
2419
|
+
const rules = REQUIRED_GITATTRIBUTES_RULES;
|
|
2420
|
+
const routed = [];
|
|
2421
|
+
try {
|
|
2422
|
+
if (lstatOrNull(path)?.isSymbolicLink() === true)
|
|
2423
|
+
return "failed";
|
|
2424
|
+
if (!existsSync3(path)) {
|
|
2425
|
+
writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
|
|
2426
|
+
return "written";
|
|
2427
|
+
}
|
|
2428
|
+
const existing = readFileSync4(path, "utf-8");
|
|
2429
|
+
const present = new Set(existing.split("\n").map((l) => l.trim()));
|
|
2430
|
+
const toAdd = rules.filter((rule) => !present.has(rule));
|
|
2431
|
+
if (toAdd.length === 0)
|
|
2432
|
+
return "skipped";
|
|
2433
|
+
const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
2434
|
+
const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
|
|
2435
|
+
writeAllowlisted(path, targetRepo, existing + suffix, routed);
|
|
2436
|
+
return "written";
|
|
2437
|
+
} catch {
|
|
2438
|
+
return "failed";
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2543
2441
|
function previousPinForEngagement(targetRepoRoot, engagementId) {
|
|
2544
2442
|
try {
|
|
2545
2443
|
const existing = readBundlePin(targetRepoRoot);
|
|
@@ -2752,9 +2650,6 @@ async function install(options) {
|
|
|
2752
2650
|
break;
|
|
2753
2651
|
}
|
|
2754
2652
|
if (credential) {
|
|
2755
|
-
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
2756
|
-
const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
|
|
2757
|
-
noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
|
|
2758
2653
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
2759
2654
|
const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
|
|
2760
2655
|
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
@@ -2766,6 +2661,24 @@ async function install(options) {
|
|
|
2766
2661
|
} else {
|
|
2767
2662
|
report.skippedPaths.push(MCP_REGISTRATION_REL);
|
|
2768
2663
|
}
|
|
2664
|
+
const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
|
|
2665
|
+
switch (removeRetiredMcpHeadersHelper(targetRepo)) {
|
|
2666
|
+
case "removed":
|
|
2667
|
+
recordRemovedFile(draft, helperRel);
|
|
2668
|
+
report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
|
|
2669
|
+
break;
|
|
2670
|
+
case "kept-foreign":
|
|
2671
|
+
report.skippedPaths.push(`${helperRel} (KEPT \u2014 Halfcycle no longer uses this file, and it is not one Halfcycle generated byte for byte, so it was left alone; delete it if nothing of yours needs it)`);
|
|
2672
|
+
break;
|
|
2673
|
+
case "kept-uncommitted":
|
|
2674
|
+
report.skippedPaths.push(`${helperRel} (KEPT \u2014 Halfcycle no longer uses this file, but it is not a clean, committed copy in this repository, so it was left alone rather than risk losing an edit; delete it when you are ready)`);
|
|
2675
|
+
break;
|
|
2676
|
+
case "failed":
|
|
2677
|
+
report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
|
|
2678
|
+
break;
|
|
2679
|
+
case "absent":
|
|
2680
|
+
break;
|
|
2681
|
+
}
|
|
2769
2682
|
} else {
|
|
2770
2683
|
report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
|
|
2771
2684
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
@@ -2781,6 +2694,15 @@ async function install(options) {
|
|
|
2781
2694
|
} else {
|
|
2782
2695
|
record(report, gitignoreOutcome, ".gitignore");
|
|
2783
2696
|
}
|
|
2697
|
+
const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
|
|
2698
|
+
const gitattributesBefore = readTextOrNull(gitattributesPath);
|
|
2699
|
+
const gitattributesOutcome = reconcileGitattributes(targetRepo);
|
|
2700
|
+
recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
|
|
2701
|
+
if (gitattributesOutcome === "failed") {
|
|
2702
|
+
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)`);
|
|
2703
|
+
} else {
|
|
2704
|
+
record(report, gitattributesOutcome, GITATTRIBUTES_REL);
|
|
2705
|
+
}
|
|
2784
2706
|
const identityOutcome = writeProjectIdentity(targetRepo);
|
|
2785
2707
|
record(report, identityOutcome, PROJECT_IDENTITY_REL);
|
|
2786
2708
|
noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
|
|
@@ -3554,7 +3476,7 @@ function renderCouldNotRun(entries, p) {
|
|
|
3554
3476
|
}
|
|
3555
3477
|
return lines.join("\n");
|
|
3556
3478
|
}
|
|
3557
|
-
function headline(observed, attested) {
|
|
3479
|
+
function headline(observed, attested, checks) {
|
|
3558
3480
|
const met = attested.decision === "done-when-met";
|
|
3559
3481
|
const problemsReported = attested.walk !== null && attested.walk.verdict === "defects";
|
|
3560
3482
|
switch (observed.crossed.kind) {
|
|
@@ -3562,7 +3484,10 @@ function headline(observed, attested) {
|
|
|
3562
3484
|
if (!met) {
|
|
3563
3485
|
return "**Closed by override.** Halfcycle saw nothing outstanding, and the person closing the phase chose to override rather than state its finish conditions were met.";
|
|
3564
3486
|
}
|
|
3565
|
-
|
|
3487
|
+
if (problemsReported) {
|
|
3488
|
+
return "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk.";
|
|
3489
|
+
}
|
|
3490
|
+
return checks === "not-observed" ? "**Closed with nothing outstanding, but no checks ran.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met, but Halfcycle has no record of any check running during this phase, so this is not a clean result." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
|
|
3566
3491
|
case "unfinished-steps":
|
|
3567
3492
|
case "clean-over-findings":
|
|
3568
3493
|
return met ? "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase stated its finish conditions were met anyway." : "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase chose to override it.";
|
|
@@ -3650,10 +3575,10 @@ function renderStated(attested, p) {
|
|
|
3650
3575
|
}
|
|
3651
3576
|
return lines.join("\n");
|
|
3652
3577
|
}
|
|
3653
|
-
function renderClose(heading, observed, attested, p) {
|
|
3578
|
+
function renderClose(heading, observed, attested, checks, p) {
|
|
3654
3579
|
return [
|
|
3655
3580
|
heading,
|
|
3656
|
-
headline(observed, attested),
|
|
3581
|
+
headline(observed, attested, checks),
|
|
3657
3582
|
OBSERVED_LABEL,
|
|
3658
3583
|
[
|
|
3659
3584
|
`- Closed at: ${closedAtText(observed.closedAt, p)}`,
|
|
@@ -3688,12 +3613,12 @@ function renderBuildRecordV2Markdown(record2) {
|
|
|
3688
3613
|
].join("\n"),
|
|
3689
3614
|
total === 1 ? "This phase was closed once." : `This phase was closed ${String(total)} times. The latest close is described first; each earlier close follows, as it was recorded when it was made.`,
|
|
3690
3615
|
...renderChecks(record2.observed.checks, p),
|
|
3691
|
-
...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), p)
|
|
3616
|
+
...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), record2.observed.checks.state, p)
|
|
3692
3617
|
];
|
|
3693
3618
|
if (total > 1) {
|
|
3694
3619
|
body.push("## Earlier closes");
|
|
3695
3620
|
for (const close of closes.slice(0, -1)) {
|
|
3696
|
-
body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), p));
|
|
3621
|
+
body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), record2.observed.checks.state, p));
|
|
3697
3622
|
}
|
|
3698
3623
|
}
|
|
3699
3624
|
const header = [`${TITLE_PREFIX}${phase}`, HEADER_WHAT, HEADER_REGISTERS, HEADER_CHECKS, HEADER_KEPT];
|