@junghanacs/entwurf 0.22.0 → 0.23.1
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/AGENTS.md +4 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -3,6 +3,15 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { parse } from "smol-toml";
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
CODEX_BIRTH_EVENT,
|
|
8
|
+
classifyOwnedPath,
|
|
9
|
+
entwurfDeclarationCommand,
|
|
10
|
+
selectEntwurfDeclaration,
|
|
11
|
+
statOwnedPath,
|
|
12
|
+
trustReceiptKey,
|
|
13
|
+
} from "./codex-declaration.js";
|
|
14
|
+
|
|
6
15
|
import {
|
|
7
16
|
type CodexProtocolOpener,
|
|
8
17
|
type CodexRpcProtocol,
|
|
@@ -65,6 +74,16 @@ export const CODEX_CALLER_PREFLIGHT_HINT: Record<CodexCallerPreflightRejectReaso
|
|
|
65
74
|
"this Codex caller's tui.terminal_title does not include thread-id, so the multiplexer reports no pane title naming this thread and there is no caller seat to open the sibling beside; run `entwurf install-codex-terminal-title`, then `entwurf doctor-codex-terminal-title` (an explicit placement.tmuxSession skips this check entirely, because it never needs the seat)",
|
|
66
75
|
};
|
|
67
76
|
|
|
77
|
+
/**
|
|
78
|
+
* What `codex-birth-install-state/v2` records ABOUT the declaration — the normalized digest of
|
|
79
|
+
* entwurf's own `SessionStart` group and the command string that selects it. Never a file digest:
|
|
80
|
+
* hooks.json is shared, and its bytes are not this unit's to certify.
|
|
81
|
+
*/
|
|
82
|
+
interface RecordedDeclaration {
|
|
83
|
+
command: string;
|
|
84
|
+
sha256: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
export interface CodexUnitPaths {
|
|
69
88
|
hooksFile: string;
|
|
70
89
|
helperDir: string;
|
|
@@ -108,28 +127,22 @@ const CODEX_MCP_ENV_VARS = [
|
|
|
108
127
|
"TMUX_PANE",
|
|
109
128
|
] as const;
|
|
110
129
|
|
|
130
|
+
/**
|
|
131
|
+
* ONE POLICY, FOUR SURFACES. The predicate itself lives in `codex-declaration.js` (sol B3,
|
|
132
|
+
* 2026-09-18) because the installer, the inverse and the doctor decide about the SAME shared
|
|
133
|
+
* hooks.json and used to decide differently: they checked symlink-and-regular, this checked owner
|
|
134
|
+
* and mode as well, and the doctor checked neither. That split let an install and a doctor say yes
|
|
135
|
+
* about bytes every fresh call then refused. The executable bit stays HERE — it is this rail's
|
|
136
|
+
* question about the launcher codex is going to exec, not a fact about shared ownership.
|
|
137
|
+
*/
|
|
111
138
|
function isSafeOwnedFile(file: string, expectedUid: number, executable = false): boolean {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
stat.isFile() &&
|
|
116
|
-
!stat.isSymbolicLink() &&
|
|
117
|
-
stat.uid === expectedUid &&
|
|
118
|
-
(stat.mode & 0o022) === 0 &&
|
|
119
|
-
(!executable || (stat.mode & 0o111) !== 0)
|
|
120
|
-
);
|
|
121
|
-
} catch {
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
139
|
+
const stat = statOwnedPath(fs, file);
|
|
140
|
+
if (classifyOwnedPath(stat, expectedUid) !== "ok") return false;
|
|
141
|
+
return !executable || (stat.mode & 0o111) !== 0;
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
function isSafeOwnedDir(dir: string, expectedUid: number): boolean {
|
|
127
|
-
|
|
128
|
-
const stat = fs.lstatSync(dir);
|
|
129
|
-
return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === expectedUid && (stat.mode & 0o022) === 0;
|
|
130
|
-
} catch {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
145
|
+
return classifyOwnedPath(statOwnedPath(fs, dir), expectedUid, { kind: "directory" }) === "ok";
|
|
133
146
|
}
|
|
134
147
|
|
|
135
148
|
/**
|
|
@@ -137,21 +150,37 @@ function isSafeOwnedDir(dir: string, expectedUid: number): boolean {
|
|
|
137
150
|
* EXIST prove nothing: the launcher codex is about to exec must be the one this unit
|
|
138
151
|
* published, so every recorded member is compared to its recorded digest before a sibling
|
|
139
152
|
* is opened. No digest is computed for the VENDOR here — that is a different axis below.
|
|
153
|
+
*
|
|
154
|
+
* WHAT THIS NO LONGER READS (#117): a WHOLE-FILE `hooksSha256`. `codex-birth-install-state/v2`
|
|
155
|
+
* records the normalized digest of entwurf's own declaration instead, because hooks.json is a
|
|
156
|
+
* file this unit SHARES — a neighbouring integration appending its own `SessionStart` group
|
|
157
|
+
* changes every byte of that file and none of our declaration's meaning. The declaration axis
|
|
158
|
+
* lives in `birthDeclaration` below, which needs the live document anyway to find where our group
|
|
159
|
+
* currently sits.
|
|
140
160
|
*/
|
|
141
|
-
function
|
|
161
|
+
function certifiedState(paths: CodexUnitPaths, expectedUid: number): RecordedDeclaration | null {
|
|
142
162
|
// The directory holding the state carries the state's authority: anyone who can write it
|
|
143
163
|
// can replace the inventory every digest below is compared against.
|
|
144
|
-
if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid)) return
|
|
145
|
-
if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid)) return
|
|
146
|
-
if (!isSafeOwnedFile(paths.stateFile, expectedUid)) return
|
|
164
|
+
if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid)) return null;
|
|
165
|
+
if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid)) return null;
|
|
166
|
+
if (!isSafeOwnedFile(paths.stateFile, expectedUid)) return null;
|
|
147
167
|
let state: Record<string, unknown>;
|
|
148
168
|
try {
|
|
149
169
|
state = JSON.parse(fs.readFileSync(paths.stateFile, "utf8")) as Record<string, unknown>;
|
|
150
170
|
} catch {
|
|
151
|
-
return
|
|
171
|
+
return null;
|
|
152
172
|
}
|
|
153
|
-
|
|
154
|
-
|
|
173
|
+
// v2 ONLY, and a v1 state is refused rather than read leniently: v1 recorded a whole-file
|
|
174
|
+
// digest, so a reader that tolerated it would be certifying an authority nobody holds any
|
|
175
|
+
// more. `entwurf install-codex-birth` supersedes it — that is the one forward path.
|
|
176
|
+
if (state.schema !== "codex-birth-install-state/v2" || state.status !== "installed") return null;
|
|
177
|
+
if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir) return null;
|
|
178
|
+
const declaration = state.declaration;
|
|
179
|
+
if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration)) return null;
|
|
180
|
+
const recorded = declaration as Record<string, unknown>;
|
|
181
|
+
if (recorded.event !== CODEX_BIRTH_EVENT) return null;
|
|
182
|
+
if (typeof recorded.command !== "string" || typeof recorded.sha256 !== "string") return null;
|
|
183
|
+
if (!/^[0-9a-f]{64}$/.test(recorded.sha256)) return null;
|
|
155
184
|
const digest = (file: string): string | null => {
|
|
156
185
|
try {
|
|
157
186
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
@@ -159,9 +188,8 @@ function closureDriftedFromState(paths: CodexUnitPaths, expectedUid: number): bo
|
|
|
159
188
|
return null;
|
|
160
189
|
}
|
|
161
190
|
};
|
|
162
|
-
if (typeof state.hooksSha256 !== "string" || digest(paths.hooksFile) !== state.hooksSha256) return true;
|
|
163
191
|
const members = state.helperFiles;
|
|
164
|
-
if (!Array.isArray(members)) return
|
|
192
|
+
if (!Array.isArray(members)) return null;
|
|
165
193
|
// Exactly the closure the installer publishes: a short inventory would leave a member
|
|
166
194
|
// nothing compares, which is the same hole as not checking digests at all.
|
|
167
195
|
const named = members.map((raw) => (raw as Record<string, unknown>)?.path);
|
|
@@ -173,21 +201,38 @@ function closureDriftedFromState(paths: CodexUnitPaths, expectedUid: number): bo
|
|
|
173
201
|
"lib/session-id.js",
|
|
174
202
|
"entwurf-capabilities.json",
|
|
175
203
|
];
|
|
176
|
-
if (named.length !== expected.length) return
|
|
177
|
-
if (expected.some((name) => !named.includes(name))) return
|
|
178
|
-
if (named.some((name, index) => named.indexOf(name) !== index)) return
|
|
204
|
+
if (named.length !== expected.length) return null;
|
|
205
|
+
if (expected.some((name) => !named.includes(name))) return null;
|
|
206
|
+
if (named.some((name, index) => named.indexOf(name) !== index)) return null;
|
|
179
207
|
for (const raw of members) {
|
|
180
208
|
const member = raw as Record<string, unknown>;
|
|
181
209
|
const rel = member.path;
|
|
182
210
|
if (typeof rel !== "string" || rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
|
|
183
|
-
return
|
|
211
|
+
return null;
|
|
184
212
|
}
|
|
185
|
-
if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256) return
|
|
213
|
+
if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256) return null;
|
|
186
214
|
}
|
|
187
|
-
return
|
|
215
|
+
return { command: recorded.command, sha256: recorded.sha256 };
|
|
188
216
|
}
|
|
189
217
|
|
|
190
|
-
|
|
218
|
+
/**
|
|
219
|
+
* WHERE ENTWURF'S DECLARATION CURRENTLY SITS, or nothing.
|
|
220
|
+
*
|
|
221
|
+
* `null` here means the SAME reject as before — `codex-birth-unit-missing` — but the question
|
|
222
|
+
* it answers is narrower than it used to be (#117). It no longer asks "is hooks.json exactly the
|
|
223
|
+
* file we wrote"; it asks "is entwurf's own `SessionStart` group present exactly once, shaped the
|
|
224
|
+
* way the operator approved, and still the declaration this unit recorded". A Herdr (or any
|
|
225
|
+
* other) integration declaring its own group beside ours changes neither answer.
|
|
226
|
+
*
|
|
227
|
+
* The POSITION is the return value rather than a side note because the vendor keys its trust
|
|
228
|
+
* receipt by index: a declaration that has been renumbered by a neighbour's install needs its
|
|
229
|
+
* receipt read at the new index, and reading the old one would report a NEIGHBOUR's approval as
|
|
230
|
+
* our own.
|
|
231
|
+
*/
|
|
232
|
+
function birthDeclaration(
|
|
233
|
+
paths: CodexUnitPaths,
|
|
234
|
+
expectedUid: number,
|
|
235
|
+
): { groupIndex: number; handlerIndex: number } | null {
|
|
191
236
|
const launcher = path.join(paths.helperDir, "codex-birth-launch.sh");
|
|
192
237
|
const directories = [
|
|
193
238
|
path.dirname(paths.hooksFile),
|
|
@@ -195,8 +240,8 @@ function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
|
|
|
195
240
|
path.join(paths.helperDir, "lib"),
|
|
196
241
|
path.join(paths.helperDir, "lib", "native-push"),
|
|
197
242
|
];
|
|
198
|
-
if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid))) return
|
|
199
|
-
if (!isSafeOwnedFile(paths.hooksFile, expectedUid)) return
|
|
243
|
+
if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid))) return null;
|
|
244
|
+
if (!isSafeOwnedFile(paths.hooksFile, expectedUid)) return null;
|
|
200
245
|
const closure = [
|
|
201
246
|
[launcher, true],
|
|
202
247
|
[path.join(paths.helperDir, "meta-bridge-hook-codex.ts"), false],
|
|
@@ -205,20 +250,23 @@ function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
|
|
|
205
250
|
[path.join(paths.helperDir, "lib", "session-id.js"), false],
|
|
206
251
|
[path.join(paths.helperDir, "entwurf-capabilities.json"), false],
|
|
207
252
|
] as const;
|
|
208
|
-
if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable))) return
|
|
253
|
+
if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable))) return null;
|
|
254
|
+
const recorded = certifiedState(paths, expectedUid);
|
|
255
|
+
if (recorded === null) return null;
|
|
256
|
+
// The state's own account of what it certified must be the declaration these paths produce;
|
|
257
|
+
// a receipt bound to a different launcher is a receipt for a different unit.
|
|
258
|
+
if (recorded.command !== entwurfDeclarationCommand(launcher)) return null;
|
|
259
|
+
let selected: ReturnType<typeof selectEntwurfDeclaration>;
|
|
209
260
|
try {
|
|
210
|
-
|
|
211
|
-
const events = (parsed.hooks as Record<string, unknown> | undefined)?.SessionStart;
|
|
212
|
-
if (!Array.isArray(events) || events.length !== 1) return true;
|
|
213
|
-
const group = events[0] as Record<string, unknown>;
|
|
214
|
-
if ("matcher" in group || !Array.isArray(group.hooks) || group.hooks.length !== 1) return true;
|
|
215
|
-
const hook = group.hooks[0] as Record<string, unknown>;
|
|
216
|
-
if (Object.keys(hook).sort().join(",") !== "command,timeout,type") return true;
|
|
217
|
-
if (hook.type !== "command" || hook.command !== `'${launcher}'` || hook.timeout !== 30) return true;
|
|
261
|
+
selected = selectEntwurfDeclaration(JSON.parse(fs.readFileSync(paths.hooksFile, "utf8")), launcher);
|
|
218
262
|
} catch {
|
|
219
|
-
return
|
|
263
|
+
return null;
|
|
220
264
|
}
|
|
221
|
-
|
|
265
|
+
if (!selected.ok) return null;
|
|
266
|
+
// The NORMALIZED digest, so a neighbour re-serializing the document (measured: Herdr does)
|
|
267
|
+
// cannot make our intact declaration look edited — and an edit to our own handler still can.
|
|
268
|
+
if (selected.digest !== recorded.sha256) return null;
|
|
269
|
+
return { groupIndex: selected.groupIndex, handlerIndex: selected.handlerIndex };
|
|
222
270
|
}
|
|
223
271
|
|
|
224
272
|
/**
|
|
@@ -227,13 +275,19 @@ function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
|
|
|
227
275
|
* `trusted_hash` of the shape `sha256:<64 hex>`. What this asserts is that a receipt EXISTS
|
|
228
276
|
* for our declaration identity — never that the hash is correct, which only the vendor can
|
|
229
277
|
* say, and never by launching Codex to find out.
|
|
278
|
+
*
|
|
279
|
+
* THE KEY IS MEASURED, NOT ASSUMED (#117). It used to be spelled `…:session_start:0:0`, which
|
|
280
|
+
* was true only while entwurf was the sole declaration in the file. With a neighbour at index 0
|
|
281
|
+
* that constant reads THEIR receipt and calls it ours — a false green for a hook the vendor has
|
|
282
|
+
* never been asked to run. The caller passes the key built from the position our declaration was
|
|
283
|
+
* just found at.
|
|
230
284
|
*/
|
|
231
|
-
function trustReceiptMissing(config: Record<string, unknown>,
|
|
285
|
+
function trustReceiptMissing(config: Record<string, unknown>, key: string): boolean {
|
|
232
286
|
const hooks = config.hooks;
|
|
233
287
|
if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks)) return true;
|
|
234
288
|
const state = (hooks as Record<string, unknown>).state;
|
|
235
289
|
if (state == null || typeof state !== "object" || Array.isArray(state)) return true;
|
|
236
|
-
const entry = (state as Record<string, unknown>)[
|
|
290
|
+
const entry = (state as Record<string, unknown>)[key];
|
|
237
291
|
if (entry == null || typeof entry !== "object" || Array.isArray(entry)) return true;
|
|
238
292
|
const digest = (entry as Record<string, unknown>).trusted_hash;
|
|
239
293
|
return typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest);
|
|
@@ -558,14 +612,16 @@ export async function codexFreshPreflight(
|
|
|
558
612
|
if (!path.isAbsolute(paths.hooksFile) || !path.isAbsolute(paths.helperDir) || !path.isAbsolute(paths.stateFile)) {
|
|
559
613
|
return "codex-birth-unit-missing";
|
|
560
614
|
}
|
|
561
|
-
|
|
615
|
+
const declaration = birthDeclaration(paths, deps.operatorUid ?? process.getuid?.() ?? -1);
|
|
616
|
+
if (declaration === null) {
|
|
562
617
|
return "codex-birth-unit-missing";
|
|
563
618
|
}
|
|
564
619
|
const config = readConfig(env);
|
|
565
620
|
// The vendor receipt is a SEPARATE reject from our bytes: perfect bytes the vendor will
|
|
566
621
|
// not run and absent bytes are two different repairs, and folding them would send the
|
|
567
622
|
// operator to the installer for something only they can answer in their own Codex.
|
|
568
|
-
|
|
623
|
+
const key = trustReceiptKey(paths.hooksFile, declaration.groupIndex, declaration.handlerIndex);
|
|
624
|
+
if (config === null || trustReceiptMissing(config, key)) return "codex-birth-trust-missing";
|
|
569
625
|
if (mcpMissing(config)) return "codex-mcp-hand-missing";
|
|
570
626
|
if (visibleIdentityMissing(config)) return "codex-visible-identity-missing";
|
|
571
627
|
const socketPath = resolveCodexDefaultSocketPath(env);
|
|
@@ -102,6 +102,52 @@ export interface RpcGetInfoCommand {
|
|
|
102
102
|
|
|
103
103
|
export type RpcCommand = RpcSendCommand | RpcGetMessageCommand | RpcClearCommand | RpcAbortCommand | RpcGetInfoCommand;
|
|
104
104
|
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Accepted-connection disconnect policy (server half of the same wire)
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
/** The two codes that mean THE PEER WENT AWAY, and nothing else. `EPIPE` is a write to a pipe the
|
|
110
|
+
* far side already closed; `ECONNRESET` is the far side resetting it. Both describe the client, not
|
|
111
|
+
* this process's state, which is why they are absorbed rather than diagnosed. */
|
|
112
|
+
const PEER_DISCONNECT_CODES = new Set(["EPIPE", "ECONNRESET"]);
|
|
113
|
+
|
|
114
|
+
export function isPeerDisconnect(error: NodeJS.ErrnoException): boolean {
|
|
115
|
+
return typeof error.code === "string" && PEER_DISCONNECT_CODES.has(error.code);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Install the disconnect policy on a connection this process ACCEPTED. Must run before any data
|
|
120
|
+
* handler and before any response can be written back.
|
|
121
|
+
*
|
|
122
|
+
* `[측정 2026-09-14, .agent-reports/116-c4-live-blocker-20260914.md]` a resident pi DIED without
|
|
123
|
+
* this. A sibling's control-socket send timed out while the receiving session was mid-turn, the
|
|
124
|
+
* sender closed its end, and the server then wrote its late response to a socket whose peer was
|
|
125
|
+
* gone. That EPIPE does NOT arrive as a throw — `writeResponse`'s synchronous try/catch cannot see
|
|
126
|
+
* it — it arrives asynchronously as an `error` event, and an `error` event with no listener is an
|
|
127
|
+
* uncaught exception, so Node terminated the whole session. The citizen then simply read as `dead`
|
|
128
|
+
* and the stale socket it left behind was the only trace.
|
|
129
|
+
*
|
|
130
|
+
* A client that went away is a BOUNDED environment condition, not invalid state: Rule 15's "crash,
|
|
131
|
+
* don't warn" governs states we cannot reason about, and losing an entire resident session because
|
|
132
|
+
* someone hung up is itself the silent failure. Same policy the MCP probe already carries for an
|
|
133
|
+
* async EPIPE on a child's stdin (`scripts/probe-bridge-command.ts:88-97`).
|
|
134
|
+
*
|
|
135
|
+
* Anything that is NOT a peer hanging up is diagnosed exactly once with its code and message and
|
|
136
|
+
* then served on. Swallowing every error would hide the class this listener is not here to absorb;
|
|
137
|
+
* rethrowing from an event callback would be the crash this whole function exists to prevent.
|
|
138
|
+
*
|
|
139
|
+
* `diagnose` is injected so a gate can count diagnostics without capturing global stderr.
|
|
140
|
+
*/
|
|
141
|
+
export function attachAcceptedSocketDisconnectPolicy(
|
|
142
|
+
socket: net.Socket,
|
|
143
|
+
diagnose: (line: string) => void = (line) => console.error(line),
|
|
144
|
+
): void {
|
|
145
|
+
socket.on("error", (error: NodeJS.ErrnoException) => {
|
|
146
|
+
if (isPeerDisconnect(error)) return;
|
|
147
|
+
diagnose(`[entwurf-control] control socket error (${error.code ?? "no code"}): ${error.message}`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
105
151
|
export interface RpcClientOptions {
|
|
106
152
|
timeout?: number;
|
|
107
153
|
}
|
|
@@ -34,8 +34,9 @@ import {
|
|
|
34
34
|
resolveFactList,
|
|
35
35
|
UNOBSERVED_PEER,
|
|
36
36
|
} from "./entwurf-facts.ts";
|
|
37
|
-
import {
|
|
37
|
+
import { makeObservePeerFacts, readHerdrPlacementIndex } from "./entwurf-peer-observe.ts";
|
|
38
38
|
import { isLivenessSupported } from "./entwurf-v2-contract.ts";
|
|
39
|
+
import type { HerdrPlacementIndex } from "./herdr-placement.ts";
|
|
39
40
|
import {
|
|
40
41
|
type ActiveStoreEntry,
|
|
41
42
|
FRESH_CUT_PRESCRIPTION,
|
|
@@ -105,6 +106,12 @@ export interface EntwurfFactsDeps {
|
|
|
105
106
|
* wiring sites (MCP + pi-native) cannot drift by each passing their own observer, and a
|
|
106
107
|
* gate still drives the assembly with a fake and no filesystem. */
|
|
107
108
|
observe?: PeerObserver;
|
|
109
|
+
/** Placement axis (#116 S1): the ONE placement-owner read this listing is allowed.
|
|
110
|
+
* Injected so a gate drives the whole assembly with no herdr binary and no child
|
|
111
|
+
* process. `undefined` uses the real read; a reader that returns `null` is the
|
|
112
|
+
* no-herdr host and every row then says `unobserved`. Deliberately a READ, not a
|
|
113
|
+
* per-citizen lookup: a per-citizen hook would invite one child process per record. */
|
|
114
|
+
readPlacementIndex?: () => HerdrPlacementIndex | null;
|
|
108
115
|
/** Optional presentation budget. Undefined preserves the generic fact provider's full
|
|
109
116
|
* observation contract; peers surfaces pass their shared render limit. Older machine
|
|
110
117
|
* payload rows then say `unobserved` rather than fabricating `active` / `exists`. */
|
|
@@ -209,7 +216,11 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
|
|
|
209
216
|
// not performed becomes `unobserved`.
|
|
210
217
|
const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
|
|
211
218
|
const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
|
|
212
|
-
|
|
219
|
+
// The placement owner is read EXACTLY ONCE per listing, before any row is shaped, and
|
|
220
|
+
// never again for this call. That single read is the anti-watcher rule in code: herdr
|
|
221
|
+
// publishes no "the session reference landed" event, and a retry loop around that gap
|
|
222
|
+
// is the discovery watcher `docs/mux-launch-rail.md` §7 refuses by name.
|
|
223
|
+
const observe = deps.observe ?? makeObservePeerFacts((deps.readPlacementIndex ?? readHerdrPlacementIndex)());
|
|
213
224
|
let selectedObserve = observe;
|
|
214
225
|
if (deps.observationLimit !== undefined) {
|
|
215
226
|
if (!Number.isSafeInteger(deps.observationLimit) || deps.observationLimit < 0) {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { type FactLiveness, factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.ts";
|
|
34
|
+
import { type PlacementObservation, UNOBSERVED_PLACEMENT } from "./herdr-placement.ts";
|
|
34
35
|
import type { MetaCitizenBackend, MetaIdentity } from "./meta-session.ts";
|
|
35
36
|
import type { SocketLiveness } from "./socket-probe.ts";
|
|
36
37
|
|
|
@@ -57,6 +58,12 @@ export interface PeerFact {
|
|
|
57
58
|
// "the newest record in this cwd" picked the phantom as often as the real one.
|
|
58
59
|
receiver: ReceiverObservation;
|
|
59
60
|
transcript: TranscriptObservation;
|
|
61
|
+
// — the third observed axis (#116 S1): WHERE a citizen is visible, when a
|
|
62
|
+
// placement owner reported it on a key we independently own. A pane is an
|
|
63
|
+
// ephemeral view (Hard Rule 16), so this sits beside `receiver`/`transcript`
|
|
64
|
+
// as evidence and never beside `liveness` as a claim about being alive: a
|
|
65
|
+
// pane can outlive its process and a process can outlive its pane.
|
|
66
|
+
placement: PlacementObservation;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
/**
|
|
@@ -79,11 +86,16 @@ export type TranscriptObservation = "exists" | "absent" | "unobserved";
|
|
|
79
86
|
export interface PeerObservations {
|
|
80
87
|
receiver: ReceiverObservation;
|
|
81
88
|
transcript: TranscriptObservation;
|
|
89
|
+
placement: PlacementObservation;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
/** What a caller that measured nothing must say. Explicit and greppable — a row that
|
|
85
93
|
* silently defaulted to `none`/`absent` would be a fabricated fact. */
|
|
86
|
-
export const UNOBSERVED_PEER: PeerObservations = {
|
|
94
|
+
export const UNOBSERVED_PEER: PeerObservations = {
|
|
95
|
+
receiver: "unobserved",
|
|
96
|
+
transcript: "unobserved",
|
|
97
|
+
placement: UNOBSERVED_PLACEMENT,
|
|
98
|
+
};
|
|
87
99
|
|
|
88
100
|
/** Measure the two observed axes for one citizen. Injected, so this module stays pure. */
|
|
89
101
|
export type PeerObserver = (identity: MetaIdentity) => PeerObservations;
|
|
@@ -116,6 +128,7 @@ export function resolvePeerFact(
|
|
|
116
128
|
liveness: factLivenessOf(identity.backend, socket),
|
|
117
129
|
receiver: observations.receiver,
|
|
118
130
|
transcript: observations.transcript,
|
|
131
|
+
placement: observations.placement,
|
|
119
132
|
};
|
|
120
133
|
}
|
|
121
134
|
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* about it: no `sendable`, no `deliverable`, no transport. The listing stays a listing —
|
|
14
14
|
* dispatch still computes routing at call time from the same underlying markers.
|
|
15
15
|
*
|
|
16
|
+
* THE PLACEMENT AXIS IS MEASURED ONCE, NOT PER CITIZEN (#116 S1). `receiver` and
|
|
17
|
+
* `transcript` are per-citizen filesystem questions; placement is one question asked of
|
|
18
|
+
* one placement owner about every pane it has. So the herdr read happens ABOVE this
|
|
19
|
+
* module (the provider does it once) and arrives here as an already-built index. That
|
|
20
|
+
* is also the anti-watcher shape: one read per listing, no retry, no wait for a pane
|
|
21
|
+
* whose session reference has not landed yet.
|
|
22
|
+
*
|
|
16
23
|
* ONE MEASUREMENT, TWO WORDS. `receiver` is derived from the SAME
|
|
17
24
|
* `resolveMailboxReceiverFacts` composition the v2 dispatch seam and `entwurf_self` use.
|
|
18
25
|
* The surfaces are allowed to phrase it differently — a listing wants an enum, a reject
|
|
@@ -20,9 +27,16 @@
|
|
|
20
27
|
* rather than a second opinion about the same markers.
|
|
21
28
|
*/
|
|
22
29
|
|
|
30
|
+
import { execFileSync } from "node:child_process";
|
|
23
31
|
import * as fs from "node:fs";
|
|
24
32
|
import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.ts";
|
|
25
33
|
import type { PeerObservations, ReceiverObservation, TranscriptObservation } from "./entwurf-facts.ts";
|
|
34
|
+
import {
|
|
35
|
+
buildPlacementIndex,
|
|
36
|
+
type HerdrPlacementIndex,
|
|
37
|
+
parseHerdrPaneList,
|
|
38
|
+
resolvePlacement,
|
|
39
|
+
} from "./herdr-placement.ts";
|
|
26
40
|
import {
|
|
27
41
|
type MetaBackend,
|
|
28
42
|
type MetaIdentity,
|
|
@@ -71,7 +85,63 @@ function observeTranscript(identity: MetaIdentity): TranscriptObservation {
|
|
|
71
85
|
}
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
/**
|
|
88
|
+
/** How long the one placement read may take before we stop waiting for it. A listing
|
|
89
|
+
* must not hang on a placement owner: the column is a convenience and `unobserved` is
|
|
90
|
+
* a complete answer. */
|
|
91
|
+
const HERDR_READ_TIMEOUT_MS = 2000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read the placement owner ONCE, or decline to.
|
|
95
|
+
*
|
|
96
|
+
* TWO ENV FACTS DECIDE, AND BOTH ARE HERDR'S OWN (measured, herdr 0.9.0). `HERDR_ENV=1`
|
|
97
|
+
* is how herdr tells a process it is running inside herdr, and `HERDR_BIN_PATH` is the
|
|
98
|
+
* invocation path herdr's own plugin contract tells callers to use. Neither is
|
|
99
|
+
* discovered: if herdr did not put them in this process's environment we are not inside
|
|
100
|
+
* herdr and we make no claim. There is no path guess, no socket scan, and no PATH
|
|
101
|
+
* lookup — this must stay as explicit as Hard Rule 6 wants configuration to be.
|
|
102
|
+
*
|
|
103
|
+
* EVERY FAILURE IS `null`, NEVER AN EMPTY INDEX. A missing binary, a timeout, a nonzero
|
|
104
|
+
* exit or a payload we cannot parse all mean nobody measured. An empty index would say
|
|
105
|
+
* something much stronger — "herdr was read and has none of your citizens" — about a
|
|
106
|
+
* read that did not happen.
|
|
107
|
+
*/
|
|
108
|
+
export function readHerdrPlacementIndex(env: NodeJS.ProcessEnv = process.env): HerdrPlacementIndex | null {
|
|
109
|
+
if (env.HERDR_ENV !== "1") return null;
|
|
110
|
+
const bin = env.HERDR_BIN_PATH;
|
|
111
|
+
if (typeof bin !== "string" || bin.length === 0) return null;
|
|
112
|
+
let stdout: string;
|
|
113
|
+
try {
|
|
114
|
+
stdout = execFileSync(bin, ["pane", "list"], {
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
timeout: HERDR_READ_TIMEOUT_MS,
|
|
117
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
// Bounded environment probe: a placement owner that did not answer is a citizen
|
|
121
|
+
// column that reads `unobserved`, not a listing that fails.
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const rows = parseHerdrPaneList(stdout);
|
|
125
|
+
return rows === null ? null : buildPlacementIndex(rows);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build the production observer over an already-resolved placement index.
|
|
130
|
+
*
|
|
131
|
+
* `null` means no placement owner was read on this host — every citizen then reads
|
|
132
|
+
* `unobserved`, which is the only honest answer when nobody looked. It is NOT `none`:
|
|
133
|
+
* that word is reserved for a herdr that WAS read and does not have this citizen.
|
|
134
|
+
*/
|
|
135
|
+
export function makeObservePeerFacts(placementIndex: HerdrPlacementIndex | null) {
|
|
136
|
+
return (identity: MetaIdentity): PeerObservations => ({
|
|
137
|
+
receiver: observeReceiver(identity),
|
|
138
|
+
transcript: observeTranscript(identity),
|
|
139
|
+
placement: resolvePlacement(placementIndex, identity),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The production observer with no placement owner read — the shape every caller that
|
|
144
|
+
* has not resolved an index gets, and the default on a host with no herdr. */
|
|
75
145
|
export function observePeerFacts(identity: MetaIdentity): PeerObservations {
|
|
76
|
-
return
|
|
146
|
+
return makeObservePeerFacts(null)(identity);
|
|
77
147
|
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import type { EntwurfDiagnostic, EntwurfFactsResult } from "./entwurf-fact-provider.ts";
|
|
27
27
|
import type { PeerFact } from "./entwurf-facts.ts";
|
|
28
|
+
import { renderPlacement } from "./herdr-placement.ts";
|
|
28
29
|
|
|
29
30
|
/** The full `entwurf_peers` JSON payload: citizens + diagnostics, nothing else.
|
|
30
31
|
* NO verb-routing field anywhere (the gate enforces this by deep key scan) and
|
|
@@ -50,11 +51,18 @@ export const ENTWURF_PEERS_RENDER_LIMIT = 32;
|
|
|
50
51
|
// be indistinguishable here. `receiver=active|inactive|none|n/a` is a projection of the
|
|
51
52
|
// same measurement dispatch uses; `transcript=exists|absent` is the record's transcript,
|
|
52
53
|
// existence only, never the path.
|
|
54
|
+
//
|
|
55
|
+
// `placement` (#116 S1) is the third observed fact and the one most likely to be
|
|
56
|
+
// MISREAD, so it is placed at the END of the row — away from `liveness` — and printed
|
|
57
|
+
// as `herdr <pane>` rather than a bare pane id. A pane says where a citizen was last
|
|
58
|
+
// SEEN by a placement owner; it says nothing about whether that citizen is running, and
|
|
59
|
+
// nothing about how to reach it. `unobserved` = nobody looked (no herdr here), `none` =
|
|
60
|
+
// herdr was read and does not have this one, `ambiguous` = more than one pane claimed it.
|
|
53
61
|
function renderPeerLine(p: PeerFact): string {
|
|
54
62
|
const model = p.model ?? "(unknown)";
|
|
55
63
|
return (
|
|
56
64
|
`- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} receiver=${p.receiver} ` +
|
|
57
|
-
`transcript=${p.transcript} cwd=${p.cwd} model=${model}`
|
|
65
|
+
`transcript=${p.transcript} cwd=${p.cwd} model=${model} placement=${renderPlacement(p.placement)}`
|
|
58
66
|
);
|
|
59
67
|
}
|
|
60
68
|
|
|
@@ -168,3 +168,36 @@ export function computeSelfAddressability(facts: SelfAddressabilityFacts): SelfA
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
|
+
|
|
172
|
+
/** Facts the uncitizened-notice decision is made from. Nothing is read here — all three are given. */
|
|
173
|
+
export interface UncitizenedNoticeFacts {
|
|
174
|
+
/** Did this session get `--entwurf-control`? A citizen is never told it is not one. */
|
|
175
|
+
controlEnabled: boolean;
|
|
176
|
+
/** Is there a human surface to say it on? `pi -p …` has none, and neither does any pipeline. */
|
|
177
|
+
hasUI: boolean;
|
|
178
|
+
/** Has this process already said it? `refreshServer` runs again on session switches. */
|
|
179
|
+
alreadyShown: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The mirror of `computeSelfAddressability`, as a decision rather than a claim.
|
|
184
|
+
*
|
|
185
|
+
* That function exists so a surface never CLAIMS an addressability it lacks. This one exists so a
|
|
186
|
+
* surface does not stay SILENT about lacking one: `[관측: GLG, 날것 PC, 2026-09-17]` a herdr plugin
|
|
187
|
+
* install wires this extension at user scope, so it loads in every pi on the host while citizenship
|
|
188
|
+
* stays argv-gated on purpose — and a plain `pi` after a green install is therefore silent,
|
|
189
|
+
* tool-less and indistinguishable from an install that did nothing.
|
|
190
|
+
*
|
|
191
|
+
* WHY THIS IS A FUNCTION AND NOT THREE `if`s AT THE CALL SITE. It was three `if`s, and its gate was
|
|
192
|
+
* a source regex asserting the call existed. `[측정 2026-09-17, 독립 검수 claude-opus-5 + 재현]` two
|
|
193
|
+
* mutants walked straight through that green: moving the call into the CITIZEN branch (so citizens
|
|
194
|
+
* are told they are not citizens and plain pi stays silent), and deleting the once-latch (so the
|
|
195
|
+
* line repeats on every session switch). A regex can see that a call exists; it cannot see which
|
|
196
|
+
* branch it is in or how often it fires. Those are properties of FACTS, so the facts are now
|
|
197
|
+
* arguments and the answer is a value a truth table can exhaust.
|
|
198
|
+
*/
|
|
199
|
+
export function decideUncitizenedNotice(facts: UncitizenedNoticeFacts): boolean {
|
|
200
|
+
if (facts.controlEnabled) return false;
|
|
201
|
+
if (!facts.hasUI) return false;
|
|
202
|
+
return !facts.alreadyShown;
|
|
203
|
+
}
|
|
@@ -111,7 +111,7 @@ export type DispatchLiveness = (typeof DISPATCH_LIVENESSES)[number];
|
|
|
111
111
|
// unreachable (the native-push rail measures it — entwurf-v2-decider.ts). Widening
|
|
112
112
|
// THIS set is a deliberate future decision, gated by a real compatible
|
|
113
113
|
// control-socket predicate — never by silently mapping sessions to dead/indeterminate
|
|
114
|
-
// (R1 핵심).
|
|
114
|
+
// (R1 핵심). pi-extensions/lib/entwurf-facts.test.ts pins this == ["pi"] and asserts the native-push
|
|
115
115
|
// domain is disjoint from it.
|
|
116
116
|
export const LIVENESS_DOMAIN_BACKENDS = ["pi"] as const;
|
|
117
117
|
export type LivenessDomainBackend = (typeof LIVENESS_DOMAIN_BACKENDS)[number];
|
|
@@ -125,7 +125,7 @@ export function isLivenessSupported(backend: string): boolean {
|
|
|
125
125
|
// live vendor app-server target probe), NOT a control socket. This domain is DISJOINT
|
|
126
126
|
// from LIVENESS_DOMAIN_BACKENDS: an Antigravity or Codex session is `unsupported` on
|
|
127
127
|
// the socket FACT axis (entwurf_peers) yet independently measured on the native-push
|
|
128
|
-
// axis. The two are separate rails on purpose —
|
|
128
|
+
// axis. The two are separate rails on purpose — pi-extensions/lib/entwurf-facts.test.ts pins both sets
|
|
129
129
|
// and asserts their intersection is ∅ (a backend can never be in both domains).
|
|
130
130
|
export const NATIVE_PUSH_BACKENDS = ["antigravity", "codex"] as const;
|
|
131
131
|
export type NativePushBackend = (typeof NATIVE_PUSH_BACKENDS)[number];
|
|
@@ -82,7 +82,7 @@ export const LOCK_SUFFIX = ".lock";
|
|
|
82
82
|
export const LOCK_OWNER = "entwurf_v2" as const;
|
|
83
83
|
|
|
84
84
|
/** The reject reason a lock conflict maps to. Kept as a literal here (the lock
|
|
85
|
-
* primitive stays decoupled from the full contract); `
|
|
85
|
+
* primitive stays decoupled from the full contract); `pi-extensions/lib/entwurf-v2-lock.test.ts`
|
|
86
86
|
* cross-checks it against the contract's ENTWURF_V2_REJECT_REASONS so the two
|
|
87
87
|
* cannot drift. */
|
|
88
88
|
export const LOCK_CONFLICT_REASON = "target-locked" as const;
|
|
@@ -201,7 +201,7 @@ export async function executeDispatch(
|
|
|
201
201
|
/**
|
|
202
202
|
* 5d-2a: the deps `runEntwurfV2` joins. `decide` is the WHOLE decider as ONE injected
|
|
203
203
|
* function — NOT `DispatchDeciderDeps`. The runner does not re-validate the 5b decider
|
|
204
|
-
* logic (that is `
|
|
204
|
+
* logic (that is `pi-extensions/lib/entwurf-v2-decider.test.ts`'s job); it proves only the decide→execute
|
|
205
205
|
* COMPOSITION contract over a fake `decide`. Production wraps the real decider as
|
|
206
206
|
* `decide: (input) => decideDispatch(input, productionDeciderDeps)` (assembled in 5d-2b),
|
|
207
207
|
* so the runner stays gate-provable without any decider IO seam leaking in.
|