@useshifu/coding-harness 0.3.11 → 0.3.13

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
@@ -34,6 +34,10 @@ To disconnect this device, run `npx --yes @useshifu/coding-harness disconnect --
34
34
 
35
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
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
+
39
+ Version `0.3.13` requires reviewed evidence for force sync. Empty replacements fail before changing existing work, and the harness instructions require semantic review of final notes instead of the scheduled keyword projection.
40
+
37
41
  ```sh
38
42
  npx --yes @useshifu/coding-harness status --harness codex
39
43
  npx --yes @useshifu/coding-harness sessions --harness codex --hours 24
@@ -77,16 +81,18 @@ The AI Profile page shows detected categories, model and thinking labels, and Je
77
81
 
78
82
  ## Recommended package updates
79
83
 
80
- The connection endpoint returns `recommendedPackageVersion`, currently pinned to `0.3.10` (publish this release before deploying the recommendation). 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.
84
+ 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.
85
+
86
+ 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.13 install --harness codex`). Raise the server recommendation only after the target release is published and its backend requirements are deployed.
81
87
 
82
- 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.11 install --harness codex`). Raise the server recommendation only after the target release is published and its backend requirements are deployed.
88
+ ## Force sync local work
83
89
 
84
- ## Replace previously synced work
90
+ 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.
85
91
 
86
- An explicit force request re-reviews the original accepted segments and replaces their current work evidence and claims while retaining history. It does not reset or lower checkpoints, and never changes scheduled sync or AI Profile observations.
92
+ 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.
93
+ 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.
94
+ 3. Have the coding harness read the final notes semantically and prepare meaningful redacted evidence about attributable work. Preserve supported technologies, patterns, problem, approach, and verified results, including work described with bold Markdown actions or inline-code technology names. Keep the exact session reference and turn range and add `force: true`. Pipe it to `sync --harness codex --force`, following the saved manual/automatic approval policy.
87
95
 
88
- 1. Discover accepted targets with `sessions --harness codex --days 3 --force` (or a requested hours/count selection). Keep that initial target list fixed; new pending turns still use ordinary sync.
89
- 2. Re-review each target with `review --harness codex --session-ref <sessionRef> --days 3 --force --from-turn <fromTurn> --to-turn <toTurn> --supersedes-sync-id <supersedesSyncId>`. The original local turns must exist. Explicit backfill/count selections use `--all`.
90
- 3. Prepare and redact the payload again, keeping the exact session reference and turn range and adding its `supersedesSyncId`. Pipe it to `sync --harness codex --force`, following the saved manual/automatic approval policy.
96
+ Force requires at least one supported evidence item. Do not use the unattended `payloadForSegment` keyword projection, empty evidence, or a title-only payload for force. When final notes support no work items, report the range as not replaced; its existing work and checkpoints stay untouched. Ordinary scheduled sync can still accept a claim-free checkpoint.
91
97
 
92
- A changed target is rejected so newer work cannot be overwritten accidentally. Re-discover and re-review before attempting that replacement again. Identical retries are safe. Existing conversations retain their context; start a new chat in Shifu to use the latest context.
98
+ 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", "supersedesSyncId"]);
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.11";
42
+ const RUNNER_VERSION = "0.3.13";
43
43
  const REDACTION_VERSION = 4;
44
44
  const DEFAULT_INTERVAL_HOURS = 1;
45
45
  const LEGACY_INTERVAL_HOURS = 4;
@@ -786,6 +786,7 @@ function safeActivityEvidence(notes) {
786
786
  }
787
787
 
788
788
  function payloadForSegment(harness, segment) {
789
+ 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.");
789
790
  const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
790
791
  const evidence = safeActivityEvidence(notes);
791
792
  const title = evidence.length ? evidence[0].title : "No supported work identified";
@@ -1330,10 +1331,14 @@ function validateSync(input, harness) {
1330
1331
  if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
1331
1332
  if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
1332
1333
  if (Object.hasOwn(input, "supersedesSyncId") && (!Number.isSafeInteger(input.supersedesSyncId) || input.supersedesSyncId < 1)) throw new Error("Use the positive supersedesSyncId returned by force discovery.");
1334
+ if (Object.hasOwn(input, "force") && typeof input.force !== "boolean") throw new Error("Use force: true only for explicitly requested force sync.");
1335
+ if (input.force && input.supersedesSyncId) throw new Error("Use force: true without supersedesSyncId, or the legacy supersedesSyncId mode alone.");
1333
1336
  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.");
1334
1337
  if (input.redactionVersion >= 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
1335
1338
  if (!textIsSafe(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
1336
- const minimumEvidence = input.redactionVersion >= REDACTION_VERSION ? 0 : 1;
1339
+ const forced = input.force || Boolean(input.supersedesSyncId);
1340
+ 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.");
1341
+ const minimumEvidence = forced || input.redactionVersion < REDACTION_VERSION ? 1 : 0;
1337
1342
  if (!Array.isArray(input.evidence) || input.evidence.length < minimumEvidence || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => {
1338
1343
  if (!item || !EVIDENCE_KINDS.has(item.kind) || (input.redactionVersion >= 3 && !textIsSafe(item.title, MAX_TITLE_LENGTH)) || !textIsSafe(item.statement, MAX_WORK_DETAIL_LENGTH)) return false;
1339
1344
  if (item.kind === "activity" && input.redactionVersion >= REDACTION_VERSION) return item.scope === undefined || item.scope === "";
@@ -1374,7 +1379,7 @@ function validateAIProfileObservation(input, harness) {
1374
1379
 
1375
1380
  function validSyncReceipt(receipt, input) {
1376
1381
  return receipt?.sessionRef === input.sessionRef && receipt.acceptedToTurn === input.toTurn && Number.isInteger(receipt.lastSyncedTurn) && receipt.lastSyncedTurn <= 1_000_000 &&
1377
- (receipt.duplicate || input.supersedesSyncId ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
1382
+ (receipt.duplicate || input.force || input.supersedesSyncId ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
1378
1383
  }
1379
1384
 
1380
1385
  function sleep(milliseconds) {
@@ -1392,6 +1397,7 @@ function retryDelay(response, attempt) {
1392
1397
  }
1393
1398
 
1394
1399
  async function postSync(config, input) {
1400
+ const body = JSON.stringify(input.force ? { ...input, forceRequestId: randomUUID() } : input);
1395
1401
  let lastError;
1396
1402
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
1397
1403
  let response;
@@ -1399,7 +1405,7 @@ async function postSync(config, input) {
1399
1405
  response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
1400
1406
  method: "POST",
1401
1407
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
1402
- body: JSON.stringify(input),
1408
+ body,
1403
1409
  signal: AbortSignal.timeout(30_000),
1404
1410
  });
1405
1411
  } catch (error) {
@@ -1620,9 +1626,10 @@ async function sendSync(harness, input, config = readConfig(harness)) {
1620
1626
  const state = readState(harness);
1621
1627
  const checkpoint = state.sessions[input.sessionRef];
1622
1628
  const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
1623
- if (!input.supersedesSyncId && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
1624
- if (input.supersedesSyncId && reviewSegment(harness, input.sessionRef, input.fromTurn, input.toTurn).turnCount < input.toTurn) throw new Error("The original local turns must still exist before replacing this sync.");
1625
- const profile = input.supersedesSyncId ? undefined : profileForSegment(harness, input);
1629
+ const force = input.force || Boolean(input.supersedesSyncId);
1630
+ if (!force && input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
1631
+ 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.");
1632
+ const profile = force ? undefined : profileForSegment(harness, input);
1626
1633
  const receipt = await postSync(config, input);
1627
1634
  if (!validSyncReceipt(receipt, input)) {
1628
1635
  throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
@@ -1634,7 +1641,7 @@ async function sendSync(harness, input, config = readConfig(harness)) {
1634
1641
  state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: Math.max(checkpoint?.lastSyncedTurn || 0, receipt.lastSyncedTurn), syncDue: false };
1635
1642
  saveState(harness, state);
1636
1643
  if (profile) await flushPendingProfiles(harness, config);
1637
- console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : input.supersedesSyncId ? `Replaced the reviewed sync for turns ${input.fromTurn}–${input.toTurn}.` : `Synced through turn ${receipt.lastSyncedTurn}.`);
1644
+ 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}.`);
1638
1645
  return receipt;
1639
1646
  }
1640
1647
 
@@ -1672,7 +1679,7 @@ async function sync(harness) {
1672
1679
  const config = readConfig(harness);
1673
1680
  const input = readJSON(0, undefined);
1674
1681
  validateSync(input, harness);
1675
- if (hasFlag("--force") !== Boolean(input.supersedesSyncId)) throw new Error("Force replacement requires both --force and the reviewed payload's supersedesSyncId. Use ordinary sync for new segments.");
1682
+ 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).");
1676
1683
  const policy = normalizedPolicy(config);
1677
1684
  if (policy.approvalMode === "manual" && !hasFlag("--approved")) {
1678
1685
  console.error(`\nThis exact redacted segment will be sent to Shifu:\n${JSON.stringify(input, null, 2)}`);
@@ -1906,6 +1913,30 @@ async function acceptedSegments(config, sessionRef) {
1906
1913
  throw new Error("Shifu returned an invalid sync-segment cursor.");
1907
1914
  }
1908
1915
 
1916
+ function forceSegments(session, accepted, since) {
1917
+ const selected = session.turnTimes.map((timestamp) => since === undefined || Date.parse(timestamp) >= since);
1918
+ const covered = new Set();
1919
+ const segments = [];
1920
+ const unavailable = [];
1921
+ for (const segment of accepted) {
1922
+ if (!selected.slice(segment.fromTurn - 1, segment.toTurn).some(Boolean)) continue;
1923
+ for (let turn = segment.fromTurn; turn <= Math.min(segment.toTurn, session.turnCount); turn += 1) covered.add(turn);
1924
+ if (segment.toTurn > session.turnCount) {
1925
+ unavailable.push({ sessionRef: session.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, reason: "local_turns_missing" });
1926
+ continue;
1927
+ }
1928
+ segments.push({ sessionRef: session.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, force: true });
1929
+ }
1930
+ for (let fromTurn = 1; fromTurn <= session.turnCount; fromTurn += 1) {
1931
+ if (!selected[fromTurn - 1] || covered.has(fromTurn)) continue;
1932
+ let toTurn = fromTurn;
1933
+ while (toTurn < session.turnCount && toTurn - fromTurn + 1 < MAX_TURNS_PER_SEGMENT && selected[toTurn] && !covered.has(toTurn + 1)) toTurn += 1;
1934
+ segments.push({ sessionRef: session.sessionRef, fromTurn, toTurn, force: true });
1935
+ fromTurn = toTurn;
1936
+ }
1937
+ return { segments: segments.sort((left, right) => left.fromTurn - right.fromTurn), unavailable };
1938
+ }
1939
+
1909
1940
  async function sessions(harness) {
1910
1941
  const config = readConfig(harness);
1911
1942
  const count = option("--count");
@@ -1919,25 +1950,21 @@ async function sessions(harness) {
1919
1950
  if (all && !sessionRef) throw new Error("Use --all only with an explicitly selected --session-ref.");
1920
1951
  const countValue = count === undefined ? undefined : Number(count);
1921
1952
  if (hasFlag("--force")) {
1922
- if (hasFlag("--turns")) throw new Error("Force replacement uses the original stored ranges; do not provide --turns.");
1953
+ if (hasFlag("--turns")) throw new Error("Force sync preserves stored ranges and bounds new ranges to 12 turns; do not provide --turns.");
1923
1954
  const selected = selectedSessionSegments(harness, { hours, selectedRef: sessionRef, includeOlder: true, count: countValue });
1924
1955
  const replacements = [];
1925
1956
  const unavailable = [];
1926
- const since = Date.now() - hours * 3_600_000;
1957
+ const since = all || count !== undefined ? undefined : Date.now() - hours * 3_600_000;
1927
1958
  const metadataByRef = new Map();
1928
1959
  for (const metadata of localSessions(harness)) {
1929
- if (metadata.turnCount >= (metadataByRef.get(metadata.sessionRef)?.turnCount || 0)) metadataByRef.set(metadata.sessionRef, metadata);
1960
+ const prior = metadataByRef.get(metadata.sessionRef);
1961
+ if (!prior || metadata.turnCount > prior.turnCount || (metadata.turnCount === prior.turnCount && metadata.updatedAt > prior.updatedAt)) metadataByRef.set(metadata.sessionRef, metadata);
1930
1962
  }
1931
1963
  for (const session of selected) {
1932
1964
  const metadata = metadataByRef.get(session.sessionRef);
1933
- for (const segment of await acceptedSegments(config, session.sessionRef)) {
1934
- if (segment.toTurn > session.turnCount) {
1935
- unavailable.push({ sessionRef: session.sessionRef, supersedesSyncId: segment.id, reason: "local_turns_missing" });
1936
- continue;
1937
- }
1938
- if (!all && count === undefined && !(Date.parse(metadata.turnTimes[segment.fromTurn - 1]) >= since)) continue;
1939
- replacements.push({ sessionRef: session.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn, supersedesSyncId: segment.id });
1940
- }
1965
+ const ranges = forceSegments(metadata, await acceptedSegments(config, session.sessionRef), since);
1966
+ replacements.push(...ranges.segments);
1967
+ unavailable.push(...ranges.unavailable);
1941
1968
  }
1942
1969
  console.log(JSON.stringify({ harness, ...(count === undefined ? { hours } : { count: countValue, selectedSessionRefs: selected.map((item) => item.sessionRef) }), force: true, sessions: replacements, unavailableSegments: unavailable }, null, 2));
1943
1970
  return;
@@ -1973,17 +2000,25 @@ async function review(harness) {
1973
2000
  let toTurn = Math.min(selected.turnCount, fromTurn + turns - 1);
1974
2001
  let supersedesSyncId;
1975
2002
  if (hasFlag("--force")) {
1976
- if (hasFlag("--turns")) throw new Error("Force review uses the original stored range; do not provide --turns.");
2003
+ if (hasFlag("--turns")) throw new Error("Force review uses the discovered range; do not provide --turns.");
1977
2004
  fromTurn = numberOption("--from-turn");
1978
2005
  toTurn = numberOption("--to-turn");
1979
- supersedesSyncId = numberOption("--supersedes-sync-id");
1980
- const target = (await acceptedSegments(config, sessionRef)).find((segment) => segment.id === supersedesSyncId && segment.fromTurn === fromTurn && segment.toTurn === toTurn);
1981
- if (!target || toTurn > selected.turnCount) throw new Error("Use the exact current segment and supersedesSyncId from sessions --force; the original local turns must still exist.");
2006
+ 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.");
2007
+ const accepted = await acceptedSegments(config, sessionRef);
2008
+ const overlapping = accepted.filter((segment) => segment.fromTurn <= toTurn && segment.toTurn >= fromTurn);
2009
+ 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.");
2010
+ if (hasFlag("--supersedes-sync-id")) {
2011
+ supersedesSyncId = numberOption("--supersedes-sync-id");
2012
+ if (!overlapping.some((segment) => segment.id === supersedesSyncId)) throw new Error("The replacement target changed. Run sessions --force and re-review the current range.");
2013
+ }
1982
2014
  } else if (["--from-turn", "--to-turn", "--supersedes-sync-id"].some(hasFlag)) {
1983
2015
  throw new Error("Explicit replacement boundaries require --force.");
1984
2016
  }
1985
2017
  if (fromTurn > toTurn) throw new Error(`This ${HARNESS_NAMES[harness]} session has no unsynced turns to review.`);
1986
- if (!hasFlag("--all") && !(Date.parse(selected.turnTimes[fromTurn - 1]) >= Date.now() - hours * 3_600_000)) {
2018
+ const withinWindow = hasFlag("--force")
2019
+ ? selected.turnTimes.slice(fromTurn - 1, toTurn).some((timestamp) => Date.parse(timestamp) >= Date.now() - hours * 3_600_000)
2020
+ : Date.parse(selected.turnTimes[fromTurn - 1]) >= Date.now() - hours * 3_600_000;
2021
+ if (!hasFlag("--all") && !withinWindow) {
1987
2022
  throw new Error("This session's selected turn predates the selected window. Ask for this specific session and use --all to review its backlog.");
1988
2023
  }
1989
2024
  const { notes } = reviewSegment(harness, sessionRef, fromTurn, toTurn);
@@ -1992,7 +2027,7 @@ async function review(harness) {
1992
2027
  sessionRef,
1993
2028
  fromTurn,
1994
2029
  toTurn,
1995
- ...(supersedesSyncId ? { supersedesSyncId } : {}),
2030
+ ...(hasFlag("--force") ? { force: true } : {}),
1996
2031
  oldestUnsyncedAt: selected.turnTimes[fromTurn - 1] || null,
1997
2032
  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.",
1998
2033
  notes,
@@ -21,4 +21,4 @@ For an explicit sync-now request:
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
23
 
24
- For an explicit user force-sync request, use `sessions --harness opencode --force` with the requested window/count. Keep its initial targets fixed. Each accepted segment includes `supersedesSyncId` and exact original boundaries. Re-review with `review --harness opencode --session-ref <sessionRef> --force --from-turn <fromTurn> --to-turn <toTurn> --supersedes-sync-id <supersedesSyncId>` using the same window or explicit `--all`. Redact the final notes again and add the returned `supersedesSyncId` to the prepared payload; send with `sync --harness opencode --force`, following the saved approval policy. New pending turns use ordinary sync. Never guess boundaries, replace a changed target, or force scheduled/file sync. Checkpoints and history are preserved; identical retries are safe. Work replacements leave AI Profile observations unchanged. Start a new chat in Shifu to use the latest work context.
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. Read the final notes semantically and prepare meaningful redacted evidence about attributable work, retaining supported technologies, patterns, problem, approach, and results. Markdown such as `**Implemented**` and inline-code technology names still describe work. Do not use `payloadForSegment` or an unattended keyword projection, and never send empty evidence or a title-only replacement. If no work items are supported, report the range as not replaced and leave its existing work/checkpoints untouched. 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.11",
3
+ "version": "0.3.13",
4
4
  "description": "Scheduled, redacted coding-harness activity sync for Shifu",
5
5
  "bin": {
6
6
  "coding-harness": "bin/shifu-harness.js",
@@ -97,10 +97,10 @@ printf '%s' '<redacted JSON>' | npx --yes @useshifu/coding-harness sync --approv
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
99
 
100
- ## Explicit force replacement
100
+ ## Explicit force sync
101
101
 
102
- Only when the user explicitly asks to force-sync or replace previously synced work, add `--force` to `sessions` with their requested window or count. It returns current server segments with `supersedesSyncId` and their original `fromTurn`/`toTurn`; never guess replacement ranges from local checkpoints. Force discovery lists accepted segments only. New pending turns still use ordinary sync. Keep the initial returned targets fixed rather than repeating force discovery after each replacement.
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
103
 
104
- Re-review each returned target with `review --harness codex --session-ref <sessionRef> --force --from-turn <fromTurn> --to-turn <toTurn> --supersedes-sync-id <supersedesSyncId>` and the same window (or `--all` for explicitly selected backfill/count selection). The original local turns must still exist. Inspect and redact the final notes again, preserving supported work detail. Add that exact `supersedesSyncId` to the prepared payload and send with `sync --harness codex --force` (plus `--approved` only after manual approval). Preserve the original reference and turn range. If another replacement already changed the target, stop and re-discover/re-review; do not silently force over newer work.
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. Read the returned final notes semantically and synthesize meaningful, attributable work evidence; do not use `payloadForSegment` or its unattended keyword projection for force sync. Completed work remains supported when the note uses Markdown such as `**Implemented**` or names a technique in inline code such as `Rust`. Retain supported technologies, design patterns, the problem, approach, and verified result while redacting names and sensitive identifiers. Never send an empty evidence list or a title-only replacement. If the notes support no work items, report that range as not replaced and leave its existing work and checkpoints untouched. Keep the returned reference and range unchanged and include `force: true` in the prepared payload. Send with `sync --harness codex --force` (plus `--approved` only after manual approval). If accepted ranges changed and partially overlap the selection, stop and re-discover/re-review the complete ranges instead of guessing boundaries.
105
105
 
106
- Force replacement preserves sync history and never lowers checkpoints. Identical retries are safe. It replaces work evidence and claims for that segment; 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.
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.