@useshifu/coding-harness 0.3.28 → 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.
@@ -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.28";
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;
@@ -109,13 +109,9 @@ function claudeSessionsRoot() {
109
109
  function opencodeQuery(query) {
110
110
  if (process.env.SHIFU_OPENCODE_DB && !process.env.SHIFU_OPENCODE_BIN) {
111
111
  try {
112
- const sqliteOutput = execFileSync("sqlite3", ["-json", process.env.SHIFU_OPENCODE_DB, query], {
113
- encoding: "utf8",
114
- stdio: ["ignore", "pipe", "pipe"],
115
- maxBuffer: 64 * 1024 * 1024,
116
- });
117
- return JSON.parse(sqliteOutput);
118
- } catch {
112
+ return openCodeDatabaseQuery(process.env.SHIFU_OPENCODE_DB, query);
113
+ } catch (error) {
114
+ if (error.code === "SHIFU_SQLITE_UNAVAILABLE") throw error;
119
115
  throw new Error("Could not read OpenCode session metadata. Ensure the opencode command is installed and its local database is available.");
120
116
  }
121
117
  }
@@ -137,16 +133,13 @@ function opencodeQuery(query) {
137
133
  try { fs.closeSync(fd); } catch {}
138
134
  fd = undefined;
139
135
  }
140
- const dbPath = process.env.SHIFU_OPENCODE_DB || path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "opencode", "opencode.db");
136
+ const dbPath = process.env.SHIFU_OPENCODE_DB || openCodeDatabasePath();
141
137
  if (fs.existsSync(dbPath)) {
142
138
  try {
143
- const sqliteOutput = execFileSync("sqlite3", ["-json", dbPath, query], {
144
- encoding: "utf8",
145
- stdio: ["ignore", "pipe", "pipe"],
146
- maxBuffer: 64 * 1024 * 1024,
147
- });
148
- return JSON.parse(sqliteOutput);
149
- } catch {}
139
+ return openCodeDatabaseQuery(dbPath, query);
140
+ } catch (error) {
141
+ if (error.code === "SHIFU_SQLITE_UNAVAILABLE") throw error;
142
+ }
150
143
  }
151
144
  throw new Error("Could not read OpenCode session metadata. Ensure the opencode command is installed and its local database is available.");
152
145
  } finally {
@@ -157,6 +150,56 @@ function opencodeQuery(query) {
157
150
  }
158
151
  }
159
152
 
153
+ function openCodeDatabasePath() {
154
+ try {
155
+ const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
156
+ const location = execFileSync(resolveNativeBinary("opencode"), ["debug", "paths", "db"], {
157
+ env: nativeEnvironment(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, maxBuffer: 1024 * 1024,
158
+ }).trim();
159
+ if (path.isAbsolute(location) && !/[\r\n\0]/.test(location)) return location;
160
+ } catch {}
161
+ return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "opencode", "opencode.db");
162
+ }
163
+
164
+ function openCodeDatabaseQuery(dbPath, query) {
165
+ function read(sql) {
166
+ let output;
167
+ try {
168
+ output = execFileSync("sqlite3", ["-readonly", "-json", "-cmd", ".timeout 5000", dbPath, sql], {
169
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000, maxBuffer: 64 * 1024 * 1024,
170
+ });
171
+ } catch (error) {
172
+ if (error.code !== "ENOENT") throw error;
173
+ const unavailable = new Error("Reading the local OpenCode database requires sqlite3. Install sqlite3 and retry; no work was synced.");
174
+ unavailable.code = "SHIFU_SQLITE_UNAVAILABLE";
175
+ throw unavailable;
176
+ }
177
+ return output.trim() ? JSON.parse(output) : [];
178
+ }
179
+ const tables = read("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('session_v2', 'session_message')");
180
+ if (tables.length !== 2) return read(query);
181
+ // V2 stores typed messages, ordered by sequence, instead of separate parts.
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");
184
+ const projection = `WITH
185
+ session AS (SELECT id, time_created, time_updated, ${hasDirectory ? "directory" : "NULL AS directory"} FROM session_v2),
186
+ message AS (
187
+ SELECT id, session_id, seq, time_created, json_object(
188
+ 'role', type, 'finish', CASE WHEN json_extract(data, '$.time.completed') IS NOT NULL THEN json_extract(data, '$.finish') END,
189
+ 'error', json_extract(data, '$.error'), 'modelID', json_extract(data, '$.model.id'), 'storageVersion', 2
190
+ ) AS data FROM session_message WHERE type IN ('user', 'assistant', 'idle')
191
+ ),
192
+ part AS (
193
+ SELECT id || '_user' AS id, id AS message_id, json_object('type', 'text', 'text', json_extract(data, '$.text')) AS data
194
+ FROM session_message WHERE type = 'user'
195
+ UNION ALL
196
+ SELECT m.id || '_' || printf('%08d', p.key), m.id, p.value
197
+ FROM session_message m, json_each(m.data, '$.content') p WHERE m.type = 'assistant'
198
+ ) `;
199
+ return read(projection + query.replaceAll("ORDER BY s.time_updated, m.time_created", "ORDER BY s.time_updated, m.seq")
200
+ .replaceAll("ORDER BY m.time_created, p.id", "ORDER BY m.seq, p.id"));
201
+ }
202
+
160
203
  function opencodeSessions(rows) {
161
204
  const sessions = new Map();
162
205
  for (const row of rows) {
@@ -202,6 +245,7 @@ function validateSyncOptions(command, start = 3) {
202
245
  "sync-files": common,
203
246
  "scheduled-sync": common,
204
247
  pending: common,
248
+ "retry-preparation": [...common, "--session-ref", "--from-turn"],
205
249
  approve: common,
206
250
  install: common,
207
251
  connect: [...common, ...setup, ...syncSettings, "--api-url"],
@@ -254,6 +298,7 @@ function writeJSON(file, value) {
254
298
  }
255
299
 
256
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.");
257
302
  const config = readJSON(configPath(harness), undefined);
258
303
  if (!config || typeof config.token !== "string" || typeof config.apiUrl !== "string") {
259
304
  throw new Error(`No ${HARNESS_NAMES[harness]} connection is configured. Run connect first.`);
@@ -589,7 +634,7 @@ function reviewSourcePacket(harness, sessionRef, fromTurn, toTurn, scheduled = f
589
634
 
590
635
  function sourceRecords(harness, sessionRef) {
591
636
  if (harness === "opencode") {
592
- return opencodeQuery(`SELECT m.id AS messageId, m.time_created AS createdAt, json_extract(m.data, '$.role') AS role, json_extract(m.data, '$.finish') AS finish, json_extract(m.data, '$.error') AS error, p.data AS part FROM message m LEFT JOIN part p ON p.message_id = m.id WHERE m.session_id = ${JSON.stringify(sessionRef)} ORDER BY m.time_created, p.id`);
637
+ return opencodeQuery(`SELECT m.id AS messageId, m.time_created AS createdAt, json_extract(m.data, '$.role') AS role, json_extract(m.data, '$.finish') AS finish, json_extract(m.data, '$.error') AS error, json_extract(m.data, '$.storageVersion') AS storageVersion, p.data AS part FROM message m LEFT JOIN part p ON p.message_id = m.id WHERE m.session_id = ${JSON.stringify(sessionRef)} ORDER BY m.time_created, p.id`);
593
638
  }
594
639
  const file = sessionFile(harness, sessionRef);
595
640
  try { return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
@@ -1231,7 +1276,7 @@ function copyRunner() {
1231
1276
  const installedDependency = path.join(configRoot(), "node_modules", name);
1232
1277
  if (!sameFile(dependency, installedDependency)) copyDirectory(dependency, installedDependency);
1233
1278
  }
1234
- for (const name of ["source-review.js", "v5-payload.js", "native-preparation.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"]) {
1235
1280
  const source = path.join(__dirname, name);
1236
1281
  const destination = path.join(configRoot(), name);
1237
1282
  if (!sameFile(source, destination)) {
@@ -1515,7 +1560,7 @@ async function connect(harness, readKey = readSecret, choices) {
1515
1560
  const token = (await readKey()).trim();
1516
1561
  if (!TOKEN_PATTERN.test(token)) throw new Error("The connection key is invalid.");
1517
1562
  const apiUrl = requireSafeApiUrl(option("--api-url") || existing?.apiUrl || process.env.SHIFU_API_URL || DEFAULT_API_URL);
1518
- await validateConnection(apiUrl, token, harness);
1563
+ const connectionInfo = await validateConnection(apiUrl, token, harness);
1519
1564
  const savedPolicy = existing && normalizedPolicy(existing);
1520
1565
  const currentPolicy = savedPolicy?.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic", excludedSessionRefs: savedPolicy?.excludedSessionRefs || [] };
1521
1566
  const selected = await chooseSyncPolicy(currentPolicy);
@@ -1528,15 +1573,17 @@ async function connect(harness, readKey = readSecret, choices) {
1528
1573
  install(harness, true, choices.contextPreferences);
1529
1574
  const scheduleFile = scheduleEnabled ? installSchedule(harness) : undefined;
1530
1575
  if (!scheduleEnabled && savedPolicy?.scheduleEnabled) removeSchedule(harness);
1531
- writeJSON(configPath(harness), { ...existing, ...choices, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder });
1532
- refreshManagedContextInstructions(harness, choices.contextPreferences);
1533
- if (refreshContextKey) writePrivateText(openCodeTokenPath(), token);
1534
- if (existing?.token && existing.token !== token) {
1535
- for (const file of [statePath(harness), pendingPath(harness)]) {
1536
- try { fs.unlinkSync(file); } catch (error) { if (error?.code !== "ENOENT") throw error; }
1537
- }
1538
- }
1539
- 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.");
1540
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."}`);
1541
1588
  console.error(`Shifu sync folder: ${inputFolder}. Place .txt, .md, .rtf, or .docx files there. Only grounded contributions will become claims.`);
1542
1589
  if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
@@ -2175,10 +2222,11 @@ function retryDelay(response, attempt) {
2175
2222
  return Math.min(base * (2 ** attempt), 30_000);
2176
2223
  }
2177
2224
 
2178
- async function postSync(config, input) {
2225
+ async function postSync(config, input, beforeAttempt, onPermanentFailure) {
2179
2226
  const body = JSON.stringify(input.force ? { ...input, forceRequestId: randomUUID() } : input);
2180
2227
  let lastError;
2181
2228
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
2229
+ beforeAttempt?.();
2182
2230
  let response;
2183
2231
  try {
2184
2232
  response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
@@ -2195,7 +2243,7 @@ async function postSync(config, input) {
2195
2243
  const payload = await response.json().catch(() => undefined);
2196
2244
  if (response.ok) return payload?.data;
2197
2245
  lastError = new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
2198
- if (response.status !== 429 && response.status < 500) throw lastError;
2246
+ if (response.status !== 429 && response.status < 500) { onPermanentFailure?.(); throw lastError; }
2199
2247
  if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
2200
2248
  }
2201
2249
  throw lastError;
@@ -2404,20 +2452,27 @@ async function sendSync(harness, input, config = readConfig(harness)) {
2404
2452
  validateSync(input, harness);
2405
2453
  const state = readState(harness);
2406
2454
  const checkpoint = state.sessions[input.sessionRef];
2455
+ const store = continuityState(harness, config);
2456
+ const frozen = store.find(input);
2407
2457
  const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
2408
2458
  const force = input.force || Boolean(input.supersedesSyncId);
2409
- 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.`);
2410
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.");
2411
- if (!force) input = queuePayload(harness, input);
2461
+ if (!force && (!frozen || input.toTurn > (checkpoint?.lastSyncedTurn || 0))) input = queuePayload(harness, input);
2412
2462
  let profile;
2413
2463
  if (!force) {
2414
2464
  try { profile = profileForSegment(harness, input); }
2415
2465
  catch { console.error("AI Profile source is unavailable; the prepared activity payload remains retryable."); }
2416
2466
  }
2417
- const receipt = await postSync(config, input);
2418
- if (!validSyncReceipt(receipt, input)) {
2419
- 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;
2420
2474
  }
2475
+ if (frozen) store.bind(input, receipt);
2421
2476
  if (profile) {
2422
2477
  state.profilePending ||= {};
2423
2478
  state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
@@ -2526,27 +2581,107 @@ async function reconcilePreparedSession(harness, sessionRef, config) {
2526
2581
  saveState(harness, state);
2527
2582
  }
2528
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
+
2529
2607
  async function prepareV5Payload(harness, segment, prepare = require("./native-preparation.js").prepareNative) {
2530
- 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 };
2531
2638
  const deadline = Date.now() + 180_000;
2532
2639
  let validationFeedback;
2533
2640
  for (let attempt = 0; attempt < 2; attempt += 1) {
2534
2641
  const remainingMs = deadline - Date.now();
2535
2642
  if (remainingMs <= 0) throw new Error("Native preparation timed out; the source remains pending.");
2536
- const output = await prepare(harness, packet, { validationFeedback, timeoutMs: remainingMs });
2643
+ const reservation = store.reservation(packet, bindings);
2644
+ let output;
2537
2645
  try {
2538
- const payload = require("./v5-payload.js").preparedPayload(packet, output);
2539
- validateSync(payload, harness);
2540
- 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;
2541
2653
  } catch (error) {
2542
- if (attempt === 1) throw error;
2543
- // Only local validator diagnostics enter the isolated correction prompt.
2544
- // Failed model output is never queued, echoed, or sent to Shifu.
2654
+ store.failure(packet);
2655
+ if (reservation.attempt >= 2 || output === undefined) throw error;
2545
2656
  validationFeedback = preparationValidationFeedback(error, output);
2546
2657
  }
2547
2658
  }
2548
2659
  }
2549
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
+
2550
2685
  function preparationValidationFeedback(error, output) {
2551
2686
  const fields = [["title", output?.title, 160], ["summary", output?.summary, 1100]];
2552
2687
  for (const name of ["decisions", "verification"]) {
@@ -2589,6 +2724,7 @@ function preparationState(harness, queue) {
2589
2724
  }
2590
2725
 
2591
2726
  async function preparePendingQueue(harness, config, blockedSessions = new Set()) {
2727
+ restoreContinuityQueue(harness, config);
2592
2728
  let queue = prunePendingQueue(harness);
2593
2729
  const previousFailures = new Set((queue.preparationErrors || []).map((error) => error.sessionRef));
2594
2730
  const errors = [];
@@ -2638,8 +2774,13 @@ async function preparePendingQueue(harness, config, blockedSessions = new Set())
2638
2774
  async function drainPendingQueue(harness, config, blockedSessions) {
2639
2775
  let synced = 0;
2640
2776
  let failures = 0;
2641
- for (const input of eligibleQueuedSegments(readPendingQueue(harness), config)) {
2642
- 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;
2643
2784
  try { await sendSync(harness, input, config); synced += 1; }
2644
2785
  catch (error) {
2645
2786
  failures += 1;
@@ -2650,11 +2791,35 @@ async function drainPendingQueue(harness, config, blockedSessions) {
2650
2791
  return { synced, failures };
2651
2792
  }
2652
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
+
2653
2813
  function eligibleQueuedSegments(queue, config) {
2654
2814
  const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
2655
2815
  return queue.segments.filter((input) => !excluded.has(input.sessionRef));
2656
2816
  }
2657
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
+
2658
2823
  function approvalNotificationInvocation(harness, count, sessionRef) {
2659
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.`;
2660
2825
  if (harness === "codex") {
@@ -2706,7 +2871,9 @@ function notifyManualApproval(harness, count, existingSessionRef) {
2706
2871
  async function showPending(harness) {
2707
2872
  const config = readConfig(harness);
2708
2873
  const result = await withSyncLock(harness, async () => {
2709
- 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));
2710
2877
  });
2711
2878
  if (result?.skipped) throw new Error("Another Shifu sync is already preparing this queue. Retry later.");
2712
2879
  }
@@ -2714,18 +2881,21 @@ async function showPending(harness) {
2714
2881
  async function approvePending(harness) {
2715
2882
  const config = readConfig(harness);
2716
2883
  const result = await withSyncLock(harness, async () => {
2884
+ restoreContinuityQueue(harness, config);
2717
2885
  const queue = prunePendingQueue(harness);
2718
2886
  queue.segments = eligibleQueuedSegments(queue, config);
2719
- if (queue.segments.length === 0) {
2887
+ const recoveries = continuityRecoveries(harness, config, queue.segments);
2888
+ if (queue.segments.length === 0 && recoveries.length === 0) {
2720
2889
  console.error("There is no prepared Shifu activity to approve. Run pending to prepare the next ranges.");
2721
2890
  return;
2722
2891
  }
2723
- 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)}`);
2724
2893
  if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
2725
2894
  console.error("Nothing was sent. The prepared queue is unchanged.");
2726
2895
  return;
2727
2896
  }
2728
2897
  const sent = await drainPendingQueue(harness, config, new Set());
2898
+ await flushContinuityAttachments(harness, config);
2729
2899
  if (sent.failures) throw new Error("Some approved segments could not be acknowledged. Their exact payloads remain pending for retry.");
2730
2900
  });
2731
2901
  if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
@@ -2758,9 +2928,12 @@ async function scheduledSync(harness) {
2758
2928
  }
2759
2929
  }
2760
2930
  await flushPendingProfiles(harness, config);
2931
+ restoreContinuityQueue(harness, config);
2932
+ await flushContinuityAttachments(harness, config);
2761
2933
  if (policy.approvalMode === "manual") {
2762
2934
  const queue = await preparePendingQueue(harness, config);
2763
- const eligible = eligibleQueuedSegments(queue, config);
2935
+ const queued = eligibleQueuedSegments(queue, config);
2936
+ const eligible = [...queued, ...continuityRecoveries(harness, config, queued)];
2764
2937
  const pendingFingerprint = createHash("sha256").update(eligible.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
2765
2938
  const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
2766
2939
  let approvalSessionRef = state.schedule?.approvalSessionRef;
@@ -2818,6 +2991,7 @@ async function scheduledSync(harness) {
2818
2991
  nextRunAt: Date.now() + (failures || failedFiles || pendingProfiles || remaining ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
2819
2992
  };
2820
2993
  }
2994
+ await flushContinuityAttachments(harness, config);
2821
2995
  const latest = readState(harness);
2822
2996
  latest.schedule = state.schedule;
2823
2997
  saveState(harness, latest);
@@ -2848,7 +3022,7 @@ function status(harness) {
2848
3022
  if (!eligibility.eligible) initialHistoryBlocked.push({ sessionRef: session.sessionRef, reason: eligibility.reason, cutoffAt: eligibility.cutoffAt });
2849
3023
  }
2850
3024
  }
2851
- 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));
2852
3026
  }
2853
3027
 
2854
3028
  async function syncFilesNow(harness) {
@@ -3088,21 +3262,21 @@ async function main() {
3088
3262
  if (command === "sync-files") return syncFilesNow(harness);
3089
3263
  if (command === "scheduled-sync") return scheduledSync(harness);
3090
3264
  if (command === "pending") return showPending(harness);
3265
+ if (command === "retry-preparation") return retryPreparation(harness);
3091
3266
  if (command === "approve") return approvePending(harness);
3092
3267
  if (command === "status") return status(harness);
3093
3268
  if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
3094
3269
  if (command === "review") return review(harness);
3095
3270
  if (command === "review-source") return console.log(JSON.stringify(reviewSourcePacket(harness, option("--session-ref"), numberOption("--from-turn"), numberOption("--to-turn")), null, 2));
3096
- 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.");
3097
3272
  }
3098
3273
 
3099
3274
  if (require.main === module) {
3100
3275
  main()
3101
- .then(() => process.exit(0))
3102
3276
  .catch((error) => {
3103
3277
  console.error(error.message);
3104
- process.exit(1);
3278
+ process.exitCode = 1;
3105
3279
  });
3106
3280
  }
3107
3281
 
3108
- 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 };
@@ -191,10 +191,36 @@ function completedSourceTurn(selection, records) {
191
191
  if (message.role === "user") {
192
192
  turnCount += 1;
193
193
  closed = false;
194
- } else if (message.role === "assistant") closed = message.finish === "stop" && message.error == null;
194
+ } else if (message.role === "assistant") closed = message.storageVersion !== 2 && message.finish === "stop" && message.error == null;
195
+ else if (message.storageVersion === 2 && message.role === "idle") closed = true;
195
196
  }
196
197
  } else throw new Error("Unsupported original-source harness.");
197
198
  return Math.max(0, turnCount - (closed ? 0 : 1));
198
199
  }
199
200
 
200
- 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.28",
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.28 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.