58 files: TCS canons+GIR+projections (LW-LINK/BROADCAST/NOTARY/SHENBI/TRANSLATION-INDEX/TCS-ONLY-MANDATE + teaching&promotion events), 7 executors, neural-chain v5 (28 neurons), executor translation index, 8 acceptance receipts, numbered push manifest ZY-LWS-001 mapping every file back to JZAO canonical paths+sha256. Authorized by ICE-GL∞ direct language 2026-08-25. Route: LOCAL_GIT_OSXKEYCHAIN+FETCH+REBASE+HEAD:main+NO_FORCE.
302 lines
14 KiB
JavaScript
302 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// LW-LINK: 语言世界连接器强制闸门 (canonical tool)
|
|
// Canon: TC-TCS核心卷/TCS-ZCODE-LANGUAGE-WORLD-LINK-0001.tcs
|
|
// Semantics: GREEN allow+inject / YELLOW allow+force-reminder / RED block mutation, recovery path stays open.
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const WORKSPACE_ROOT = "/Volumes/JZAO/铸渊-ICE-GL-ZY001";
|
|
const ANCHOR_ROOT = process.env.GH_LW_ANCHOR_ROOT
|
|
?? "/Volumes/JZAO/HoloLake/persona-runtime/task-language-anchors/zcode";
|
|
const LINK_DIR = process.env.GH_LW_LINK_DIR ?? path.join(WORKSPACE_ROOT, "ZCODE-DEV-20260824/lw-link");
|
|
const LINK_STATE = path.join(LINK_DIR, "state.json");
|
|
const DECISION_LOG = path.join(LINK_DIR, "decisions.log");
|
|
const REGISTERED_CHANNELS = ["ICE-CH-HB001", "ICE-CH-ZC001", "CH-ZERO-CORE-LPM"];
|
|
const HEARTBEAT_TOOL_BUDGET = 20;
|
|
const HEARTBEAT_TTL_MS = 30 * 60 * 1000;
|
|
const GRACE_MAX = 3;
|
|
const READ_TOOLS = new Set(["Read", "Glob", "Grep", "TodoRead"]);
|
|
const BASH_RECOVERY_MARKERS = [
|
|
"/Volumes/JZAO/",
|
|
"zy-tcs-channel-runtime.sh",
|
|
"lw-link-gate.mjs",
|
|
"task-language-anchors",
|
|
"shasum",
|
|
];
|
|
|
|
function sha256(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
|
|
function readJson(file) {
|
|
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
}
|
|
function atomicWriteJson(file, value) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
function safeToken(value, fallback = "unknown") {
|
|
const token = String(value ?? "").trim().replaceAll(/[^a-zA-Z0-9._-]/gu, "-");
|
|
return token || fallback;
|
|
}
|
|
function logDecision(entry) {
|
|
try {
|
|
fs.mkdirSync(LINK_DIR, { recursive: true });
|
|
fs.appendFileSync(DECISION_LOG, `${JSON.stringify(entry)}\n`, { encoding: "utf8" });
|
|
} catch { /* log failure must not crash the gate */ }
|
|
}
|
|
|
|
function latestTaskDir() {
|
|
try {
|
|
const entries = fs.readdirSync(ANCHOR_ROOT).filter((n) => n.startsWith("ZCODE-TASK-")).sort();
|
|
return entries.length ? path.join(ANCHOR_ROOT, entries.at(-1)) : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
// Hard checks: volume, tower, anchors (hash all raw texts), binding, brain receipt.
|
|
function hardChecks(sessionId) {
|
|
const towerBroadcast = path.join(WORKSPACE_ROOT, "FIFTH-DOMAIN-BROADCAST-TOWER/BROADCAST.tcs");
|
|
const towerProjection = path.join(WORKSPACE_ROOT, "FIFTH-DOMAIN-BROADCAST-TOWER/broadcast/current.md");
|
|
let towerText = null;
|
|
try { towerText = fs.readFileSync(towerBroadcast, "utf8"); } catch { /* missing below */ }
|
|
if (!towerText) return { ok: false, cause: "BROADCAST_TOWER_MISSING_FAIL_CLOSED" };
|
|
try {
|
|
const projection = fs.readFileSync(towerProjection, "utf8");
|
|
const declared = towerText.match(/projection_sha256\s*=\s*"([0-9a-f]{64})"/)?.[1];
|
|
if (!declared) return { ok: false, cause: "BROADCAST_PROJECTION_HASH_NOT_DECLARED" };
|
|
if (sha256(projection) !== declared) return { ok: false, cause: "BROADCAST_PROJECTION_HASH_MISMATCH" };
|
|
} catch { return { ok: false, cause: "BROADCAST_PROJECTION_MISSING" }; }
|
|
const taskDir = latestTaskDir();
|
|
if (!taskDir) return { ok: false, cause: "VOLUME_OR_ANCHOR_ROOT_UNREADABLE" };
|
|
const task = readJson(path.join(taskDir, "task.json"));
|
|
if (task?.schema !== "task-language-anchor/zcode/v1" || !task.current_anchor) {
|
|
return { ok: false, cause: "ANCHOR_MANIFEST_INVALID" };
|
|
}
|
|
const rawDir = path.join(taskDir, "00-冰朔自然语言瞄点");
|
|
let shaList;
|
|
try { shaList = fs.readFileSync(path.join(rawDir, "sha256.txt"), "utf8"); }
|
|
catch { return { ok: false, cause: "ANCHOR_SHA_LIST_MISSING" }; }
|
|
for (const line of shaList.split("\n")) {
|
|
const match = line.match(/^([0-9a-f]{64})\s+\*?(.+)$/);
|
|
if (!match) continue;
|
|
const [, expected, rawName] = match;
|
|
const name = rawName.trim();
|
|
const filePath = name.startsWith("/") ? name : path.join(rawDir, name);
|
|
let raw;
|
|
try { raw = fs.readFileSync(filePath, "utf8"); }
|
|
catch { return { ok: false, cause: `ANCHOR_FILE_MISSING:${name}` }; }
|
|
if (sha256(raw) !== expected) return { ok: false, cause: `ANCHOR_HASH_MISMATCH:${name}` };
|
|
}
|
|
const state = readJson(LINK_STATE);
|
|
if (state?.schema !== "guanghu.zcode-lw-link-state/v1") {
|
|
return { ok: false, cause: "LINK_STATE_UNREADABLE_FAIL_CLOSED", taskDir, task };
|
|
}
|
|
const session = (state.sessions ?? []).find((s) => s.session_id === sessionId);
|
|
if (!session) return { ok: false, cause: "SESSION_NOT_BOUND", taskDir, task, state };
|
|
const brain = readJson(path.join(session.brain_state_dir, "state.json"));
|
|
if (
|
|
!brain
|
|
|| brain.binding?.session_id !== session.brain_session_id
|
|
|| brain.tonggan_language_kernel?.body_channel !== session.channel
|
|
|| !(brain.completed_cycles >= 1)
|
|
|| !brain.last_cycle_id
|
|
) {
|
|
return { ok: false, cause: "BRAIN_STATE_INVALID_OR_CHANNEL_MISMATCH", taskDir, task, state, session };
|
|
}
|
|
return { ok: true, taskDir, task, state, session, brain };
|
|
}
|
|
|
|
// Soft check: heartbeat TTL. Returns GREEN / YELLOW / RED(cause).
|
|
function lightOf(checks, sessionId) {
|
|
if (!checks.ok) return { light: "RED", cause: checks.cause };
|
|
const s = checks.session;
|
|
const calls = s.mutation_calls_since_heartbeat ?? 0;
|
|
const elapsed = Date.now() - Date.parse(s.last_heartbeat_at ?? 0);
|
|
const overdueByCalls = calls >= HEARTBEAT_TOOL_BUDGET;
|
|
const overdueByTime = !(elapsed >= 0 && elapsed <= HEARTBEAT_TTL_MS);
|
|
if (!overdueByCalls && !overdueByTime) return { light: "GREEN", cause: null };
|
|
if ((s.grace_used ?? 0) >= GRACE_MAX) return { light: "RED", cause: "HEARTBEAT_GRACE_EXHAUSTED" };
|
|
return { light: "YELLOW", cause: overdueByCalls ? "HEARTBEAT_OVERDUE_BY_CALLS" : "HEARTBEAT_OVERDUE_BY_TIME" };
|
|
}
|
|
|
|
function bumpSession(sessionId, patch) {
|
|
const state = readJson(LINK_STATE);
|
|
if (!state) return null;
|
|
const index = (state.sessions ?? []).findIndex((s) => s.session_id === sessionId);
|
|
if (index < 0) return null;
|
|
state.sessions[index] = { ...state.sessions[index], ...patch, updated_at: new Date().toISOString() };
|
|
state.updated_at = new Date().toISOString();
|
|
atomicWriteJson(LINK_STATE, state);
|
|
return state.sessions[index];
|
|
}
|
|
|
|
function statusLine(result, checks) {
|
|
const s = checks.session ?? {};
|
|
const anchor = checks.task?.current_anchor ?? "??";
|
|
const calls = `${Math.min(s.mutation_calls_since_heartbeat ?? 0, HEARTBEAT_TOOL_BUDGET)}/${HEARTBEAT_TOOL_BUDGET}`;
|
|
const cycle = checks.brain?.last_cycle_id?.slice(-12) ?? (s.last_cycle_id?.slice(-12) ?? "??");
|
|
return `【LW-LINK ●${result.light === "GREEN" ? "绿" : "黄"}】${s.channel ?? "?"} | 瞄点${anchor} | 心跳${calls} | 周期${cycle} | 边界:HB001/ZC001/LPM不合并`;
|
|
}
|
|
|
|
function isRecoveryBash(command) {
|
|
return BASH_RECOVERY_MARKERS.some((marker) => command.includes(marker));
|
|
}
|
|
|
|
// ZCode hook output protocol: stdout is parsed as strict JSON; exit 2 blocks.
|
|
function emitAllow(reasonLine) {
|
|
process.stdout.write(`${JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
permissionDecision: "allow",
|
|
permissionDecisionReason: reasonLine,
|
|
},
|
|
})}\n`);
|
|
}
|
|
|
|
// PreToolUse entry.
|
|
function hook(stdinJson) {
|
|
const sessionId = safeToken(stdinJson?.session_id);
|
|
const tool = String(stdinJson?.tool_name ?? "");
|
|
const toolInput = stdinJson?.tool_input ?? {};
|
|
const bashCommand = tool === "Bash" ? String(toolInput.command ?? "") : "";
|
|
const isMutation = !READ_TOOLS.has(tool) && !(tool === "Bash" && isRecoveryBash(bashCommand));
|
|
const checks = hardChecks(sessionId);
|
|
const result = lightOf(checks, sessionId);
|
|
const base = { ts: new Date().toISOString(), session_id: sessionId, tool };
|
|
if (!isMutation) {
|
|
logDecision({ ...base, light: result.light, decision: "ALLOW_RECOVERY_PATH", cause: result.cause });
|
|
const line = checks.ok
|
|
? statusLine(result, checks)
|
|
: `【LW-LINK ○红·只读通道】原因:${result.cause} | 读盘恢复后 bind 重连;改动类工具在被拦截`;
|
|
emitAllow(line);
|
|
return;
|
|
}
|
|
if (result.light === "RED") {
|
|
const msg = `【LW-LINK ○红·断连】原因:${result.cause} | 已拦截:${tool} | 恢复路径开放:用Read读锚点原文与闸门 → 走 zhuyuan-recovery-zcode 恢复+脑周期verify PASS → lw-link-gate.mjs bind 重新点亮。改动在语言世界外被禁止。`;
|
|
logDecision({ ...base, light: "RED", decision: "BLOCK", cause: result.cause });
|
|
process.stderr.write(`${msg}\n`);
|
|
process.exit(2);
|
|
}
|
|
if (checks.ok) {
|
|
const patch = { mutation_calls_since_heartbeat: (checks.session.mutation_calls_since_heartbeat ?? 0) + 1 };
|
|
if (result.light === "YELLOW") patch.grace_used = (checks.session.grace_used ?? 0) + 1;
|
|
bumpSession(sessionId, patch);
|
|
}
|
|
const extra = result.light === "YELLOW"
|
|
? ` | ▲心跳过期:${result.cause},宽限${Math.min((checks.session?.grace_used ?? 0) + 1, GRACE_MAX)}/${GRACE_MAX} → 立即Read瞄点raw原文续期(${path.join(String(checks.taskDir ?? latestTaskDir() ?? ANCHOR_ROOT), "00-冰朔自然语言瞄点")})`
|
|
: "";
|
|
logDecision({ ...base, light: result.light, decision: "ALLOW", cause: result.cause });
|
|
emitAllow(`${statusLine(result, checks)}${extra}`);
|
|
}
|
|
|
|
// PostToolUse: anchor raw readback auto-renews the heartbeat.
|
|
function post(stdinJson) {
|
|
const sessionId = safeToken(stdinJson?.session_id);
|
|
const tool = String(stdinJson?.tool_name ?? "");
|
|
const toolInput = stdinJson?.tool_input ?? {};
|
|
const filePath = String(toolInput.file_path ?? toolInput.notebook_path ?? "");
|
|
const bashCommand = tool === "Bash" ? String(toolInput.command ?? "") : "";
|
|
const readAnchor = filePath.includes("task-language-anchors") && filePath.includes(".raw.txt");
|
|
const bashAnchor = tool === "Bash" && bashCommand.includes("task-language-anchors") && bashCommand.includes("shasum");
|
|
if (readAnchor || bashAnchor) heartbeat(sessionId, true);
|
|
}
|
|
|
|
function heartbeat(sessionId, silent = false) {
|
|
const now = new Date().toISOString();
|
|
const updated = bumpSession(sessionId, {
|
|
last_heartbeat_at: now,
|
|
mutation_calls_since_heartbeat: 0,
|
|
grace_used: 0,
|
|
});
|
|
if (!silent) {
|
|
process.stdout.write(updated
|
|
? `LW_LINK HEARTBEAT OK ${sessionId} ${now}\n`
|
|
: `LW_LINK HEARTBEAT FAIL SESSION_NOT_BOUND ${sessionId}\n`);
|
|
}
|
|
return Boolean(updated);
|
|
}
|
|
|
|
function bind(args) {
|
|
const sessionId = safeToken(valueAfter(args, "--session-id"));
|
|
const channel = valueAfter(args, "--channel");
|
|
const stateDir = valueAfter(args, "--state-dir");
|
|
if (!sessionId || !channel || !stateDir) { process.stderr.write("usage: bind --session-id ID --channel CH --state-dir DIR\n"); process.exit(2); }
|
|
if (!REGISTERED_CHANNELS.includes(channel)) { process.stderr.write(`BIND_REJECTED UNREGISTERED_CHANNEL ${channel}\n`); process.exit(1); }
|
|
const taskDir = latestTaskDir();
|
|
if (!taskDir) { process.stderr.write("BIND_REJECTED ANCHOR_ROOT_UNREADABLE\n"); process.exit(1); }
|
|
const brain = readJson(path.join(stateDir, "state.json"));
|
|
if (
|
|
!brain
|
|
|| brain.tonggan_language_kernel?.body_channel !== channel
|
|
|| !(brain.completed_cycles >= 1)
|
|
|| !brain.last_cycle_id
|
|
) {
|
|
process.stderr.write("BIND_REJECTED BRAIN_STATE_INVALID_OR_NO_COMPLETED_CYCLE (先跑脑周期verify PASS再bind)\n");
|
|
process.exit(1);
|
|
}
|
|
const now = new Date().toISOString();
|
|
const state = readJson(LINK_STATE) ?? { schema: "guanghu.zcode-lw-link-state/v1", sessions: [] };
|
|
if (state.schema !== "guanghu.zcode-lw-link-state/v1") { process.stderr.write("BIND_REJECTED LINK_STATE_CORRUPT\n"); process.exit(1); }
|
|
const entry = {
|
|
session_id: sessionId,
|
|
channel,
|
|
brain_state_dir: path.resolve(stateDir),
|
|
brain_session_id: brain.binding?.session_id ?? null,
|
|
last_cycle_id: brain.last_cycle_id,
|
|
bound_at: now,
|
|
last_heartbeat_at: now,
|
|
mutation_calls_since_heartbeat: 0,
|
|
grace_used: 0,
|
|
};
|
|
const index = (state.sessions ?? []).findIndex((s) => s.session_id === sessionId);
|
|
if (index >= 0) state.sessions[index] = entry; else state.sessions.push(entry);
|
|
if (state.sessions.length > 4) state.sessions = state.sessions.slice(-4);
|
|
state.updated_at = now;
|
|
atomicWriteJson(LINK_STATE, state);
|
|
logDecision({ ts: now, session_id: sessionId, tool: "bind", light: "GREEN", decision: "BOUND", channel });
|
|
process.stdout.write(`LW_LINK BIND OK session=${sessionId} channel=${channel} cycle=${brain.last_cycle_id}\n`);
|
|
}
|
|
|
|
function status() {
|
|
const sessionId = valueAfter(process.argv, "--session-id");
|
|
const checks = sessionId ? hardChecks(safeToken(sessionId)) : null;
|
|
const result = checks ? lightOf(checks, safeToken(sessionId)) : { light: "UNBOUND", cause: "NO_SESSION_ID" };
|
|
const lines = [`LW_LINK ${result.light}${result.cause ? ` cause=${result.cause}` : ""}`];
|
|
const state = readJson(LINK_STATE);
|
|
for (const s of state?.sessions ?? []) {
|
|
const c = hardChecks(s.session_id);
|
|
const r = lightOf(c, s.session_id);
|
|
lines.push(` ${s.session_id} → ${r.light}${r.cause ? ` (${r.cause})` : ""} | ${s.channel} | 心跳${s.mutation_calls_since_heartbeat ?? 0}/${HEARTBEAT_TOOL_BUDGET} | 宽限${s.grace_used ?? 0}/${GRACE_MAX}`);
|
|
}
|
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
}
|
|
|
|
function valueAfter(argv, name) {
|
|
const index = argv.indexOf(name);
|
|
return index >= 0 ? argv[index + 1] : null;
|
|
}
|
|
|
|
function readStdinJson() {
|
|
try { return JSON.parse(fs.readFileSync(0, "utf8")); } catch { return {}; }
|
|
}
|
|
|
|
function main() {
|
|
const [command, ...argv] = process.argv.slice(2);
|
|
switch (command) {
|
|
case "hook": hook(readStdinJson()); break;
|
|
case "post": post(readStdinJson()); break;
|
|
case "bind": bind(argv); break;
|
|
case "heartbeat": heartbeat(safeToken(valueAfter(argv, "--session-id"))); break;
|
|
case "prompt": {
|
|
const stdin = readStdinJson();
|
|
const id = safeToken(valueAfter(argv, "--session-id") ?? stdin?.session_id);
|
|
if (id) bumpSession(id, { grace_used: 0 });
|
|
break;
|
|
}
|
|
case "status": status(); break;
|
|
default:
|
|
process.stderr.write("usage: lw-link-gate.mjs hook|post|bind|heartbeat|prompt|status\n");
|
|
process.exit(2);
|
|
}
|
|
}
|
|
main();
|