throughline 0.3.24 → 0.4.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/.claude/commands/tl.md +6 -21
- package/.codex-sidecar.yml +62 -0
- package/CHANGELOG.md +632 -0
- package/README.ja.md +71 -46
- package/README.md +420 -76
- package/bin/throughline.mjs +169 -7
- package/codex/skills/throughline/SKILL.md +157 -0
- package/codex/skills/throughline/agents/openai.yaml +7 -0
- package/docs/INHERITANCE_ON_CLEAR_ONLY.md +159 -0
- package/docs/L1_L2_L3_REDESIGN.md +415 -0
- package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
- package/docs/archive/CONCEPT.md +476 -0
- package/docs/archive/EXPERIMENT.md +371 -0
- package/docs/archive/README.md +22 -0
- package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
- package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
- package/docs/throughline-handoff-context.example.json +57 -0
- package/docs/throughline-rollback-context-trim-insight.md +455 -0
- package/package.json +6 -2
- package/src/baton.mjs +17 -45
- package/src/baton.test.mjs +4 -41
- package/src/cli/codex-capture.mjs +95 -0
- package/src/cli/codex-handoff-model-smoke.mjs +292 -0
- package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
- package/src/cli/codex-handoff-smoke.mjs +163 -0
- package/src/cli/codex-handoff-smoke.test.mjs +149 -0
- package/src/cli/codex-handoff-start.mjs +291 -0
- package/src/cli/codex-handoff-start.test.mjs +194 -0
- package/src/cli/codex-hook.mjs +276 -0
- package/src/cli/codex-hook.test.mjs +293 -0
- package/src/cli/codex-host-primitive-audit.mjs +110 -0
- package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
- package/src/cli/codex-restore-smoke.mjs +357 -0
- package/src/cli/codex-restore-source-audit.mjs +304 -0
- package/src/cli/codex-resume.mjs +138 -0
- package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
- package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
- package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
- package/src/cli/codex-sidecar-dry-run.mjs +85 -0
- package/src/cli/codex-summarize.mjs +224 -0
- package/src/cli/codex-threads.mjs +89 -0
- package/src/cli/codex-visibility-smoke.mjs +196 -0
- package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
- package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
- package/src/cli/doctor.mjs +503 -1
- package/src/cli/doctor.test.mjs +542 -3
- package/src/cli/handoff-preview.mjs +78 -0
- package/src/cli/help.test.mjs +64 -0
- package/src/cli/install.mjs +226 -3
- package/src/cli/install.test.mjs +205 -4
- package/src/cli/trim.mjs +564 -0
- package/src/codex-app-server.mjs +1816 -0
- package/src/codex-app-server.test.mjs +512 -0
- package/src/codex-auto-refresh.mjs +194 -0
- package/src/codex-auto-refresh.test.mjs +182 -0
- package/src/codex-capture.mjs +235 -0
- package/src/codex-capture.test.mjs +393 -0
- package/src/codex-handoff-model-smoke.mjs +114 -0
- package/src/codex-handoff-model-smoke.test.mjs +89 -0
- package/src/codex-handoff-smoke.mjs +124 -0
- package/src/codex-handoff-smoke.test.mjs +103 -0
- package/src/codex-handoff.mjs +331 -0
- package/src/codex-handoff.test.mjs +220 -0
- package/src/codex-host-primitive-audit.mjs +374 -0
- package/src/codex-host-primitive-audit.test.mjs +208 -0
- package/src/codex-restore-smoke.test.mjs +639 -0
- package/src/codex-restore-source-audit.mjs +1348 -0
- package/src/codex-restore-source-audit.test.mjs +623 -0
- package/src/codex-resume.test.mjs +242 -0
- package/src/codex-rollout-memory.mjs +711 -0
- package/src/codex-rollout-memory.test.mjs +610 -0
- package/src/codex-sidecar-cli.test.mjs +75 -0
- package/src/codex-sidecar.mjs +246 -0
- package/src/codex-sidecar.test.mjs +172 -0
- package/src/codex-summarize.test.mjs +143 -0
- package/src/codex-thread-identity.mjs +23 -0
- package/src/codex-thread-index.mjs +173 -0
- package/src/codex-thread-index.test.mjs +164 -0
- package/src/codex-usage.mjs +110 -0
- package/src/codex-usage.test.mjs +140 -0
- package/src/codex-visibility-smoke.test.mjs +222 -0
- package/src/codex-vscode-restore-smoke.mjs +206 -0
- package/src/codex-vscode-restore-smoke.test.mjs +325 -0
- package/src/codex-vscode-rollback-smoke.mjs +90 -0
- package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
- package/src/db-schema.test.mjs +96 -0
- package/src/db.mjs +14 -1
- package/src/haiku-summarizer.mjs +267 -26
- package/src/haiku-summarizer.test.mjs +282 -0
- package/src/handoff-preview.test.mjs +108 -0
- package/src/handoff-record.mjs +294 -0
- package/src/handoff-record.test.mjs +226 -0
- package/src/hook-entrypoints.test.mjs +286 -0
- package/src/package-files.test.mjs +19 -0
- package/src/prompt-submit.mjs +9 -6
- package/src/resume-context.mjs +58 -171
- package/src/resume-context.test.mjs +177 -0
- package/src/session-start.mjs +85 -26
- package/src/state-file.mjs +50 -6
- package/src/state-file.test.mjs +50 -0
- package/src/token-monitor.mjs +14 -10
- package/src/token-monitor.test.mjs +27 -0
- package/src/trim-cli.test.mjs +1584 -0
- package/src/trim-model.mjs +584 -0
- package/src/trim-model.test.mjs +568 -0
- package/src/turn-processor.mjs +17 -10
- package/src/vscode-task.mjs +33 -10
- package/src/vscode-task.test.mjs +19 -9
- package/src/cli/save-inflight.mjs +0 -81
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
renderCodexActiveWorkContext,
|
|
5
|
+
renderCodexNewThreadHandoff,
|
|
6
|
+
toCodexDeveloperMessageItem,
|
|
7
|
+
toThroughlineHandoffBlock,
|
|
8
|
+
} from './codex-handoff.mjs';
|
|
9
|
+
|
|
10
|
+
function makeRecord() {
|
|
11
|
+
return {
|
|
12
|
+
kind: 'handoff_record',
|
|
13
|
+
version: 1,
|
|
14
|
+
session: {
|
|
15
|
+
id: 'sess-1',
|
|
16
|
+
projectPath: '/repo',
|
|
17
|
+
status: 'active',
|
|
18
|
+
mergedInto: null,
|
|
19
|
+
},
|
|
20
|
+
source: {
|
|
21
|
+
adapter: 'claude',
|
|
22
|
+
inheritance: true,
|
|
23
|
+
excludeOriginId: null,
|
|
24
|
+
originSessionIds: ['old'],
|
|
25
|
+
},
|
|
26
|
+
intent: 'continue implementation',
|
|
27
|
+
constraints: ['preserve Claude contract'],
|
|
28
|
+
memory: {
|
|
29
|
+
inflightMemo: 'Next: implement projection',
|
|
30
|
+
latestThinking: [{ time: '12:00:03', text: 'latest hidden reasoning note' }],
|
|
31
|
+
l1Summaries: [{ time: '12:00:01', role: 'assistant', summary: 'old summary' }],
|
|
32
|
+
recentBodies: [{ time: '12:00:02', role: 'assistant', text: 'recent body' }],
|
|
33
|
+
},
|
|
34
|
+
references: {
|
|
35
|
+
l3: [
|
|
36
|
+
{
|
|
37
|
+
kind: 'tool_input',
|
|
38
|
+
toolName: 'Bash',
|
|
39
|
+
sourceId: 'toolu_1',
|
|
40
|
+
originSessionId: 'old',
|
|
41
|
+
turnNumber: 2,
|
|
42
|
+
createdAt: 1000,
|
|
43
|
+
detailCommand: 'throughline detail 12:00:01',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
stats: {
|
|
48
|
+
l1Rows: 1,
|
|
49
|
+
l2Rows: 1,
|
|
50
|
+
thinkingRows: 0,
|
|
51
|
+
l3References: 1,
|
|
52
|
+
preservedContextRows: 2,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('toThroughlineHandoffBlock: creates stable Codex-facing JSON block', () => {
|
|
58
|
+
const block = toThroughlineHandoffBlock(makeRecord());
|
|
59
|
+
|
|
60
|
+
assert.equal(block.kind, 'throughline_handoff');
|
|
61
|
+
assert.equal(block.source, 'throughline');
|
|
62
|
+
assert.equal(block.trust, 'local');
|
|
63
|
+
assert.equal(block.schemaVersion, undefined);
|
|
64
|
+
assert.equal(block.data.throughlineHandoffSchemaVersion, 1);
|
|
65
|
+
assert.equal(block.summary, 'In-flight handoff: Next: implement projection');
|
|
66
|
+
assert.equal(block.references, undefined);
|
|
67
|
+
assert.deepEqual(block.data.detailReferences, [
|
|
68
|
+
{
|
|
69
|
+
type: 'throughline_detail',
|
|
70
|
+
label: 'tool_input:Bash',
|
|
71
|
+
command: 'throughline detail 12:00:01',
|
|
72
|
+
sourceId: 'toolu_1',
|
|
73
|
+
detailKind: 'tool_input',
|
|
74
|
+
originSessionId: 'old',
|
|
75
|
+
turnNumber: 2,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
assert.equal(block.data.sessionId, 'sess-1');
|
|
79
|
+
assert.equal(block.data.projectPath, '/repo');
|
|
80
|
+
assert.equal(block.data.sourceAgent, 'claude');
|
|
81
|
+
assert.equal(block.data.hostMode, 'claude-primary');
|
|
82
|
+
assert.equal(block.data.intent, 'continue implementation');
|
|
83
|
+
assert.deepEqual(block.data.constraints, ['preserve Claude contract']);
|
|
84
|
+
assert.equal(block.data.memory.inflightMemo, 'Next: implement projection');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('toThroughlineHandoffBlock: supports explicit codex-primary mode', () => {
|
|
88
|
+
const block = toThroughlineHandoffBlock(makeRecord(), { hostMode: 'codex-primary' });
|
|
89
|
+
assert.equal(block.data.hostMode, 'codex-primary');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('toThroughlineHandoffBlock: rejects missing record', () => {
|
|
93
|
+
assert.throws(() => toThroughlineHandoffBlock(null), /record is required/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('renderCodexActiveWorkContext: renders persisted memory as active work context', () => {
|
|
97
|
+
const text = renderCodexActiveWorkContext(makeRecord());
|
|
98
|
+
|
|
99
|
+
assert.match(text, /## Throughline: Active Work Context/);
|
|
100
|
+
assert.match(text, /### Reading Contract/);
|
|
101
|
+
assert.match(text, /current-task context for continuation/);
|
|
102
|
+
assert.match(text, /Throughline session: sess-1/);
|
|
103
|
+
assert.match(text, /Source agent: claude/);
|
|
104
|
+
assert.match(text, /### In-flight Memo\nNext: implement projection/);
|
|
105
|
+
assert.match(text, /### Latest Thinking/);
|
|
106
|
+
assert.match(text, /latest hidden reasoning note/);
|
|
107
|
+
assert.match(text, /### L1 Summaries/);
|
|
108
|
+
assert.match(text, /old summary/);
|
|
109
|
+
assert.match(text, /### Active Work Thread \(L2\)/);
|
|
110
|
+
assert.match(text, /\[12:00:02\] \[assistant\] recent body/);
|
|
111
|
+
assert.match(text, /### Detail References/);
|
|
112
|
+
assert.match(text, /throughline detail 12:00:01/);
|
|
113
|
+
assert.match(text, /### Continuation Instruction/);
|
|
114
|
+
assert.match(text, /Continue from the latest actionable state/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('renderCodexNewThreadHandoff: renders concise fresh-thread handoff context', () => {
|
|
118
|
+
const record = makeRecord();
|
|
119
|
+
const text = renderCodexNewThreadHandoff(record);
|
|
120
|
+
|
|
121
|
+
assert.match(text, /## Throughline: New Codex Thread Handoff/);
|
|
122
|
+
assert.match(text, /fresh Codex thread without mutating the risky current thread/);
|
|
123
|
+
assert.match(text, /Reading contract: This is current-task context/);
|
|
124
|
+
assert.match(text, /### Work Boundary/);
|
|
125
|
+
assert.match(text, /preserve Claude contract/);
|
|
126
|
+
assert.match(text, /### In-flight Memo\nNext: implement projection/);
|
|
127
|
+
assert.match(text, /### L1 Memory Summaries/);
|
|
128
|
+
assert.match(text, /### Recent Active Thread \(L2\)/);
|
|
129
|
+
assert.match(text, /throughline detail 12:00:01/);
|
|
130
|
+
assert.match(text, /Do not mutate the original Codex thread/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('renderCodexNewThreadHandoff: caps detail references for pasteable new-thread prompts', () => {
|
|
134
|
+
const record = makeRecord();
|
|
135
|
+
record.references.l3 = [
|
|
136
|
+
{
|
|
137
|
+
kind: 'tool_input',
|
|
138
|
+
toolName: 'Bash',
|
|
139
|
+
sourceId: 'toolu_1',
|
|
140
|
+
originSessionId: 'old',
|
|
141
|
+
turnNumber: 1,
|
|
142
|
+
createdAt: 1000,
|
|
143
|
+
detailCommand: 'throughline detail 12:00:01',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
kind: 'tool_output',
|
|
147
|
+
toolName: 'Bash',
|
|
148
|
+
sourceId: 'toolu_2',
|
|
149
|
+
originSessionId: 'old',
|
|
150
|
+
turnNumber: 2,
|
|
151
|
+
createdAt: 2000,
|
|
152
|
+
detailCommand: 'throughline detail 12:00:02',
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
const text = renderCodexNewThreadHandoff(record, { maxDetailRefs: 1 });
|
|
157
|
+
|
|
158
|
+
assert.match(text, /Showing latest 1 of 2 detail commands; 1 older omitted/);
|
|
159
|
+
assert.doesNotMatch(text, /throughline detail 12:00:01/);
|
|
160
|
+
assert.match(text, /throughline detail 12:00:02/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('renderCodexNewThreadHandoff: caps recent L2 entries and long bodies', () => {
|
|
164
|
+
const record = makeRecord();
|
|
165
|
+
record.memory.recentBodies = [
|
|
166
|
+
{ time: '12:00:01', role: 'assistant', text: 'older body' },
|
|
167
|
+
{ time: '12:00:02', role: 'assistant', text: 'latest body with a long tail' },
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
const text = renderCodexNewThreadHandoff(record, {
|
|
171
|
+
maxRecentBodies: 1,
|
|
172
|
+
maxBodyChars: 11,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
assert.match(text, /full context: throughline codex-resume --session sess-1/);
|
|
176
|
+
assert.match(text, /Showing latest 1 of 2 active L2 entries; 1 older omitted/);
|
|
177
|
+
assert.doesNotMatch(text, /older body/);
|
|
178
|
+
assert.match(text, /latest body/);
|
|
179
|
+
assert.match(text, /\[entry truncated to 11 chars\]/);
|
|
180
|
+
assert.doesNotMatch(text, /long tail/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('renderCodexNewThreadHandoff: deduplicates repeated detail commands', () => {
|
|
184
|
+
const record = makeRecord();
|
|
185
|
+
record.references.l3 = [
|
|
186
|
+
{
|
|
187
|
+
kind: 'tool_input',
|
|
188
|
+
toolName: 'Bash',
|
|
189
|
+
sourceId: 'toolu_1',
|
|
190
|
+
originSessionId: 'old',
|
|
191
|
+
turnNumber: 1,
|
|
192
|
+
createdAt: 1000,
|
|
193
|
+
detailCommand: 'throughline detail 12:00:01',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
kind: 'tool_output',
|
|
197
|
+
toolName: 'Bash',
|
|
198
|
+
sourceId: 'toolu_2',
|
|
199
|
+
originSessionId: 'old',
|
|
200
|
+
turnNumber: 1,
|
|
201
|
+
createdAt: 1000,
|
|
202
|
+
detailCommand: 'throughline detail 12:00:01',
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
const text = renderCodexNewThreadHandoff(record);
|
|
207
|
+
|
|
208
|
+
assert.equal(text.match(/throughline detail 12:00:01/g)?.length, 1);
|
|
209
|
+
assert.doesNotMatch(text, /Showing latest 1 of 2/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('toCodexDeveloperMessageItem: wraps active work context as a developer message item', () => {
|
|
213
|
+
const item = toCodexDeveloperMessageItem(makeRecord());
|
|
214
|
+
|
|
215
|
+
assert.equal(item.type, 'message');
|
|
216
|
+
assert.equal(item.role, 'developer');
|
|
217
|
+
assert.equal(item.content[0].type, 'input_text');
|
|
218
|
+
assert.match(item.content[0].text, /Throughline: Active Work Context/);
|
|
219
|
+
assert.match(item.content[0].text, /recent body/);
|
|
220
|
+
});
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const GENERATED_SCHEMA_MAX_BUFFER = 10 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export function runCodexHostPrimitiveAudit({
|
|
9
|
+
command = 'codex',
|
|
10
|
+
schemaDir = null,
|
|
11
|
+
keepGeneratedSchema = false,
|
|
12
|
+
} = {}) {
|
|
13
|
+
assertNonEmptyString(command, 'command');
|
|
14
|
+
if (schemaDir !== null) assertNonEmptyString(schemaDir, 'schemaDir');
|
|
15
|
+
|
|
16
|
+
const generatedDir = schemaDir ? null : mkdtempSync(join(tmpdir(), 'tl-codex-host-primitives-'));
|
|
17
|
+
const root = schemaDir ?? generatedDir;
|
|
18
|
+
let schemaSource = schemaDir ? 'provided-schema-dir' : 'generated-from-codex-cli';
|
|
19
|
+
let generation = null;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
if (!schemaDir) {
|
|
23
|
+
const outDir = join(root, 'schema');
|
|
24
|
+
generation = generateSchema({ command, outDir });
|
|
25
|
+
if (generation.status !== 'ok') {
|
|
26
|
+
return {
|
|
27
|
+
status: 'refused',
|
|
28
|
+
reason: 'codex_app_server_schema_generation_failed',
|
|
29
|
+
restartSafePrimitive: false,
|
|
30
|
+
command,
|
|
31
|
+
generation,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
schemaSource = 'generated-from-codex-cli';
|
|
35
|
+
return classifyCodexHostPrimitiveSchema({
|
|
36
|
+
schemaDir: outDir,
|
|
37
|
+
schemaSource,
|
|
38
|
+
command,
|
|
39
|
+
generation,
|
|
40
|
+
schemaRetained: Boolean(keepGeneratedSchema),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return classifyCodexHostPrimitiveSchema({
|
|
45
|
+
schemaDir: root,
|
|
46
|
+
schemaSource,
|
|
47
|
+
command,
|
|
48
|
+
generation,
|
|
49
|
+
schemaRetained: true,
|
|
50
|
+
});
|
|
51
|
+
} finally {
|
|
52
|
+
if (generatedDir && !keepGeneratedSchema) {
|
|
53
|
+
rmSync(generatedDir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function classifyCodexHostPrimitiveSchema({
|
|
59
|
+
schemaDir,
|
|
60
|
+
schemaSource = 'schema-dir',
|
|
61
|
+
command = 'codex',
|
|
62
|
+
generation = null,
|
|
63
|
+
schemaRetained = true,
|
|
64
|
+
} = {}) {
|
|
65
|
+
assertNonEmptyString(schemaDir, 'schemaDir');
|
|
66
|
+
const clientRequestPath = join(schemaDir, 'ClientRequest.json');
|
|
67
|
+
const threadResumeParamsPath = join(schemaDir, 'v2', 'ThreadResumeParams.json');
|
|
68
|
+
if (!existsSync(clientRequestPath)) {
|
|
69
|
+
return {
|
|
70
|
+
status: 'refused',
|
|
71
|
+
reason: 'client_request_schema_missing',
|
|
72
|
+
restartSafePrimitive: false,
|
|
73
|
+
schemaDir,
|
|
74
|
+
expectedPath: clientRequestPath,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const clientRequestSchema = readJson(clientRequestPath);
|
|
79
|
+
const methods = [...extractJsonRpcMethods(clientRequestSchema)].sort();
|
|
80
|
+
const methodSet = new Set(methods);
|
|
81
|
+
const resumeSchema = existsSync(threadResumeParamsPath) ? readJson(threadResumeParamsPath) : null;
|
|
82
|
+
const resumeHistory = classifyResumeHistoryPrimitive(resumeSchema);
|
|
83
|
+
const inPlaceHistoryRewriteMethods = methods.filter(isInPlaceHistoryRewriteMethod);
|
|
84
|
+
const inPlaceCompactedHistoryClearMethods = methods.filter(isCompactedHistoryClearMethod);
|
|
85
|
+
const inPlaceRestoreIsolationMethods = methods.filter(isRestoreIsolationOrProjectionMethod);
|
|
86
|
+
const hasCurrentThreadRemediationPrimitive =
|
|
87
|
+
inPlaceHistoryRewriteMethods.length > 0 ||
|
|
88
|
+
inPlaceCompactedHistoryClearMethods.length > 0 ||
|
|
89
|
+
inPlaceRestoreIsolationMethods.length > 0;
|
|
90
|
+
|
|
91
|
+
const facts = {
|
|
92
|
+
threadRollback: methodSet.has('thread/rollback'),
|
|
93
|
+
threadInjectItems: methodSet.has('thread/inject_items'),
|
|
94
|
+
threadCompactStart: methodSet.has('thread/compact/start'),
|
|
95
|
+
threadRead: methodSet.has('thread/read'),
|
|
96
|
+
threadTurnsList: methodSet.has('thread/turns/list'),
|
|
97
|
+
threadStart: methodSet.has('thread/start'),
|
|
98
|
+
threadFork: methodSet.has('thread/fork'),
|
|
99
|
+
threadArchive: methodSet.has('thread/archive'),
|
|
100
|
+
threadResume: methodSet.has('thread/resume'),
|
|
101
|
+
threadResumeHistory: resumeHistory,
|
|
102
|
+
inPlaceHistoryRewriteMethods,
|
|
103
|
+
inPlaceCompactedHistoryClearMethods,
|
|
104
|
+
inPlaceRestoreIsolationMethods,
|
|
105
|
+
hasCurrentThreadRemediationPrimitive,
|
|
106
|
+
hasCurrentThreadNonResurrectionPrimitive: hasCurrentThreadRemediationPrimitive,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const decisions = buildDecisions(facts);
|
|
110
|
+
const repairContract = buildHostAgnosticRepairContract(facts);
|
|
111
|
+
const status =
|
|
112
|
+
hasCurrentThreadRemediationPrimitive || resumeHistory.supportedForThroughline
|
|
113
|
+
? 'host-primitive-audit-needs-live-validation'
|
|
114
|
+
: 'host-primitive-audit-blocked';
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
status,
|
|
118
|
+
reason:
|
|
119
|
+
status === 'host-primitive-audit-blocked'
|
|
120
|
+
? 'no_current_thread_restore_non_resurrection_primitive'
|
|
121
|
+
: 'candidate_host_primitive_requires_live_validation',
|
|
122
|
+
proofScope: 'codex_app_server_protocol_schema_only',
|
|
123
|
+
restartSafePrimitive: false,
|
|
124
|
+
command,
|
|
125
|
+
schemaSource,
|
|
126
|
+
schemaDir,
|
|
127
|
+
schemaRetained,
|
|
128
|
+
generation,
|
|
129
|
+
methodCount: methods.length,
|
|
130
|
+
methods,
|
|
131
|
+
facts,
|
|
132
|
+
decisions,
|
|
133
|
+
repairContract,
|
|
134
|
+
recommendation: {
|
|
135
|
+
status: 'diagnostic-only',
|
|
136
|
+
nextAction:
|
|
137
|
+
'Use this audit as diagnostic evidence only; Codex trim execute and auto-refresh are no longer blocked on this schema-only contract.',
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildHostAgnosticRepairContract(facts) {
|
|
143
|
+
const currentThreadRepairCandidates = [
|
|
144
|
+
...facts.inPlaceHistoryRewriteMethods,
|
|
145
|
+
...facts.inPlaceCompactedHistoryClearMethods,
|
|
146
|
+
...facts.inPlaceRestoreIsolationMethods,
|
|
147
|
+
];
|
|
148
|
+
const resumeHistoryCandidates = facts.threadResumeHistory.supportedForThroughline
|
|
149
|
+
? ['thread/resume(history)']
|
|
150
|
+
: [];
|
|
151
|
+
const hasCurrentThreadRepairCandidate = currentThreadRepairCandidates.length > 0;
|
|
152
|
+
const restoreSourceDeletionCandidates = [
|
|
153
|
+
...facts.inPlaceHistoryRewriteMethods,
|
|
154
|
+
...facts.inPlaceCompactedHistoryClearMethods,
|
|
155
|
+
];
|
|
156
|
+
const restoreSourceIsolationCandidates = [...facts.inPlaceRestoreIsolationMethods];
|
|
157
|
+
const nonResurrectionCandidates = [
|
|
158
|
+
...restoreSourceDeletionCandidates,
|
|
159
|
+
...restoreSourceIsolationCandidates,
|
|
160
|
+
];
|
|
161
|
+
const readVerificationMethods = [
|
|
162
|
+
...(facts.threadRead ? ['thread/read'] : []),
|
|
163
|
+
...(facts.threadResume ? ['thread/resume'] : []),
|
|
164
|
+
...(facts.threadTurnsList ? ['thread/turns/list'] : []),
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const criteria = [
|
|
168
|
+
{
|
|
169
|
+
id: 'same_current_thread_repair_primitive',
|
|
170
|
+
required: true,
|
|
171
|
+
status: hasCurrentThreadRepairCandidate ? 'candidate' : 'missing',
|
|
172
|
+
evidence: currentThreadRepairCandidates,
|
|
173
|
+
message:
|
|
174
|
+
'The repair path must mutate or replace the same host thread, not only create a new thread or handoff.',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'restore_source_non_resurrection_guarantee',
|
|
178
|
+
required: true,
|
|
179
|
+
status: nonResurrectionCandidates.length > 0 ? 'candidate' : 'missing',
|
|
180
|
+
evidence: nonResurrectionCandidates,
|
|
181
|
+
message:
|
|
182
|
+
'The host must either delete/rewrite retained rollback sources or isolate/project them so rolled-back text cannot become model-visible again.',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'memory_reinjection_after_repair',
|
|
186
|
+
required: true,
|
|
187
|
+
status: facts.threadInjectItems ? 'present' : 'missing',
|
|
188
|
+
evidence: facts.threadInjectItems ? ['thread/inject_items'] : [],
|
|
189
|
+
message:
|
|
190
|
+
'After repair, Throughline still needs an explicit memory injection primitive for the replacement active-work context.',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'post_repair_host_read_verification',
|
|
194
|
+
required: true,
|
|
195
|
+
status: readVerificationMethods.length > 0 ? 'present' : 'missing',
|
|
196
|
+
evidence: readVerificationMethods,
|
|
197
|
+
message:
|
|
198
|
+
'The host must expose a way to read the repaired thread and verify that rolled-back user text did not reappear.',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'restart_reconnect_non_resurrection_verification',
|
|
202
|
+
required: true,
|
|
203
|
+
status: 'requires-live-smoke',
|
|
204
|
+
evidence: [],
|
|
205
|
+
message:
|
|
206
|
+
'Schema evidence is not enough; a restart or reconnect smoke must prove retained rollback text stays absent.',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: 'host_agnostic_boundary',
|
|
210
|
+
required: true,
|
|
211
|
+
status: 'required',
|
|
212
|
+
evidence: [],
|
|
213
|
+
message:
|
|
214
|
+
'VS Code diagnostics can collect incident evidence, but they cannot define or satisfy the repair primitive contract.',
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
const missingRequired = criteria.some((criterion) => criterion.required && criterion.status === 'missing');
|
|
219
|
+
return {
|
|
220
|
+
status: missingRequired
|
|
221
|
+
? 'blocked-missing-current-thread-non-resurrection-guarantee'
|
|
222
|
+
: 'candidate-requires-live-validation',
|
|
223
|
+
scope: 'host_agnostic_same_thread_repair_contract',
|
|
224
|
+
currentThreadRepairCandidates,
|
|
225
|
+
restoreSourceDeletionCandidates,
|
|
226
|
+
restoreSourceIsolationCandidates,
|
|
227
|
+
resumeHistoryCandidates,
|
|
228
|
+
criteria,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function generateSchema({ command, outDir }) {
|
|
233
|
+
const result = spawnSync(
|
|
234
|
+
command,
|
|
235
|
+
['app-server', 'generate-json-schema', '--experimental', '--out', outDir],
|
|
236
|
+
{
|
|
237
|
+
encoding: 'utf8',
|
|
238
|
+
maxBuffer: GENERATED_SCHEMA_MAX_BUFFER,
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
status: result.status === 0 ? 'ok' : 'error',
|
|
244
|
+
command,
|
|
245
|
+
args: ['app-server', 'generate-json-schema', '--experimental', '--out', outDir],
|
|
246
|
+
exitCode: result.status,
|
|
247
|
+
signal: result.signal,
|
|
248
|
+
stdout: clipOutput(result.stdout),
|
|
249
|
+
stderr: clipOutput(result.stderr),
|
|
250
|
+
outDir,
|
|
251
|
+
error: result.error ? String(result.error.message ?? result.error) : null,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function readJson(path) {
|
|
256
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function extractJsonRpcMethods(schema) {
|
|
260
|
+
const methods = new Set();
|
|
261
|
+
walk(schema, (value) => {
|
|
262
|
+
if (typeof value === 'string' && value.includes('/')) methods.add(value);
|
|
263
|
+
});
|
|
264
|
+
return methods;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function walk(value, visit) {
|
|
268
|
+
if (Array.isArray(value)) {
|
|
269
|
+
for (const item of value) walk(item, visit);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!value || typeof value !== 'object') return;
|
|
273
|
+
|
|
274
|
+
if (typeof value.const === 'string') visit(value.const);
|
|
275
|
+
if (Array.isArray(value.enum)) {
|
|
276
|
+
for (const item of value.enum) visit(item);
|
|
277
|
+
}
|
|
278
|
+
for (const item of Object.values(value)) {
|
|
279
|
+
walk(item, visit);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function classifyResumeHistoryPrimitive(schema) {
|
|
284
|
+
const rootDescription = String(schema?.description ?? '');
|
|
285
|
+
const historyDescription = String(schema?.properties?.history?.description ?? '');
|
|
286
|
+
const threadIdIgnored =
|
|
287
|
+
/thread_id param will be ignored/i.test(rootDescription) ||
|
|
288
|
+
/threadId param will be ignored/i.test(rootDescription);
|
|
289
|
+
const doNotUse = /DO NOT USE/i.test(historyDescription);
|
|
290
|
+
const present = Boolean(schema?.properties?.history);
|
|
291
|
+
return {
|
|
292
|
+
present,
|
|
293
|
+
supportedForThroughline: present && !doNotUse && !threadIdIgnored,
|
|
294
|
+
reason: !present
|
|
295
|
+
? 'thread_resume_history_param_absent'
|
|
296
|
+
: doNotUse
|
|
297
|
+
? 'thread_resume_history_is_marked_do_not_use'
|
|
298
|
+
: threadIdIgnored
|
|
299
|
+
? 'thread_resume_history_ignores_thread_id'
|
|
300
|
+
: 'thread_resume_history_candidate_requires_live_validation',
|
|
301
|
+
rootDescription: clipOutput(rootDescription, 800),
|
|
302
|
+
historyDescription: clipOutput(historyDescription, 800),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isInPlaceHistoryRewriteMethod(method) {
|
|
307
|
+
return (
|
|
308
|
+
/^thread\/(history|turns?)\/(replace|rewrite|set|clear|delete|remove|truncate)$/.test(method) ||
|
|
309
|
+
/^thread\/(replace|rewrite|clear|delete|remove|truncate)(_history)?$/.test(method)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isCompactedHistoryClearMethod(method) {
|
|
314
|
+
return /thread\/.*compact.*(clear|delete|remove|rewrite|replace|reset)/.test(method);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isRestoreIsolationOrProjectionMethod(method) {
|
|
318
|
+
return /^thread\/.*(restore|resume|history|context|projection|visibility|rollback).*(isolate|exclude|filter|suppress|mask|tombstone|project|boundary|non[-_]?resurrect)/i.test(
|
|
319
|
+
method,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildDecisions(facts) {
|
|
324
|
+
const decisions = [];
|
|
325
|
+
decisions.push({
|
|
326
|
+
id: 'rollback_present_but_not_sufficient',
|
|
327
|
+
status: facts.threadRollback ? 'present-not-sufficient' : 'absent',
|
|
328
|
+
message:
|
|
329
|
+
'thread/rollback can prune active thread history, but the live incident showed rollback-targeted user text can remain in compacted.replacement_history.',
|
|
330
|
+
});
|
|
331
|
+
decisions.push({
|
|
332
|
+
id: 'inject_present_but_append_only',
|
|
333
|
+
status: facts.threadInjectItems ? 'present-not-remediation' : 'absent',
|
|
334
|
+
message:
|
|
335
|
+
'thread/inject_items appends model-visible memory and does not delete or rewrite retained compacted history.',
|
|
336
|
+
});
|
|
337
|
+
decisions.push({
|
|
338
|
+
id: 'compaction_present_but_wrong_direction',
|
|
339
|
+
status: facts.threadCompactStart ? 'present-not-remediation' : 'absent',
|
|
340
|
+
message:
|
|
341
|
+
'thread/compact/start starts another compaction turn; it is not a primitive for clearing existing compacted.replacement_history.',
|
|
342
|
+
});
|
|
343
|
+
decisions.push({
|
|
344
|
+
id: 'fork_or_start_replacement_not_current_thread_repair',
|
|
345
|
+
status: facts.threadStart || facts.threadFork ? 'available-new-thread-only' : 'absent',
|
|
346
|
+
message:
|
|
347
|
+
'thread/start or thread/fork can produce another thread, but Throughline cannot treat that as repairing the current VS Code thread unless the host switches current-thread identity safely.',
|
|
348
|
+
});
|
|
349
|
+
decisions.push({
|
|
350
|
+
id: 'resume_history_not_supported_for_throughline',
|
|
351
|
+
status: facts.threadResumeHistory.supportedForThroughline ? 'candidate' : 'blocked',
|
|
352
|
+
message:
|
|
353
|
+
'thread/resume(history) is not a current-thread repair primitive for Throughline when the schema marks it unstable/do-not-use or says thread_id is ignored.',
|
|
354
|
+
});
|
|
355
|
+
decisions.push({
|
|
356
|
+
id: 'non_resurrection_guarantee_absent',
|
|
357
|
+
status: facts.hasCurrentThreadNonResurrectionPrimitive ? 'candidate' : 'blocking',
|
|
358
|
+
message:
|
|
359
|
+
'No current-thread primitive was found that clears, rewrites, isolates, or projects retained rollback sources away from model-visible context.',
|
|
360
|
+
});
|
|
361
|
+
return decisions;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function clipOutput(value, max = 4000) {
|
|
365
|
+
const text = typeof value === 'string' ? value : '';
|
|
366
|
+
if (text.length <= max) return text;
|
|
367
|
+
return `${text.slice(0, max).trimEnd()}\n[truncated]`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function assertNonEmptyString(value, name) {
|
|
371
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
372
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
373
|
+
}
|
|
374
|
+
}
|