@useshifu/coding-harness 0.3.24 → 0.3.25
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 +21 -13
- package/bin/native-preparation.js +261 -0
- package/bin/shifu-harness.js +231 -83
- package/bin/source-review.js +101 -9
- package/bin/v5-payload.js +112 -0
- package/commands/shifu-sync.md +8 -6
- package/package.json +3 -2
- package/skills/shifu-sync/SKILL.md +32 -18
package/bin/shifu-harness.js
CHANGED
|
@@ -42,8 +42,11 @@ const MAX_TURNS_PER_SEGMENT = 12;
|
|
|
42
42
|
const MAX_TITLE_LENGTH = 160;
|
|
43
43
|
const MAX_WORK_DETAIL_LENGTH = 600;
|
|
44
44
|
const CONFIG_VERSION = 2;
|
|
45
|
-
const RUNNER_VERSION = "0.3.
|
|
46
|
-
const REDACTION_VERSION =
|
|
45
|
+
const RUNNER_VERSION = "0.3.25";
|
|
46
|
+
const REDACTION_VERSION = 5;
|
|
47
|
+
const LEGACY_REDACTION_VERSION = 4;
|
|
48
|
+
const MAX_PREPARATIONS_PER_RUN = 4;
|
|
49
|
+
const PREPARATION_RUN_MS = 5 * 60_000;
|
|
47
50
|
const DEFAULT_INTERVAL_HOURS = 1;
|
|
48
51
|
const LEGACY_INTERVAL_HOURS = 4;
|
|
49
52
|
const MIN_INTERVAL_HOURS = 0.25;
|
|
@@ -115,8 +118,10 @@ function opencodeQuery(query) {
|
|
|
115
118
|
const temporary = path.join(os.tmpdir(), `shifu-opencode-${randomUUID()}.json`);
|
|
116
119
|
let fd;
|
|
117
120
|
try {
|
|
121
|
+
const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
|
|
118
122
|
fd = fs.openSync(temporary, "wx", 0o600);
|
|
119
|
-
execFileSync(
|
|
123
|
+
execFileSync(resolveNativeBinary("opencode"), ["db", query, "--format", "json"], {
|
|
124
|
+
env: nativeEnvironment(),
|
|
120
125
|
stdio: ["ignore", fd, "pipe"],
|
|
121
126
|
maxBuffer: 64 * 1024 * 1024,
|
|
122
127
|
});
|
|
@@ -562,20 +567,23 @@ function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
|
|
|
562
567
|
return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
|
|
563
568
|
}
|
|
564
569
|
|
|
565
|
-
function reviewSourcePacket(harness, sessionRef, fromTurn, toTurn) {
|
|
570
|
+
function reviewSourcePacket(harness, sessionRef, fromTurn, toTurn, scheduled = false) {
|
|
566
571
|
if (!sessionRefIsSafe(sessionRef) || !OPAQUE_SESSION_REF_PATTERN.test(sessionRef)) throw new Error("Provide an opaque --session-ref for selected local source review.");
|
|
567
572
|
if (!Number.isInteger(fromTurn) || !Number.isInteger(toTurn) || fromTurn < 1 || toTurn < fromTurn || toTurn > 1_000_000 || toTurn - fromTurn >= MAX_TURNS_PER_SEGMENT) {
|
|
568
573
|
throw new Error("Select --from-turn and --to-turn explicitly, covering at most 12 original turns.");
|
|
569
574
|
}
|
|
570
|
-
|
|
575
|
+
const records = sourceRecords(harness, sessionRef);
|
|
576
|
+
const adapter = require("./source-review.js");
|
|
577
|
+
return (scheduled ? adapter.scheduledSourcePacket : adapter.selectedSourcePacket)({ harness, sessionRef, fromTurn, toTurn }, records);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function sourceRecords(harness, sessionRef) {
|
|
571
581
|
if (harness === "opencode") {
|
|
572
|
-
|
|
573
|
-
} else {
|
|
574
|
-
const file = sessionFile(harness, sessionRef);
|
|
575
|
-
try { records = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
|
|
576
|
-
catch { throw new Error("Could not read selected original messages locally. Nothing was reconstructed or synced."); }
|
|
582
|
+
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`);
|
|
577
583
|
}
|
|
578
|
-
|
|
584
|
+
const file = sessionFile(harness, sessionRef);
|
|
585
|
+
try { return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
|
|
586
|
+
catch { throw new Error("Could not read selected original messages locally. Nothing was reconstructed or synced."); }
|
|
579
587
|
}
|
|
580
588
|
|
|
581
589
|
function sessionFile(harness, sessionRef) {
|
|
@@ -953,6 +961,7 @@ function safeActivityEvidence(notes) {
|
|
|
953
961
|
return evidence;
|
|
954
962
|
}
|
|
955
963
|
|
|
964
|
+
// Retained for legacy comparison adapters; schedules use prepareV5Payload.
|
|
956
965
|
function payloadForSegment(harness, segment) {
|
|
957
966
|
if (segment.force || segment.supersedesSyncId) throw new Error("Force sync requires reviewing final notes and preparing meaningful redacted evidence; do not use the unattended payload projection.");
|
|
958
967
|
const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
|
|
@@ -974,7 +983,7 @@ function payloadForSegment(harness, segment) {
|
|
|
974
983
|
evidence,
|
|
975
984
|
verification: [],
|
|
976
985
|
decisions: evidence.filter((item) => /^(?:Decided|Rejected) /.test(item.title)).slice(0, MAX_SYNC_ITEMS).map((item) => item.statement.length <= 240 ? item.statement : `${item.statement.slice(0, 239).replace(/\s+\S*$/, "")}…`),
|
|
977
|
-
redactionVersion:
|
|
986
|
+
redactionVersion: LEGACY_REDACTION_VERSION,
|
|
978
987
|
};
|
|
979
988
|
}
|
|
980
989
|
|
|
@@ -1145,8 +1154,7 @@ function unsyncedCodexSessions(harness, options = {}) {
|
|
|
1145
1154
|
return selectedSessionSegments(harness, options).filter((session) => session.fromTurn <= session.toTurn);
|
|
1146
1155
|
}
|
|
1147
1156
|
|
|
1148
|
-
function pendingSegments(harness) {
|
|
1149
|
-
const state = readState(harness);
|
|
1157
|
+
function pendingSegments(harness, state = readState(harness)) {
|
|
1150
1158
|
const policy = normalizedPolicy(readConfig(harness));
|
|
1151
1159
|
const excluded = new Set(policy.excludedSessionRefs);
|
|
1152
1160
|
const segments = [];
|
|
@@ -1154,15 +1162,10 @@ function pendingSegments(harness) {
|
|
|
1154
1162
|
.filter((candidate) => !excluded.has(candidate.sessionRef))
|
|
1155
1163
|
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))) {
|
|
1156
1164
|
let fromTurn = (state.sessions[session.sessionRef]?.lastSyncedTurn || 0) + 1;
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
let completedTo = fromTurn - 1;
|
|
1162
|
-
while (completedTurns.has(completedTo + 1)) completedTo += 1;
|
|
1163
|
-
if (completedTo < fromTurn) break;
|
|
1164
|
-
toTurn = completedTo;
|
|
1165
|
-
}
|
|
1165
|
+
if (fromTurn > session.turnCount) continue;
|
|
1166
|
+
const completedTo = require("./source-review.js").completedSourceTurn({ harness }, sourceRecords(harness, session.sessionRef));
|
|
1167
|
+
while (fromTurn <= completedTo) {
|
|
1168
|
+
const toTurn = Math.min(completedTo, fromTurn + MAX_TURNS_PER_SEGMENT - 1);
|
|
1166
1169
|
segments.push({ ...session, fromTurn, toTurn });
|
|
1167
1170
|
fromTurn = toTurn + 1;
|
|
1168
1171
|
}
|
|
@@ -1203,14 +1206,18 @@ async function readSecret(input = process.stdin, output = process.stderr) {
|
|
|
1203
1206
|
|
|
1204
1207
|
function copyRunner() {
|
|
1205
1208
|
fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1209
|
+
for (const name of ["jsonc-parser", "smol-toml"]) {
|
|
1210
|
+
const dependency = name === "smol-toml" ? path.dirname(path.dirname(require.resolve(name))) : path.dirname(require.resolve(`${name}/package.json`));
|
|
1211
|
+
const installedDependency = path.join(configRoot(), "node_modules", name);
|
|
1212
|
+
if (path.resolve(dependency) !== path.resolve(installedDependency)) copyDirectory(dependency, installedDependency);
|
|
1213
|
+
}
|
|
1214
|
+
for (const name of ["source-review.js", "v5-payload.js", "native-preparation.js"]) {
|
|
1215
|
+
const source = path.join(__dirname, name);
|
|
1216
|
+
const destination = path.join(configRoot(), name);
|
|
1217
|
+
if (path.resolve(source) !== path.resolve(destination)) {
|
|
1218
|
+
fs.copyFileSync(source, destination);
|
|
1219
|
+
fs.chmodSync(destination, 0o600);
|
|
1220
|
+
}
|
|
1214
1221
|
}
|
|
1215
1222
|
const temporary = `${installedRunnerPath()}.${process.pid}.tmp`;
|
|
1216
1223
|
try {
|
|
@@ -2000,8 +2007,8 @@ async function updateConfig(harness) {
|
|
|
2000
2007
|
if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
|
|
2001
2008
|
}
|
|
2002
2009
|
|
|
2003
|
-
function textIsSafe(value, maximum) {
|
|
2004
|
-
return typeof value === "string" && value.trim().length > 0 && value.length <= maximum && !/[\x00-\x1f\x7f]/.test(value) && !PHONE_NUMBER_PATTERN.test(value) && !UNSAFE_CODE_PATTERN.test(value) && !CREDENTIAL_ASSIGNMENT_PATTERN.test(value) && !CLOUD_CREDENTIAL_PATTERN.test(value) && !SENSITIVE_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern));
|
|
2010
|
+
function textIsSafe(value, maximum, unicode = false) {
|
|
2011
|
+
return typeof value === "string" && value.trim().length > 0 && (unicode ? [...value].length : value.length) <= maximum && !/[\x00-\x1f\x7f]/.test(value) && !PHONE_NUMBER_PATTERN.test(value) && !UNSAFE_CODE_PATTERN.test(value) && !CREDENTIAL_ASSIGNMENT_PATTERN.test(value) && !CLOUD_CREDENTIAL_PATTERN.test(value) && !SENSITIVE_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern));
|
|
2005
2012
|
}
|
|
2006
2013
|
|
|
2007
2014
|
function sessionRefIsSafe(value) {
|
|
@@ -2012,26 +2019,27 @@ function sessionRefIsSafe(value) {
|
|
|
2012
2019
|
}
|
|
2013
2020
|
|
|
2014
2021
|
function validateSync(input, harness) {
|
|
2022
|
+
const safeText = (value, maximum) => textIsSafe(value, maximum, input?.redactionVersion === 5);
|
|
2015
2023
|
if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
|
|
2016
2024
|
if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
|
|
2017
2025
|
if (Object.hasOwn(input, "supersedesSyncId") && (!Number.isSafeInteger(input.supersedesSyncId) || input.supersedesSyncId < 1)) throw new Error("Use the positive supersedesSyncId returned by force discovery.");
|
|
2018
2026
|
if (Object.hasOwn(input, "force") && typeof input.force !== "boolean") throw new Error("Use force: true only for explicitly requested force sync.");
|
|
2019
2027
|
if (input.force && input.supersedesSyncId) throw new Error("Use force: true without supersedesSyncId, or the legacy supersedesSyncId mode alone.");
|
|
2020
2028
|
if (!Number.isInteger(input.fromTurn) || !Number.isInteger(input.toTurn) || input.fromTurn < 1 || input.toTurn < input.fromTurn || input.toTurn > 1_000_000 || input.toTurn - input.fromTurn >= MAX_TURNS_PER_SEGMENT) throw new Error("Use a valid incremental range of at most 12 turns.");
|
|
2021
|
-
if (input.redactionVersion >= 3 && !
|
|
2022
|
-
if (!
|
|
2029
|
+
if (input.redactionVersion >= 3 && !safeText(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
|
|
2030
|
+
if (!safeText(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
|
|
2023
2031
|
const forced = input.force || Boolean(input.supersedesSyncId);
|
|
2024
2032
|
if (forced && Array.isArray(input.evidence) && input.evidence.length === 0) throw new Error("Force sync has no supported evidence. Re-review the final notes and prepare meaningful redacted work items; existing work and checkpoints were left untouched.");
|
|
2025
|
-
const minimumEvidence = forced || input.redactionVersion <
|
|
2033
|
+
const minimumEvidence = forced || input.redactionVersion < 4 ? 1 : 0;
|
|
2026
2034
|
if (!Array.isArray(input.evidence) || input.evidence.length < minimumEvidence || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => {
|
|
2027
|
-
if (!item || !EVIDENCE_KINDS.has(item.kind) || (input.redactionVersion >= 3 && !
|
|
2028
|
-
if (item.kind === "activity" && input.redactionVersion >=
|
|
2035
|
+
if (!item || !EVIDENCE_KINDS.has(item.kind) || (input.redactionVersion >= 3 && !safeText(item.title, MAX_TITLE_LENGTH)) || !safeText(item.statement, MAX_WORK_DETAIL_LENGTH)) return false;
|
|
2036
|
+
if (item.kind === "activity" && input.redactionVersion >= 4) return item.scope === undefined || item.scope === "";
|
|
2029
2037
|
return SCOPE_LEVELS.has(item.scope);
|
|
2030
2038
|
})) {
|
|
2031
2039
|
throw new Error(`evidence must contain ${minimumEvidence}-${MAX_EVIDENCE_ITEMS} redacted activity or work items with valid titles, details, kinds, and scopes.`);
|
|
2032
2040
|
}
|
|
2033
2041
|
for (const field of ["verification", "decisions"]) {
|
|
2034
|
-
if (!Array.isArray(input[field]) || input[field].length > MAX_SYNC_ITEMS || !input[field].every((item) =>
|
|
2042
|
+
if (!Array.isArray(input[field]) || input[field].length > MAX_SYNC_ITEMS || !input[field].every((item) => safeText(item, 240))) throw new Error(`${field} must contain at most ${MAX_SYNC_ITEMS} redacted statements.`);
|
|
2035
2043
|
}
|
|
2036
2044
|
if (!input.evidence.every((item) =>
|
|
2037
2045
|
Object.keys(item).every((field) => (input.redactionVersion === 5 ? REVIEWED_EVIDENCE_FIELDS : EVIDENCE_FIELDS).has(field)) &&
|
|
@@ -2051,7 +2059,7 @@ function validateSync(input, harness) {
|
|
|
2051
2059
|
if (identities.has(identity)) throw new Error("Each work item must have a distinct identity.");
|
|
2052
2060
|
identities.add(identity);
|
|
2053
2061
|
}
|
|
2054
|
-
if (
|
|
2062
|
+
if (![2, 3, 4, 5].includes(input.redactionVersion)) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs; existing version-two through version-four retries remain supported.`);
|
|
2055
2063
|
}
|
|
2056
2064
|
|
|
2057
2065
|
function validUTCEventTime(value) {
|
|
@@ -2340,7 +2348,12 @@ async function sendSync(harness, input, config = readConfig(harness)) {
|
|
|
2340
2348
|
const force = input.force || Boolean(input.supersedesSyncId);
|
|
2341
2349
|
if (!force && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
|
|
2342
2350
|
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.");
|
|
2343
|
-
|
|
2351
|
+
if (!force) input = queuePayload(harness, input);
|
|
2352
|
+
let profile;
|
|
2353
|
+
if (!force) {
|
|
2354
|
+
try { profile = profileForSegment(harness, input); }
|
|
2355
|
+
catch { console.error("AI Profile source is unavailable; the prepared activity payload remains retryable."); }
|
|
2356
|
+
}
|
|
2344
2357
|
const receipt = await postSync(config, input);
|
|
2345
2358
|
if (!validSyncReceipt(receipt, input)) {
|
|
2346
2359
|
throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
|
|
@@ -2349,8 +2362,9 @@ async function sendSync(harness, input, config = readConfig(harness)) {
|
|
|
2349
2362
|
state.profilePending ||= {};
|
|
2350
2363
|
state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
|
|
2351
2364
|
}
|
|
2352
|
-
state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: Math.max(checkpoint?.lastSyncedTurn || 0, receipt.lastSyncedTurn), syncDue: false };
|
|
2365
|
+
state.sessions[input.sessionRef] = { ...checkpoint, turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: Math.max(checkpoint?.lastSyncedTurn || 0, receipt.lastSyncedTurn), syncDue: false };
|
|
2353
2366
|
saveState(harness, state);
|
|
2367
|
+
if (!force) prunePendingQueue(harness);
|
|
2354
2368
|
if (profile) await flushPendingProfiles(harness, config);
|
|
2355
2369
|
console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : force ? `Force synced the reviewed range for turns ${input.fromTurn}–${input.toTurn}.` : `Synced through turn ${receipt.lastSyncedTurn}.`);
|
|
2356
2370
|
return receipt;
|
|
@@ -2403,13 +2417,142 @@ async function sync(harness) {
|
|
|
2403
2417
|
if (result?.skipped) throw new Error("Another Shifu sync is already running. This segment was not sent; retry later.");
|
|
2404
2418
|
}
|
|
2405
2419
|
|
|
2406
|
-
function
|
|
2407
|
-
const
|
|
2408
|
-
|
|
2420
|
+
function readPendingQueue(harness) {
|
|
2421
|
+
const queue = readJSON(pendingPath(harness), { segments: [] });
|
|
2422
|
+
if (!Array.isArray(queue.segments)) throw new Error("The prepared sync queue is invalid. Existing work was left untouched.");
|
|
2423
|
+
for (const input of queue.segments) {
|
|
2424
|
+
validateSync(input, harness);
|
|
2425
|
+
if (input.force || input.supersedesSyncId) throw new Error("Historical replacements cannot enter the automatic queue.");
|
|
2426
|
+
}
|
|
2427
|
+
return queue;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function prunePendingQueue(harness) {
|
|
2431
|
+
const queue = readPendingQueue(harness);
|
|
2432
|
+
const state = readState(harness);
|
|
2433
|
+
queue.segments = queue.segments.filter((input) => input.toTurn > (state.sessions[input.sessionRef]?.lastSyncedTurn || 0));
|
|
2434
|
+
queue.updatedAt = new Date().toISOString();
|
|
2409
2435
|
writeJSON(pendingPath(harness), queue);
|
|
2410
2436
|
return queue;
|
|
2411
2437
|
}
|
|
2412
2438
|
|
|
2439
|
+
function queuePayload(harness, input) {
|
|
2440
|
+
const queue = prunePendingQueue(harness);
|
|
2441
|
+
const overlap = queue.segments.find((queued) => queued.sessionRef === input.sessionRef && queued.fromTurn <= input.toTurn && queued.toTurn >= input.fromTurn);
|
|
2442
|
+
if (overlap) {
|
|
2443
|
+
if (JSON.stringify(overlap) !== JSON.stringify(input)) throw new Error("This range already has a different prepared payload. Retry the queued payload before preparing another version.");
|
|
2444
|
+
return overlap;
|
|
2445
|
+
}
|
|
2446
|
+
queue.segments.push(input);
|
|
2447
|
+
writeJSON(pendingPath(harness), queue);
|
|
2448
|
+
return input;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
async function reconcilePreparedSession(harness, sessionRef, config) {
|
|
2452
|
+
const state = readState(harness);
|
|
2453
|
+
const checkpoint = state.sessions[sessionRef] || {};
|
|
2454
|
+
if (checkpoint.preparationVersion === REDACTION_VERSION) return;
|
|
2455
|
+
if (readPendingQueue(harness).segments.some((input) => input.sessionRef === sessionRef)) return;
|
|
2456
|
+
// Older runners did not persist automatic payloads before HTTP. Recover an
|
|
2457
|
+
// accepted prefix before switching an uncertain legacy range to v5.
|
|
2458
|
+
const accepted = await acceptedSegments(config, sessionRef);
|
|
2459
|
+
let lastSyncedTurn = checkpoint.lastSyncedTurn || 0;
|
|
2460
|
+
for (const segment of accepted) {
|
|
2461
|
+
if (segment.toTurn <= lastSyncedTurn) continue;
|
|
2462
|
+
if (segment.fromTurn !== lastSyncedTurn + 1) throw new Error("Accepted sync history has a gap. This session remains pending.");
|
|
2463
|
+
lastSyncedTurn = segment.toTurn;
|
|
2464
|
+
}
|
|
2465
|
+
state.sessions[sessionRef] = { ...checkpoint, lastSyncedTurn, preparationVersion: REDACTION_VERSION };
|
|
2466
|
+
saveState(harness, state);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
async function prepareV5Payload(harness, segment, prepare = require("./native-preparation.js").prepareNative) {
|
|
2470
|
+
const packet = reviewSourcePacket(harness, segment.sessionRef, segment.fromTurn, segment.toTurn, true);
|
|
2471
|
+
const output = await prepare(harness, packet);
|
|
2472
|
+
const payload = require("./v5-payload.js").preparedPayload(packet, output);
|
|
2473
|
+
validateSync(payload, harness);
|
|
2474
|
+
return payload;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
function preparationState(harness, queue) {
|
|
2478
|
+
const state = readState(harness);
|
|
2479
|
+
const virtual = { ...state, sessions: structuredClone(state.sessions) };
|
|
2480
|
+
const blocked = new Set();
|
|
2481
|
+
for (const input of [...queue.segments].sort((a, b) => a.fromTurn - b.fromTurn)) {
|
|
2482
|
+
const prior = virtual.sessions[input.sessionRef] || {};
|
|
2483
|
+
if (input.fromTurn !== (prior.lastSyncedTurn || 0) + 1) blocked.add(input.sessionRef);
|
|
2484
|
+
else virtual.sessions[input.sessionRef] = { ...prior, lastSyncedTurn: input.toTurn };
|
|
2485
|
+
}
|
|
2486
|
+
return { virtual, blocked };
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
async function preparePendingQueue(harness, config, blockedSessions = new Set()) {
|
|
2490
|
+
let queue = prunePendingQueue(harness);
|
|
2491
|
+
const previousFailures = new Set((queue.preparationErrors || []).map((error) => error.sessionRef));
|
|
2492
|
+
const errors = [];
|
|
2493
|
+
const attempted = new Set();
|
|
2494
|
+
const deadline = Date.now() + PREPARATION_RUN_MS;
|
|
2495
|
+
let attempts = 0;
|
|
2496
|
+
while (attempts < MAX_PREPARATIONS_PER_RUN && Date.now() < deadline) {
|
|
2497
|
+
const { virtual, blocked } = preparationState(harness, queue);
|
|
2498
|
+
const candidates = pendingSegments(harness, virtual).filter((item) => !blocked.has(item.sessionRef) && !blockedSessions.has(item.sessionRef));
|
|
2499
|
+
const segment = candidates.find((item) => !previousFailures.has(item.sessionRef)) || candidates[0];
|
|
2500
|
+
if (!segment) break;
|
|
2501
|
+
try {
|
|
2502
|
+
const original = readState(harness).sessions[segment.sessionRef];
|
|
2503
|
+
if (original?.preparationVersion !== REDACTION_VERSION && queue.segments.some((input) => input.sessionRef === segment.sessionRef)) {
|
|
2504
|
+
blockedSessions.add(segment.sessionRef);
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
if (!attempted.has(segment.sessionRef)) {
|
|
2508
|
+
attempted.add(segment.sessionRef);
|
|
2509
|
+
await reconcilePreparedSession(harness, segment.sessionRef, config);
|
|
2510
|
+
// Remote acknowledgement can change the next unsynced range.
|
|
2511
|
+
continue;
|
|
2512
|
+
}
|
|
2513
|
+
let payload;
|
|
2514
|
+
attempts += 1;
|
|
2515
|
+
for (let toTurn = segment.toTurn; toTurn >= segment.fromTurn; toTurn -= 1) {
|
|
2516
|
+
try { payload = await prepareV5Payload(harness, { ...segment, toTurn }); break; }
|
|
2517
|
+
catch (error) {
|
|
2518
|
+
if (toTurn > segment.fromTurn && /exceeds 128 messages or 256 KiB/.test(error.message)) continue;
|
|
2519
|
+
throw error;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
queuePayload(harness, payload);
|
|
2523
|
+
queue = readPendingQueue(harness);
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
blockedSessions.add(segment.sessionRef);
|
|
2526
|
+
errors.push({ sessionRef: segment.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, reason: error.message });
|
|
2527
|
+
console.error(`${HARNESS_NAMES[harness]} preparation remains pending: ${error.message}`);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
queue = prunePendingQueue(harness);
|
|
2531
|
+
queue.preparationErrors = errors;
|
|
2532
|
+
writeJSON(pendingPath(harness), queue);
|
|
2533
|
+
return queue;
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
async function drainPendingQueue(harness, config, blockedSessions) {
|
|
2537
|
+
let synced = 0;
|
|
2538
|
+
let failures = 0;
|
|
2539
|
+
for (const input of eligibleQueuedSegments(readPendingQueue(harness), config)) {
|
|
2540
|
+
if (blockedSessions.has(input.sessionRef)) continue;
|
|
2541
|
+
try { await sendSync(harness, input, config); synced += 1; }
|
|
2542
|
+
catch (error) {
|
|
2543
|
+
failures += 1;
|
|
2544
|
+
blockedSessions.add(input.sessionRef);
|
|
2545
|
+
console.error(`${HARNESS_NAMES[harness]} prepared segment remains pending: ${error.message}`);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return { synced, failures };
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function eligibleQueuedSegments(queue, config) {
|
|
2552
|
+
const excluded = new Set(normalizedPolicy(config).excludedSessionRefs);
|
|
2553
|
+
return queue.segments.filter((input) => !excluded.has(input.sessionRef));
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2413
2556
|
function approvalNotificationInvocation(harness, count, sessionRef) {
|
|
2414
2557
|
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.`;
|
|
2415
2558
|
if (harness === "codex") {
|
|
@@ -2440,8 +2583,10 @@ function notificationSessionRef(output) {
|
|
|
2440
2583
|
|
|
2441
2584
|
function notifyManualApproval(harness, count, existingSessionRef) {
|
|
2442
2585
|
const invocation = approvalNotificationInvocation(harness, count, existingSessionRef);
|
|
2586
|
+
const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
|
|
2443
2587
|
const before = existingSessionRef ? new Set() : new Set(localSessions(harness).map((session) => session.sessionRef));
|
|
2444
|
-
const output = execFileSync(
|
|
2588
|
+
const output = execFileSync(resolveNativeBinary(harness), invocation.args, {
|
|
2589
|
+
env: nativeEnvironment(),
|
|
2445
2590
|
cwd: os.homedir(),
|
|
2446
2591
|
encoding: "utf8",
|
|
2447
2592
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -2456,29 +2601,32 @@ function notifyManualApproval(harness, count, existingSessionRef) {
|
|
|
2456
2601
|
return created;
|
|
2457
2602
|
}
|
|
2458
2603
|
|
|
2459
|
-
function showPending(harness) {
|
|
2460
|
-
readConfig(harness);
|
|
2461
|
-
const
|
|
2462
|
-
|
|
2604
|
+
async function showPending(harness) {
|
|
2605
|
+
const config = readConfig(harness);
|
|
2606
|
+
const result = await withSyncLock(harness, async () => {
|
|
2607
|
+
console.log(JSON.stringify(await preparePendingQueue(harness, config), null, 2));
|
|
2608
|
+
});
|
|
2609
|
+
if (result?.skipped) throw new Error("Another Shifu sync is already preparing this queue. Retry later.");
|
|
2463
2610
|
}
|
|
2464
2611
|
|
|
2465
2612
|
async function approvePending(harness) {
|
|
2466
2613
|
const config = readConfig(harness);
|
|
2467
|
-
const queue = rebuildPendingQueue(harness);
|
|
2468
|
-
if (queue.segments.length === 0) {
|
|
2469
|
-
console.error("There is no pending Shifu activity to approve.");
|
|
2470
|
-
return;
|
|
2471
|
-
}
|
|
2472
|
-
console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
|
|
2473
|
-
if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
|
|
2474
|
-
console.error("Nothing was sent. The pending list will keep accumulating.");
|
|
2475
|
-
return;
|
|
2476
|
-
}
|
|
2477
2614
|
const result = await withSyncLock(harness, async () => {
|
|
2478
|
-
|
|
2615
|
+
const queue = prunePendingQueue(harness);
|
|
2616
|
+
queue.segments = eligibleQueuedSegments(queue, config);
|
|
2617
|
+
if (queue.segments.length === 0) {
|
|
2618
|
+
console.error("There is no prepared Shifu activity to approve. Run pending to prepare the next ranges.");
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
|
|
2622
|
+
if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
|
|
2623
|
+
console.error("Nothing was sent. The prepared queue is unchanged.");
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
const sent = await drainPendingQueue(harness, config, new Set());
|
|
2627
|
+
if (sent.failures) throw new Error("Some approved segments could not be acknowledged. Their exact payloads remain pending for retry.");
|
|
2479
2628
|
});
|
|
2480
2629
|
if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
|
|
2481
|
-
rebuildPendingQueue(harness);
|
|
2482
2630
|
}
|
|
2483
2631
|
|
|
2484
2632
|
async function scheduledSync(harness) {
|
|
@@ -2509,14 +2657,15 @@ async function scheduledSync(harness) {
|
|
|
2509
2657
|
}
|
|
2510
2658
|
await flushPendingProfiles(harness, config);
|
|
2511
2659
|
if (policy.approvalMode === "manual") {
|
|
2512
|
-
const queue =
|
|
2513
|
-
const
|
|
2660
|
+
const queue = await preparePendingQueue(harness, config);
|
|
2661
|
+
const eligible = eligibleQueuedSegments(queue, config);
|
|
2662
|
+
const pendingFingerprint = createHash("sha256").update(eligible.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
|
|
2514
2663
|
const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
|
|
2515
2664
|
let approvalSessionRef = state.schedule?.approvalSessionRef;
|
|
2516
2665
|
let notificationError;
|
|
2517
|
-
if (
|
|
2666
|
+
if (eligible.length > 0 && changed) {
|
|
2518
2667
|
try {
|
|
2519
|
-
approvalSessionRef = notifyManualApproval(harness,
|
|
2668
|
+
approvalSessionRef = notifyManualApproval(harness, eligible.length, approvalSessionRef);
|
|
2520
2669
|
if (approvalSessionRef && !config.syncPolicy.excludedSessionRefs.includes(approvalSessionRef)) {
|
|
2521
2670
|
config.syncPolicy.excludedSessionRefs.push(approvalSessionRef);
|
|
2522
2671
|
writeJSON(configPath(harness), config);
|
|
@@ -2526,38 +2675,37 @@ async function scheduledSync(harness) {
|
|
|
2526
2675
|
}
|
|
2527
2676
|
}
|
|
2528
2677
|
const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
|
|
2678
|
+
const unprepared = pendingSegments(harness, preparationState(harness, queue).virtual).length;
|
|
2679
|
+
const preparationFailures = queue.preparationErrors.length;
|
|
2529
2680
|
state.schedule = {
|
|
2530
2681
|
...state.schedule,
|
|
2531
2682
|
lastAttemptAt: startedAt,
|
|
2532
|
-
lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles || pendingProfiles ? "partial_failure" : "awaiting_approval",
|
|
2683
|
+
lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles || pendingProfiles || preparationFailures ? "partial_failure" : "awaiting_approval",
|
|
2533
2684
|
submittedFiles,
|
|
2534
2685
|
pendingFiles,
|
|
2535
2686
|
failedFiles,
|
|
2536
2687
|
unsupportedFiles,
|
|
2537
2688
|
pendingProfiles,
|
|
2538
|
-
pendingSegments:
|
|
2689
|
+
pendingSegments: eligible.length,
|
|
2690
|
+
unpreparedSegments: unprepared,
|
|
2691
|
+
preparationFailures,
|
|
2539
2692
|
pendingFingerprint,
|
|
2540
2693
|
approvalSessionRef,
|
|
2541
2694
|
notificationError,
|
|
2542
|
-
nextRunAt:
|
|
2695
|
+
nextRunAt: Date.now() + (notificationError || failedFiles || pendingProfiles || preparationFailures || unprepared ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
2543
2696
|
};
|
|
2544
2697
|
} else {
|
|
2545
2698
|
const blockedSessions = new Set();
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
failures += 1;
|
|
2553
|
-
blockedSessions.add(segment.sessionRef);
|
|
2554
|
-
console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
|
|
2555
|
-
}
|
|
2556
|
-
}
|
|
2699
|
+
const previous = await drainPendingQueue(harness, config, blockedSessions);
|
|
2700
|
+
const queue = await preparePendingQueue(harness, config, blockedSessions);
|
|
2701
|
+
const current = await drainPendingQueue(harness, config, blockedSessions);
|
|
2702
|
+
synced = previous.synced + current.synced;
|
|
2703
|
+
failures = previous.failures + current.failures + queue.preparationErrors.length;
|
|
2704
|
+
const remaining = pendingSegments(harness).length || eligibleQueuedSegments(readPendingQueue(harness), config).length;
|
|
2557
2705
|
const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
|
|
2558
2706
|
state.schedule = {
|
|
2559
2707
|
lastAttemptAt: startedAt,
|
|
2560
|
-
lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : "complete",
|
|
2708
|
+
lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : remaining ? "pending_preparation" : "complete",
|
|
2561
2709
|
syncedSegments: synced,
|
|
2562
2710
|
failedSessions: failures,
|
|
2563
2711
|
submittedFiles,
|
|
@@ -2565,7 +2713,7 @@ async function scheduledSync(harness) {
|
|
|
2565
2713
|
failedFiles,
|
|
2566
2714
|
unsupportedFiles,
|
|
2567
2715
|
pendingProfiles,
|
|
2568
|
-
nextRunAt:
|
|
2716
|
+
nextRunAt: Date.now() + (failures || failedFiles || pendingProfiles || remaining ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
2569
2717
|
};
|
|
2570
2718
|
}
|
|
2571
2719
|
const latest = readState(harness);
|
|
@@ -2825,4 +2973,4 @@ if (require.main === module) {
|
|
|
2825
2973
|
});
|
|
2826
2974
|
}
|
|
2827
2975
|
|
|
2828
|
-
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, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, reviewSourcePacket, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
|
|
2976
|
+
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 };
|