@junghanacs/entwurf 0.12.0
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 +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-entwurf-session-identity — deterministic gate for the locked garden
|
|
3
|
+
* session identity & name grammar (NEXT.md "Locked — session identity & name
|
|
4
|
+
* grammar"). No backend, no API, no spawn. Pure-string + synthetic-fs.
|
|
5
|
+
*
|
|
6
|
+
* Locks the convention HARD (per GLG: "우리가 잡은 규약은 싹 잡혀야돼"):
|
|
7
|
+
* - T-grammar sessionId validator + build/parse round-trip (incl. `.`-bearing
|
|
8
|
+
* registry models gpt-5.5 / gemini-3.1-pro-preview).
|
|
9
|
+
* - T-titleSlug raw title → canonical slug (sanitize, not reject); `_`/`__`
|
|
10
|
+
* destroyed; unicode dropped; empty → untitled.
|
|
11
|
+
* - T-registry provider/model is an EXACT registry tuple; invented or dotted
|
|
12
|
+
* non-tuples (claude-opus-4.8) are refused.
|
|
13
|
+
* - T-no-logic title/tags vary, sessionId stays stable; name is info only.
|
|
14
|
+
* - T-model-immut name model mirrors recorded model; drift is detectable for
|
|
15
|
+
* corrupt-metadata fail-fast.
|
|
16
|
+
* - T-collision header-scan lookup is the authority (filename glob is only an
|
|
17
|
+
* index); spawn pre-check throws on a duplicate header id in ANY
|
|
18
|
+
* cwd dir (the wrong-cwd footgun).
|
|
19
|
+
*
|
|
20
|
+
* Registry + sessions base are isolated to a temp dir BEFORE the module computes
|
|
21
|
+
* its load-time paths, so the real ~/.pi/agent is never touched.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
31
|
+
|
|
32
|
+
// Isolate env BEFORE importing the module (entwurf-core computes AGENT_DIR /
|
|
33
|
+
// ENTWURF_TARGETS_PATH at module load). Dynamic import after the env is set.
|
|
34
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "psa-session-identity-"));
|
|
35
|
+
process.env.PI_CODING_AGENT_DIR = tmp;
|
|
36
|
+
process.env.ENTWURF_TARGETS_PATH = path.join(REPO_ROOT, "pi", "entwurf-targets.json");
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
isValidSessionId,
|
|
40
|
+
generateSessionId,
|
|
41
|
+
formatSessionTimestamp,
|
|
42
|
+
slugifyTitle,
|
|
43
|
+
isKnownProviderModel,
|
|
44
|
+
buildSessionName,
|
|
45
|
+
buildGardenSessionName,
|
|
46
|
+
assertGardenNativeSessionId,
|
|
47
|
+
computeResidentStatusLabel,
|
|
48
|
+
RESIDENT_SESSION_TAG,
|
|
49
|
+
parseSessionName,
|
|
50
|
+
isEntwurfSessionName,
|
|
51
|
+
findSessionFileById,
|
|
52
|
+
findSessionFilesById,
|
|
53
|
+
assertSessionIdAvailableForSpawn,
|
|
54
|
+
createGardenSessionFile,
|
|
55
|
+
removeUnadoptedGardenSessionFile,
|
|
56
|
+
GARDEN_SESSION_FILE_VERSION,
|
|
57
|
+
readSessionHeader,
|
|
58
|
+
readSessionIdentity,
|
|
59
|
+
analyzeSessionFileLike,
|
|
60
|
+
assertLocalOnlyEntwurf,
|
|
61
|
+
} = await import("../pi-extensions/lib/entwurf-core.ts");
|
|
62
|
+
|
|
63
|
+
let n = 0;
|
|
64
|
+
const ok = (cond: unknown, msg: string) => {
|
|
65
|
+
assert.ok(cond, msg);
|
|
66
|
+
n++;
|
|
67
|
+
};
|
|
68
|
+
const eq = (a: unknown, b: unknown, msg: string) => {
|
|
69
|
+
assert.equal(a, b, msg);
|
|
70
|
+
n++;
|
|
71
|
+
};
|
|
72
|
+
const throws = (fn: () => void, label: string) => {
|
|
73
|
+
let threw = false;
|
|
74
|
+
try {
|
|
75
|
+
fn();
|
|
76
|
+
} catch {
|
|
77
|
+
threw = true;
|
|
78
|
+
}
|
|
79
|
+
assert.ok(threw, `expected throw: ${label}`);
|
|
80
|
+
n++;
|
|
81
|
+
};
|
|
82
|
+
const noThrow = (fn: () => void, label: string) => {
|
|
83
|
+
try {
|
|
84
|
+
fn();
|
|
85
|
+
} catch (e) {
|
|
86
|
+
assert.fail(`unexpected throw (${label}): ${e instanceof Error ? e.message : String(e)}`);
|
|
87
|
+
}
|
|
88
|
+
n++;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// ---- T-grammar: sessionId validator ----
|
|
93
|
+
ok(isValidSessionId("20260603T191245-a3f09c"), "valid sessionId");
|
|
94
|
+
ok(!isValidSessionId("20260603T191245-a3f8"), "4-hex suffix rejected");
|
|
95
|
+
ok(!isValidSessionId("20260603T191245-A3F09C"), "uppercase hex rejected");
|
|
96
|
+
ok(!isValidSessionId("20260603191245-a3f09c"), "missing T rejected");
|
|
97
|
+
ok(!isValidSessionId("20260603T191245_a3f09c"), "underscore separator rejected");
|
|
98
|
+
ok(!isValidSessionId("x20260603T191245-a3f09c"), "leading slop rejected");
|
|
99
|
+
ok(!isValidSessionId("20260603T191245-a3f09c "), "trailing space rejected");
|
|
100
|
+
ok(!isValidSessionId(123 as unknown), "non-string rejected");
|
|
101
|
+
|
|
102
|
+
// generateSessionId: format + uniqueness (same-second parallel spawn safety)
|
|
103
|
+
const ids = Array.from({ length: 256 }, () => generateSessionId());
|
|
104
|
+
ok(
|
|
105
|
+
ids.every((id) => isValidSessionId(id)),
|
|
106
|
+
"all generated ids valid",
|
|
107
|
+
);
|
|
108
|
+
eq(new Set(ids).size, 256, "256 generated ids unique (6-hex suffix)");
|
|
109
|
+
eq(formatSessionTimestamp(new Date(2026, 5, 3, 19, 12, 45)), "20260603T191245", "timestamp value (local)");
|
|
110
|
+
|
|
111
|
+
// ---- T-titleSlug ----
|
|
112
|
+
eq(slugifyTitle("Review substrate smoke / --name 검증"), "review-substrate-smoke-name", "GPT slug example");
|
|
113
|
+
eq(slugifyTitle("a__b"), "a-b", "double underscore → single hyphen");
|
|
114
|
+
eq(slugifyTitle("Hello_World"), "hello-world", "underscore → hyphen, lowercased");
|
|
115
|
+
eq(slugifyTitle(" ...!!! "), "untitled", "punctuation-only → untitled");
|
|
116
|
+
eq(slugifyTitle(""), "untitled", "empty → untitled");
|
|
117
|
+
eq(slugifyTitle(undefined), "untitled", "undefined → untitled");
|
|
118
|
+
eq(slugifyTitle("검증만"), "untitled", "all-unicode → untitled");
|
|
119
|
+
eq(slugifyTitle("-leading-and-trailing-"), "leading-and-trailing", "trim hyphens");
|
|
120
|
+
ok(!slugifyTitle("any__thing_here").includes("_"), "slug never contains underscore");
|
|
121
|
+
|
|
122
|
+
// ---- T-registry: exact tuple, no regex invention ----
|
|
123
|
+
ok(isKnownProviderModel("entwurf", "claude-opus-4-8"), "known opus tuple");
|
|
124
|
+
ok(isKnownProviderModel("openai-codex", "gpt-5.5"), "known dotted tuple (gpt-5.5)");
|
|
125
|
+
ok(isKnownProviderModel("entwurf", "gemini-3.1-pro-preview"), "known dotted+hyphen tuple");
|
|
126
|
+
ok(!isKnownProviderModel("entwurf", "claude-opus-4.8"), "dotted opus is NOT a tuple (hyphen canonical)");
|
|
127
|
+
ok(!isKnownProviderModel("entwurf", "claude-opus-9-9"), "invented model rejected");
|
|
128
|
+
ok(!isKnownProviderModel("totally", "made-up"), "invented provider rejected");
|
|
129
|
+
|
|
130
|
+
// ---- T-grammar: build/parse round-trip ----
|
|
131
|
+
const cases = [
|
|
132
|
+
{ p: "entwurf", m: "claude-opus-4-8", title: "Review substrate smoke", tags: ["entwurf", "review"] },
|
|
133
|
+
{ p: "openai-codex", m: "gpt-5.5", title: "async resume check", tags: ["entwurf", "smoke"] },
|
|
134
|
+
{ p: "entwurf", m: "gemini-3.1-pro-preview", title: "vision team", tags: ["entwurf"] },
|
|
135
|
+
{ p: "entwurf", m: "claude-sonnet-4-6", title: "manual session", tags: [] as string[] },
|
|
136
|
+
];
|
|
137
|
+
for (const c of cases) {
|
|
138
|
+
const sid = generateSessionId(new Date(2026, 5, 3, 19, 12, 45));
|
|
139
|
+
const name = buildSessionName({ sessionId: sid, provider: c.p, model: c.m, rawTitle: c.title, tags: c.tags });
|
|
140
|
+
const parsed = parseSessionName(name);
|
|
141
|
+
if (!parsed) assert.fail(`parse failed: ${name}`);
|
|
142
|
+
eq(parsed.sessionId, sid, `round-trip sessionId (${c.m})`);
|
|
143
|
+
eq(parsed.provider, c.p, `round-trip provider (${c.m})`);
|
|
144
|
+
eq(parsed.model, c.m, `round-trip model survives (${c.m})`);
|
|
145
|
+
eq(parsed.titleSlug, slugifyTitle(c.title), `round-trip titleSlug (${c.m})`);
|
|
146
|
+
assert.deepEqual(parsed.tags, c.tags, `round-trip tags (${c.m})`);
|
|
147
|
+
n++;
|
|
148
|
+
eq(isEntwurfSessionName(name), c.tags.includes("entwurf"), `entwurf-tag detection (${c.m})`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// concrete canonical example pinned to NEXT.md
|
|
152
|
+
eq(
|
|
153
|
+
buildSessionName({
|
|
154
|
+
sessionId: "20260603T191245-a3f09c",
|
|
155
|
+
provider: "entwurf",
|
|
156
|
+
model: "claude-opus-4-8",
|
|
157
|
+
rawTitle: "Review substrate smoke",
|
|
158
|
+
tags: ["entwurf", "review"],
|
|
159
|
+
}),
|
|
160
|
+
"20260603T191245-a3f09c==entwurf/claude-opus-4-8--review-substrate-smoke__entwurf_review",
|
|
161
|
+
"canonical NEXT.md example",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ---- builder fail-fast (corrupt metadata never reaches --name) ----
|
|
165
|
+
throws(
|
|
166
|
+
() => buildSessionName({ sessionId: "bad", provider: "entwurf", model: "claude-opus-4-8" }),
|
|
167
|
+
"invalid sessionId",
|
|
168
|
+
);
|
|
169
|
+
throws(
|
|
170
|
+
() => buildSessionName({ sessionId: "20260603T191245-a3f09c", provider: "entwurf", model: "invented-9" }),
|
|
171
|
+
"unknown tuple",
|
|
172
|
+
);
|
|
173
|
+
throws(
|
|
174
|
+
() => buildSessionName({ sessionId: "20260603T191245-a3f09c", provider: "entwurf", model: "claude-opus-4.8" }),
|
|
175
|
+
"dotted opus (non-tuple)",
|
|
176
|
+
);
|
|
177
|
+
throws(
|
|
178
|
+
() =>
|
|
179
|
+
buildSessionName({
|
|
180
|
+
sessionId: "20260603T191245-a3f09c",
|
|
181
|
+
provider: "entwurf",
|
|
182
|
+
model: "claude-opus-4-8",
|
|
183
|
+
tags: ["Bad_Tag"],
|
|
184
|
+
}),
|
|
185
|
+
"invalid tag charset",
|
|
186
|
+
);
|
|
187
|
+
throws(
|
|
188
|
+
() =>
|
|
189
|
+
buildSessionName({
|
|
190
|
+
sessionId: "20260603T191245-a3f09c",
|
|
191
|
+
provider: "entwurf",
|
|
192
|
+
model: "claude-opus-4-8",
|
|
193
|
+
tags: ["UPPER"],
|
|
194
|
+
}),
|
|
195
|
+
"uppercase tag",
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// ---- parser negatives ----
|
|
199
|
+
eq(parseSessionName("no-separators-here"), null, "no == → null");
|
|
200
|
+
eq(parseSessionName("20260603T191245-a3f09c==entwurf/claude-opus-4-8"), null, "no -- (no title) → null");
|
|
201
|
+
eq(parseSessionName("badid==entwurf/claude-opus-4-8--title"), null, "bad sessionId → null");
|
|
202
|
+
eq(parseSessionName("20260603T191245-a3f09c==noslash--title"), null, "no / → null");
|
|
203
|
+
eq(parseSessionName("20260603T191245-a3f09c==p/m--title__Bad"), null, "bad tag charset → null");
|
|
204
|
+
// canonical-only titleSlug: non-slug titles must not parse (the builder could
|
|
205
|
+
// never emit them, so accepting them would break the build↔parse contract).
|
|
206
|
+
eq(
|
|
207
|
+
parseSessionName("20260603T191245-a3f09c==entwurf/claude-opus-4-8--Bad Title__entwurf"),
|
|
208
|
+
null,
|
|
209
|
+
"space/uppercase title → null",
|
|
210
|
+
);
|
|
211
|
+
eq(parseSessionName("20260603T191245-a3f09c==entwurf/claude-opus-4-8--검증__entwurf"), null, "unicode title → null");
|
|
212
|
+
eq(
|
|
213
|
+
parseSessionName("20260603T191245-a3f09c==entwurf/claude-opus-4-8--a__b__entwurf"),
|
|
214
|
+
null,
|
|
215
|
+
"raw __ delimiter smuggle → null",
|
|
216
|
+
);
|
|
217
|
+
eq(
|
|
218
|
+
parseSessionName("20260603T191245-a3f09c==entwurf/claude-opus-4-8---leading__entwurf"),
|
|
219
|
+
null,
|
|
220
|
+
"leading-hyphen title → null",
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// ---- T-no-logic: title/tags vary, sessionId stable ----
|
|
224
|
+
const sid2 = "20260603T191245-deadbe";
|
|
225
|
+
const nameA = buildSessionName({
|
|
226
|
+
sessionId: sid2,
|
|
227
|
+
provider: "entwurf",
|
|
228
|
+
model: "claude-opus-4-8",
|
|
229
|
+
rawTitle: "first title",
|
|
230
|
+
tags: ["entwurf"],
|
|
231
|
+
});
|
|
232
|
+
const nameB = buildSessionName({
|
|
233
|
+
sessionId: sid2,
|
|
234
|
+
provider: "entwurf",
|
|
235
|
+
model: "claude-opus-4-8",
|
|
236
|
+
rawTitle: "COMPLETELY different / 다른 제목",
|
|
237
|
+
tags: ["entwurf", "phase1", "review"],
|
|
238
|
+
});
|
|
239
|
+
const pA = parseSessionName(nameA);
|
|
240
|
+
const pB = parseSessionName(nameB);
|
|
241
|
+
if (!pA || !pB) assert.fail("no-logic parse failed");
|
|
242
|
+
eq(pA.sessionId, pB.sessionId, "sessionId stable across title/tag change");
|
|
243
|
+
ok(nameA !== nameB, "names differ but identity is the same");
|
|
244
|
+
|
|
245
|
+
// ---- T-model-immut: name model is an integrity mirror ----
|
|
246
|
+
const recorded = "claude-opus-4-8";
|
|
247
|
+
const goodName = buildSessionName({
|
|
248
|
+
sessionId: sid2,
|
|
249
|
+
provider: "entwurf",
|
|
250
|
+
model: recorded,
|
|
251
|
+
rawTitle: "x",
|
|
252
|
+
tags: ["entwurf"],
|
|
253
|
+
});
|
|
254
|
+
eq(parseSessionName(goodName)?.model, recorded, "name model mirrors recorded model");
|
|
255
|
+
const driftName = "20260603T191245-deadbe==entwurf/claude-sonnet-4-6--x__entwurf";
|
|
256
|
+
ok(parseSessionName(driftName)?.model !== recorded, "model drift is detectable for fail-fast");
|
|
257
|
+
|
|
258
|
+
// ---- T-collision: header scan is authority; spawn pre-check ----
|
|
259
|
+
const sessionsBase = path.join(tmp, "sessions");
|
|
260
|
+
const dir = path.join(sessionsBase, "--home-fake-cwd--");
|
|
261
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
262
|
+
const existingId = "20260603T200000-abc123";
|
|
263
|
+
const existingFile = path.join(dir, `2026-06-03T11-00-00-000Z_${existingId}.jsonl`);
|
|
264
|
+
fs.writeFileSync(
|
|
265
|
+
existingFile,
|
|
266
|
+
`${JSON.stringify({ type: "session", id: existingId, cwd: "/home/fake/cwd" })}\n${JSON.stringify({ type: "user_message" })}\n`,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
eq(findSessionFileById(existingId), existingFile, "findSessionFileById resolves by header");
|
|
270
|
+
eq(findSessionFileById("20260603T200000-bbbbbb"), null, "unknown (valid, absent) id → null");
|
|
271
|
+
|
|
272
|
+
// readSessionHeader must be bounded to the first line. Session transcripts can
|
|
273
|
+
// be multi-MB; header-scan lookup must not read/split the whole JSONL body.
|
|
274
|
+
const largeBodyId = "20260603T200000-b0d1ee";
|
|
275
|
+
const largeBodyFile = path.join(dir, `2026-06-03T11-00-00-000Z_${largeBodyId}.jsonl`);
|
|
276
|
+
fs.writeFileSync(
|
|
277
|
+
largeBodyFile,
|
|
278
|
+
`${JSON.stringify({ type: "session", id: largeBodyId, cwd: "/large" })}\n${"x".repeat(1024 * 1024)}`,
|
|
279
|
+
);
|
|
280
|
+
eq(readSessionHeader(largeBodyFile)?.id, largeBodyId, "readSessionHeader reads header from large transcript prefix");
|
|
281
|
+
eq(findSessionFileById(largeBodyId), largeBodyFile, "large transcript header scan stays bounded and authoritative");
|
|
282
|
+
|
|
283
|
+
throws(() => assertSessionIdAvailableForSpawn(existingId), "collision pre-check throws on existing id (any cwd)");
|
|
284
|
+
noThrow(() => assertSessionIdAvailableForSpawn("20260603T200000-fed210"), "fresh id passes pre-check");
|
|
285
|
+
|
|
286
|
+
// header is the authority: filename carries the id but header disagrees → no match
|
|
287
|
+
const dir2 = path.join(sessionsBase, "--other-cwd--");
|
|
288
|
+
fs.mkdirSync(dir2, { recursive: true });
|
|
289
|
+
const spoofId = "20260603T200000-5900fa";
|
|
290
|
+
const spoofFile = path.join(dir2, `2026-06-03T11-00-00-000Z_${spoofId}.jsonl`);
|
|
291
|
+
fs.writeFileSync(spoofFile, `${JSON.stringify({ type: "session", id: "20260603T200000-d1ffe7", cwd: "/x" })}\n`);
|
|
292
|
+
eq(findSessionFileById(spoofId), null, "filename match but header mismatch → not found (header authority)");
|
|
293
|
+
|
|
294
|
+
// header authority (positive): filename does NOT carry the id, header does → found.
|
|
295
|
+
const headerOnlyId = "20260603T200000-c0ffee";
|
|
296
|
+
const renamedFile = path.join(dir2, "renamed-without-id.jsonl");
|
|
297
|
+
fs.writeFileSync(renamedFile, `${JSON.stringify({ type: "session", id: headerOnlyId, cwd: "/y" })}\n`);
|
|
298
|
+
eq(findSessionFileById(headerOnlyId), renamedFile, "header id found even when filename omits it (header authority)");
|
|
299
|
+
|
|
300
|
+
// duplicate header id across two cwd dirs → ambiguous → fail-fast everywhere.
|
|
301
|
+
const dupId = "20260603T200000-dddddd";
|
|
302
|
+
fs.writeFileSync(
|
|
303
|
+
path.join(dir, `2026-06-03T11-00-00-000Z_${dupId}.jsonl`),
|
|
304
|
+
`${JSON.stringify({ type: "session", id: dupId, cwd: "/home/fake/cwd" })}\n`,
|
|
305
|
+
);
|
|
306
|
+
fs.writeFileSync(
|
|
307
|
+
path.join(dir2, `2026-06-03T11-00-00-000Z_${dupId}.jsonl`),
|
|
308
|
+
`${JSON.stringify({ type: "session", id: dupId, cwd: "/x" })}\n`,
|
|
309
|
+
);
|
|
310
|
+
eq(findSessionFilesById(dupId).length, 2, "findSessionFilesById returns all duplicates");
|
|
311
|
+
throws(() => findSessionFileById(dupId), "findSessionFileById throws on ambiguous duplicate");
|
|
312
|
+
throws(() => assertSessionIdAvailableForSpawn(dupId), "spawn pre-check throws on duplicate id");
|
|
313
|
+
|
|
314
|
+
// ---- T-gnew: createGardenSessionFile (the /gnew writer) is fail-closed ----
|
|
315
|
+
// The /gnew in-process birth path pre-creates an EMPTY garden session file that
|
|
316
|
+
// ctx.switchSession() adopts. switchSession→SessionManager.open() reads the
|
|
317
|
+
// header id BEFORE session_start, so a VALID garden header is the only thing
|
|
318
|
+
// keeping the backend/bridge identity off a uuid (setSessionFile silently
|
|
319
|
+
// re-mints a uuid on an empty/invalid header). Lock the writer's guarantees.
|
|
320
|
+
const gnewCwd = "/home/fake/gnew";
|
|
321
|
+
const gnewDir = path.join(sessionsBase, "--home-fake-gnew--");
|
|
322
|
+
const fixedNow = new Date(2026, 5, 4, 9, 30, 0);
|
|
323
|
+
|
|
324
|
+
// happy path: header is exactly {type:session, version:3, id:<garden>, cwd}
|
|
325
|
+
const gnew1 = createGardenSessionFile({ cwd: gnewCwd, sessionDir: gnewDir, now: fixedNow });
|
|
326
|
+
ok(isValidSessionId(gnew1.sessionId), "gnew: created id is garden-native");
|
|
327
|
+
ok(fs.existsSync(gnew1.sessionFile), "gnew: session file written");
|
|
328
|
+
ok(gnew1.sessionFile.endsWith(`_${gnew1.sessionId}.jsonl`), "gnew: filename carries the garden id (pi convention)");
|
|
329
|
+
eq(path.dirname(gnew1.sessionFile), gnewDir, "gnew: file lands in the live sessionDir (not recomputed from cwd)");
|
|
330
|
+
const gnew1Header = JSON.parse(fs.readFileSync(gnew1.sessionFile, "utf8").split("\n", 1)[0] ?? "{}");
|
|
331
|
+
eq(gnew1Header.type, "session", "gnew: header type is session");
|
|
332
|
+
eq(gnew1Header.version, GARDEN_SESSION_FILE_VERSION, "gnew: header version pinned to pi CURRENT_SESSION_VERSION");
|
|
333
|
+
eq(gnew1Header.id, gnew1.sessionId, "gnew: header id IS the garden id (no uuid)");
|
|
334
|
+
eq(gnew1Header.cwd, gnewCwd, "gnew: header cwd recorded");
|
|
335
|
+
eq(gnew1Header.timestamp, fixedNow.toISOString(), "gnew: header timestamp recorded (read-back asserts it)");
|
|
336
|
+
eq(readSessionHeader(gnew1.sessionFile)?.id, gnew1.sessionId, "gnew: readSessionHeader resolves the garden id");
|
|
337
|
+
// the file must be header-ONLY (an empty session switchSession can adopt cleanly)
|
|
338
|
+
eq(
|
|
339
|
+
fs
|
|
340
|
+
.readFileSync(gnew1.sessionFile, "utf8")
|
|
341
|
+
.split("\n")
|
|
342
|
+
.filter((l) => l.trim()).length,
|
|
343
|
+
1,
|
|
344
|
+
"gnew: file is header-only (empty session)",
|
|
345
|
+
);
|
|
346
|
+
// discoverable as itself; never a uuid anywhere
|
|
347
|
+
eq(findSessionFileById(gnew1.sessionId), gnew1.sessionFile, "gnew: header-scan discovery finds the new session");
|
|
348
|
+
|
|
349
|
+
// collision: an id already on disk is refused (would APPEND, not create) and
|
|
350
|
+
// must NOT fall back to a uuid — the writer throws and writes nothing new.
|
|
351
|
+
const beforeCollision = findSessionFilesById(gnew1.sessionId).length;
|
|
352
|
+
throws(
|
|
353
|
+
() => createGardenSessionFile({ cwd: gnewCwd, sessionDir: gnewDir, sessionId: gnew1.sessionId, now: fixedNow }),
|
|
354
|
+
"gnew: collision (existing id) refused — no uuid fallback",
|
|
355
|
+
);
|
|
356
|
+
eq(findSessionFilesById(gnew1.sessionId).length, beforeCollision, "gnew: collision left no extra file");
|
|
357
|
+
|
|
358
|
+
// wx: a different header already at the EXACT target path is refused too (the
|
|
359
|
+
// in-flight same-ms collision the header scan can miss). Pre-place a foreign
|
|
360
|
+
// header at the deterministic path, then a same-(id,now) call must throw EEXIST.
|
|
361
|
+
const wxId = "20260604T093000-abcd12";
|
|
362
|
+
const wxNow = new Date(2026, 5, 4, 9, 30, 0);
|
|
363
|
+
const wxPath = path.join(gnewDir, `${wxNow.toISOString().replace(/[:.]/g, "-")}_${wxId}.jsonl`);
|
|
364
|
+
fs.writeFileSync(wxPath, `${JSON.stringify({ type: "session", id: "20260604T093000-ffffff", cwd: "/x" })}\n`);
|
|
365
|
+
throws(
|
|
366
|
+
() => createGardenSessionFile({ cwd: gnewCwd, sessionDir: gnewDir, sessionId: wxId, now: wxNow }),
|
|
367
|
+
"gnew: wx refuses to overwrite a pre-existing file at the target path",
|
|
368
|
+
);
|
|
369
|
+
// the foreign file is untouched (header id unchanged) — writer never clobbered it
|
|
370
|
+
eq(readSessionHeader(wxPath)?.id, "20260604T093000-ffffff", "gnew: wx-refused write left the existing file intact");
|
|
371
|
+
|
|
372
|
+
// invalid inputs are refused (crash-don't-warn)
|
|
373
|
+
throws(
|
|
374
|
+
() => createGardenSessionFile({ cwd: "relative/path", sessionDir: gnewDir }),
|
|
375
|
+
"gnew: non-absolute cwd refused",
|
|
376
|
+
);
|
|
377
|
+
throws(() => createGardenSessionFile({ cwd: gnewCwd, sessionDir: "" }), "gnew: empty sessionDir refused");
|
|
378
|
+
throws(
|
|
379
|
+
() => createGardenSessionFile({ cwd: gnewCwd, sessionDir: "relative/sessions" }),
|
|
380
|
+
"gnew: non-absolute sessionDir refused",
|
|
381
|
+
);
|
|
382
|
+
throws(
|
|
383
|
+
() => createGardenSessionFile({ cwd: gnewCwd, sessionDir: gnewDir, sessionId: "not-a-garden-id" }),
|
|
384
|
+
"gnew: invalid sessionId refused",
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// ---- T-gnew-cleanup: removeUnadoptedGardenSessionFile is guarded ----
|
|
388
|
+
// header-only + matching id → removed (orphan from a cancelled/failed switch)
|
|
389
|
+
const orphan = createGardenSessionFile({ cwd: gnewCwd, sessionDir: gnewDir, now: new Date(2026, 5, 4, 9, 31, 0) });
|
|
390
|
+
removeUnadoptedGardenSessionFile(orphan.sessionFile, orphan.sessionId);
|
|
391
|
+
ok(!fs.existsSync(orphan.sessionFile), "gnew-cleanup: header-only orphan with matching id removed");
|
|
392
|
+
|
|
393
|
+
// gained a second entry (adopted/active session) → NOT removed
|
|
394
|
+
const adopted = createGardenSessionFile({ cwd: gnewCwd, sessionDir: gnewDir, now: new Date(2026, 5, 4, 9, 32, 0) });
|
|
395
|
+
fs.appendFileSync(adopted.sessionFile, `${JSON.stringify({ type: "user_message" })}\n`);
|
|
396
|
+
removeUnadoptedGardenSessionFile(adopted.sessionFile, adopted.sessionId);
|
|
397
|
+
ok(
|
|
398
|
+
fs.existsSync(adopted.sessionFile),
|
|
399
|
+
"gnew-cleanup: a session with entries is kept (never delete an active session)",
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// header id differs from the expected id → NOT removed (not ours / re-minted)
|
|
403
|
+
const foreign = createGardenSessionFile({ cwd: gnewCwd, sessionDir: gnewDir, now: new Date(2026, 5, 4, 9, 33, 0) });
|
|
404
|
+
removeUnadoptedGardenSessionFile(foreign.sessionFile, "20260604T093300-000000");
|
|
405
|
+
ok(fs.existsSync(foreign.sessionFile), "gnew-cleanup: file with a different header id is left untouched");
|
|
406
|
+
|
|
407
|
+
// ---- analyzeSessionFileLike: streamed, bounded, semantics preserved ----
|
|
408
|
+
const msg = (m: Record<string, unknown>) =>
|
|
409
|
+
`${JSON.stringify({ type: "message", message: { role: "assistant", ...m } })}\n`;
|
|
410
|
+
const analyzeFile = path.join(dir, "analyze-semantics.jsonl");
|
|
411
|
+
fs.writeFileSync(
|
|
412
|
+
analyzeFile,
|
|
413
|
+
`${JSON.stringify({ type: "session", id: "20260603T210000-aaaaaa", cwd: "/a" })}\n` +
|
|
414
|
+
`${JSON.stringify({ type: "message", message: { role: "user", content: "hi" } })}\n` + // non-assistant: not counted
|
|
415
|
+
msg({ content: "first", model: "m1", provider: "p1", stopReason: "end_turn", usage: { cost: { total: 0.01 } } }) +
|
|
416
|
+
"{ this is not valid json\n" + // malformed: skipped
|
|
417
|
+
msg({
|
|
418
|
+
content: "second",
|
|
419
|
+
model: "claude-opus-4-8",
|
|
420
|
+
provider: "entwurf",
|
|
421
|
+
stopReason: "tool_use",
|
|
422
|
+
errorMessage: "oops",
|
|
423
|
+
usage: { cost: { total: 0.02 } },
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
const a1 = analyzeSessionFileLike(analyzeFile);
|
|
427
|
+
eq(a1.turns, 2, "analyze: only assistant turns counted (user + malformed skipped)");
|
|
428
|
+
eq(a1.lastAssistantText, "second", "analyze: last-wins assistant text");
|
|
429
|
+
eq(a1.lastModel, "claude-opus-4-8", "analyze: last-wins model");
|
|
430
|
+
eq(a1.lastProvider, "entwurf", "analyze: last-wins provider");
|
|
431
|
+
eq(a1.lastStopReason, "tool_use", "analyze: last-wins stopReason");
|
|
432
|
+
eq(a1.lastError, "oops", "analyze: last error captured");
|
|
433
|
+
eq(Math.round(a1.cost * 100) / 100, 0.03, "analyze: cost accumulated");
|
|
434
|
+
|
|
435
|
+
// bounded streaming: a single line > chunk size (incl. multibyte) spanning
|
|
436
|
+
// several reads must reassemble correctly and not corrupt the trailing turn.
|
|
437
|
+
const bigBody = `한${"a".repeat(300 * 1024)}글`; // > 64KB chunk, multibyte at both ends
|
|
438
|
+
const bigFile = path.join(dir, "analyze-big.jsonl");
|
|
439
|
+
fs.writeFileSync(
|
|
440
|
+
bigFile,
|
|
441
|
+
`${JSON.stringify({ type: "session", id: "20260603T210000-bbbbbb", cwd: "/b" })}\n` +
|
|
442
|
+
msg({ content: bigBody, model: "m-big", provider: "p-big", usage: { cost: { total: 1 } } }) +
|
|
443
|
+
msg({ content: "final", model: "claude-sonnet-4-6", provider: "entwurf", usage: { cost: { total: 0.5 } } }),
|
|
444
|
+
);
|
|
445
|
+
const a2 = analyzeSessionFileLike(bigFile);
|
|
446
|
+
eq(a2.turns, 2, "analyze(big): turns correct across chunk boundaries");
|
|
447
|
+
eq(a2.lastAssistantText, "final", "analyze(big): trailing turn intact after multi-chunk line");
|
|
448
|
+
eq(a2.lastModel, "claude-sonnet-4-6", "analyze(big): trailing model intact");
|
|
449
|
+
eq(Math.round(a2.cost * 100) / 100, 1.5, "analyze(big): cost summed across large line");
|
|
450
|
+
|
|
451
|
+
// ---- T-identity: resume identity authority = FIRST model_change ----
|
|
452
|
+
// (NEXT.md "Authority separation": model authority is the first model_change,
|
|
453
|
+
// NOT the last assistant message's model. Drift / corrupt name mirror fail-fast.)
|
|
454
|
+
const idDir = path.join(sessionsBase, "--identity-cwd--");
|
|
455
|
+
fs.mkdirSync(idDir, { recursive: true });
|
|
456
|
+
const mc = (provider: string, modelId: string) => `${JSON.stringify({ type: "model_change", provider, modelId })}\n`;
|
|
457
|
+
const sessionLine = (id: string, cwd: string) => `${JSON.stringify({ type: "session", id, cwd })}\n`;
|
|
458
|
+
const infoLine = (name: string) => `${JSON.stringify({ type: "session_info", name })}\n`;
|
|
459
|
+
|
|
460
|
+
// 1. first model_change = A, later ASSISTANT message reports model B →
|
|
461
|
+
// identity follows the first model_change (A), never the assistant message.
|
|
462
|
+
const idA = "20260603T220000-1111aa";
|
|
463
|
+
const fileA = path.join(idDir, `2026-06-03T22-00-00-000Z_${idA}.jsonl`);
|
|
464
|
+
fs.writeFileSync(
|
|
465
|
+
fileA,
|
|
466
|
+
sessionLine(idA, "/identity/a") +
|
|
467
|
+
mc("openai-codex", "gpt-5.5") +
|
|
468
|
+
msg({ content: "drifted", model: "claude-opus-4-8", provider: "entwurf" }),
|
|
469
|
+
);
|
|
470
|
+
const recA = readSessionIdentity(fileA);
|
|
471
|
+
eq(recA?.provider, "openai-codex", "identity: provider = first model_change (not assistant message)");
|
|
472
|
+
eq(recA?.modelId, "gpt-5.5", "identity: modelId = first model_change (not assistant message)");
|
|
473
|
+
eq(recA?.cwd, "/identity/a", "identity: cwd from header");
|
|
474
|
+
|
|
475
|
+
// 2. later model_change differs from the first → drift fail-fast.
|
|
476
|
+
const idB = "20260603T220000-2222bb";
|
|
477
|
+
const fileB = path.join(idDir, `2026-06-03T22-00-00-000Z_${idB}.jsonl`);
|
|
478
|
+
fs.writeFileSync(
|
|
479
|
+
fileB,
|
|
480
|
+
sessionLine(idB, "/identity/b") + mc("entwurf", "claude-opus-4-8") + mc("openai-codex", "gpt-5.5"),
|
|
481
|
+
);
|
|
482
|
+
throws(() => readSessionIdentity(fileB), "identity: later model_change drift → fail-fast");
|
|
483
|
+
|
|
484
|
+
// 3. session name provider/model mirror disagrees with first model_change → corrupt.
|
|
485
|
+
const idC = "20260603T220000-3333cc";
|
|
486
|
+
const fileC = path.join(idDir, `2026-06-03T22-00-00-000Z_${idC}.jsonl`);
|
|
487
|
+
const mismatchName = buildSessionName({
|
|
488
|
+
sessionId: idC,
|
|
489
|
+
provider: "entwurf",
|
|
490
|
+
model: "claude-opus-4-8",
|
|
491
|
+
rawTitle: "x",
|
|
492
|
+
tags: ["entwurf"],
|
|
493
|
+
});
|
|
494
|
+
fs.writeFileSync(fileC, sessionLine(idC, "/identity/c") + mc("openai-codex", "gpt-5.5") + infoLine(mismatchName));
|
|
495
|
+
throws(() => readSessionIdentity(fileC), "identity: name provider/model mirror mismatch → fail-fast");
|
|
496
|
+
|
|
497
|
+
// 4. session name sessionId mirror disagrees with header id → corrupt.
|
|
498
|
+
const idD = "20260603T220000-4444dd";
|
|
499
|
+
const fileD = path.join(idDir, `2026-06-03T22-00-00-000Z_${idD}.jsonl`);
|
|
500
|
+
const wrongIdName = buildSessionName({
|
|
501
|
+
sessionId: "20260603T220000-9999ff",
|
|
502
|
+
provider: "openai-codex",
|
|
503
|
+
model: "gpt-5.5",
|
|
504
|
+
rawTitle: "x",
|
|
505
|
+
tags: ["entwurf"],
|
|
506
|
+
});
|
|
507
|
+
fs.writeFileSync(fileD, sessionLine(idD, "/identity/d") + mc("openai-codex", "gpt-5.5") + infoLine(wrongIdName));
|
|
508
|
+
throws(() => readSessionIdentity(fileD), "identity: name sessionId mirror mismatch → fail-fast");
|
|
509
|
+
|
|
510
|
+
// 5. clean session (matching name + single model_change) → identity, no throw.
|
|
511
|
+
const idE = "20260603T220000-5555ee";
|
|
512
|
+
const fileE = path.join(idDir, `2026-06-03T22-00-00-000Z_${idE}.jsonl`);
|
|
513
|
+
const cleanName = buildSessionName({
|
|
514
|
+
sessionId: idE,
|
|
515
|
+
provider: "entwurf",
|
|
516
|
+
model: "claude-sonnet-4-6",
|
|
517
|
+
rawTitle: "clean resume",
|
|
518
|
+
tags: ["entwurf", "async"],
|
|
519
|
+
});
|
|
520
|
+
fs.writeFileSync(
|
|
521
|
+
fileE,
|
|
522
|
+
sessionLine(idE, "/identity/e") +
|
|
523
|
+
mc("entwurf", "claude-sonnet-4-6") +
|
|
524
|
+
infoLine(cleanName) +
|
|
525
|
+
msg({ content: "ok", model: "claude-sonnet-4-6", provider: "entwurf" }),
|
|
526
|
+
);
|
|
527
|
+
noThrow(() => readSessionIdentity(fileE), "identity: clean session does not throw");
|
|
528
|
+
const recE = readSessionIdentity(fileE);
|
|
529
|
+
eq(recE?.provider, "entwurf", "identity(clean): provider");
|
|
530
|
+
eq(recE?.modelId, "claude-sonnet-4-6", "identity(clean): modelId mirrors name");
|
|
531
|
+
|
|
532
|
+
// 6. no model_change → null (caller refuses with its own no-recorded-model result).
|
|
533
|
+
const idF = "20260603T220000-6666ff";
|
|
534
|
+
const fileF = path.join(idDir, `2026-06-03T22-00-00-000Z_${idF}.jsonl`);
|
|
535
|
+
fs.writeFileSync(
|
|
536
|
+
fileF,
|
|
537
|
+
sessionLine(idF, "/identity/f") + msg({ content: "no model_change", model: "x", provider: "y" }),
|
|
538
|
+
);
|
|
539
|
+
eq(readSessionIdentity(fileF), null, "identity: no model_change → null");
|
|
540
|
+
|
|
541
|
+
// ---- T-require-entwurf: resume path only accepts genuine Entwurf sessions ----
|
|
542
|
+
// (locked 0.9.0 rule: entwurf 여부 = name tag 중 'entwurf' 존재; no compatibility.)
|
|
543
|
+
// G1. model_change but NO session_info name → not an Entwurf session.
|
|
544
|
+
throws(
|
|
545
|
+
() => readSessionIdentity(fileA, { requireEntwurf: true }),
|
|
546
|
+
"requireEntwurf: no session_info name → fail-fast",
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
// G2. session_info name present but non-canonical → not an Entwurf session.
|
|
550
|
+
const idG = "20260603T230000-7777aa";
|
|
551
|
+
const fileG = path.join(idDir, `2026-06-03T23-00-00-000Z_${idG}.jsonl`);
|
|
552
|
+
fs.writeFileSync(
|
|
553
|
+
fileG,
|
|
554
|
+
sessionLine(idG, "/identity/g") + mc("entwurf", "claude-opus-4-8") + infoLine("not a canonical name"),
|
|
555
|
+
);
|
|
556
|
+
throws(
|
|
557
|
+
() => readSessionIdentity(fileG, { requireEntwurf: true }),
|
|
558
|
+
"requireEntwurf: non-canonical session name → fail-fast",
|
|
559
|
+
);
|
|
560
|
+
noThrow(() => readSessionIdentity(fileG), "requireEntwurf off: non-canonical name is ignored (general path)");
|
|
561
|
+
|
|
562
|
+
// G3. canonical name that MIRRORS correctly but has NO entwurf tag → general pi session.
|
|
563
|
+
const idH = "20260603T230000-8888bb";
|
|
564
|
+
const fileH = path.join(idDir, `2026-06-03T23-00-00-000Z_${idH}.jsonl`);
|
|
565
|
+
const noTagName = buildSessionName({
|
|
566
|
+
sessionId: idH,
|
|
567
|
+
provider: "entwurf",
|
|
568
|
+
model: "claude-opus-4-8",
|
|
569
|
+
rawTitle: "general session",
|
|
570
|
+
tags: [],
|
|
571
|
+
});
|
|
572
|
+
fs.writeFileSync(fileH, sessionLine(idH, "/identity/h") + mc("entwurf", "claude-opus-4-8") + infoLine(noTagName));
|
|
573
|
+
throws(
|
|
574
|
+
() => readSessionIdentity(fileH, { requireEntwurf: true }),
|
|
575
|
+
"requireEntwurf: canonical name without 'entwurf' tag → fail-fast",
|
|
576
|
+
);
|
|
577
|
+
noThrow(() => readSessionIdentity(fileH), "requireEntwurf off: no-tag canonical name passes (general path)");
|
|
578
|
+
|
|
579
|
+
// G4. canonical name WITH the entwurf tag (fileE from above) → accepted.
|
|
580
|
+
noThrow(
|
|
581
|
+
() => readSessionIdentity(fileE, { requireEntwurf: true }),
|
|
582
|
+
"requireEntwurf: canonical __entwurf name → accepted",
|
|
583
|
+
);
|
|
584
|
+
eq(
|
|
585
|
+
readSessionIdentity(fileE, { requireEntwurf: true })?.modelId,
|
|
586
|
+
"claude-sonnet-4-6",
|
|
587
|
+
"requireEntwurf: accepted Entwurf session returns first-model_change identity",
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
// ========================================================================
|
|
591
|
+
// T-resident: top-level --entwurf-control garden session (0.9.0)
|
|
592
|
+
// - assertGardenNativeSessionId: uuid → throw, garden → pass (immediate
|
|
593
|
+
// enforcement; uuidv7 from a raw launch has no back-compat path).
|
|
594
|
+
// - buildGardenSessionName: registry-FREE (native deepseek passes where
|
|
595
|
+
// buildSessionName would throw); `entwurf` tag FORBIDDEN; round-trips.
|
|
596
|
+
// - computeResidentStatusLabel: 🪛 ready before file, 🪛 <id> after.
|
|
597
|
+
// - resident `control` name must NOT be resumable as an Entwurf child.
|
|
598
|
+
// ========================================================================
|
|
599
|
+
|
|
600
|
+
// assertGardenNativeSessionId — the immediate-enforcement guard core.
|
|
601
|
+
noThrow(() => assertGardenNativeSessionId("20260604T083632-f9c3b3"), "garden id passes the resident guard");
|
|
602
|
+
throws(
|
|
603
|
+
() => assertGardenNativeSessionId("019e8faa-04ea-7b73-bf2c-1465d525c2e8"),
|
|
604
|
+
"uuidv7 id rejected by resident guard (no back-compat)",
|
|
605
|
+
);
|
|
606
|
+
throws(() => assertGardenNativeSessionId(undefined), "missing id rejected by resident guard");
|
|
607
|
+
throws(() => assertGardenNativeSessionId("20260604T083632-f9c3"), "short-suffix id rejected by resident guard");
|
|
608
|
+
|
|
609
|
+
const residentSid = "20260604T083632-aa11bb";
|
|
610
|
+
|
|
611
|
+
// buildGardenSessionName is registry-FREE: a native model absent from the
|
|
612
|
+
// Entwurf target registry (deepseek/deepseek-v4-pro) must pass here, while the
|
|
613
|
+
// child builder buildSessionName refuses it.
|
|
614
|
+
throws(
|
|
615
|
+
() => buildSessionName({ sessionId: residentSid, provider: "deepseek", model: "deepseek-v4-pro" }),
|
|
616
|
+
"buildSessionName (child) refuses a non-registry native model",
|
|
617
|
+
);
|
|
618
|
+
noThrow(
|
|
619
|
+
() =>
|
|
620
|
+
buildGardenSessionName({
|
|
621
|
+
sessionId: residentSid,
|
|
622
|
+
provider: "deepseek",
|
|
623
|
+
model: "deepseek-v4-pro",
|
|
624
|
+
rawTitle: "home",
|
|
625
|
+
tags: [RESIDENT_SESSION_TAG],
|
|
626
|
+
}),
|
|
627
|
+
"buildGardenSessionName accepts a non-registry native model",
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
const residentName = buildGardenSessionName({
|
|
631
|
+
sessionId: residentSid,
|
|
632
|
+
provider: "deepseek",
|
|
633
|
+
model: "deepseek-v4-pro",
|
|
634
|
+
rawTitle: "entwurf",
|
|
635
|
+
tags: [RESIDENT_SESSION_TAG],
|
|
636
|
+
});
|
|
637
|
+
eq(residentName, `${residentSid}==deepseek/deepseek-v4-pro--entwurf__control`, "resident garden name shape");
|
|
638
|
+
const residentParsed = parseSessionName(residentName);
|
|
639
|
+
if (!residentParsed) assert.fail(`resident name did not parse: ${residentName}`);
|
|
640
|
+
eq(residentParsed.provider, "deepseek", "resident name parses provider");
|
|
641
|
+
eq(residentParsed.model, "deepseek-v4-pro", "resident name parses model");
|
|
642
|
+
eq(residentParsed.tags.join(","), "control", "resident name carries the control tag");
|
|
643
|
+
ok(!isEntwurfSessionName(residentName), "resident control name is NOT an Entwurf session name");
|
|
644
|
+
|
|
645
|
+
// The `entwurf` tag is FORBIDDEN on a resident name — that tag is the
|
|
646
|
+
// Entwurf resume marker; a resident session must never be resumable as a child.
|
|
647
|
+
throws(
|
|
648
|
+
() =>
|
|
649
|
+
buildGardenSessionName({
|
|
650
|
+
sessionId: residentSid,
|
|
651
|
+
provider: "deepseek",
|
|
652
|
+
model: "deepseek-v4-pro",
|
|
653
|
+
tags: ["entwurf"],
|
|
654
|
+
}),
|
|
655
|
+
"buildGardenSessionName forbids the entwurf tag",
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
// Regression guard for the closed blocker: a resident `control` session on
|
|
659
|
+
// disk must be REFUSED by readSessionIdentity(requireEntwurf) — it is not an
|
|
660
|
+
// Entwurf child and entwurf_v2 must not open it.
|
|
661
|
+
const fileResident = path.join(tmp, "resident-control.jsonl");
|
|
662
|
+
fs.writeFileSync(
|
|
663
|
+
fileResident,
|
|
664
|
+
sessionLine(residentSid, "/identity/resident") + mc("deepseek", "deepseek-v4-pro") + infoLine(residentName),
|
|
665
|
+
);
|
|
666
|
+
throws(
|
|
667
|
+
() => readSessionIdentity(fileResident, { requireEntwurf: true }),
|
|
668
|
+
"requireEntwurf: resident control session is NOT resumable as an Entwurf child",
|
|
669
|
+
);
|
|
670
|
+
noThrow(
|
|
671
|
+
() => readSessionIdentity(fileResident),
|
|
672
|
+
"general path: resident control session reads fine (registry-free mirror)",
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// computeResidentStatusLabel — 🪛 lifecycle signal (no "entwurf" text).
|
|
676
|
+
eq(
|
|
677
|
+
computeResidentStatusLabel({ sessionId: residentSid, sessionFileExists: false }),
|
|
678
|
+
"🪛 ready",
|
|
679
|
+
"status label before first turn (no file) = 🪛 ready",
|
|
680
|
+
);
|
|
681
|
+
eq(
|
|
682
|
+
computeResidentStatusLabel({ sessionId: residentSid, sessionFileExists: true }),
|
|
683
|
+
`🪛 ${residentSid}`,
|
|
684
|
+
"status label after first turn (file exists) = 🪛 <gardenId>",
|
|
685
|
+
);
|
|
686
|
+
ok(
|
|
687
|
+
!computeResidentStatusLabel({ sessionId: residentSid, sessionFileExists: true }).includes("entwurf"),
|
|
688
|
+
"status label never contains the word 'entwurf'",
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
// ---- T-local-only: remote/SSH entwurf is fail-fast in 0.9.0 (#11) ----
|
|
692
|
+
// CHANGELOG/BASELINE classify remote fail-fast as a 0.9.0 breaking change.
|
|
693
|
+
// Lock it so a later remote revival cannot silently re-enable a non-local host
|
|
694
|
+
// (header scan + collision pre-check are local-filesystem only).
|
|
695
|
+
noThrow(() => assertLocalOnlyEntwurf("local"), "local host passes");
|
|
696
|
+
noThrow(() => assertLocalOnlyEntwurf(undefined), "undefined host (defaults local) passes");
|
|
697
|
+
throws(() => assertLocalOnlyEntwurf("oracle"), "non-local host 'oracle' fails fast");
|
|
698
|
+
throws(() => assertLocalOnlyEntwurf("user@host"), "ssh-style host 'user@host' fails fast");
|
|
699
|
+
|
|
700
|
+
console.log(`[check-entwurf-session-identity] ${n} assertions ok`);
|
|
701
|
+
} finally {
|
|
702
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
703
|
+
}
|