@useshifu/coding-harness 0.3.23 → 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 +24 -12
- package/bin/native-preparation.js +261 -0
- package/bin/shifu-harness.js +275 -74
- package/bin/source-review.js +200 -0
- package/bin/v5-payload.js +112 -0
- package/commands/shifu-sync.md +9 -5
- package/package.json +3 -2
- package/skills/shifu-sync/SKILL.md +48 -16
package/bin/shifu-harness.js
CHANGED
|
@@ -26,6 +26,8 @@ const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:token|secret|password|api[_-]?key)\s
|
|
|
26
26
|
const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
|
|
27
27
|
const EVIDENCE_KINDS = new Set(["implementation", "decision", "activity"]);
|
|
28
28
|
const EVIDENCE_FIELDS = new Set(["kind", "title", "statement", "scope", "area", "itemRef", "verificationRefs"]);
|
|
29
|
+
const REVIEWED_EVIDENCE_FIELDS = new Set([...EVIDENCE_FIELDS, "workContext", "decisionRefs", "sources"]);
|
|
30
|
+
const SOURCE_FIELDS = new Set(["role", "turn", "locator", "eventAt", "excerptMode"]);
|
|
29
31
|
const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion", "supersedesSyncId", "force"]);
|
|
30
32
|
const AI_PROFILE_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "category", "model", "thinking", "evidence"]);
|
|
31
33
|
const AI_PROFILE_EVIDENCE_FIELDS = new Set(["dimension", "statement", "signal", "turn"]);
|
|
@@ -40,8 +42,11 @@ const MAX_TURNS_PER_SEGMENT = 12;
|
|
|
40
42
|
const MAX_TITLE_LENGTH = 160;
|
|
41
43
|
const MAX_WORK_DETAIL_LENGTH = 600;
|
|
42
44
|
const CONFIG_VERSION = 2;
|
|
43
|
-
const RUNNER_VERSION = "0.3.
|
|
44
|
-
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;
|
|
45
50
|
const DEFAULT_INTERVAL_HOURS = 1;
|
|
46
51
|
const LEGACY_INTERVAL_HOURS = 4;
|
|
47
52
|
const MIN_INTERVAL_HOURS = 0.25;
|
|
@@ -110,11 +115,13 @@ function opencodeQuery(query) {
|
|
|
110
115
|
throw new Error("Could not read OpenCode session metadata. Ensure the opencode command is installed and its local database is available.");
|
|
111
116
|
}
|
|
112
117
|
}
|
|
113
|
-
const temporary = path.join(os.tmpdir(), `shifu-opencode-${
|
|
118
|
+
const temporary = path.join(os.tmpdir(), `shifu-opencode-${randomUUID()}.json`);
|
|
114
119
|
let fd;
|
|
115
120
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
|
|
122
|
+
fd = fs.openSync(temporary, "wx", 0o600);
|
|
123
|
+
execFileSync(resolveNativeBinary("opencode"), ["db", query, "--format", "json"], {
|
|
124
|
+
env: nativeEnvironment(),
|
|
118
125
|
stdio: ["ignore", fd, "pipe"],
|
|
119
126
|
maxBuffer: 64 * 1024 * 1024,
|
|
120
127
|
});
|
|
@@ -184,6 +191,7 @@ function validateSyncOptions(command, start = 3) {
|
|
|
184
191
|
const options = {
|
|
185
192
|
sessions: [...common, "--hours", "--days", "--count", "--turns", "--session-ref"],
|
|
186
193
|
review: [...common, "--hours", "--days", "--turns", "--session-ref", "--from-turn", "--to-turn", "--supersedes-sync-id"],
|
|
194
|
+
"review-source": [...common, "--session-ref", "--from-turn", "--to-turn"],
|
|
187
195
|
sync: common,
|
|
188
196
|
"sync-files": common,
|
|
189
197
|
"scheduled-sync": common,
|
|
@@ -559,6 +567,25 @@ function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
|
|
|
559
567
|
return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
|
|
560
568
|
}
|
|
561
569
|
|
|
570
|
+
function reviewSourcePacket(harness, sessionRef, fromTurn, toTurn, scheduled = false) {
|
|
571
|
+
if (!sessionRefIsSafe(sessionRef) || !OPAQUE_SESSION_REF_PATTERN.test(sessionRef)) throw new Error("Provide an opaque --session-ref for selected local source review.");
|
|
572
|
+
if (!Number.isInteger(fromTurn) || !Number.isInteger(toTurn) || fromTurn < 1 || toTurn < fromTurn || toTurn > 1_000_000 || toTurn - fromTurn >= MAX_TURNS_PER_SEGMENT) {
|
|
573
|
+
throw new Error("Select --from-turn and --to-turn explicitly, covering at most 12 original turns.");
|
|
574
|
+
}
|
|
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) {
|
|
581
|
+
if (harness === "opencode") {
|
|
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`);
|
|
583
|
+
}
|
|
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."); }
|
|
587
|
+
}
|
|
588
|
+
|
|
562
589
|
function sessionFile(harness, sessionRef) {
|
|
563
590
|
const files = harness === "codex" ? sessionFiles(codexSessionsRoot()) : sessionFiles(claudeSessionsRoot());
|
|
564
591
|
const selectedFile = files.map((file) => ({ file, session: harness === "codex" ? codexSession(file) : claudeSession(file) }))
|
|
@@ -934,6 +961,7 @@ function safeActivityEvidence(notes) {
|
|
|
934
961
|
return evidence;
|
|
935
962
|
}
|
|
936
963
|
|
|
964
|
+
// Retained for legacy comparison adapters; schedules use prepareV5Payload.
|
|
937
965
|
function payloadForSegment(harness, segment) {
|
|
938
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.");
|
|
939
967
|
const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
|
|
@@ -955,7 +983,7 @@ function payloadForSegment(harness, segment) {
|
|
|
955
983
|
evidence,
|
|
956
984
|
verification: [],
|
|
957
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*$/, "")}…`),
|
|
958
|
-
redactionVersion:
|
|
986
|
+
redactionVersion: LEGACY_REDACTION_VERSION,
|
|
959
987
|
};
|
|
960
988
|
}
|
|
961
989
|
|
|
@@ -1126,8 +1154,7 @@ function unsyncedCodexSessions(harness, options = {}) {
|
|
|
1126
1154
|
return selectedSessionSegments(harness, options).filter((session) => session.fromTurn <= session.toTurn);
|
|
1127
1155
|
}
|
|
1128
1156
|
|
|
1129
|
-
function pendingSegments(harness) {
|
|
1130
|
-
const state = readState(harness);
|
|
1157
|
+
function pendingSegments(harness, state = readState(harness)) {
|
|
1131
1158
|
const policy = normalizedPolicy(readConfig(harness));
|
|
1132
1159
|
const excluded = new Set(policy.excludedSessionRefs);
|
|
1133
1160
|
const segments = [];
|
|
@@ -1135,15 +1162,10 @@ function pendingSegments(harness) {
|
|
|
1135
1162
|
.filter((candidate) => !excluded.has(candidate.sessionRef))
|
|
1136
1163
|
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))) {
|
|
1137
1164
|
let fromTurn = (state.sessions[session.sessionRef]?.lastSyncedTurn || 0) + 1;
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
let completedTo = fromTurn - 1;
|
|
1143
|
-
while (completedTurns.has(completedTo + 1)) completedTo += 1;
|
|
1144
|
-
if (completedTo < fromTurn) break;
|
|
1145
|
-
toTurn = completedTo;
|
|
1146
|
-
}
|
|
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);
|
|
1147
1169
|
segments.push({ ...session, fromTurn, toTurn });
|
|
1148
1170
|
fromTurn = toTurn + 1;
|
|
1149
1171
|
}
|
|
@@ -1184,9 +1206,19 @@ async function readSecret(input = process.stdin, output = process.stderr) {
|
|
|
1184
1206
|
|
|
1185
1207
|
function copyRunner() {
|
|
1186
1208
|
fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
+
}
|
|
1221
|
+
}
|
|
1190
1222
|
const temporary = `${installedRunnerPath()}.${process.pid}.tmp`;
|
|
1191
1223
|
try {
|
|
1192
1224
|
fs.copyFileSync(__filename, temporary);
|
|
@@ -1975,8 +2007,8 @@ async function updateConfig(harness) {
|
|
|
1975
2007
|
if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
|
|
1976
2008
|
}
|
|
1977
2009
|
|
|
1978
|
-
function textIsSafe(value, maximum) {
|
|
1979
|
-
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));
|
|
1980
2012
|
}
|
|
1981
2013
|
|
|
1982
2014
|
function sessionRefIsSafe(value) {
|
|
@@ -1987,29 +2019,30 @@ function sessionRefIsSafe(value) {
|
|
|
1987
2019
|
}
|
|
1988
2020
|
|
|
1989
2021
|
function validateSync(input, harness) {
|
|
2022
|
+
const safeText = (value, maximum) => textIsSafe(value, maximum, input?.redactionVersion === 5);
|
|
1990
2023
|
if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
|
|
1991
2024
|
if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
|
|
1992
2025
|
if (Object.hasOwn(input, "supersedesSyncId") && (!Number.isSafeInteger(input.supersedesSyncId) || input.supersedesSyncId < 1)) throw new Error("Use the positive supersedesSyncId returned by force discovery.");
|
|
1993
2026
|
if (Object.hasOwn(input, "force") && typeof input.force !== "boolean") throw new Error("Use force: true only for explicitly requested force sync.");
|
|
1994
2027
|
if (input.force && input.supersedesSyncId) throw new Error("Use force: true without supersedesSyncId, or the legacy supersedesSyncId mode alone.");
|
|
1995
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.");
|
|
1996
|
-
if (input.redactionVersion >= 3 && !
|
|
1997
|
-
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.");
|
|
1998
2031
|
const forced = input.force || Boolean(input.supersedesSyncId);
|
|
1999
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.");
|
|
2000
|
-
const minimumEvidence = forced || input.redactionVersion <
|
|
2033
|
+
const minimumEvidence = forced || input.redactionVersion < 4 ? 1 : 0;
|
|
2001
2034
|
if (!Array.isArray(input.evidence) || input.evidence.length < minimumEvidence || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => {
|
|
2002
|
-
if (!item || !EVIDENCE_KINDS.has(item.kind) || (input.redactionVersion >= 3 && !
|
|
2003
|
-
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 === "";
|
|
2004
2037
|
return SCOPE_LEVELS.has(item.scope);
|
|
2005
2038
|
})) {
|
|
2006
2039
|
throw new Error(`evidence must contain ${minimumEvidence}-${MAX_EVIDENCE_ITEMS} redacted activity or work items with valid titles, details, kinds, and scopes.`);
|
|
2007
2040
|
}
|
|
2008
2041
|
for (const field of ["verification", "decisions"]) {
|
|
2009
|
-
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.`);
|
|
2010
2043
|
}
|
|
2011
2044
|
if (!input.evidence.every((item) =>
|
|
2012
|
-
Object.keys(item).every((field) => EVIDENCE_FIELDS.has(field)) &&
|
|
2045
|
+
Object.keys(item).every((field) => (input.redactionVersion === 5 ? REVIEWED_EVIDENCE_FIELDS : EVIDENCE_FIELDS).has(field)) &&
|
|
2013
2046
|
(!Object.hasOwn(item, "area") || item.area === "" || WORK_AREAS.has(item.area)) &&
|
|
2014
2047
|
(!Object.hasOwn(item, "itemRef") || item.itemRef === "" || (typeof item.itemRef === "string" && ITEM_REF_PATTERN.test(item.itemRef))) &&
|
|
2015
2048
|
(!Object.hasOwn(item, "verificationRefs") || (Array.isArray(item.verificationRefs) &&
|
|
@@ -2018,12 +2051,39 @@ function validateSync(input, harness) {
|
|
|
2018
2051
|
throw new Error("Each work item must use a supported area, safe itemRef, and distinct indexes into verification.");
|
|
2019
2052
|
}
|
|
2020
2053
|
const identities = new Set();
|
|
2054
|
+
if (input.redactionVersion === 5 && !input.evidence.every((item) => validReviewedContext(item, input))) {
|
|
2055
|
+
throw new Error("Each reviewed work item must use safe bounded workContext, distinct decisionRefs, and synthesized source metadata within its turn range.");
|
|
2056
|
+
}
|
|
2021
2057
|
for (const item of input.evidence) {
|
|
2022
2058
|
const identity = item.itemRef || `${item.kind}:${item.scope}:${item.statement.trim()}`;
|
|
2023
2059
|
if (identities.has(identity)) throw new Error("Each work item must have a distinct identity.");
|
|
2024
2060
|
identities.add(identity);
|
|
2025
2061
|
}
|
|
2026
|
-
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.`);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function validUTCEventTime(value) {
|
|
2066
|
+
if (value === "") return true;
|
|
2067
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value)) return false;
|
|
2068
|
+
if (/^0001-01-01T00:00:00(?:\.0{1,9})?Z$/.test(value)) return false;
|
|
2069
|
+
const instant = Date.parse(value);
|
|
2070
|
+
return Number.isFinite(instant) && new Date(instant).toISOString().slice(0, 19) === value.slice(0, 19);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function validReviewedContext(item, input) {
|
|
2074
|
+
if (Object.hasOwn(item, "workContext")) {
|
|
2075
|
+
if (typeof item.workContext !== "string") return false;
|
|
2076
|
+
const context = item.workContext.trim();
|
|
2077
|
+
if (context !== "" && ([...context].length > 300 || !textIsSafe(context, context.length))) return false;
|
|
2078
|
+
}
|
|
2079
|
+
if (Object.hasOwn(item, "decisionRefs") && (!Array.isArray(item.decisionRefs) || item.decisionRefs.length > MAX_SYNC_ITEMS ||
|
|
2080
|
+
new Set(item.decisionRefs).size !== item.decisionRefs.length || !item.decisionRefs.every((ref) => Number.isInteger(ref) && ref >= 0 && ref < input.decisions.length))) return false;
|
|
2081
|
+
if (!Object.hasOwn(item, "sources")) return true;
|
|
2082
|
+
return Array.isArray(item.sources) && item.sources.length <= 8 && new Set(item.sources.map((source) => source?.locator)).size === item.sources.length &&
|
|
2083
|
+
item.sources.every((source) => source && typeof source === "object" && !Array.isArray(source) && Object.keys(source).every((field) => SOURCE_FIELDS.has(field)) &&
|
|
2084
|
+
["user", "assistant"].includes(source.role) && Number.isInteger(source.turn) && source.turn >= input.fromTurn && source.turn <= input.toTurn &&
|
|
2085
|
+
typeof source.locator === "string" && ITEM_REF_PATTERN.test(source.locator) && source.excerptMode === "synthesized" &&
|
|
2086
|
+
(!Object.hasOwn(source, "eventAt") || validUTCEventTime(source.eventAt)));
|
|
2027
2087
|
}
|
|
2028
2088
|
|
|
2029
2089
|
function validateAIProfileObservation(input, harness) {
|
|
@@ -2288,7 +2348,12 @@ async function sendSync(harness, input, config = readConfig(harness)) {
|
|
|
2288
2348
|
const force = input.force || Boolean(input.supersedesSyncId);
|
|
2289
2349
|
if (!force && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
|
|
2290
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.");
|
|
2291
|
-
|
|
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
|
+
}
|
|
2292
2357
|
const receipt = await postSync(config, input);
|
|
2293
2358
|
if (!validSyncReceipt(receipt, input)) {
|
|
2294
2359
|
throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
|
|
@@ -2297,8 +2362,9 @@ async function sendSync(harness, input, config = readConfig(harness)) {
|
|
|
2297
2362
|
state.profilePending ||= {};
|
|
2298
2363
|
state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
|
|
2299
2364
|
}
|
|
2300
|
-
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 };
|
|
2301
2366
|
saveState(harness, state);
|
|
2367
|
+
if (!force) prunePendingQueue(harness);
|
|
2302
2368
|
if (profile) await flushPendingProfiles(harness, config);
|
|
2303
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}.`);
|
|
2304
2370
|
return receipt;
|
|
@@ -2351,13 +2417,142 @@ async function sync(harness) {
|
|
|
2351
2417
|
if (result?.skipped) throw new Error("Another Shifu sync is already running. This segment was not sent; retry later.");
|
|
2352
2418
|
}
|
|
2353
2419
|
|
|
2354
|
-
function
|
|
2355
|
-
const
|
|
2356
|
-
|
|
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();
|
|
2435
|
+
writeJSON(pendingPath(harness), queue);
|
|
2436
|
+
return queue;
|
|
2437
|
+
}
|
|
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;
|
|
2357
2532
|
writeJSON(pendingPath(harness), queue);
|
|
2358
2533
|
return queue;
|
|
2359
2534
|
}
|
|
2360
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
|
+
|
|
2361
2556
|
function approvalNotificationInvocation(harness, count, sessionRef) {
|
|
2362
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.`;
|
|
2363
2558
|
if (harness === "codex") {
|
|
@@ -2388,8 +2583,10 @@ function notificationSessionRef(output) {
|
|
|
2388
2583
|
|
|
2389
2584
|
function notifyManualApproval(harness, count, existingSessionRef) {
|
|
2390
2585
|
const invocation = approvalNotificationInvocation(harness, count, existingSessionRef);
|
|
2586
|
+
const { resolveNativeBinary, nativeEnvironment } = require("./native-preparation.js");
|
|
2391
2587
|
const before = existingSessionRef ? new Set() : new Set(localSessions(harness).map((session) => session.sessionRef));
|
|
2392
|
-
const output = execFileSync(
|
|
2588
|
+
const output = execFileSync(resolveNativeBinary(harness), invocation.args, {
|
|
2589
|
+
env: nativeEnvironment(),
|
|
2393
2590
|
cwd: os.homedir(),
|
|
2394
2591
|
encoding: "utf8",
|
|
2395
2592
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -2404,29 +2601,32 @@ function notifyManualApproval(harness, count, existingSessionRef) {
|
|
|
2404
2601
|
return created;
|
|
2405
2602
|
}
|
|
2406
2603
|
|
|
2407
|
-
function showPending(harness) {
|
|
2408
|
-
readConfig(harness);
|
|
2409
|
-
const
|
|
2410
|
-
|
|
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.");
|
|
2411
2610
|
}
|
|
2412
2611
|
|
|
2413
2612
|
async function approvePending(harness) {
|
|
2414
2613
|
const config = readConfig(harness);
|
|
2415
|
-
const queue = rebuildPendingQueue(harness);
|
|
2416
|
-
if (queue.segments.length === 0) {
|
|
2417
|
-
console.error("There is no pending Shifu activity to approve.");
|
|
2418
|
-
return;
|
|
2419
|
-
}
|
|
2420
|
-
console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
|
|
2421
|
-
if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
|
|
2422
|
-
console.error("Nothing was sent. The pending list will keep accumulating.");
|
|
2423
|
-
return;
|
|
2424
|
-
}
|
|
2425
2614
|
const result = await withSyncLock(harness, async () => {
|
|
2426
|
-
|
|
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.");
|
|
2427
2628
|
});
|
|
2428
2629
|
if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
|
|
2429
|
-
rebuildPendingQueue(harness);
|
|
2430
2630
|
}
|
|
2431
2631
|
|
|
2432
2632
|
async function scheduledSync(harness) {
|
|
@@ -2457,14 +2657,15 @@ async function scheduledSync(harness) {
|
|
|
2457
2657
|
}
|
|
2458
2658
|
await flushPendingProfiles(harness, config);
|
|
2459
2659
|
if (policy.approvalMode === "manual") {
|
|
2460
|
-
const queue =
|
|
2461
|
-
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");
|
|
2462
2663
|
const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
|
|
2463
2664
|
let approvalSessionRef = state.schedule?.approvalSessionRef;
|
|
2464
2665
|
let notificationError;
|
|
2465
|
-
if (
|
|
2666
|
+
if (eligible.length > 0 && changed) {
|
|
2466
2667
|
try {
|
|
2467
|
-
approvalSessionRef = notifyManualApproval(harness,
|
|
2668
|
+
approvalSessionRef = notifyManualApproval(harness, eligible.length, approvalSessionRef);
|
|
2468
2669
|
if (approvalSessionRef && !config.syncPolicy.excludedSessionRefs.includes(approvalSessionRef)) {
|
|
2469
2670
|
config.syncPolicy.excludedSessionRefs.push(approvalSessionRef);
|
|
2470
2671
|
writeJSON(configPath(harness), config);
|
|
@@ -2474,38 +2675,37 @@ async function scheduledSync(harness) {
|
|
|
2474
2675
|
}
|
|
2475
2676
|
}
|
|
2476
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;
|
|
2477
2680
|
state.schedule = {
|
|
2478
2681
|
...state.schedule,
|
|
2479
2682
|
lastAttemptAt: startedAt,
|
|
2480
|
-
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",
|
|
2481
2684
|
submittedFiles,
|
|
2482
2685
|
pendingFiles,
|
|
2483
2686
|
failedFiles,
|
|
2484
2687
|
unsupportedFiles,
|
|
2485
2688
|
pendingProfiles,
|
|
2486
|
-
pendingSegments:
|
|
2689
|
+
pendingSegments: eligible.length,
|
|
2690
|
+
unpreparedSegments: unprepared,
|
|
2691
|
+
preparationFailures,
|
|
2487
2692
|
pendingFingerprint,
|
|
2488
2693
|
approvalSessionRef,
|
|
2489
2694
|
notificationError,
|
|
2490
|
-
nextRunAt:
|
|
2695
|
+
nextRunAt: Date.now() + (notificationError || failedFiles || pendingProfiles || preparationFailures || unprepared ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
2491
2696
|
};
|
|
2492
2697
|
} else {
|
|
2493
2698
|
const blockedSessions = new Set();
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
failures += 1;
|
|
2501
|
-
blockedSessions.add(segment.sessionRef);
|
|
2502
|
-
console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
|
|
2503
|
-
}
|
|
2504
|
-
}
|
|
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;
|
|
2505
2705
|
const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
|
|
2506
2706
|
state.schedule = {
|
|
2507
2707
|
lastAttemptAt: startedAt,
|
|
2508
|
-
lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : "complete",
|
|
2708
|
+
lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : remaining ? "pending_preparation" : "complete",
|
|
2509
2709
|
syncedSegments: synced,
|
|
2510
2710
|
failedSessions: failures,
|
|
2511
2711
|
submittedFiles,
|
|
@@ -2513,7 +2713,7 @@ async function scheduledSync(harness) {
|
|
|
2513
2713
|
failedFiles,
|
|
2514
2714
|
unsupportedFiles,
|
|
2515
2715
|
pendingProfiles,
|
|
2516
|
-
nextRunAt:
|
|
2716
|
+
nextRunAt: Date.now() + (failures || failedFiles || pendingProfiles || remaining ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
2517
2717
|
};
|
|
2518
2718
|
}
|
|
2519
2719
|
const latest = readState(harness);
|
|
@@ -2760,7 +2960,8 @@ async function main() {
|
|
|
2760
2960
|
if (command === "status") return status(harness);
|
|
2761
2961
|
if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
|
|
2762
2962
|
if (command === "review") return review(harness);
|
|
2763
|
-
|
|
2963
|
+
if (command === "review-source") return console.log(JSON.stringify(reviewSourcePacket(harness, option("--session-ref"), numberOption("--from-turn"), numberOption("--to-turn")), null, 2));
|
|
2964
|
+
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.");
|
|
2764
2965
|
}
|
|
2765
2966
|
|
|
2766
2967
|
if (require.main === module) {
|
|
@@ -2772,4 +2973,4 @@ if (require.main === module) {
|
|
|
2772
2973
|
});
|
|
2773
2974
|
}
|
|
2774
2975
|
|
|
2775
|
-
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, 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 };
|