@useshifu/coding-harness 0.3.10 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,10 @@ To disconnect this device, run `npx --yes @useshifu/coding-harness disconnect --
32
32
 
33
33
  ## Operate
34
34
 
35
+ Version `0.3.11` adds rolling days-based windows and explicit force replacement of reviewed segments. Force replacement requires backend migration 049 and the matching connector endpoints to be deployed first.
36
+
37
+ Version `0.3.12` makes force sync independent of local and server checkpoints, creates missing work, and protects automatic retries from overwriting a newer force sync. Deploy backend migrations 049 and 050 before using it.
38
+
35
39
  ```sh
36
40
  npx --yes @useshifu/coding-harness status --harness codex
37
41
  npx --yes @useshifu/coding-harness sessions --harness codex --hours 24
@@ -43,6 +47,8 @@ npx --yes @useshifu/coding-harness sync-files --harness codex
43
47
 
44
48
  To select by session count instead of a time window, run `sessions --harness codex --count 5`. It selects the five most recently updated eligible sessions, newest first, without an hours cutoff. Current and setup sessions are excluded. `selectedSessionRefs` fixes that selection; `sessions` contains pending segments and `skippedSessions` reports already-synced sessions. Skipped sessions are not replaced with older ones. Review each pending session with `review --harness codex --session-ref <sessionRef> --all`. After an accepted segment, request the next with `sessions --harness codex --session-ref <sessionRef> --all` until that selected session is exhausted. Keep the original references instead of selecting another five.
45
49
 
50
+ Time-window selection accepts any positive whole number of days or hours. `sessions --harness codex --days 3` uses the same window as `--hours 72`; pass the same window to `review`. `--days 7` also works. Mixed, missing, invalid, or unsupported options fail explicitly. A recently active session with older unsynced turns appears in `blockedSessions`: sync cannot skip those turns. Explicitly select that session with `--session-ref <sessionRef> --all` to backfill in chronological segments of at most 12 turns.
51
+
46
52
  Manual session sync runs immediately, independently of the hourly schedule. If a segment fails, report it as failed/pending; a checkpoint advances only after a matching server receipt. Report completed, already-synced, and failed session counts separately.
47
53
 
48
54
  To upgrade an existing installation, run `npx --yes @useshifu/coding-harness@latest install --harness codex` (or `claude_code` / `opencode`). It replaces the local runner and harness instruction while preserving the connection, checkpoints, and schedule. Reconnecting is unnecessary. `status` includes `runnerVersion` so you can verify the installed version.
@@ -73,6 +79,16 @@ The AI Profile page shows detected categories, model and thinking labels, and Je
73
79
 
74
80
  ## Recommended package updates
75
81
 
76
- The connection endpoint returns `recommendedPackageVersion`, currently pinned to the latest published release, `0.3.9`. Before `sync`, `sync-files`, `approve`, or `scheduled-sync`, the runner compares its embedded release version with that property. An older runner installs the exact recommended release through npm and resumes the original command with the updated runner. Equal or newer runners continue unchanged. Explicitly recommended major releases follow the same comparison rule. Missing or invalid recommendations, connection failures, and npm failures do not block sync. The connection, pending work, checkpoints, and schedule are preserved.
82
+ The connection endpoint returns `recommendedPackageVersion`, pinned to a published release. Before `sync`, `sync-files`, `approve`, or `scheduled-sync`, the runner compares its embedded release version with that property. An older runner installs the exact recommended release through npm and resumes the original command with the updated runner. Equal or newer runners continue unchanged. Explicitly recommended major releases follow the same comparison rule. Missing or invalid recommendations, connection failures, and npm failures do not block sync. The connection, pending work, checkpoints, and schedule are preserved.
83
+
84
+ This update mechanism first ships in `0.3.10`. Runners from earlier releases need a one-time installation of a release containing it (`npx --yes @useshifu/coding-harness@0.3.12 install --harness codex`). Raise the server recommendation only after the target release is published and its backend requirements are deployed.
85
+
86
+ ## Force sync local work
87
+
88
+ An explicit force request selects local work independently of both local and server checkpoints. It includes already synced sessions and ranges the server has never accepted. It creates missing ranges or replaces their current work evidence and claims while retaining history. Force never resets or lowers checkpoints and does not change scheduled sync or AI Profile observations.
89
+
90
+ 1. Discover ranges with `sessions --harness codex --days 3 --force` (or a requested hours/count selection). Keep that initial target list fixed. Complete stored ranges touching selected turns are preserved, including older turns across a window boundary; uncovered selected turns are split into ranges of at most 12.
91
+ 2. Re-review each range with `review --harness codex --session-ref <sessionRef> --days 3 --force --from-turn <fromTurn> --to-turn <toTurn>`. The original local turns must exist. Explicit backfill/count selections use `--all`. No old receipt ID is required.
92
+ 3. Prepare and redact the payload again, keeping the exact session reference and turn range and adding `force: true`. Pipe it to `sync --harness codex --force`, following the saved manual/automatic approval policy.
77
93
 
78
- This update mechanism first ships in `0.3.10`. Runners from earlier releases need a one-time installation of a release containing it (`npx --yes @useshifu/coding-harness@0.3.10 install --harness codex`, after publication). Raise the server recommendation only after the target release is published.
94
+ Missing local turns are reported in `unavailableSegments`. A partial overlap with accepted work is rejected; re-discover and re-review complete ranges before retrying. Automatic retries preserve the accepted result even if another force sync finishes meanwhile. A new invocation is a new explicit force request. Existing conversations retain their context; start a new chat in Shifu to use the latest context.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const fs = require("node:fs");
4
- const { createHash } = require("node:crypto");
4
+ const { createHash, randomUUID } = require("node:crypto");
5
5
  const os = require("node:os");
6
6
  const path = require("node:path");
7
7
  const { execFile, execFileSync, spawn } = require("node:child_process");
@@ -25,7 +25,7 @@ const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:token|secret|password|api[_-]?key)\s
25
25
  const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
26
26
  const EVIDENCE_KINDS = new Set(["implementation", "decision", "activity"]);
27
27
  const EVIDENCE_FIELDS = new Set(["kind", "title", "statement", "scope", "area", "itemRef", "verificationRefs"]);
28
- const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion"]);
28
+ const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion", "supersedesSyncId", "force"]);
29
29
  const AI_PROFILE_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "category", "model", "thinking", "evidence"]);
30
30
  const AI_PROFILE_EVIDENCE_FIELDS = new Set(["dimension", "statement", "signal", "turn"]);
31
31
  const AI_PROFILE_DIMENSIONS = new Set(["task_selection", "goal_clarity", "context_sharing", "constraint_setting", "work_decomposition", "iterative_steering", "critical_judgment", "verification", "application", "responsible_use"]);
@@ -39,7 +39,7 @@ const MAX_TURNS_PER_SEGMENT = 12;
39
39
  const MAX_TITLE_LENGTH = 160;
40
40
  const MAX_WORK_DETAIL_LENGTH = 600;
41
41
  const CONFIG_VERSION = 2;
42
- const RUNNER_VERSION = "0.3.10";
42
+ const RUNNER_VERSION = "0.3.12";
43
43
  const REDACTION_VERSION = 4;
44
44
  const DEFAULT_INTERVAL_HOURS = 1;
45
45
  const LEGACY_INTERVAL_HOURS = 4;
@@ -178,6 +178,43 @@ function hasFlag(name) {
178
178
  return process.argv.includes(name);
179
179
  }
180
180
 
181
+ function validateSyncOptions(command, start = 3) {
182
+ const common = ["--harness", "--config-root"];
183
+ const options = {
184
+ sessions: [...common, "--hours", "--days", "--count", "--turns", "--session-ref"],
185
+ review: [...common, "--hours", "--days", "--turns", "--session-ref", "--from-turn", "--to-turn", "--supersedes-sync-id"],
186
+ sync: common,
187
+ "sync-files": common,
188
+ "scheduled-sync": common,
189
+ pending: common,
190
+ approve: common,
191
+ };
192
+ const flags = {
193
+ sessions: ["--all", "--force"], review: ["--all", "--force"], sync: ["--approved", "--force"], approve: ["--approved"],
194
+ };
195
+ if (!options[command]) return;
196
+ const seen = new Set();
197
+ for (let index = start; index < process.argv.length; index += 1) {
198
+ const argument = process.argv[index];
199
+ if (seen.has(argument)) throw new Error(`Provide ${argument} only once.`);
200
+ seen.add(argument);
201
+ if ((flags[command] || []).includes(argument)) continue;
202
+ if (!options[command].includes(argument)) throw new Error(`Unsupported option ${argument} for ${command}.`);
203
+ const value = process.argv[++index];
204
+ if (value === undefined || value.startsWith("--")) throw new Error(`Provide a value for ${argument}.`);
205
+ }
206
+ }
207
+
208
+ function syncWindowHours() {
209
+ if (hasFlag("--days") && hasFlag("--hours")) throw new Error("Use either --days or --hours, not both.");
210
+ const name = hasFlag("--days") ? "--days" : "--hours";
211
+ const value = numberOption(name, 24);
212
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error(`Choose a positive whole number for ${name}.`);
213
+ const hours = name === "--days" ? value * 24 : value;
214
+ if (!Number.isSafeInteger(hours) || hours * 3_600_000 > 8_640_000_000_000_000) throw new Error("The requested sync window is too large.");
215
+ return hours;
216
+ }
217
+
181
218
  function readJSON(file, fallback) {
182
219
  try {
183
220
  return JSON.parse(fs.readFileSync(file, "utf8"));
@@ -892,7 +929,7 @@ function validateGeneratedProfile(profile, harness) {
892
929
  }
893
930
 
894
931
  function selectedSessionSegments(harness, { hours = 24, turns = MAX_TURNS_PER_SEGMENT, selectedRef, includeOlder = false, count } = {}) {
895
- if (count === undefined && ![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
932
+ if (count === undefined && (!Number.isSafeInteger(hours) || hours < 1)) throw new Error("Choose a positive whole number for --hours.");
896
933
  if (count !== undefined && (!Number.isSafeInteger(count) || count < 1)) throw new Error("Choose a positive whole number for --count.");
897
934
  if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
898
935
  const since = Date.now() - hours * 3_600_000;
@@ -1292,6 +1329,9 @@ function sessionRefIsSafe(value) {
1292
1329
  function validateSync(input, harness) {
1293
1330
  if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
1294
1331
  if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
1332
+ if (Object.hasOwn(input, "supersedesSyncId") && (!Number.isSafeInteger(input.supersedesSyncId) || input.supersedesSyncId < 1)) throw new Error("Use the positive supersedesSyncId returned by force discovery.");
1333
+ if (Object.hasOwn(input, "force") && typeof input.force !== "boolean") throw new Error("Use force: true only for explicitly requested force sync.");
1334
+ if (input.force && input.supersedesSyncId) throw new Error("Use force: true without supersedesSyncId, or the legacy supersedesSyncId mode alone.");
1295
1335
  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.");
1296
1336
  if (input.redactionVersion >= 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
1297
1337
  if (!textIsSafe(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
@@ -1335,8 +1375,8 @@ function validateAIProfileObservation(input, harness) {
1335
1375
  }
1336
1376
 
1337
1377
  function validSyncReceipt(receipt, input) {
1338
- return receipt?.sessionRef === input.sessionRef && receipt.acceptedToTurn === input.toTurn && Number.isInteger(receipt.lastSyncedTurn) &&
1339
- (receipt.duplicate ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
1378
+ return receipt?.sessionRef === input.sessionRef && receipt.acceptedToTurn === input.toTurn && Number.isInteger(receipt.lastSyncedTurn) && receipt.lastSyncedTurn <= 1_000_000 &&
1379
+ (receipt.duplicate || input.force || input.supersedesSyncId ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
1340
1380
  }
1341
1381
 
1342
1382
  function sleep(milliseconds) {
@@ -1354,6 +1394,7 @@ function retryDelay(response, attempt) {
1354
1394
  }
1355
1395
 
1356
1396
  async function postSync(config, input) {
1397
+ const body = JSON.stringify(input.force ? { ...input, forceRequestId: randomUUID() } : input);
1357
1398
  let lastError;
1358
1399
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
1359
1400
  let response;
@@ -1361,7 +1402,7 @@ async function postSync(config, input) {
1361
1402
  response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
1362
1403
  method: "POST",
1363
1404
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
1364
- body: JSON.stringify(input),
1405
+ body,
1365
1406
  signal: AbortSignal.timeout(30_000),
1366
1407
  });
1367
1408
  } catch (error) {
@@ -1582,18 +1623,22 @@ async function sendSync(harness, input, config = readConfig(harness)) {
1582
1623
  const state = readState(harness);
1583
1624
  const checkpoint = state.sessions[input.sessionRef];
1584
1625
  const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
1585
- if (input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
1586
- const profile = profileForSegment(harness, input);
1626
+ const force = input.force || Boolean(input.supersedesSyncId);
1627
+ if (!force && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
1628
+ 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.");
1629
+ const profile = force ? undefined : profileForSegment(harness, input);
1587
1630
  const receipt = await postSync(config, input);
1588
1631
  if (!validSyncReceipt(receipt, input)) {
1589
1632
  throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
1590
1633
  }
1591
- state.profilePending ||= {};
1592
- state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
1593
- state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: receipt.lastSyncedTurn, syncDue: false };
1634
+ if (profile) {
1635
+ state.profilePending ||= {};
1636
+ state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
1637
+ }
1638
+ state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: Math.max(checkpoint?.lastSyncedTurn || 0, receipt.lastSyncedTurn), syncDue: false };
1594
1639
  saveState(harness, state);
1595
- await flushPendingProfiles(harness, config);
1596
- console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : `Synced through turn ${receipt.lastSyncedTurn}.`);
1640
+ if (profile) await flushPendingProfiles(harness, config);
1641
+ 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}.`);
1597
1642
  return receipt;
1598
1643
  }
1599
1644
 
@@ -1631,6 +1676,7 @@ async function sync(harness) {
1631
1676
  const config = readConfig(harness);
1632
1677
  const input = readJSON(0, undefined);
1633
1678
  validateSync(input, harness);
1679
+ if (hasFlag("--force") !== Boolean(input.force || input.supersedesSyncId)) throw new Error("Force sync requires both --force and force: true in the reviewed payload (or a legacy supersedesSyncId).");
1634
1680
  const policy = normalizedPolicy(config);
1635
1681
  if (policy.approvalMode === "manual" && !hasFlag("--approved")) {
1636
1682
  console.error(`\nThis exact redacted segment will be sent to Shifu:\n${JSON.stringify(input, null, 2)}`);
@@ -1836,23 +1882,95 @@ async function syncFilesNow(harness) {
1836
1882
  if (result.failures) throw new Error("Some input files need retry. They will be checked on the next sync.");
1837
1883
  }
1838
1884
 
1839
- function sessions(harness) {
1840
- readConfig(harness);
1885
+ async function acceptedSegments(config, sessionRef) {
1886
+ const segments = [];
1887
+ let cursor = 0;
1888
+ do {
1889
+ const url = new URL(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`);
1890
+ url.searchParams.set("sessionRef", sessionRef);
1891
+ if (cursor) url.searchParams.set("cursor", String(cursor));
1892
+ const response = await fetch(url, { headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(30_000) });
1893
+ const payload = await response.json().catch(() => undefined);
1894
+ if (!response.ok) throw new Error(payload?.error?.message || `Could not inspect accepted sync segments (${response.status}).`);
1895
+ const page = payload?.data;
1896
+ if (!Array.isArray(page?.segments) || page.segments.length > 100 || page.segments.some((segment, index) =>
1897
+ !Number.isSafeInteger(segment.id) || segment.id < 1 || segment.sessionRef !== sessionRef ||
1898
+ !Number.isInteger(segment.fromTurn) || !Number.isInteger(segment.toTurn) || segment.fromTurn < 1 ||
1899
+ segment.toTurn < segment.fromTurn || segment.toTurn - segment.fromTurn >= MAX_TURNS_PER_SEGMENT ||
1900
+ segment.toTurn > 1_000_000 || segment.fromTurn <= (index ? page.segments[index - 1].toTurn : cursor))) {
1901
+ throw new Error("Shifu returned invalid accepted sync segments. No replacement was sent.");
1902
+ }
1903
+ segments.push(...page.segments);
1904
+ const next = page.nextCursor;
1905
+ if (next === undefined || next === null || next === "") return segments;
1906
+ const nextTurn = Number(next);
1907
+ if (typeof next !== "string" || !/^[1-9]\d*$/.test(next) || !Number.isInteger(nextTurn) || nextTurn <= cursor || nextTurn !== page.segments.at(-1)?.toTurn) throw new Error("Shifu returned an invalid sync-segment cursor.");
1908
+ cursor = nextTurn;
1909
+ } while (cursor < 1_000_000);
1910
+ throw new Error("Shifu returned an invalid sync-segment cursor.");
1911
+ }
1912
+
1913
+ function forceSegments(session, accepted, since) {
1914
+ const selected = session.turnTimes.map((timestamp) => since === undefined || Date.parse(timestamp) >= since);
1915
+ const covered = new Set();
1916
+ const segments = [];
1917
+ const unavailable = [];
1918
+ for (const segment of accepted) {
1919
+ if (!selected.slice(segment.fromTurn - 1, segment.toTurn).some(Boolean)) continue;
1920
+ for (let turn = segment.fromTurn; turn <= Math.min(segment.toTurn, session.turnCount); turn += 1) covered.add(turn);
1921
+ if (segment.toTurn > session.turnCount) {
1922
+ unavailable.push({ sessionRef: session.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, reason: "local_turns_missing" });
1923
+ continue;
1924
+ }
1925
+ segments.push({ sessionRef: session.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, force: true });
1926
+ }
1927
+ for (let fromTurn = 1; fromTurn <= session.turnCount; fromTurn += 1) {
1928
+ if (!selected[fromTurn - 1] || covered.has(fromTurn)) continue;
1929
+ let toTurn = fromTurn;
1930
+ while (toTurn < session.turnCount && toTurn - fromTurn + 1 < MAX_TURNS_PER_SEGMENT && selected[toTurn] && !covered.has(toTurn + 1)) toTurn += 1;
1931
+ segments.push({ sessionRef: session.sessionRef, fromTurn, toTurn, force: true });
1932
+ fromTurn = toTurn;
1933
+ }
1934
+ return { segments: segments.sort((left, right) => left.fromTurn - right.fromTurn), unavailable };
1935
+ }
1936
+
1937
+ async function sessions(harness) {
1938
+ const config = readConfig(harness);
1841
1939
  const count = option("--count");
1842
1940
  if (hasFlag("--count") && count === undefined) throw new Error("Provide a value for --count.");
1843
- if (count !== undefined && option("--hours") !== undefined) throw new Error("Use either --count or --hours, not both.");
1844
- const hours = numberOption("--hours", 24);
1941
+ if (count !== undefined && (hasFlag("--hours") || hasFlag("--days"))) throw new Error("Use --count without --hours or --days.");
1942
+ const hours = syncWindowHours();
1845
1943
  const turns = numberOption("--turns", MAX_TURNS_PER_SEGMENT);
1846
1944
  const sessionRef = option("--session-ref");
1847
1945
  const all = hasFlag("--all");
1848
1946
  if (count !== undefined && (sessionRef || all)) throw new Error("Use --count without --session-ref or --all.");
1849
1947
  if (all && !sessionRef) throw new Error("Use --all only with an explicitly selected --session-ref.");
1850
1948
  const countValue = count === undefined ? undefined : Number(count);
1949
+ if (hasFlag("--force")) {
1950
+ if (hasFlag("--turns")) throw new Error("Force sync preserves stored ranges and bounds new ranges to 12 turns; do not provide --turns.");
1951
+ const selected = selectedSessionSegments(harness, { hours, selectedRef: sessionRef, includeOlder: true, count: countValue });
1952
+ const replacements = [];
1953
+ const unavailable = [];
1954
+ const since = all || count !== undefined ? undefined : Date.now() - hours * 3_600_000;
1955
+ const metadataByRef = new Map();
1956
+ for (const metadata of localSessions(harness)) {
1957
+ const prior = metadataByRef.get(metadata.sessionRef);
1958
+ if (!prior || metadata.turnCount > prior.turnCount || (metadata.turnCount === prior.turnCount && metadata.updatedAt > prior.updatedAt)) metadataByRef.set(metadata.sessionRef, metadata);
1959
+ }
1960
+ for (const session of selected) {
1961
+ const metadata = metadataByRef.get(session.sessionRef);
1962
+ const ranges = forceSegments(metadata, await acceptedSegments(config, session.sessionRef), since);
1963
+ replacements.push(...ranges.segments);
1964
+ unavailable.push(...ranges.unavailable);
1965
+ }
1966
+ console.log(JSON.stringify({ harness, ...(count === undefined ? { hours } : { count: countValue, selectedSessionRefs: selected.map((item) => item.sessionRef) }), force: true, sessions: replacements, unavailableSegments: unavailable }, null, 2));
1967
+ return;
1968
+ }
1851
1969
  const selected = selectedSessionSegments(harness, { hours, turns, selectedRef: sessionRef, includeOlder: all, count: countValue });
1852
1970
  const pending = selected.filter((session) => session.fromTurn <= session.toTurn);
1853
1971
  const blocked = all || count !== undefined ? [] : unsyncedCodexSessions(harness, { hours, turns, selectedRef: sessionRef, includeOlder: true })
1854
1972
  .filter((session) => !pending.some((item) => item.sessionRef === session.sessionRef))
1855
- .map(({ sessionRef, oldestUnsyncedAt }) => ({ sessionRef, oldestUnsyncedAt }));
1973
+ .map(({ sessionRef, oldestUnsyncedAt }) => ({ sessionRef, oldestUnsyncedAt, reason: "older_unsynced_turns", nextStep: "Select this session explicitly with --session-ref and --all to backfill in order." }));
1856
1974
  console.log(JSON.stringify(count === undefined
1857
1975
  ? { harness, hours, turns, sessions: pending, blockedSessions: blocked }
1858
1976
  : {
@@ -1864,32 +1982,51 @@ function sessions(harness) {
1864
1982
  }, null, 2));
1865
1983
  }
1866
1984
 
1867
- function review(harness) {
1868
- readConfig(harness);
1985
+ async function review(harness) {
1986
+ const config = readConfig(harness);
1869
1987
  const sessionRef = option("--session-ref");
1870
1988
  if (typeof sessionRef !== "string" || sessionRef.length < 8) throw new Error(`Provide an opaque ${HARNESS_NAMES[harness]} --session-ref from the sessions command.`);
1871
1989
  const state = readState(harness);
1872
- const fromTurn = (state.sessions[sessionRef]?.lastSyncedTurn || 0) + 1;
1990
+ let fromTurn = (state.sessions[sessionRef]?.lastSyncedTurn || 0) + 1;
1873
1991
  const selected = localSessions(harness).filter((session) => session.sessionRef === sessionRef)
1874
1992
  .sort((left, right) => right.turnCount - left.turnCount || right.updatedAt.localeCompare(left.updatedAt))[0];
1875
1993
  if (!selected) throw new Error(`No local ${HARNESS_NAMES[harness]} session matches this reference.`);
1876
- const hours = numberOption("--hours", 24);
1877
- if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
1878
- if (!hasFlag("--all") && !(Date.parse(selected.turnTimes[fromTurn - 1]) >= Date.now() - hours * 3_600_000)) {
1879
- throw new Error("This session's next unsynced turn predates the selected window. Ask for this specific session and use --all to review its backlog.");
1880
- }
1994
+ const hours = syncWindowHours();
1881
1995
  const turns = numberOption("--turns", MAX_TURNS_PER_SEGMENT);
1882
1996
  if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
1883
- const toTurn = Math.min(selected.turnCount, fromTurn + turns - 1);
1997
+ let toTurn = Math.min(selected.turnCount, fromTurn + turns - 1);
1998
+ let supersedesSyncId;
1999
+ if (hasFlag("--force")) {
2000
+ if (hasFlag("--turns")) throw new Error("Force review uses the discovered range; do not provide --turns.");
2001
+ fromTurn = numberOption("--from-turn");
2002
+ toTurn = numberOption("--to-turn");
2003
+ if (!Number.isInteger(fromTurn) || !Number.isInteger(toTurn) || fromTurn < 1 || toTurn < fromTurn || toTurn - fromTurn >= MAX_TURNS_PER_SEGMENT || toTurn > selected.turnCount) throw new Error("Use the exact range from sessions --force; the original local turns must still exist.");
2004
+ const accepted = await acceptedSegments(config, sessionRef);
2005
+ const overlapping = accepted.filter((segment) => segment.fromTurn <= toTurn && segment.toTurn >= fromTurn);
2006
+ if (overlapping.some((segment) => segment.fromTurn !== fromTurn || segment.toTurn !== toTurn)) throw new Error("This range partially overlaps accepted work. Run sessions --force and re-review its original complete range.");
2007
+ if (hasFlag("--supersedes-sync-id")) {
2008
+ supersedesSyncId = numberOption("--supersedes-sync-id");
2009
+ if (!overlapping.some((segment) => segment.id === supersedesSyncId)) throw new Error("The replacement target changed. Run sessions --force and re-review the current range.");
2010
+ }
2011
+ } else if (["--from-turn", "--to-turn", "--supersedes-sync-id"].some(hasFlag)) {
2012
+ throw new Error("Explicit replacement boundaries require --force.");
2013
+ }
1884
2014
  if (fromTurn > toTurn) throw new Error(`This ${HARNESS_NAMES[harness]} session has no unsynced turns to review.`);
2015
+ const withinWindow = hasFlag("--force")
2016
+ ? selected.turnTimes.slice(fromTurn - 1, toTurn).some((timestamp) => Date.parse(timestamp) >= Date.now() - hours * 3_600_000)
2017
+ : Date.parse(selected.turnTimes[fromTurn - 1]) >= Date.now() - hours * 3_600_000;
2018
+ if (!hasFlag("--all") && !withinWindow) {
2019
+ throw new Error("This session's selected turn predates the selected window. Ask for this specific session and use --all to review its backlog.");
2020
+ }
1885
2021
  const { notes } = reviewSegment(harness, sessionRef, fromTurn, toTurn);
1886
2022
  const candidates = reviewCandidates(notes);
1887
2023
  console.log(JSON.stringify({
1888
2024
  sessionRef,
1889
2025
  fromTurn,
1890
2026
  toTurn,
2027
+ ...(hasFlag("--force") ? { force: true } : {}),
1891
2028
  oldestUnsyncedAt: selected.turnTimes[fromTurn - 1] || null,
1892
- source: "Local assistant final notes for unsynced user turns. Candidates are suggestions, not verified or guaranteed redacted; inspect them before constructing a Shifu payload.",
2029
+ source: "Local assistant final notes for the selected user turns. Candidates are suggestions, not verified or guaranteed redacted; inspect them before constructing a Shifu payload.",
1893
2030
  notes,
1894
2031
  candidates,
1895
2032
  draft: { title: null, summary: null, evidence: candidates.map((candidate) => ({ ...candidate, title: null })) },
@@ -1935,6 +2072,7 @@ async function main() {
1935
2072
  if (option("--config-root")) process.env.SHIFU_CONFIG_ROOT = path.resolve(option("--config-root"));
1936
2073
  const command = process.argv[2];
1937
2074
  const subCommand = process.argv[3];
2075
+ validateSyncOptions(command === "discover" || command === "session" ? "sessions" : command, command === "session" && ["discover", "list"].includes(subCommand) ? 4 : 3);
1938
2076
  const harness = requireHarness(option("--harness"));
1939
2077
  if (["sync", "sync-files", "scheduled-sync", "approve"].includes(command) && process.env.SHIFU_UPDATE_CHECKED !== "1" && await updateRecommendedPackage(harness)) {
1940
2078
  const child = spawn(process.execPath, [installedRunnerPath(), ...process.argv.slice(2)], {
@@ -12,11 +12,13 @@ If manual approval has queued scheduled work, run `pending --harness opencode`,
12
12
 
13
13
  For an explicit sync-now request:
14
14
 
15
- 1. For a time-window request, run `sessions --harness opencode --hours 24`, using 48 or 72 only when requested. For a request by number of recent sessions, run `sessions --harness opencode --count <count>`; this ignores the hours window and returns the most recently updated sessions first.
16
- 2. Review each returned segment with `review --harness opencode --session-ref <sessionRef> --hours <hours>`. For count-based selection, use `review --harness opencode --session-ref <sessionRef> --all` so older sessions are allowed.
15
+ 1. For a time-window request, run `sessions --harness opencode --hours 24`, using the positive whole number of hours requested. A day request uses `--days N`; `--days 3` and `--hours 72` are identical. Never combine the two flags. For a request by number of recent sessions, run `sessions --harness opencode --count <count>`; this ignores the hours window and returns the most recently updated sessions first.
16
+ 2. Report `blockedSessions` when older unsynced turns prevent the requested window from syncing; user-selected backfill uses `--session-ref <sessionRef> --all` and keeps chronological 12-turn segments. Review each returned segment with `review --harness opencode --session-ref <sessionRef> --hours <hours>`. Use the same hours or days value for discovery and review. For count-based selection, use `review --harness opencode --session-ref <sessionRef> --all` so older sessions are allowed.
17
17
  3. Prepare a redaction-version-four payload from final assistant notes only. Use `activity` evidence for writing, feedback, research, planning, product work, and any work that is not clearly an engineering implementation or decision. Activity evidence has no technical scope. Preserve the supported action, feature or project purpose, specific problem, approach, technologies and design patterns used, and verified result. Synthesize a descriptive project label (such as "payment reconciliation service") from its purpose; never copy a raw label that could identify a company or person. Keep distinct contributions separate, including different fixes within one feature. An empty evidence list advances the checkpoint without creating a claim.
18
18
  4. In automatic mode, pipe the payload to `sync --harness opencode` without asking again. In manual mode, show the exact payload, ask once, and then use `sync --approved --harness opencode`.
19
19
  5. Keep the returned session reference and turn range unchanged. Never upload prompts, raw transcripts, tool output, code, paths, commands, URLs, credentials, company or person names, customer details, or proprietary identifiers.
20
20
  6. For a count-based sync, keep `selectedSessionRefs` from the first discovery and review only pending `sessions`. `skippedSessions` reports already-synced selections; do not replace them with older sessions. If one has remaining turns, request its next segment with `sessions --harness opencode --session-ref <sessionRef> --all`. Stop after those selected sessions are exhausted. Report completed, already-synced, and failed/pending sessions separately. Manual sync runs immediately, independently of the hourly schedule.
21
21
 
22
22
  To connect or replace a key, run `npx --yes @useshifu/coding-harness connect --harness opencode`. Connect asks for a key with a normal visible terminal prompt and verifies it with Shifu before reporting success. Do not ask the user to paste the key into chat. If this tool terminal cannot run the command or accept input, ask the user to run it in their own interactive terminal and report the output; do not claim the connection succeeded. To disconnect locally, run `npx --yes @useshifu/coding-harness disconnect --harness opencode`. New connections use one-hour automatic sync and `~/Desktop/Shifu` on macOS by default. To change the interval, approval policy, or folder later, run `config update --harness opencode` with the desired flags (for example, `--input-folder <existing-readable-folder>`).
23
+
24
+ For an explicit user force-sync request, use `sessions --harness opencode --force` with the requested window/count. It selects local turns independently of checkpoints, including already synced work and ranges missing from the server. It preserves whole stored ranges touching selected turns (including older turns across a window boundary) and splits uncovered selected turns into ranges of at most 12. Keep the initial targets fixed and report `unavailableSegments` honestly. Re-review with `review --harness opencode --session-ref <sessionRef> --force --from-turn <fromTurn> --to-turn <toTurn>` using the same window or explicit `--all`; no old receipt ID is needed. Redact the final notes again and add `force: true` to the prepared payload; send with `sync --harness opencode --force`, following the saved approval policy. Never guess boundaries or force scheduled/file sync. On a partial-overlap rejection, re-discover and re-review the complete ranges. Checkpoints and history are preserved; identical retries are safe. Force sync leaves AI Profile observations unchanged. Start a new chat in Shifu to use the latest work context.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@useshifu/coding-harness",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "Scheduled, redacted coding-harness activity sync for Shifu",
5
5
  "bin": {
6
6
  "coding-harness": "bin/shifu-harness.js",
@@ -55,11 +55,11 @@ Show the returned list and ask for one approval for the whole list. After approv
55
55
 
56
56
  ## Sync now
57
57
 
58
- For an explicit sync request, first read `status` to determine the saved approval mode. Manual sync runs immediately and does not wait for the hourly schedule. Use the time window the user names; otherwise use 24 hours. Supported interactive windows are 24, 48, and 72 hours. If the user specifies a number of recent sessions, use count-based selection instead; it has no hours cutoff and orders sessions from latest to earliest. It selects the latest eligible sessions, excluding current/setup sessions, and reports already-synced selections in `skippedSessions` without replacing them with older sessions.
58
+ For an explicit sync request, first read `status` to determine the saved approval mode. Manual sync runs immediately and does not wait for the hourly schedule. Use the time window the user names; otherwise use 24 hours. Use `--days N` for a positive whole number of days or `--hours N` for a positive whole number of hours. Days use exactly the same selection and review rules as hours (`--days 3` equals `--hours 72`). Never combine them or silently substitute the default window. If the user specifies a number of recent sessions, use count-based selection instead; it has no hours cutoff and orders sessions from latest to earliest. It selects the latest eligible sessions, excluding current/setup sessions, and reports already-synced selections in `skippedSessions` without replacing them with older sessions.
59
59
 
60
- 1. For a time-window sync, run `npx --yes @useshifu/coding-harness sessions --harness codex --hours <24|48|72>`. For a count-based sync, run `npx --yes @useshifu/coding-harness sessions --harness codex --count <count>`; do not add `--hours`.
61
- 2. Ignore `blockedSessions` unless the user specifically selects one. For a selected older backlog, add `--session-ref <sessionRef> --all`.
62
- 3. For each returned segment, run `npx --yes @useshifu/coding-harness review --harness codex --session-ref <sessionRef> --hours <24|48|72>`. For count-based selection, use `--all` instead of `--hours` so the review has no time cutoff.
60
+ 1. For a time-window sync, run `npx --yes @useshifu/coding-harness sessions --harness codex --hours <hours>`. For a count-based sync, run `npx --yes @useshifu/coding-harness sessions --harness codex --count <count>`; do not add `--hours` or `--days`.
61
+ 2. Report `blockedSessions` honestly: these recently active sessions have older unsynced turns and cannot skip ahead. Explain that explicit backfill is needed. For a user-selected older backlog, add `--session-ref <sessionRef> --all`; process its original turns chronologically in segments of at most 12.
62
+ 3. For each returned segment, run `npx --yes @useshifu/coding-harness review --harness codex --session-ref <sessionRef> --hours <hours>`. Use the same `--days` or `--hours` value for discovery and review. For count-based selection, use `--all` so the review has no time cutoff.
63
63
  4. Prepare a version-four payload from the returned final-assistant notes. Keep the returned `sessionRef`, `fromTurn`, and `toTurn` unchanged.
64
64
  5. In automatic mode, send it without another approval. In manual mode, show the exact payload and ask once; after approval, pass `--approved` to the sync command.
65
65
  6. For a time-window sync, repeat discovery after accepted segments until the selected window is exhausted. For a count-based sync, keep `selectedSessionRefs` from the first discovery and review only pending `sessions`. If a selected session has remaining turns, request its next segment with `sessions --harness codex --session-ref <sessionRef> --all`; stop after those selected sessions are exhausted. Report completed, already-synced, and failed/pending sessions separately. A failed session does not count as completed.
@@ -96,3 +96,11 @@ printf '%s' '<redacted JSON>' | npx --yes @useshifu/coding-harness sync --approv
96
96
  ```
97
97
 
98
98
  If a transport or server error remains after the runner's retries, report that the checkpoint was not advanced. Do not alter the segment or skip ahead.
99
+
100
+ ## Explicit force sync
101
+
102
+ Only when the user explicitly asks to force-sync, add `--force` to `sessions` with their requested window or count. Force chooses local turns independently of local and server checkpoints, including already synced sessions and work never accepted by the server. It preserves complete stored range boundaries when they touch the selected window and splits uncovered selected turns into ranges of at most 12. A stored range crossing the window can include older turns so it is replaced in full. Missing local turns are reported in `unavailableSegments`; never invent them or silently count them as synced. Keep the initial returned targets fixed while processing this force request.
103
+
104
+ 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. Inspect and redact the final notes again, preserving supported work detail. 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.
105
+
106
+ 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.