@useshifu/coding-harness 0.3.29 → 0.3.30

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/README.md CHANGED
@@ -44,6 +44,8 @@ To disconnect this device, run `npx --yes @useshifu/coding-harness disconnect --
44
44
 
45
45
  ## Operate
46
46
 
47
+ Version `0.3.30` adds separately versioned episode and entity continuity without changing the V5 sync body or older queued payloads. Preparation uses bounded prior-turn references, source coverage checks and durable two-attempt review holds. Base acceptance and attachment completion have separate status. See [V5 continuity](#continuity-beside-version-five) below for privacy, recovery and mixed-version operation.
48
+
47
49
  Version `0.3.29` verifies native v5 sync across Codex, Claude Code, OpenCode 1 and OpenCode 2. Codex now loads a private allowlisted configuration and home instead of an ignored profile, with executable tools disabled. OpenCode 2 uses its standalone CLI, native provider/agent configuration, typed session database and isolated account storage. Native CI tests exercise original-source discovery, extraction, redaction, frozen retries and matching receipt checkpoints against local synthetic endpoints, without live credentials or paid models.
48
50
 
49
51
  Version `0.3.28` adds one isolated, pre-queue correction attempt for invalid v5 preparation, with field-specific reference and privacy diagnostics. Both attempts share the 180-second deadline. Privacy rules, trusted source binding, accepted checkpoints and exact queued retries remain unchanged.
@@ -126,7 +128,7 @@ The AI Profile page shows detected categories, model and thinking labels, and Je
126
128
 
127
129
  The connection endpoint returns `recommendedPackageVersion`, pinned to a published release. Before `sync`, `sync-files`, `approve`, or `scheduled-sync`, the runner compares its embedded release version with that property. An older runner installs the exact recommended release through npm and resumes the original command with the updated runner. Equal or newer runners continue unchanged. Explicitly recommended major releases follow the same comparison rule. Missing or invalid recommendations, connection failures, and npm failures do not block sync. The connection, pending work, checkpoints, and schedule are preserved.
128
130
 
129
- This update mechanism first ships in `0.3.10`. Runners from earlier releases need a one-time installation of a release containing it (`npx --yes @useshifu/coding-harness@0.3.29 install --harness codex`). Raise the server recommendation only after the target release is published and its backend requirements are deployed.
131
+ This update mechanism first ships in `0.3.10`. Runners from earlier releases need a one-time installation of a release containing it (`npx --yes @useshifu/coding-harness@0.3.30 install --harness codex`). Raise the server recommendation only after the target release is published and its backend requirements are deployed.
130
132
 
131
133
  ## Force sync local work
132
134
 
@@ -161,7 +163,7 @@ Setup preserves other instruction text and MCP settings. Package installation re
161
163
  The context branch adds a remote MCP connection using the same saved Codex key:
162
164
 
163
165
  ```sh
164
- npx --yes @useshifu/coding-harness@0.3.29 mcp-setup --harness codex --mcp-url https://YOUR-BRANCH-HOST/mcp
166
+ npx --yes @useshifu/coding-harness@0.3.30 mcp-setup --harness codex --mcp-url https://YOUR-BRANCH-HOST/mcp
165
167
  ```
166
168
 
167
169
  For development, install dependencies and run from the checkout root:
@@ -192,7 +194,7 @@ Official configuration reference: https://learn.chatgpt.com/docs/config-file/con
192
194
  OpenCode uses the same `/mcp` endpoint and three tools, authenticated with its own saved OpenCode connection key:
193
195
 
194
196
  ```sh
195
- npx --yes @useshifu/coding-harness@0.3.29 mcp-setup --harness opencode --mcp-url https://YOUR-SHIFU-HOST/mcp
197
+ npx --yes @useshifu/coding-harness@0.3.30 mcp-setup --harness opencode --mcp-url https://YOUR-SHIFU-HOST/mcp
196
198
  ```
197
199
 
198
200
  For checkout usage, install dependencies with `npm ci --prefix packages/shifu-coding-harness --ignore-scripts` from the checkout root, then run the checkout CLI with the same arguments. Setup preserves existing sync settings and other MCP servers, checks authenticated initialization and tool discovery, and installs the `shifu-context` usage instructions. A missing OpenCode connection prompts for its key in the terminal. A Codex key cannot authorize an OpenCode connection.
@@ -212,7 +214,7 @@ For the optional native integration gate, set `SHIFU_TEST_OPENCODE_BIN` to a tem
212
214
  Claude Code connects to the same `/mcp` endpoint and three tools with its own saved connection key:
213
215
 
214
216
  ```sh
215
- npx --yes @useshifu/coding-harness@0.3.29 mcp-setup --harness claude_code --mcp-url https://YOUR-SHIFU-HOST/mcp
217
+ npx --yes @useshifu/coding-harness@0.3.30 mcp-setup --harness claude_code --mcp-url https://YOUR-SHIFU-HOST/mcp
216
218
  ```
217
219
 
218
220
  For checkout usage, install dependencies as above and run the checkout CLI with these arguments. Setup checks the existing Claude Code key, authenticated initialization and tool discovery, and installs the `shifu-context` skill. It preserves the sync endpoint, schedule, checkpoints and other MCP servers. A missing connection prompts for a Claude Code key in the terminal; do not substitute a Codex or OpenCode key.
@@ -224,3 +226,23 @@ The API records memory provenance as `claude_code` from the authenticated key. C
224
226
  Official reference: [Claude Code MCP connections](https://code.claude.com/docs/en/mcp).
225
227
 
226
228
  The same proactive context policy is installed for Codex, Claude Code and OpenCode through their native global instructions.
229
+
230
+ ## Continuity beside version five
231
+
232
+ New scheduled preparation includes up to six preceding completed human turns as local reference context. Whole oldest turns are dropped to keep references within 64 KiB and the combined packet within 128 messages and 256 KiB. Saved history cutoffs and excluded sessions still apply. Earlier messages explain a current request; they do not become another upload of earlier work. Version-five source links retain only current-range authors and timestamps.
233
+
234
+ Native preparation uses a separate internal schema with source coverage, episode stage and entity placeholders. Code permits the public technology labels Turbopuffer, PostgreSQL, Go, TypeScript, React and Node.js. Other named identities use neutral scoped aliases. A public technology permission does not authorize a confidential customer relationship. The private name dictionary stays in an owner-readable local file, scoped to the connection and trusted original workspace; unknown workspaces use a session-specific scope. Existing dictionary candidates must be selected explicitly; equal names alone do not merge identities. Cross-device private-name lookup and dictionary sharing are not provided.
235
+
236
+ A useful summary or decision cannot be silently accepted with no evidence. Legitimate empty results remain possible with an explicit local reason and neutral summary. Each unchanged input has at most two native attempts, reserved durably before invocation and shared across restarts. Unresolved ranges block later ranges in that session while other sessions continue. `status` reports preparation, base acceptance and continuity attachment separately. To retry a selected unqueued hold after reviewing its reason:
237
+
238
+ ```sh
239
+ coding-harness retry-preparation --harness codex --session-ref <opaque-session> --from-turn <original-start>
240
+ ```
241
+
242
+ That command cannot regenerate a frozen or accepted payload. Historical recovery remains separate.
243
+
244
+ Continuity uses separate `*-continuity.json` and `*-entity-dictionary.json` files; the version-five body, hash, receipt and base queue stay unchanged. Semantic metadata is frozen before the base POST. The exact matching receipt ID is durably saved before checkpoint advancement or base queue removal. A lost response retries the frozen base payload, including after an older runner has advanced the local checkpoint; range equality is never used to pick an attachment target.
245
+
246
+ The companion endpoint is used only when the authenticated connection advertises `capabilities.continuityAttachment: 1`. An older backend can still accept the base upload while metadata waits locally. Attachment retries reuse one frozen request ID and body, back off, and stop for review after ten attempts. Authorization, target and body conflicts stop immediately. Replacing either the connection key or API endpoint quarantines old checkpoints, base queue, outbox and dictionary before committing the new configuration. An interrupted replacement holds sync until connect resumes with the same selected target. Source text and private names never enter the companion request or status output. Atomic file replacement and file flushing apply on every platform; directory flushing and owner permission enforcement apply on POSIX.
247
+
248
+ Use only compatible preparation writers on a shared configuration directory. An older runner can read and transport the unchanged base queue, but cannot enforce new preparation holds. Stop its preparation schedule or use a compatible rollback build. Never delete checkpoints or replay accepted history to repair missing continuity.
@@ -0,0 +1,186 @@
1
+ const { createHash, randomBytes } = require("node:crypto");
2
+ const { PREPARATION_SCHEMA, preparationPrompt, preparedPayload } = require("./v5-payload.js");
3
+
4
+ const POLICY_VERSION = 1;
5
+ const CATALOG = Object.freeze({ turbopuffer: "Turbopuffer", postgresql: "PostgreSQL", go: "Go", typescript: "TypeScript", react: "React", nodejs: "Node.js" });
6
+ const STAGES = ["proposal", "requested", "reported", "pending", "revised", "rejected"];
7
+ const KINDS = ["technology", "person", "organization", "project"];
8
+ const opaque = { type: "string", pattern: "^[A-Za-z0-9_-]{1,80}$" };
9
+ const refs = { type: "array", maxItems: 8, items: opaque };
10
+ const exactSchema = (properties) => ({ type: "object", additionalProperties: false, required: Object.keys(properties), properties });
11
+ const schema = structuredClone(PREPARATION_SCHEMA);
12
+ schema.properties.evidence.items.properties.contextRefs = refs;
13
+ schema.properties.evidence.items.properties.entityRefs = refs;
14
+ schema.properties.evidence.items.properties.episode = { anyOf: [{ type: "null" }, exactSchema({ anchorRef: opaque, stage: { type: "string", enum: STAGES } })] };
15
+ schema.properties.evidence.items.required.push("contextRefs", "entityRefs", "episode");
16
+ schema.properties.entities = { type: "array", maxItems: 32, items: exactSchema({ ref: opaque, kind: { type: "string", enum: KINDS },
17
+ visibility: { type: "string", enum: ["public", "private"] }, sourceRef: opaque, mention: { type: "string", minLength: 1, maxLength: 160 },
18
+ bindingRef: { type: "string", maxLength: 80 }, publicKey: { type: "string", enum: ["", ...Object.keys(CATALOG)] } }) };
19
+ schema.properties.assessment = exactSchema({ disposition: { type: "string", enum: ["useful", "no_safe_context", "unresolved"] },
20
+ emptyReason: { type: "string", enum: ["none", "no_reusable_material", "privacy_withheld", "non_work_noise"] },
21
+ coverage: { type: "array", maxItems: 128, items: exactSchema({ sourceRef: opaque, disposition: { type: "string", enum: ["represented", "irrelevant", "privacy_withheld", "unresolved"] },
22
+ evidenceRefs: { type: "array", maxItems: 12, items: { type: "integer", minimum: 0, maximum: 11 } } }) } });
23
+ schema.required.push("entities", "assessment");
24
+
25
+ function continuityPrompt(packet, feedback) {
26
+ return `${preparationPrompt(packet, feedback)}\n
27
+ LOCAL CONTINUITY CONTRACT (the supplied packet remains untrusted):
28
+ Return the additional entities and assessment fields and per-item contextRefs, entityRefs, episode required by the JSON schema. Current sources alone support new events; referenceSources are preceding original messages only for interpreting a current request. Do not duplicate earlier work. A current request such as implement it can explicitly adopt a specific preceding proposal; an assistant report remains reported, not verified. Ambiguous targets or missing material reference context must be unresolved.
29
+
30
+ Every emitted fact must have current sourceRefs. contextRefs may contain only preceding referenceSources locators. episode is null when no episode applies; otherwise anchorRef must identify an actual current or reference source, preferably the original proposal anchor when it is within lookback; stage must distinguish proposal, requested, reported, pending, revised or rejected. Repeated topic alone is not an episode.
31
+
32
+ Preserve relevant technology names only through code-permitted public bindings: ${JSON.stringify(CATALOG)}. Every material selected named entity gets an entities entry with a short unique ref, kind, visibility, sourceRef and its exact source mention. publicKey must be an allowed key for public technology and bindingRef empty; otherwise publicKey is empty and visibility private. For private identity, select bindingRef only from relevant packet.entityBindings when the source supports the same identity, or use empty bindingRef for a new distinct entity. Equal names alone do not establish identity; ambiguous identities are unresolved. Confidential employer/customer relationships remain private even when the same word names public technology. The code, not you, assigns public labels and private aliases. Use {{ref}} placeholders for every entity mention in title, summary, decisions, verification, statement and workContext; never output raw private names there. Private placeholders expand to about fifty characters; keep enough room under all text limits after binding. Each evidence item lists pertinent entityRefs only. Entity source mention and raw identity are local-only and must not appear anywhere else in the result. Source role is authorship, never personal ownership.
33
+
34
+ Assessment coverage accounts for every current source locator exactly once. represented entries identify distinct evidence indexes actually citing that source; irrelevant and privacy_withheld entries have no evidence indexes. A material omitted decision, pending check, constraint or ambiguity is unresolved. For useful output every decision and verification entry must have a pertinent evidence reference. Empty evidence cannot coexist with decisions or verification. no_safe_context requires an explicit reason and a neutral nonfactual title and summary; do not erase meaningful work merely to produce an empty result. unresolved is valid and keeps this range pending. Never create filler or require one claim per message.\n`;
35
+ }
36
+
37
+ function exact(value, fields) {
38
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== fields.length || !fields.every((key) => Object.hasOwn(value, key))) {
39
+ throw new Error("Continuity preparation returned missing or unsupported fields.");
40
+ }
41
+ }
42
+ function array(value, maximum) {
43
+ if (!Array.isArray(value) || value.length > maximum) throw new Error("Continuity preparation returned an invalid array.");
44
+ return value;
45
+ }
46
+ function distinct(value, maximum, valid) {
47
+ const items = array(value, maximum);
48
+ if (new Set(items).size !== items.length || !items.every(valid)) throw new Error("Continuity preparation returned invalid references.");
49
+ return items;
50
+ }
51
+ function digest(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
52
+ function randomKey(prefix) {
53
+ let key;
54
+ do { key = prefix + randomBytes(16).toString("hex"); } while (/\d{8}/.test(key));
55
+ return key;
56
+ }
57
+ function metadata(source) {
58
+ return { role: source.role, turn: source.turn, locator: source.locator, ...(source.eventAt ? { eventAt: source.eventAt } : {}), excerptMode: "synthesized" };
59
+ }
60
+
61
+ function sourceHasMention(content, mention) {
62
+ const characters = [...mention];
63
+ const word = /[\p{L}\p{N}\p{M}_]/u;
64
+ const boundary = "[\\p{L}\\p{N}\\p{M}_]";
65
+ const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
66
+ const before = word.test(characters[0]) ? `(?<!${boundary})` : "";
67
+ const after = word.test(characters.at(-1)) ? `(?!${boundary})` : "";
68
+ return new RegExp(before + escaped + after, "u").test(content);
69
+ }
70
+
71
+ function bindContinuity(packet, output, dictionary, workspace) {
72
+ exact(output, ["title", "summary", "decisions", "verification", "evidence", "entities", "assessment"]);
73
+ const current = new Map(packet.sources.map((source) => [source.locator, source]));
74
+ const references = new Map((packet.referenceSources || []).map((source) => [source.locator, source]));
75
+ const all = new Map([...current, ...references]);
76
+ for (const source of references.values()) {
77
+ if (!["user", "assistant"].includes(source.role) || !Number.isInteger(source.turn) || source.turn >= packet.fromTurn || source.turn < Math.max(1, packet.fromTurn - 6)) throw new Error("Invalid preceding source metadata.");
78
+ }
79
+ const selected = new Map();
80
+ dictionary.scopes ||= {};
81
+ const scope = dictionary.scopes[workspace] ||= { scopeRef: randomKey("s_"), entities: {} };
82
+ for (const entity of array(output.entities, 32)) {
83
+ exact(entity, ["ref", "kind", "visibility", "sourceRef", "mention", "bindingRef", "publicKey"]);
84
+ const source = all.get(entity.sourceRef);
85
+ if (typeof entity.ref !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(entity.ref) || selected.has(entity.ref) || !KINDS.includes(entity.kind) ||
86
+ !source || typeof entity.mention !== "string" || !entity.mention.trim() || [...entity.mention].length > 160 || !sourceHasMention(source.content, entity.mention)) {
87
+ throw new Error("Entity binding is not grounded in a supplied source mention.");
88
+ }
89
+ let binding;
90
+ if (entity.visibility === "public") {
91
+ if (entity.bindingRef !== "" || entity.kind !== "technology" || !Object.hasOwn(CATALOG, entity.publicKey) || entity.mention.toLocaleLowerCase() !== CATALOG[entity.publicKey].toLocaleLowerCase()) {
92
+ throw new Error("Entity binding requested an unpermitted public label.");
93
+ }
94
+ binding = { key: entity.publicKey, scopeRef: "public", kind: "technology", visibility: "public", label: CATALOG[entity.publicKey] };
95
+ } else if (entity.visibility === "private" && entity.publicKey === "") {
96
+ let key = entity.bindingRef;
97
+ if (typeof key !== "string") throw new Error("Private identity needs an explicit local binding selection.");
98
+ if (key) {
99
+ const known = scope.entities[key];
100
+ if (!known || known.kind !== entity.kind || known.mention.normalize("NFC").toLocaleLowerCase() !== entity.mention.normalize("NFC").toLocaleLowerCase() || !(packet.entityBindings || []).some((entry) => entry.bindingRef === key)) throw new Error("Private identity selected an unavailable binding.");
101
+ } else {
102
+ key = randomKey("e_");
103
+ scope.entities[key] = { key, mention: entity.mention, kind: entity.kind, createdBy: `${packet.sessionRef}:${packet.fromTurn}` };
104
+ }
105
+ binding = { key, scopeRef: scope.scopeRef, kind: entity.kind, visibility: "private", label: `${entity.kind}_${key}` };
106
+ } else throw new Error("Entity binding requested an invalid visibility.");
107
+ selected.set(entity.ref, { binding, sourceRef: entity.sourceRef, mention: entity.mention });
108
+ }
109
+ const used = new Set();
110
+ const linked = new Set();
111
+ function replace(text) {
112
+ if (typeof text !== "string") throw new Error("Continuity preparation returned invalid text.");
113
+ const value = text.replace(/\{\{([A-Za-z0-9_-]+)\}\}/g, (_, ref) => {
114
+ const entity = selected.get(ref);
115
+ if (!entity) throw new Error("Continuity preparation invented an entity placeholder.");
116
+ used.add(ref);
117
+ return entity.binding.label;
118
+ });
119
+ if (value.includes("{{") || value.includes("}}")) throw new Error("Continuity preparation returned an invalid entity placeholder.");
120
+ for (const entity of selected.values()) {
121
+ if (entity.binding.visibility === "private" && value.normalize("NFC").toLocaleLowerCase().includes(entity.mention.normalize("NFC").toLocaleLowerCase())) throw new Error("Continuity preparation retained a private entity mention.");
122
+ }
123
+ return value;
124
+ }
125
+ const base = { title: replace(output.title), summary: replace(output.summary), decisions: array(output.decisions, 16).map(replace),
126
+ verification: array(output.verification, 16).map(replace), evidence: [] };
127
+ const items = [];
128
+ for (const [index, item] of array(output.evidence, 12).entries()) {
129
+ exact(item, ["title", "statement", "workContext", "decisionRefs", "verificationRefs", "sourceRefs", "contextRefs", "episode", "entityRefs"]);
130
+ const contextRefs = distinct(item.contextRefs, 8, (ref) => references.has(ref));
131
+ const entityRefs = distinct(item.entityRefs, 8, (ref) => selected.has(ref));
132
+ const sourceRefs = distinct(item.sourceRefs, 8, (ref) => current.has(ref));
133
+ const itemText = [item.title, item.statement, item.workContext,
134
+ ...output.decisions.filter((_, i) => item.decisionRefs.includes(i)),
135
+ ...output.verification.filter((_, i) => item.verificationRefs.includes(i))];
136
+ const itemPlaceholders = new Set(itemText.flatMap((text) => typeof text === "string" ? [...text.matchAll(/\{\{([A-Za-z0-9_-]+)\}\}/g)].map((match) => match[1]) : []));
137
+ if (itemPlaceholders.size !== entityRefs.length || entityRefs.some((ref) => !itemPlaceholders.has(ref))) throw new Error("Item entity placeholders and typed bindings disagree.");
138
+ const bound = { title: replace(item.title), statement: replace(item.statement), workContext: replace(item.workContext),
139
+ decisionRefs: item.decisionRefs, verificationRefs: item.verificationRefs, sourceRefs };
140
+ base.evidence.push(bound);
141
+ for (const ref of entityRefs) {
142
+ linked.add(ref);
143
+ const entity = selected.get(ref);
144
+ if (![...sourceRefs, ...contextRefs].includes(entity.sourceRef)) throw new Error("Item entity binding is not linked to its source.");
145
+ if (![bound.title, bound.statement, bound.workContext, ...base.decisions.filter((_, i) => item.decisionRefs.includes(i)), ...base.verification.filter((_, i) => item.verificationRefs.includes(i))].some((text) => text.includes(entity.binding.label))) {
146
+ throw new Error("Item entity label was replaced by generic wording.");
147
+ }
148
+ }
149
+ let episode;
150
+ if (item.episode !== null) {
151
+ exact(item.episode, ["anchorRef", "stage"]);
152
+ if (!STAGES.includes(item.episode.stage) || ![...sourceRefs, ...contextRefs].includes(item.episode.anchorRef)) throw new Error("Episode anchor is not bound to the item sources.");
153
+ episode = { anchorLocator: item.episode.anchorRef, stage: item.episode.stage };
154
+ }
155
+ items.push({ itemRef: `pending_${index}`, contextSources: contextRefs.map((ref) => metadata(references.get(ref))),
156
+ ...(episode ? { episode } : {}), ...(entityRefs.length ? { entities: [...new Map(entityRefs.map((ref) => { const binding = selected.get(ref).binding; return [`${binding.scopeRef}:${binding.key}`, binding]; })).values()] } : {}) });
157
+ }
158
+ if (used.size !== selected.size || linked.size !== selected.size) throw new Error("Continuity preparation left a selected entity unrepresented.");
159
+ const payload = preparedPayload(packet, base);
160
+ exact(output.assessment, ["disposition", "emptyReason", "coverage"]);
161
+ const assessment = output.assessment;
162
+ if (!["useful", "no_safe_context", "unresolved"].includes(assessment.disposition) || !["none", "no_reusable_material", "privacy_withheld", "non_work_noise"].includes(assessment.emptyReason)) throw new Error("Invalid continuity assessment.");
163
+ const coverage = array(assessment.coverage, 128);
164
+ if (coverage.length !== current.size || new Set(coverage.map((entry) => entry.sourceRef)).size !== current.size) throw new Error("Continuity preparation omitted current source coverage.");
165
+ for (const entry of coverage) {
166
+ exact(entry, ["sourceRef", "disposition", "evidenceRefs"]);
167
+ if (!current.has(entry.sourceRef) || !["represented", "irrelevant", "privacy_withheld", "unresolved"].includes(entry.disposition)) throw new Error("Invalid source coverage disposition.");
168
+ const evidenceRefs = distinct(entry.evidenceRefs, 12, (ref) => Number.isInteger(ref) && ref >= 0 && ref < base.evidence.length && base.evidence[ref].sourceRefs.includes(entry.sourceRef));
169
+ if ((entry.disposition === "represented") !== base.evidence.some((item) => item.sourceRefs.includes(entry.sourceRef)) || (entry.disposition === "represented") !== (evidenceRefs.length > 0)) throw new Error("Continuity preparation returned contradictory source coverage.");
170
+ if (entry.disposition === "unresolved") throw new Error("Continuity source coverage remains unresolved.");
171
+ }
172
+ if (assessment.disposition === "unresolved") throw new Error("Continuity preparation remains unresolved.");
173
+ for (const [field, refsField] of [["decisions", "decisionRefs"], ["verification", "verificationRefs"]]) {
174
+ if (base[field].some((_, index) => !base.evidence.some((item) => item[refsField].includes(index)))) throw new Error("Continuity preparation stranded a decision or verification entry.");
175
+ }
176
+ if (assessment.disposition === "useful" ? !payload.evidence.length || assessment.emptyReason !== "none" : payload.evidence.length || base.decisions.length || base.verification.length || assessment.emptyReason === "none") {
177
+ throw new Error("Continuity preparation returned a contradictory empty result.");
178
+ }
179
+ if (!payload.evidence.length && (!/^no safe reusable context[ .]*$/i.test(base.summary.trim()) || !/^no safe reusable context[ .]*$/i.test(base.title.trim()))) {
180
+ throw new Error("An empty result must use a neutral summary rather than discard substantive context.");
181
+ }
182
+ return { payload, items: items.map((item, index) => ({ ...item, itemRef: payload.evidence[index].itemRef })),
183
+ assessment: { disposition: assessment.disposition, emptyReason: assessment.emptyReason, currentSources: current.size, referenceSources: references.size } };
184
+ }
185
+
186
+ module.exports = { CONTINUITY_SCHEMA: schema, POLICY_VERSION, CATALOG, continuityPrompt, bindContinuity, digest };
@@ -0,0 +1,202 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { randomUUID } = require("node:crypto");
4
+ const { digest, POLICY_VERSION } = require("./continuity-preparation.js");
5
+
6
+ function readPrivate(file, fallback) {
7
+ try {
8
+ const stat = fs.lstatSync(file);
9
+ if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o077) || stat.uid !== process.getuid()))) throw new Error("Not private");
10
+ return JSON.parse(fs.readFileSync(file, "utf8"));
11
+ } catch (error) {
12
+ if (error.code === "ENOENT") return fallback;
13
+ throw new Error("Continuity local state is unreadable or not private; existing work was left untouched.");
14
+ }
15
+ }
16
+ function writePrivate(file, value) {
17
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
18
+ const temporary = `${file}.${randomUUID()}.tmp`;
19
+ let fd;
20
+ try {
21
+ fd = fs.openSync(temporary, "wx", 0o600);
22
+ fs.writeFileSync(fd, JSON.stringify(value) + "\n");
23
+ fs.fsyncSync(fd);
24
+ fs.closeSync(fd); fd = undefined;
25
+ fs.renameSync(temporary, file);
26
+ if (process.platform !== "win32") {
27
+ const directory = fs.openSync(path.dirname(file), "r");
28
+ try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
29
+ }
30
+ } finally {
31
+ if (fd !== undefined) fs.closeSync(fd);
32
+ fs.rmSync(temporary, { force: true });
33
+ }
34
+ }
35
+
36
+ function connectionIdentity(config) {
37
+ const apiUrl = new URL(config.apiUrl).toString().replace(/\/$/, "");
38
+ return digest({ apiUrl, token: config.token });
39
+ }
40
+ function connectionTransitionPath(root, harness) { return path.join(root, `${harness}-connection-transition.json`); }
41
+ function assertConnectionReady(root, harness) {
42
+ if (fs.existsSync(connectionTransitionPath(root, harness))) throw new Error("An interrupted connection replacement is held locally. Run connect again with the selected connection before syncing.");
43
+ }
44
+ function commitConnection(root, harness, existing, config) {
45
+ const marker = connectionTransitionPath(root, harness);
46
+ const target = connectionIdentity(config);
47
+ let transition = readPrivate(marker, undefined);
48
+ if (!transition && existing && connectionIdentity(existing) !== target) {
49
+ transition = { version: 1, target, suffix: randomUUID() };
50
+ writePrivate(marker, transition);
51
+ }
52
+ if (transition) {
53
+ if (transition.version !== 1 || transition.target !== target || !/^[a-f0-9-]{36}$/.test(transition.suffix)) throw new Error("Resume the interrupted replacement with the same selected connection; old work remains quarantined.");
54
+ for (const name of ["checkpoints", "pending", "continuity", "entity-dictionary"]) {
55
+ const file = path.join(root, `${harness}-${name}.json`);
56
+ if (fs.existsSync(file)) fs.renameSync(file, `${file}.quarantined.${transition.suffix}`);
57
+ }
58
+ }
59
+ // Config commits last; the marker blocks all new runner reads after an interrupted transition.
60
+ writePrivate(path.join(root, `${harness}.json`), config);
61
+ if (transition) fs.unlinkSync(marker);
62
+ }
63
+
64
+ function continuityStore(root, harness, config) {
65
+ const connection = connectionIdentity(config);
66
+ const file = path.join(root, `${harness}-continuity.json`);
67
+ const dictionaryFile = path.join(root, `${harness}-entity-dictionary.json`);
68
+ function load() {
69
+ let state = readPrivate(file, { version: 1, connection, attempts: {}, outbox: [] });
70
+ if (state.connection !== connection) {
71
+ fs.renameSync(file, `${file}.quarantined.${randomUUID()}`);
72
+ state = { version: 1, connection, attempts: {}, outbox: [] };
73
+ }
74
+ if (state.version !== 1 || !state.attempts || !Array.isArray(state.outbox)) throw new Error("Unsupported continuity state; use a compatible runner.");
75
+ return state;
76
+ }
77
+ function save(state) { writePrivate(file, state); }
78
+ function dictionary() {
79
+ let value = readPrivate(dictionaryFile, { version: 1, connection, scopes: {} });
80
+ if (value.connection !== connection) {
81
+ fs.renameSync(dictionaryFile, `${dictionaryFile}.quarantined.${randomUUID()}`);
82
+ value = { version: 1, connection, scopes: {} };
83
+ }
84
+ if (value.version !== 1 || !value.scopes) throw new Error("Unsupported private entity dictionary.");
85
+ return value;
86
+ }
87
+ function key(packet) { return `${packet.sessionRef}:${packet.fromTurn}`; }
88
+ function reservation(packet, bindings) {
89
+ const state = load();
90
+ const identity = key(packet);
91
+ const fingerprint = digest({ packet, bindings, policy: POLICY_VERSION });
92
+ const prior = state.attempts[identity];
93
+ if (prior?.payloadKey) return { prepared: state.outbox.find((entry) => entry.key === prior.payloadKey) };
94
+ if (prior && prior.toTurn !== packet.toTurn) throw new Error("The unresolved preparation range remains pinned.");
95
+ const attempt = prior?.fingerprint === fingerprint ? prior : { fingerprint, fromTurn: packet.fromTurn, toTurn: packet.toTurn, attempts: 0 };
96
+ if (attempt.attempts >= 2) {
97
+ attempt.status = "needs_review";
98
+ state.attempts[identity] = attempt;
99
+ save(state);
100
+ throw new Error("Continuity preparation needs review; its unchanged input exhausted two native attempts.");
101
+ }
102
+ attempt.attempts += 1;
103
+ attempt.status = "preparing";
104
+ state.attempts[identity] = attempt;
105
+ save(state); // Reservation precedes any native process, including after a crash.
106
+ return { attempt: attempt.attempts, fingerprint };
107
+ }
108
+ function failure(packet, reason = "validation_failed") {
109
+ const state = load();
110
+ const attempt = state.attempts[key(packet)];
111
+ if (attempt) { attempt.status = attempt.attempts >= 2 ? "needs_review" : "unresolved"; attempt.reason = reason; save(state); }
112
+ }
113
+ function freeze(packet, result) {
114
+ const state = load();
115
+ const payloadKey = digest(result.payload);
116
+ const existing = state.outbox.find((entry) => entry.sessionRef === packet.sessionRef && entry.fromTurn === packet.fromTurn);
117
+ if (existing) {
118
+ if (existing.key !== payloadKey) throw new Error("This continuity range already has a frozen payload; retry it exactly.");
119
+ return existing;
120
+ }
121
+ const items = result.items.filter((item) => item.contextSources.length || item.episode || item.entities?.length);
122
+ const entry = { key: payloadKey, sessionRef: packet.sessionRef, fromTurn: packet.fromTurn, toTurn: packet.toTurn,
123
+ payload: result.payload, assessment: result.assessment, baseStatus: "awaiting_receipt", baseAttempts: 0,
124
+ continuityStatus: items.length ? "waiting_for_base" : "not_applicable", attempts: 0, nextAttemptAt: 0,
125
+ companion: { schemaVersion: 1, requestId: randomUUID(), items } };
126
+ state.outbox.push(entry);
127
+ const attempt = state.attempts[key(packet)];
128
+ if (attempt) { attempt.status = "prepared"; attempt.payloadKey = payloadKey; }
129
+ save(state); // Semantic content and base bytes are durable before entering the old queue.
130
+ return entry;
131
+ }
132
+ function find(payload) { return load().outbox.find((entry) => entry.key === digest(payload)); }
133
+ function update(payloadKey, change) {
134
+ const state = load();
135
+ const entry = state.outbox.find((item) => item.key === payloadKey);
136
+ if (!entry) throw new Error("Frozen continuity payload is unavailable; existing checkpoints were left untouched.");
137
+ try { change(entry); }
138
+ catch (error) { save(state); throw error; }
139
+ save(state);
140
+ return entry;
141
+ }
142
+ function bind(payload, receipt) {
143
+ const entry = find(payload);
144
+ if (!entry) return;
145
+ if (!Number.isSafeInteger(receipt.id) || receipt.id < 1) throw new Error("The matching base receipt has no usable ID; retry the exact frozen payload.");
146
+ return update(entry.key, (item) => {
147
+ if (item.receipt && item.receipt.id !== receipt.id) throw new Error("The frozen base receipt changed; continuity needs review.");
148
+ item.receipt = receipt;
149
+ item.baseStatus = "accepted";
150
+ if (item.continuityStatus !== "not_applicable" && item.continuityStatus !== "attached") item.continuityStatus = "pending";
151
+ });
152
+ }
153
+ function reserveBase(payload) {
154
+ const entry = find(payload);
155
+ if (!entry) return;
156
+ return update(entry.key, (item) => {
157
+ if (item.baseStatus === "needs_review" || item.baseAttempts >= 10) {
158
+ item.baseStatus = "needs_review";
159
+ throw new Error("Frozen base transport needs review after its bounded retry budget.");
160
+ }
161
+ item.baseAttempts += 1;
162
+ });
163
+ }
164
+ return { load, save, dictionary, saveDictionary: (value) => writePrivate(dictionaryFile, value), reservation, failure, freeze, find, update, bind, reserveBase,
165
+ summary() {
166
+ const state = load();
167
+ return { preparation: Object.values(state.attempts).map(({ status, attempts, fromTurn, toTurn, reason }) => ({ status, attempts, fromTurn, toTurn, reason })),
168
+ base: state.outbox.map(({ sessionRef, fromTurn, toTurn, baseStatus, continuityStatus, attempts, nextAttemptAt, reason }) => ({ sessionRef, fromTurn, toTurn, baseStatus, continuityStatus, attempts, nextAttemptAt, reason })) };
169
+ } };
170
+ }
171
+
172
+ async function flushContinuity(store, config, capable, fetcher = fetch, now = Date.now()) {
173
+ const excluded = new Set(config.syncPolicy?.excludedSessionRefs || []);
174
+ for (const snapshot of store.load().outbox) {
175
+ if (excluded.has(snapshot.sessionRef)) continue;
176
+ if (snapshot.baseStatus !== "accepted" || ["attached", "not_applicable", "needs_review"].includes(snapshot.continuityStatus)) continue;
177
+ if (!capable) { store.update(snapshot.key, (entry) => { entry.continuityStatus = "waiting_for_capability"; }); continue; }
178
+ if (snapshot.nextAttemptAt > now) continue;
179
+ if (snapshot.attempts >= 10) { store.update(snapshot.key, (entry) => { entry.continuityStatus = "needs_review"; entry.reason = "transport_budget"; }); continue; }
180
+ const entry = store.update(snapshot.key, (item) => { item.attempts += 1; item.continuityStatus = "pending";
181
+ item.nextAttemptAt = now + Math.min(15 * 60_000 * (2 ** (item.attempts - 1)), 24 * 3_600_000); });
182
+ let response;
183
+ try {
184
+ response = await fetcher(`${config.apiUrl}/v1/connectors/coding-sessions/syncs/${entry.receipt.id}/continuity`, {
185
+ method: "POST", redirect: "error", headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
186
+ body: JSON.stringify(entry.companion), signal: AbortSignal.timeout(30_000),
187
+ });
188
+ const result = await response.json().catch(() => undefined);
189
+ const ack = result?.data;
190
+ if (response.ok && ack?.schemaVersion === 1 && ack.syncId === entry.receipt.id && ack.requestId === entry.companion.requestId &&
191
+ typeof ack.attachmentId === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(ack.attachmentId) && typeof ack.duplicate === "boolean") {
192
+ store.update(entry.key, (item) => { item.continuityStatus = "attached"; item.nextAttemptAt = 0; delete item.reason; });
193
+ } else if ((response.status >= 400 && response.status < 500 && response.status !== 429) || response.ok) {
194
+ store.update(entry.key, (item) => { item.continuityStatus = "needs_review"; item.reason = response.ok ? "invalid_receipt" : "attachment_rejected"; });
195
+ } else if (entry.attempts >= 10) store.update(entry.key, (item) => { item.continuityStatus = "needs_review"; item.reason = "transport_budget"; });
196
+ } catch {
197
+ if (entry.attempts >= 10) store.update(entry.key, (item) => { item.continuityStatus = "needs_review"; item.reason = "transport_budget"; });
198
+ }
199
+ }
200
+ }
201
+
202
+ module.exports = { continuityStore, flushContinuity, readPrivate, writePrivate, commitConnection, assertConnectionReady };
@@ -104,7 +104,7 @@ async function requireFlags(command, prefix, flags, options) {
104
104
  }
105
105
  }
106
106
 
107
- async function codexInvocation(directory, env, deadlineAt) {
107
+ async function codexInvocation(directory, env, deadlineAt, outputSchema = PREPARATION_SCHEMA) {
108
108
  const command = resolveNativeBinary("codex", env);
109
109
  await requireFlags(command, ["exec"], ["--ephemeral", "--ignore-rules", "--output-schema"], { cwd: directory, env, deadlineAt });
110
110
  const home = env.CODEX_HOME || path.join(os.homedir(), ".codex");
@@ -135,12 +135,12 @@ async function codexInvocation(directory, env, deadlineAt) {
135
135
  }
136
136
  privateFile(path.join(isolatedHome, "config.toml"), toml.stringify(config));
137
137
  const schema = path.join(directory, "schema.json");
138
- privateFile(schema, JSON.stringify(PREPARATION_SCHEMA));
138
+ privateFile(schema, JSON.stringify(outputSchema));
139
139
  return { command, env: { ...env, CODEX_HOME: isolatedHome, HOME: directory, PWD: directory }, args: ["exec", "--ignore-rules",
140
140
  "--ephemeral", "--json", "--skip-git-repo-check", "--sandbox", "read-only", "--cd", directory, "--output-schema", schema, "-"] };
141
141
  }
142
142
 
143
- async function claudeInvocation(directory, env, deadlineAt) {
143
+ async function claudeInvocation(directory, env, deadlineAt, outputSchema = PREPARATION_SCHEMA) {
144
144
  const command = resolveNativeBinary("claude_code", env);
145
145
  await requireFlags(command, [], ["--safe-mode", "--no-session-persistence", "--tools", "--strict-mcp-config", "--json-schema"], { cwd: directory, env, deadlineAt });
146
146
  const isolatedEnv = { ...env };
@@ -148,7 +148,7 @@ async function claudeInvocation(directory, env, deadlineAt) {
148
148
  delete isolatedEnv.CLAUDE_CODE_SESSION_ID;
149
149
  return { command, env: isolatedEnv, args: ["--print", "--safe-mode", "--no-session-persistence", "--tools", "", "--strict-mcp-config",
150
150
  "--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands", "--max-turns", "1", "--output-format", "json",
151
- "--json-schema", JSON.stringify(PREPARATION_SCHEMA)] };
151
+ "--json-schema", JSON.stringify(outputSchema)] };
152
152
  }
153
153
 
154
154
  function parseOpenCodeConfig(text) {
@@ -175,7 +175,7 @@ function readOpenCodeConfig(directory) {
175
175
  return config;
176
176
  }
177
177
 
178
- async function opencodeInvocation(directory, env, deadlineAt) {
178
+ async function opencodeInvocation(directory, env, deadlineAt, outputSchema = PREPARATION_SCHEMA) {
179
179
  const command = resolveNativeBinary("opencode", env);
180
180
  const version = (await runNativeCommand(command, ["--version"], { cwd: directory, env, deadlineAt, timeoutMs: 10_000 })).trim().match(/^(?:opencode\s+)?v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
181
181
  if (!version || ![1, 2].includes(Number(version[1])) || (Number(version[1]) === 1 && (Number(version[2]) < 18 || (Number(version[2]) === 18 && Number(version[3]) < 30)))) {
@@ -197,7 +197,7 @@ async function opencodeInvocation(directory, env, deadlineAt) {
197
197
  const config = pick(original, ["model", "small_model", "provider", "providers", "enabled_providers", "disabled_providers"]);
198
198
  const selectedAgent = (v2 ? original.agents?.[original.default_agent || "build"] : undefined) || original.agent?.[original.default_agent || "build"];
199
199
  if (selectedAgent?.model) config.model = selectedAgent.model;
200
- const prompt = `${POLICY}\nReturn raw JSON only, without Markdown or code fences. Return JSON matching this schema exactly. Decisions and verification must be arrays of strings, never objects.\n${JSON.stringify(PREPARATION_SCHEMA)}`;
200
+ const prompt = `${POLICY}\nReturn raw JSON only, without Markdown or code fences. Return JSON matching this schema exactly. Decisions and verification must be arrays of strings, never objects.\n${JSON.stringify(outputSchema)}`;
201
201
  Object.assign(config, { snapshot: false, share: "disabled", autoupdate: false, permission: "deny", instructions: [], mcp: {},
202
202
  default_agent: "shifu-preparation", agent: { "shifu-preparation": { ...pick(selectedAgent || {}, ["model", "variant", "temperature", "top_p"]), mode: "primary", prompt, permission: "deny", tools: { "*": false }, steps: 1 } } });
203
203
  if (v2) {
@@ -276,9 +276,10 @@ async function prepareNative(harness, packet, options = {}) {
276
276
  let invocation;
277
277
  try {
278
278
  const env = { ...process.env };
279
- invocation = harness === "codex" ? await codexInvocation(directory, env, deadlineAt)
280
- : harness === "claude_code" ? await claudeInvocation(directory, env, deadlineAt) : await opencodeInvocation(directory, env, deadlineAt);
281
- const input = preparationPrompt(packet, options.validationFeedback);
279
+ const outputSchema = options.continuity ? require("./continuity-preparation.js").CONTINUITY_SCHEMA : PREPARATION_SCHEMA;
280
+ invocation = harness === "codex" ? await codexInvocation(directory, env, deadlineAt, outputSchema)
281
+ : harness === "claude_code" ? await claudeInvocation(directory, env, deadlineAt, outputSchema) : await opencodeInvocation(directory, env, deadlineAt, outputSchema);
282
+ const input = options.continuity ? require("./continuity-preparation.js").continuityPrompt(packet, options.validationFeedback) : preparationPrompt(packet, options.validationFeedback);
282
283
  if (Buffer.byteLength(input) > MAX_OUTPUT_BYTES) throw new Error("Native preparation source packet exceeds the input limit.");
283
284
  const remainingMs = timeoutMs - (Date.now() - startedAt);
284
285
  if (remainingMs <= 0) throw new Error("Native preparation timed out; the source remains pending.");
@@ -46,7 +46,7 @@ const MAX_TURNS_PER_SEGMENT = 12;
46
46
  const MAX_TITLE_LENGTH = 160;
47
47
  const MAX_WORK_DETAIL_LENGTH = 600;
48
48
  const CONFIG_VERSION = 2;
49
- const RUNNER_VERSION = "0.3.29";
49
+ const RUNNER_VERSION = "0.3.30";
50
50
  const REDACTION_VERSION = 5;
51
51
  const LEGACY_REDACTION_VERSION = 4;
52
52
  const MAX_PREPARATIONS_PER_RUN = 4;
@@ -180,8 +180,9 @@ function openCodeDatabaseQuery(dbPath, query) {
180
180
  if (tables.length !== 2) return read(query);
181
181
  // V2 stores typed messages, ordered by sequence, instead of separate parts.
182
182
  // Read-only CTEs retain the existing adapter contract without changing its database.
183
+ const hasDirectory = read("PRAGMA table_info(session_v2)").some((column) => column.name === "directory");
183
184
  const projection = `WITH
184
- session AS (SELECT id, time_created, time_updated FROM session_v2),
185
+ session AS (SELECT id, time_created, time_updated, ${hasDirectory ? "directory" : "NULL AS directory"} FROM session_v2),
185
186
  message AS (
186
187
  SELECT id, session_id, seq, time_created, json_object(
187
188
  'role', type, 'finish', CASE WHEN json_extract(data, '$.time.completed') IS NOT NULL THEN json_extract(data, '$.finish') END,
@@ -244,6 +245,7 @@ function validateSyncOptions(command, start = 3) {
244
245
  "sync-files": common,
245
246
  "scheduled-sync": common,
246
247
  pending: common,
248
+ "retry-preparation": [...common, "--session-ref", "--from-turn"],
247
249
  approve: common,
248
250
  install: common,
249
251
  connect: [...common, ...setup, ...syncSettings, "--api-url"],
@@ -296,6 +298,7 @@ function writeJSON(file, value) {
296
298
  }
297
299
 
298
300
  function readConfig(harness) {
301
+ if (fs.existsSync(path.join(configRoot(), `${harness}-connection-transition.json`))) throw new Error("An interrupted connection replacement is held locally. Run connect again with the selected connection before syncing.");
299
302
  const config = readJSON(configPath(harness), undefined);
300
303
  if (!config || typeof config.token !== "string" || typeof config.apiUrl !== "string") {
301
304
  throw new Error(`No ${HARNESS_NAMES[harness]} connection is configured. Run connect first.`);
@@ -1273,7 +1276,7 @@ function copyRunner() {
1273
1276
  const installedDependency = path.join(configRoot(), "node_modules", name);
1274
1277
  if (!sameFile(dependency, installedDependency)) copyDirectory(dependency, installedDependency);
1275
1278
  }
1276
- for (const name of ["source-review.js", "v5-payload.js", "native-preparation.js", "opencode-v2-account.js", "context-preferences.js", "setup-options.js", "initial-history.js", "onboarding-ui.js"]) {
1279
+ for (const name of ["source-review.js", "v5-payload.js", "native-preparation.js", "opencode-v2-account.js", "context-preferences.js", "setup-options.js", "initial-history.js", "onboarding-ui.js", "continuity-preparation.js", "continuity-state.js"]) {
1277
1280
  const source = path.join(__dirname, name);
1278
1281
  const destination = path.join(configRoot(), name);
1279
1282
  if (!sameFile(source, destination)) {
@@ -1557,7 +1560,7 @@ async function connect(harness, readKey = readSecret, choices) {
1557
1560
  const token = (await readKey()).trim();
1558
1561
  if (!TOKEN_PATTERN.test(token)) throw new Error("The connection key is invalid.");
1559
1562
  const apiUrl = requireSafeApiUrl(option("--api-url") || existing?.apiUrl || process.env.SHIFU_API_URL || DEFAULT_API_URL);
1560
- await validateConnection(apiUrl, token, harness);
1563
+ const connectionInfo = await validateConnection(apiUrl, token, harness);
1561
1564
  const savedPolicy = existing && normalizedPolicy(existing);
1562
1565
  const currentPolicy = savedPolicy?.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic", excludedSessionRefs: savedPolicy?.excludedSessionRefs || [] };
1563
1566
  const selected = await chooseSyncPolicy(currentPolicy);
@@ -1570,15 +1573,17 @@ async function connect(harness, readKey = readSecret, choices) {
1570
1573
  install(harness, true, choices.contextPreferences);
1571
1574
  const scheduleFile = scheduleEnabled ? installSchedule(harness) : undefined;
1572
1575
  if (!scheduleEnabled && savedPolicy?.scheduleEnabled) removeSchedule(harness);
1573
- writeJSON(configPath(harness), { ...existing, ...choices, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder });
1574
- refreshManagedContextInstructions(harness, choices.contextPreferences);
1575
- if (refreshContextKey) writePrivateText(openCodeTokenPath(), token);
1576
- if (existing?.token && existing.token !== token) {
1577
- for (const file of [statePath(harness), pendingPath(harness)]) {
1578
- try { fs.unlinkSync(file); } catch (error) { if (error?.code !== "ENOENT") throw error; }
1579
- }
1580
- }
1581
- scheduleNextRun(harness, syncPolicy.intervalHours);
1576
+ const connection = { ...existing, ...choices, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder, capabilities: connectionInfo.capabilities || {} };
1577
+ const committed = await withSyncLock(harness, async () => {
1578
+ if (JSON.stringify(readJSON(configPath(harness), undefined)) !== JSON.stringify(existing)) throw new Error("The connection changed during setup. Run connect again.");
1579
+ require("./continuity-state.js").commitConnection(configRoot(), harness, existing, connection);
1580
+ refreshManagedContextInstructions(harness, choices.contextPreferences);
1581
+ if (refreshContextKey) writePrivateText(openCodeTokenPath(), token);
1582
+ const continuity = continuityState(harness, connection);
1583
+ continuity.load(); continuity.dictionary();
1584
+ scheduleNextRun(harness, syncPolicy.intervalHours);
1585
+ });
1586
+ if (committed?.skipped) throw new Error("Another Shifu sync is running. Retry connect after it finishes; the connection was not replaced.");
1582
1587
  console.error(`${HARNESS_NAMES[harness]} is connected. ${scheduleEnabled ? `It will sync every ${syncPolicy.intervalHours} hours with ${syncPolicy.approvalMode} approval.` : "The sync schedule is off."}`);
1583
1588
  console.error(`Shifu sync folder: ${inputFolder}. Place .txt, .md, .rtf, or .docx files there. Only grounded contributions will become claims.`);
1584
1589
  if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
@@ -2217,10 +2222,11 @@ function retryDelay(response, attempt) {
2217
2222
  return Math.min(base * (2 ** attempt), 30_000);
2218
2223
  }
2219
2224
 
2220
- async function postSync(config, input) {
2225
+ async function postSync(config, input, beforeAttempt, onPermanentFailure) {
2221
2226
  const body = JSON.stringify(input.force ? { ...input, forceRequestId: randomUUID() } : input);
2222
2227
  let lastError;
2223
2228
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
2229
+ beforeAttempt?.();
2224
2230
  let response;
2225
2231
  try {
2226
2232
  response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
@@ -2237,7 +2243,7 @@ async function postSync(config, input) {
2237
2243
  const payload = await response.json().catch(() => undefined);
2238
2244
  if (response.ok) return payload?.data;
2239
2245
  lastError = new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
2240
- if (response.status !== 429 && response.status < 500) throw lastError;
2246
+ if (response.status !== 429 && response.status < 500) { onPermanentFailure?.(); throw lastError; }
2241
2247
  if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
2242
2248
  }
2243
2249
  throw lastError;
@@ -2446,20 +2452,27 @@ async function sendSync(harness, input, config = readConfig(harness)) {
2446
2452
  validateSync(input, harness);
2447
2453
  const state = readState(harness);
2448
2454
  const checkpoint = state.sessions[input.sessionRef];
2455
+ const store = continuityState(harness, config);
2456
+ const frozen = store.find(input);
2449
2457
  const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
2450
2458
  const force = input.force || Boolean(input.supersedesSyncId);
2451
- if (!force && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
2459
+ if (!force && !frozen && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
2452
2460
  if (force && reviewSegment(harness, input.sessionRef, input.fromTurn, input.toTurn).turnCount < input.toTurn) throw new Error("The original local turns must still exist before force syncing this range.");
2453
- if (!force) input = queuePayload(harness, input);
2461
+ if (!force && (!frozen || input.toTurn > (checkpoint?.lastSyncedTurn || 0))) input = queuePayload(harness, input);
2454
2462
  let profile;
2455
2463
  if (!force) {
2456
2464
  try { profile = profileForSegment(harness, input); }
2457
2465
  catch { console.error("AI Profile source is unavailable; the prepared activity payload remains retryable."); }
2458
2466
  }
2459
- const receipt = await postSync(config, input);
2460
- if (!validSyncReceipt(receipt, input)) {
2461
- throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
2467
+ let receipt;
2468
+ try {
2469
+ receipt = frozen?.receipt || await postSync(config, input, frozen ? () => store.reserveBase(input) : undefined, frozen ? () => store.update(frozen.key, (entry) => { entry.baseStatus = "needs_review"; entry.reason = "base_rejected"; }) : undefined);
2470
+ if (!validSyncReceipt(receipt, input)) throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
2471
+ } catch (error) {
2472
+ if (frozen && store.find(input).baseAttempts >= 10) store.update(frozen.key, (entry) => { entry.baseStatus = "needs_review"; entry.reason = "base_transport_budget"; });
2473
+ throw error;
2462
2474
  }
2475
+ if (frozen) store.bind(input, receipt);
2463
2476
  if (profile) {
2464
2477
  state.profilePending ||= {};
2465
2478
  state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
@@ -2568,27 +2581,107 @@ async function reconcilePreparedSession(harness, sessionRef, config) {
2568
2581
  saveState(harness, state);
2569
2582
  }
2570
2583
 
2584
+ function continuityState(harness, config = readConfig(harness)) {
2585
+ return require("./continuity-state.js").continuityStore(configRoot(), harness, config);
2586
+ }
2587
+
2588
+ function preparationPacket(harness, segment, config) {
2589
+ if (normalizedPolicy(config).excludedSessionRefs.includes(segment.sessionRef)) throw new Error("This session is excluded from preparation.");
2590
+ if (!initialHistoryEligibility(segment, segment.fromTurn, config).eligible) throw new Error("This range is outside the saved initial-history selection.");
2591
+ const records = sourceRecords(harness, segment.sessionRef);
2592
+ let minimumTurn = Math.max(1, segment.fromTurn - 6);
2593
+ for (let turn = segment.fromTurn - 1; turn >= minimumTurn; turn -= 1) {
2594
+ if (!initialHistoryEligibility(segment, turn, config).eligible) { minimumTurn = turn + 1; break; }
2595
+ }
2596
+ const packet = require("./source-review.js").continuitySourcePacket({ harness, sessionRef: segment.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn }, records, minimumTurn);
2597
+ let workspace;
2598
+ if (harness === "codex") workspace = records.find((record) => record.type === "session_meta")?.payload?.cwd;
2599
+ else if (harness === "claude_code") workspace = records.find((record) => typeof record.cwd === "string")?.cwd;
2600
+ else {
2601
+ try { workspace = opencodeQuery(`SELECT directory FROM session WHERE id = ${JSON.stringify(segment.sessionRef)}`)[0]?.directory; }
2602
+ catch { /* No trusted directory means a session-specific scope, never the scheduler workspace. */ }
2603
+ }
2604
+ return { packet, workspace: createHash("sha256").update(typeof workspace === "string" && workspace ? `workspace:${workspace}` : `${harness}:session:${segment.sessionRef}`).digest("hex") };
2605
+ }
2606
+
2571
2607
  async function prepareV5Payload(harness, segment, prepare = require("./native-preparation.js").prepareNative) {
2572
- const packet = reviewSourcePacket(harness, segment.sessionRef, segment.fromTurn, segment.toTurn, true);
2608
+ const config = readConfig(harness);
2609
+ const store = continuityState(harness, config);
2610
+ const prior = store.load().attempts[`${segment.sessionRef}:${segment.fromTurn}`];
2611
+ if (prior?.payloadKey) {
2612
+ const frozen = store.load().outbox.find((entry) => entry.key === prior.payloadKey);
2613
+ if (!frozen) throw new Error("Frozen preparation is unavailable; continuity needs review.");
2614
+ return frozen.payload;
2615
+ }
2616
+ if (prior?.toTurn) segment = { ...segment, toTurn: prior.toTurn };
2617
+ const { packet, workspace } = preparationPacket(harness, segment, config);
2618
+ const dictionary = store.dictionary();
2619
+ dictionary.scopes[workspace] ||= { scopeRef: `s_${randomUUID().replaceAll("-", "")}`, entities: {} };
2620
+ store.saveDictionary(dictionary);
2621
+ const relevantBindings = () => {
2622
+ const content = [...packet.sources, ...packet.referenceSources].map((source) => source.content).join("\n").toLocaleLowerCase();
2623
+ return Object.values(dictionary.scopes[workspace].entities).filter((entry) => entry.createdBy !== `${packet.sessionRef}:${packet.fromTurn}` && content.includes(entry.mention.toLocaleLowerCase()))
2624
+ .map(({ key, mention, kind }) => ({ bindingRef: key, mention, kind }));
2625
+ };
2626
+ packet.entityBindings = relevantBindings();
2627
+ while (packet.referenceSources.length && Buffer.byteLength(JSON.stringify(packet)) + 1 > 256 * 1024) {
2628
+ const oldest = packet.referenceSources[0].turn;
2629
+ packet.referenceSources = packet.referenceSources.filter((source) => source.turn > oldest);
2630
+ packet.referenceCoverage.limited = true;
2631
+ packet.referenceCoverage.droppedTurns += 1;
2632
+ packet.referenceCoverage.fromTurn = packet.referenceSources[0]?.turn || packet.fromTurn;
2633
+ packet.entityBindings = relevantBindings();
2634
+ }
2635
+ if (Buffer.byteLength(JSON.stringify(packet)) + 1 > 256 * 1024) throw new Error("The selected source packet exceeds 128 messages or 256 KiB. Nothing was truncated or synced.");
2636
+ if (packet.entityBindings.length > 32) throw new Error("Relevant private entity bindings exceed the bounded packet; this range needs review.");
2637
+ const bindings = { workspace, scopeRef: dictionary.scopes[workspace].scopeRef, catalog: require("./continuity-preparation.js").CATALOG };
2573
2638
  const deadline = Date.now() + 180_000;
2574
2639
  let validationFeedback;
2575
2640
  for (let attempt = 0; attempt < 2; attempt += 1) {
2576
2641
  const remainingMs = deadline - Date.now();
2577
2642
  if (remainingMs <= 0) throw new Error("Native preparation timed out; the source remains pending.");
2578
- const output = await prepare(harness, packet, { validationFeedback, timeoutMs: remainingMs });
2643
+ const reservation = store.reservation(packet, bindings);
2644
+ let output;
2579
2645
  try {
2580
- const payload = require("./v5-payload.js").preparedPayload(packet, output);
2581
- validateSync(payload, harness);
2582
- return payload;
2646
+ output = await prepare(harness, packet, { validationFeedback, timeoutMs: remainingMs, continuity: true });
2647
+ const candidateDictionary = structuredClone(dictionary);
2648
+ const result = require("./continuity-preparation.js").bindContinuity(packet, output, candidateDictionary, workspace);
2649
+ validateSync(result.payload, harness);
2650
+ if (result.assessment.disposition === "no_safe_context" && reservation.attempt < 2 && packet.sources.length) throw new Error("Recheck this proposed empty result against the original sources; preserve meaningful context or confirm a legitimate neutral empty result.");
2651
+ store.saveDictionary(candidateDictionary);
2652
+ return store.freeze(packet, result).payload;
2583
2653
  } catch (error) {
2584
- if (attempt === 1) throw error;
2585
- // Only local validator diagnostics enter the isolated correction prompt.
2586
- // Failed model output is never queued, echoed, or sent to Shifu.
2654
+ store.failure(packet);
2655
+ if (reservation.attempt >= 2 || output === undefined) throw error;
2587
2656
  validationFeedback = preparationValidationFeedback(error, output);
2588
2657
  }
2589
2658
  }
2590
2659
  }
2591
2660
 
2661
+ async function retryPreparation(harness) {
2662
+ const sessionRef = option("--session-ref");
2663
+ const fromTurn = numberOption("--from-turn");
2664
+ if (!sessionRefIsSafe(sessionRef) || !Number.isSafeInteger(fromTurn) || fromTurn < 1) throw new Error("Choose the held session and original from-turn explicitly.");
2665
+ const result = await withSyncLock(harness, async () => {
2666
+ const store = continuityState(harness);
2667
+ const state = store.load();
2668
+ const identity = `${sessionRef}:${fromTurn}`;
2669
+ const held = state.attempts[identity];
2670
+ if (!held || held.payloadKey || state.outbox.some((entry) => entry.sessionRef === sessionRef && entry.fromTurn === fromTurn) || readPendingQueue(harness).segments.some((input) => input.sessionRef === sessionRef && input.toTurn >= fromTurn)) {
2671
+ throw new Error("Only an unqueued preparation hold can be retried; frozen payloads keep their exact transport path.");
2672
+ }
2673
+ if ((readState(harness).sessions[sessionRef]?.lastSyncedTurn || 0) >= fromTurn) throw new Error("Accepted ranges require separately authorized historical recovery.");
2674
+ const session = localSessions(harness).find((item) => item.sessionRef === sessionRef);
2675
+ if (!session) throw new Error("The original session is unavailable; the hold was preserved.");
2676
+ held.attempts = 0; held.status = "unresolved"; delete held.reason;
2677
+ store.save(state);
2678
+ const payload = await prepareV5Payload(harness, { ...session, fromTurn, toTurn: held.toTurn });
2679
+ queuePayload(harness, payload);
2680
+ console.error("The selected preparation is queued for its existing approval and transport policy.");
2681
+ });
2682
+ if (result?.skipped) throw new Error("Another sync is running; the hold was preserved.");
2683
+ }
2684
+
2592
2685
  function preparationValidationFeedback(error, output) {
2593
2686
  const fields = [["title", output?.title, 160], ["summary", output?.summary, 1100]];
2594
2687
  for (const name of ["decisions", "verification"]) {
@@ -2631,6 +2724,7 @@ function preparationState(harness, queue) {
2631
2724
  }
2632
2725
 
2633
2726
  async function preparePendingQueue(harness, config, blockedSessions = new Set()) {
2727
+ restoreContinuityQueue(harness, config);
2634
2728
  let queue = prunePendingQueue(harness);
2635
2729
  const previousFailures = new Set((queue.preparationErrors || []).map((error) => error.sessionRef));
2636
2730
  const errors = [];
@@ -2680,8 +2774,13 @@ async function preparePendingQueue(harness, config, blockedSessions = new Set())
2680
2774
  async function drainPendingQueue(harness, config, blockedSessions) {
2681
2775
  let synced = 0;
2682
2776
  let failures = 0;
2683
- for (const input of eligibleQueuedSegments(readPendingQueue(harness), config)) {
2684
- if (blockedSessions.has(input.sessionRef)) continue;
2777
+ const store = continuityState(harness, config);
2778
+ const entries = store.load().outbox.filter((entry) => entry.baseStatus !== "needs_review" && (entry.baseStatus !== "accepted" || entry.toTurn > (readState(harness).sessions[entry.sessionRef]?.lastSyncedTurn || 0)));
2779
+ const queued = eligibleQueuedSegments(readPendingQueue(harness), config);
2780
+ const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
2781
+ const inputs = [...queued, ...entries.filter((entry) => !excluded.has(entry.sessionRef) && !queued.some((input) => JSON.stringify(input) === JSON.stringify(entry.payload))).map((entry) => entry.payload)];
2782
+ for (const input of inputs) {
2783
+ if (blockedSessions.has(input.sessionRef) || store.find(input)?.baseStatus === "needs_review") continue;
2685
2784
  try { await sendSync(harness, input, config); synced += 1; }
2686
2785
  catch (error) {
2687
2786
  failures += 1;
@@ -2692,11 +2791,35 @@ async function drainPendingQueue(harness, config, blockedSessions) {
2692
2791
  return { synced, failures };
2693
2792
  }
2694
2793
 
2794
+ async function flushContinuityAttachments(harness, config) {
2795
+ const store = continuityState(harness, config);
2796
+ if (!store.load().outbox.some((entry) => entry.baseStatus === "accepted" && !["attached", "not_applicable", "needs_review"].includes(entry.continuityStatus))) return;
2797
+ let capable = false;
2798
+ try {
2799
+ const connection = await validateConnection(config.apiUrl, config.token, harness);
2800
+ capable = connection.capabilities?.continuityAttachment === 1;
2801
+ } catch { /* Base receipts remain valid while capability discovery is unavailable. */ }
2802
+ await require("./continuity-state.js").flushContinuity(store, config, capable);
2803
+ }
2804
+
2805
+ function restoreContinuityQueue(harness, config) {
2806
+ const queue = readPendingQueue(harness);
2807
+ const state = readState(harness);
2808
+ for (const entry of continuityState(harness, config).load().outbox) {
2809
+ if (entry.toTurn > (state.sessions[entry.sessionRef]?.lastSyncedTurn || 0) && entry.baseStatus !== "needs_review" && !queue.segments.some((payload) => JSON.stringify(payload) === JSON.stringify(entry.payload))) queuePayload(harness, entry.payload);
2810
+ }
2811
+ }
2812
+
2695
2813
  function eligibleQueuedSegments(queue, config) {
2696
2814
  const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
2697
2815
  return queue.segments.filter((input) => !excluded.has(input.sessionRef));
2698
2816
  }
2699
2817
 
2818
+ function continuityRecoveries(harness, config, queued) {
2819
+ const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
2820
+ return continuityState(harness, config).load().outbox.filter((entry) => entry.baseStatus === "awaiting_receipt" && !excluded.has(entry.sessionRef) && !queued.some((payload) => JSON.stringify(payload) === JSON.stringify(entry.payload))).map((entry) => entry.payload);
2821
+ }
2822
+
2700
2823
  function approvalNotificationInvocation(harness, count, sessionRef) {
2701
2824
  const prompt = `Shifu sync approval: ${count} redacted segment${count === 1 ? " is" : "s are"} waiting locally. Tell the user to ask you to show the pending Shifu sync and approve it once. Do not inspect files, run tools, or sync anything in this session.`;
2702
2825
  if (harness === "codex") {
@@ -2748,7 +2871,9 @@ function notifyManualApproval(harness, count, existingSessionRef) {
2748
2871
  async function showPending(harness) {
2749
2872
  const config = readConfig(harness);
2750
2873
  const result = await withSyncLock(harness, async () => {
2751
- console.log(JSON.stringify(await preparePendingQueue(harness, config), null, 2));
2874
+ const queue = await preparePendingQueue(harness, config);
2875
+ queue.segments = [...eligibleQueuedSegments(queue, config), ...continuityRecoveries(harness, config, queue.segments)];
2876
+ console.log(JSON.stringify(queue, null, 2));
2752
2877
  });
2753
2878
  if (result?.skipped) throw new Error("Another Shifu sync is already preparing this queue. Retry later.");
2754
2879
  }
@@ -2756,18 +2881,21 @@ async function showPending(harness) {
2756
2881
  async function approvePending(harness) {
2757
2882
  const config = readConfig(harness);
2758
2883
  const result = await withSyncLock(harness, async () => {
2884
+ restoreContinuityQueue(harness, config);
2759
2885
  const queue = prunePendingQueue(harness);
2760
2886
  queue.segments = eligibleQueuedSegments(queue, config);
2761
- if (queue.segments.length === 0) {
2887
+ const recoveries = continuityRecoveries(harness, config, queue.segments);
2888
+ if (queue.segments.length === 0 && recoveries.length === 0) {
2762
2889
  console.error("There is no prepared Shifu activity to approve. Run pending to prepare the next ranges.");
2763
2890
  return;
2764
2891
  }
2765
- console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
2892
+ console.error(`\nThese ${queue.segments.length + recoveries.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify([...queue.segments, ...recoveries], null, 2)}`);
2766
2893
  if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
2767
2894
  console.error("Nothing was sent. The prepared queue is unchanged.");
2768
2895
  return;
2769
2896
  }
2770
2897
  const sent = await drainPendingQueue(harness, config, new Set());
2898
+ await flushContinuityAttachments(harness, config);
2771
2899
  if (sent.failures) throw new Error("Some approved segments could not be acknowledged. Their exact payloads remain pending for retry.");
2772
2900
  });
2773
2901
  if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
@@ -2800,9 +2928,12 @@ async function scheduledSync(harness) {
2800
2928
  }
2801
2929
  }
2802
2930
  await flushPendingProfiles(harness, config);
2931
+ restoreContinuityQueue(harness, config);
2932
+ await flushContinuityAttachments(harness, config);
2803
2933
  if (policy.approvalMode === "manual") {
2804
2934
  const queue = await preparePendingQueue(harness, config);
2805
- const eligible = eligibleQueuedSegments(queue, config);
2935
+ const queued = eligibleQueuedSegments(queue, config);
2936
+ const eligible = [...queued, ...continuityRecoveries(harness, config, queued)];
2806
2937
  const pendingFingerprint = createHash("sha256").update(eligible.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
2807
2938
  const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
2808
2939
  let approvalSessionRef = state.schedule?.approvalSessionRef;
@@ -2860,6 +2991,7 @@ async function scheduledSync(harness) {
2860
2991
  nextRunAt: Date.now() + (failures || failedFiles || pendingProfiles || remaining ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
2861
2992
  };
2862
2993
  }
2994
+ await flushContinuityAttachments(harness, config);
2863
2995
  const latest = readState(harness);
2864
2996
  latest.schedule = state.schedule;
2865
2997
  saveState(harness, latest);
@@ -2890,7 +3022,7 @@ function status(harness) {
2890
3022
  if (!eligibility.eligible) initialHistoryBlocked.push({ sessionRef: session.sessionRef, reason: eligibility.reason, cutoffAt: eligibility.cutoffAt });
2891
3023
  }
2892
3024
  }
2893
- console.log(JSON.stringify({ harness, runnerVersion: RUNNER_VERSION, apiUrl: config.apiUrl, inputFolder: folder, inputFolderError, contextPreferences: config.contextPreferences === undefined ? null : normalizeContextPreferences(config.contextPreferences), historicalContext: true, initialBackfill: config.initialBackfill || null, initialHistoryBlocked, syncPolicy: normalizedPolicy(config), schedule: state.schedule || null, pendingSegments: pending.segments.length, completedFiles: entries.filter((entry) => typeof entry === "string").length, pendingFiles: entries.filter((entry) => typeof entry === "object" && entry?.jobId).length, unsupportedFiles, sessions: state.sessions }, null, 2));
3025
+ console.log(JSON.stringify({ continuity: continuityState(harness, config).summary(), harness, runnerVersion: RUNNER_VERSION, apiUrl: config.apiUrl, inputFolder: folder, inputFolderError, contextPreferences: config.contextPreferences === undefined ? null : normalizeContextPreferences(config.contextPreferences), historicalContext: true, initialBackfill: config.initialBackfill || null, initialHistoryBlocked, syncPolicy: normalizedPolicy(config), schedule: state.schedule || null, pendingSegments: pending.segments.length, completedFiles: entries.filter((entry) => typeof entry === "string").length, pendingFiles: entries.filter((entry) => typeof entry === "object" && entry?.jobId).length, unsupportedFiles, sessions: state.sessions }, null, 2));
2894
3026
  }
2895
3027
 
2896
3028
  async function syncFilesNow(harness) {
@@ -3130,21 +3262,21 @@ async function main() {
3130
3262
  if (command === "sync-files") return syncFilesNow(harness);
3131
3263
  if (command === "scheduled-sync") return scheduledSync(harness);
3132
3264
  if (command === "pending") return showPending(harness);
3265
+ if (command === "retry-preparation") return retryPreparation(harness);
3133
3266
  if (command === "approve") return approvePending(harness);
3134
3267
  if (command === "status") return status(harness);
3135
3268
  if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
3136
3269
  if (command === "review") return review(harness);
3137
3270
  if (command === "review-source") return console.log(JSON.stringify(reviewSourcePacket(harness, option("--session-ref"), numberOption("--from-turn"), numberOption("--to-turn")), null, 2));
3138
- throw new Error("Use install, connect, mcp-setup, mcp-headers, disconnect, config update, status, sessions, review, review-source, sync, sync-files, pending, approve, or scheduled-sync.");
3271
+ throw new Error("Use install, connect, mcp-setup, mcp-headers, disconnect, config update, status, sessions, review, review-source, sync, sync-files, pending, retry-preparation, approve, or scheduled-sync.");
3139
3272
  }
3140
3273
 
3141
3274
  if (require.main === module) {
3142
3275
  main()
3143
- .then(() => process.exit(0))
3144
3276
  .catch((error) => {
3145
3277
  console.error(error.message);
3146
- process.exit(1);
3278
+ process.exitCode = 1;
3147
3279
  });
3148
3280
  }
3149
3281
 
3150
- module.exports = { setupMcp, olderPackageVersion, updateRecommendedPackage, installPackageVersion, approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, connect, defaultInputFolder, disconnect, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, prepareV5Payload, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, reviewSourcePacket, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
3282
+ module.exports = { setupMcp, olderPackageVersion, updateRecommendedPackage, installPackageVersion, approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, connect, defaultInputFolder, disconnect, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, prepareV5Payload, preparationPacket, continuityState, flushContinuityAttachments, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, reviewSourcePacket, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
@@ -198,4 +198,29 @@ function completedSourceTurn(selection, records) {
198
198
  return Math.max(0, turnCount - (closed ? 0 : 1));
199
199
  }
200
200
 
201
- module.exports = { selectedSourcePacket, scheduledSourcePacket, completedSourceTurn };
201
+ function continuitySourcePacket(selection, records, minimumTurn = 1) {
202
+ const packet = scheduledSourcePacket(selection, records);
203
+ let first = Math.max(1, selection.fromTurn - 6, minimumTurn);
204
+ const adapters = { codex: codexSources, claude_code: claudeSources, opencode: openCodeSources };
205
+ const references = first < selection.fromTurn
206
+ ? adapters[selection.harness]({ ...selection, fromTurn: first, toTurn: selection.fromTurn - 1 }, records, true).sources : [];
207
+ const originalFirst = first;
208
+ let referenceSources = references;
209
+ function oversized() {
210
+ return Buffer.byteLength(JSON.stringify(referenceSources)) > 64 * 1024 ||
211
+ packet.sources.length + referenceSources.length > MAX_SOURCE_MESSAGES ||
212
+ Buffer.byteLength(JSON.stringify({ ...packet, referenceSources })) + 512 > MAX_PACKET_BYTES;
213
+ }
214
+ while (referenceSources.length && oversized()) {
215
+ first = referenceSources[0].turn + 1;
216
+ referenceSources = references.filter((source) => source.turn >= first);
217
+ }
218
+ return { ...packet, referenceSources, referenceCoverage: {
219
+ fromTurn: referenceSources.length ? referenceSources[0].turn : selection.fromTurn,
220
+ toTurn: selection.fromTurn - 1,
221
+ limited: first > Math.max(1, selection.fromTurn - 6) || minimumTurn > Math.max(1, selection.fromTurn - 6),
222
+ droppedTurns: Math.max(0, first - originalFirst),
223
+ } };
224
+ }
225
+
226
+ module.exports = { selectedSourcePacket, scheduledSourcePacket, continuitySourcePacket, completedSourceTurn };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@useshifu/coding-harness",
3
- "version": "0.3.29",
3
+ "version": "0.3.30",
4
4
  "description": "Scheduled, redacted coding-harness activity sync for Shifu",
5
5
  "bin": {
6
6
  "coding-harness": "bin/shifu-harness.js",
@@ -12,7 +12,7 @@ Before first connection or MCP personalization, ask for any choices that are not
12
12
  When asked to connect Shifu context, run:
13
13
 
14
14
  ```sh
15
- npx --yes @useshifu/coding-harness@0.3.29 mcp-setup --harness codex
15
+ npx --yes @useshifu/coding-harness@0.3.30 mcp-setup --harness codex
16
16
  ```
17
17
 
18
18
  MCP setup verifies the connection and tools, saves the choices and installs instructions; it does not sync sessions or upload the selected history. History selection governs eligible work for subsequent sync. Do not start sync as part of this setup unless the user separately asks for it. If a saved connection exists, reuse its key and do not run `connect` again.
@@ -117,7 +117,7 @@ For an explicit sync request, first read `status` to determine the saved approva
117
117
 
118
118
  Use `kind: "activity"` for general harness work; it does not require a technical scope. Evidence may be empty when the reviewed source contains no durable context. Never invent impact, ownership, verification, or outcomes. A requested check, assistant proposal, or reported result must retain that status even when it is useful context.
119
119
 
120
- Make each supported item useful for later recall: retain the described action, feature or project purpose, specific problem, approach, and reported verification or outcome with its status and ownership intact. Preserve supported technologies, design patterns, configuration choices, and measured results; these are useful work details, not identifiers to redact. A descriptive project label such as "payment reconciliation service" is useful; synthesize it from the work's purpose instead of copying a raw project label that might contain a company or person's name. Preserve supported decisions and their rationale, accepted and rejected tradeoffs, lessons, and observed outcomes. Keep proposals explicitly labeled as proposals; do not turn an agent suggestion into a user-adopted decision or infer personal ownership. Use statements, per-item work context, linked decisions and verification, and permitted source metadata. Keep distinct facts separate, including two fixes in one feature that addressed different problems. Omit names of companies and people, customer details, proprietary identifiers, paths, code snippets, credentials, and unsupported impact. If a detail cannot be safely described, keep the supported parts. Prefer a summary that names the main distinct work items over a generic count alone.
120
+ Make each supported item useful for later recall: retain the described action, feature or project purpose, specific problem, approach, and reported verification or outcome with its status and ownership intact. Preserve supported technologies, design patterns, configuration choices, and measured results; these are useful work details, not identifiers to redact. A descriptive project label such as "payment reconciliation service" is useful; synthesize it from the work's purpose instead of copying a raw project label that might contain a company or person's name. Preserve supported decisions and their rationale, accepted and rejected tradeoffs, lessons, and observed outcomes. Keep proposals explicitly labeled as proposals; do not turn an agent suggestion into a user-adopted decision or infer personal ownership. Use statements, per-item work context, linked decisions and verification, and permitted source metadata. Keep distinct facts separate, including two fixes in one feature that addressed different problems. Preserve the code-permitted public technology labels Turbopuffer, PostgreSQL, Go, TypeScript, React and Node.js when grounded and relevant. This does not authorize confidential customer or employer relationships. Omit other company and person names, customer details, proprietary identifiers, paths, code snippets, credentials, and unsupported impact. If a detail cannot be safely described, keep the supported parts. Prefer a summary that names the main distinct work items over a generic count alone.
121
121
 
122
122
  ```json
123
123
  {
@@ -157,3 +157,9 @@ Only when the user explicitly asks to force-sync, add `--force` to `sessions` wi
157
157
  Re-review each returned range with `review --harness codex --session-ref <sessionRef> --force --from-turn <fromTurn> --to-turn <toTurn>` and the same window (or `--all` for explicitly selected backfill/count selection). No prior sync ID is required. Read the returned final notes semantically and synthesize meaningful, attributable work evidence; do not use `payloadForSegment` or its unattended keyword projection for force sync. Completed work remains supported when the note uses Markdown such as `**Implemented**` or names a technique in inline code such as `Rust`. Retain supported technologies, design patterns, the problem, approach, and verified result while redacting names and sensitive identifiers. Never send an empty evidence list or a title-only replacement. If the notes support no work items, report that range as not replaced and leave its existing work and checkpoints untouched. Keep the returned reference and range unchanged and include `force: true` in the prepared payload. Send with `sync --harness codex --force` (plus `--approved` only after manual approval). If accepted ranges changed and partially overlap the selection, stop and re-discover/re-review the complete ranges instead of guessing boundaries.
158
158
 
159
159
  Force sync creates missing ranges or replaces existing exact ranges, preserves sync history, and never resets or lowers checkpoints. Automatic retries preserve the accepted result even if another force sync finishes meanwhile. A new invocation is a new explicit force request. Existing AI Profile observations stay unchanged. Do not add force to scheduled sync, approval queues, or file sync. Existing conversations keep their context; start a new chat in Shifu to use the latest work context.
160
+
161
+ ## Continuity preparation and review holds
162
+
163
+ Scheduled preparation and pending review use bounded original lookback, code-bound entity placeholders and a separate local continuity outbox. Never add continuity fields to a v5 payload or rewrite a frozen queued body. `status` separates preparation, base receipt and companion attachment. A base receipt does not establish successful continuity attachment. Missing server capability leaves the frozen metadata pending. Do not guess a target sync from turn ranges.
164
+
165
+ A preparation hold survives restarts and stops after two native attempts for unchanged input. Inspect its code-owned reason. Genuine neutral empties stay valid; never create filler evidence to clear a hold. A specific authorized retry of an unqueued held range can use `retry-preparation --harness codex --session-ref <opaque-session> --from-turn <original-start>`; this cannot replay accepted history or regenerate frozen transport. Replacing the key or API endpoint quarantines old checkpoints, queues, local entity dictionaries and continuity metadata. An interrupted replacement holds sync until connect resumes with the same selected connection. Do not upload, display or copy the raw-name dictionary into chat.