@useshifu/coding-harness 0.2.6 → 0.3.0

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
@@ -1,33 +1,44 @@
1
1
  # Shifu coding-harness connector
2
2
 
3
- `@useshifu/coding-harness` connects Codex, Claude Code, or OpenCode to Shifu through reviewed, incremental summaries. It never uploads raw transcripts or syncs in the background.
3
+ `@useshifu/coding-harness` connects Codex, Claude Code, or OpenCode to Shifu. It reads local final-assistant notes, never user prompts or raw transcripts, and sends incremental redacted activity through durable checkpoints. Unattended runs emit controlled use-case labels rather than copying source prose.
4
+
5
+ ## Connect
4
6
 
5
7
  ```sh
6
- npx @useshifu/coding-harness install --harness codex
7
- npx @useshifu/coding-harness connect --harness codex
8
+ npx @useshifu/coding-harness connect --harness <codex|claude_code|opencode>
8
9
  ```
9
10
 
10
- `connect` and `sync` each ask for confirmation. The connector has no lifecycle hooks and never uploads in the background. Codex and OpenCode support local time-window discovery and review; Claude Code does not yet.
11
+ Connect installs the local instruction and scheduled runner, saves the connection key with owner-only permissions, and asks for two setup choices:
12
+
13
+ - sync interval: four hours by default, configurable from 0.25 to 720 hours;
14
+ - approval mode: automatic by default, or manual for one approval covering the accumulated queue.
11
15
 
12
- When asked to sync, Codex and OpenCode discover unsynced user turns from the last 24 hours by default. A user can choose 48 or 72 hours instead. Discovery reports only opaque session references, timestamps, and unsynced turn ranges; it does not send transcript content. `blockedSessions` lists recently changed sessions whose next unsynced turn is older than the chosen window. Those older contiguous backlogs are not silently included; the user can explicitly select a session and review it with `--all`.
16
+ No session content is sent during setup. Re-running `connect` on an older installation keeps its key and checkpoints and completes the missing onboarding. Change the choices later with:
13
17
 
14
18
  ```sh
15
- npx @useshifu/coding-harness sessions --harness <codex|opencode> --hours 24
16
- npx @useshifu/coding-harness review --harness <codex|opencode> --session-ref opaque-session-id
19
+ npx @useshifu/coding-harness config update --harness <codex|claude_code|opencode>
17
20
  ```
18
21
 
19
- `review` reads the next contiguous segment of at most 12 unsynced turns locally. Pass the selected `--hours` value to `review` as well; `--all` is reserved for a specifically selected session's older backlog. It shows assistant final notes, candidate work items, and a draft that leaves required sync and work-item titles blank for the reviewer. Candidates are suggestions, not proof or guaranteed redaction. Candidate scope is left unset until a reviewer chooses the smallest supported level. If a segment has more than 64 candidates, rerun `sessions` and `review` with `--turns 1` to narrow it before syncing. Review each work title, detail, and area, prepare any verification, and preserve an item's `itemRef` when a later segment revises the same work. User-message records are omitted, but assistant notes may quote sensitive material. Nothing is uploaded by this command. Repeat discovery after each accepted segment to continue the delta.
22
+ Older configuration files remain on-demand and manual until either command completes onboarding; upgrading the package alone never enables background upload.
20
23
 
21
- To submit a reviewed segment, pipe the exact payload to `sync`. The CLI prints it, asks again, and only then sends it to Shifu. Successful responses advance a local checkpoint; the server rejects out-of-order or changed retries.
24
+ ## Operate
22
25
 
23
26
  ```sh
24
- printf '%s' '{"harness":"codex","sessionRef":"opaque-session-id","fromTurn":1,"toTurn":8,"title":"Hardened connector checkpoints","summary":"Implemented a focused change and checked the result.","evidence":[{"kind":"implementation","title":"Added checkpoint recovery","statement":"Implemented a focused connector change.","scope":"service","area":"connector","itemRef":"work_1","verificationRefs":[0]}],"verification":["Focused tests passed."],"decisions":[],"redactionVersion":3}' | npx @useshifu/coding-harness sync --harness codex
27
+ npx @useshifu/coding-harness status --harness codex
28
+ npx @useshifu/coding-harness sessions --harness codex --hours 24
29
+ npx @useshifu/coding-harness review --harness codex --session-ref opaque-session-id
30
+ npx @useshifu/coding-harness pending --harness codex
31
+ npx @useshifu/coding-harness approve --harness codex
25
32
  ```
26
33
 
27
- The server stores each approved sync title and summary, plus every redacted work-item title and statement as a separate claim with its scope, optional area and item reference, cited verification, and source receipt. Uncited verification wording, standalone decisions, raw assistant notes, and transcripts are not retained as claim text. `area`, `itemRef`, and `verificationRefs` are optional; citing a verification item applies only to that work item and is displayed separately from its work statement. A decision becomes a claim only when it is included as an `evidence` item with `kind: "decision"`. At least one reviewed work item is required to advance a checkpoint. The complete cross-component contract is in [coding-session-sync-contract.md](../../docs/coding-session-sync-contract.md).
34
+ Automatic scheduled runs send each accepted segment and advance its local checkpoint only after the server returns a matching receipt. Manual scheduled runs rebuild an accumulated local queue without advancing checkpoints and create or resume one content-free harness session when the pending count changes; one approval sends the queue in order. Network failures and server throttling are retried, while rejected segments stay pending.
35
+
36
+ The connector captures any useful harness activity, including engineering, product discovery, writing, interview feedback, research, and planning. Version-four payloads support generic `activity` items without a technical scope and accept a safe session-level summary when no item survives redaction. Version 1–3 clients remain supported.
28
37
 
29
- ## Review quality
38
+ ## Sync a prepared payload
30
39
 
31
- Write each sync as a concise technical record that will remain useful after the raw session is unavailable. Separate architecture, implementation, security controls, rollout, and user-facing work when they have distinct boundaries. Describe the mechanism, boundary, and material constraint for each item; state known limits plainly. Put material architecture, security, and rollout choices in `decision` evidence if they should become claims. Keep tests and operational checks in `verification`, citing them from the work item they support. Do not claim impact, ownership, or outcomes without direct support.
40
+ ```sh
41
+ printf '%s' '{"harness":"codex","sessionRef":"opaque-session-id","fromTurn":1,"toTurn":3,"title":"Prepared structured interview feedback","summary":"Prepared structured interview feedback and clarified the recommendation.","evidence":[{"kind":"activity","title":"Drafted interview feedback","statement":"Prepared structured interview feedback."}],"verification":[],"decisions":[],"redactionVersion":4}' | npx @useshifu/coding-harness sync --harness codex
42
+ ```
32
43
 
33
- The default API URL is `https://api.useshifu.com`. Override it during local or self-hosted use with `--api-url` on `connect`, or `SHIFU_API_URL`.
44
+ Automatic mode sends immediately. Manual mode asks once unless the calling harness already obtained approval and passes `--approved`.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const fs = require("node:fs");
4
+ const { createHash } = require("node:crypto");
4
5
  const os = require("node:os");
5
6
  const path = require("node:path");
6
7
  const { execFileSync } = require("node:child_process");
@@ -21,7 +22,7 @@ const UNSAFE_SESSION_REF_PATTERNS = ["api_key", "authorization", "password", "@"
21
22
  const UNSAFE_CODE_PATTERN = /\b(?:func|class|interface|struct|package|import|select|insert|update|delete|create\s+table)\s+[a-z_][a-z0-9_]*\s*(?:\(|\{|=|$)/i;
22
23
  const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+/i;
23
24
  const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
24
- const EVIDENCE_KINDS = new Set(["implementation", "decision"]);
25
+ const EVIDENCE_KINDS = new Set(["implementation", "decision", "activity"]);
25
26
  const EVIDENCE_FIELDS = new Set(["kind", "title", "statement", "scope", "area", "itemRef", "verificationRefs"]);
26
27
  const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion"]);
27
28
  const SCOPE_LEVELS = new Set(["unit", "module", "service", "system", "product"]);
@@ -32,9 +33,16 @@ const MAX_SYNC_ITEMS = 16;
32
33
  const MAX_TURNS_PER_SEGMENT = 12;
33
34
  const MAX_TITLE_LENGTH = 160;
34
35
  const MAX_WORK_DETAIL_LENGTH = 600;
36
+ const CONFIG_VERSION = 2;
37
+ const REDACTION_VERSION = 4;
38
+ const DEFAULT_INTERVAL_HOURS = 4;
39
+ const MIN_INTERVAL_HOURS = 0.25;
40
+ const MAX_INTERVAL_HOURS = 720;
41
+ const SCHEDULER_TICK_MINUTES = 15;
42
+ const MAX_RETRIES = 4;
35
43
 
36
44
  function configRoot() {
37
- return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
45
+ return process.env.SHIFU_CONFIG_ROOT || path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
38
46
  }
39
47
 
40
48
  function configPath(harness) {
@@ -45,6 +53,14 @@ function statePath(harness) {
45
53
  return path.join(configRoot(), `${harness}-checkpoints.json`);
46
54
  }
47
55
 
56
+ function pendingPath(harness) {
57
+ return path.join(configRoot(), `${harness}-pending.json`);
58
+ }
59
+
60
+ function lockPath(harness) {
61
+ return path.join(configRoot(), `${harness}.lock`);
62
+ }
63
+
48
64
  function installedRunnerPath() {
49
65
  return path.join(configRoot(), "runner.js");
50
66
  }
@@ -53,6 +69,10 @@ function codexSessionsRoot() {
53
69
  return process.env.SHIFU_CODEX_SESSIONS_ROOT || path.join(os.homedir(), ".codex", "sessions");
54
70
  }
55
71
 
72
+ function claudeSessionsRoot() {
73
+ return process.env.SHIFU_CLAUDE_SESSIONS_ROOT || path.join(os.homedir(), ".claude", "projects");
74
+ }
75
+
56
76
  function opencodeQuery(query) {
57
77
  if (process.env.SHIFU_OPENCODE_DB && !process.env.SHIFU_OPENCODE_BIN) {
58
78
  try {
@@ -116,8 +136,9 @@ function opencodeSessions(rows) {
116
136
 
117
137
  function localSessions(harness) {
118
138
  if (harness === "codex") return sessionFiles(codexSessionsRoot()).map(codexSession).filter(Boolean);
139
+ if (harness === "claude_code") return sessionFiles(claudeSessionsRoot()).map(claudeSession).filter(Boolean);
119
140
  if (harness === "opencode") return opencodeSessions(opencodeQuery("SELECT s.id AS sessionRef, s.time_created AS startedAt, s.time_updated AS updatedAt, m.time_created AS turnAt FROM session s JOIN message m ON m.session_id = s.id WHERE json_extract(m.data, '$.role') = 'user' ORDER BY s.time_updated, m.time_created"));
120
- throw new Error("Session discovery is currently available for Codex and OpenCode only.");
141
+ throw new Error("Choose a supported coding harness.");
121
142
  }
122
143
 
123
144
  function requireHarness(value) {
@@ -160,6 +181,63 @@ function readConfig(harness) {
160
181
  return config;
161
182
  }
162
183
 
184
+ function environmentSessionRef(harness) {
185
+ return harness === "codex" ? process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID :
186
+ harness === "claude_code" ? process.env.CLAUDE_CODE_SESSION_ID : process.env.OPENCODE_SESSION_ID;
187
+ }
188
+
189
+ function currentSessionRef(harness) {
190
+ const fromEnvironment = environmentSessionRef(harness);
191
+ if (sessionRefIsSafe(fromEnvironment)) return fromEnvironment;
192
+ try {
193
+ const recent = localSessions(harness).filter((session) => Date.parse(session.updatedAt) >= Date.now() - 5 * 60_000);
194
+ if (recent.length === 1) return recent[0].sessionRef;
195
+ } catch {}
196
+ return undefined;
197
+ }
198
+
199
+ function normalizedPolicy(config) {
200
+ const interval = Number(config?.syncPolicy?.intervalHours);
201
+ const approvalMode = config?.syncPolicy?.approvalMode;
202
+ const configured = config?.version === CONFIG_VERSION && config.syncPolicy?.onboardingComplete === true &&
203
+ Number.isFinite(interval) && interval >= MIN_INTERVAL_HOURS && interval <= MAX_INTERVAL_HOURS &&
204
+ (approvalMode === "automatic" || approvalMode === "manual");
205
+ return {
206
+ onboardingComplete: configured,
207
+ intervalHours: configured ? interval : DEFAULT_INTERVAL_HOURS,
208
+ approvalMode: configured ? approvalMode : "manual",
209
+ scheduleEnabled: configured ? config.syncPolicy.scheduleEnabled !== false : false,
210
+ excludedSessionRefs: Array.isArray(config?.syncPolicy?.excludedSessionRefs) ? config.syncPolicy.excludedSessionRefs.filter(sessionRefIsSafe) : [],
211
+ };
212
+ }
213
+
214
+ function validateIntervalHours(value) {
215
+ const interval = Number(value);
216
+ if (!Number.isFinite(interval) || interval < MIN_INTERVAL_HOURS || interval > MAX_INTERVAL_HOURS) {
217
+ throw new Error(`Choose a sync interval between ${MIN_INTERVAL_HOURS} and ${MAX_INTERVAL_HOURS} hours.`);
218
+ }
219
+ return interval;
220
+ }
221
+
222
+ function validateApprovalMode(value) {
223
+ if (value !== "automatic" && value !== "manual") throw new Error("Choose automatic or manual approval.");
224
+ return value;
225
+ }
226
+
227
+ function configuredSyncPolicy(config, harness, intervalHours, approvalMode, scheduleEnabled = true) {
228
+ const existing = normalizedPolicy(config);
229
+ const excludedSessionRefs = new Set(existing.excludedSessionRefs);
230
+ const current = currentSessionRef(harness);
231
+ if (sessionRefIsSafe(current)) excludedSessionRefs.add(current.trim());
232
+ return {
233
+ onboardingComplete: true,
234
+ intervalHours: validateIntervalHours(intervalHours),
235
+ approvalMode: validateApprovalMode(approvalMode),
236
+ scheduleEnabled,
237
+ excludedSessionRefs: [...excludedSessionRefs],
238
+ };
239
+ }
240
+
163
241
  function requireSafeApiUrl(value) {
164
242
  let url;
165
243
  try { url = new URL(value); } catch { throw new Error("Use a valid Shifu API URL."); }
@@ -218,6 +296,52 @@ function codexSession(file) {
218
296
  return { sessionRef, startedAt: startedAt || updatedAt, updatedAt, turnCount, turnTimes };
219
297
  }
220
298
 
299
+ function claudeEntries(file) {
300
+ return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => {
301
+ try { return JSON.parse(line); }
302
+ catch { throw new Error(`Could not read Claude Code session metadata from ${file}.`); }
303
+ }).filter((entry) => !entry.isSidechain);
304
+ }
305
+
306
+ function isClaudeUserTurn(entry) {
307
+ if (entry.type !== "user" || entry.message?.role !== "user") return false;
308
+ const content = entry.message.content;
309
+ return typeof content === "string" || (Array.isArray(content) && content.some((part) => part.type === "text"));
310
+ }
311
+
312
+ function claudeSession(file) {
313
+ const sessionRef = path.basename(file, ".jsonl");
314
+ if (!OPAQUE_SESSION_REF_PATTERN.test(sessionRef)) return undefined;
315
+ let startedAt;
316
+ let updatedAt;
317
+ const turnTimes = [];
318
+ for (const entry of claudeEntries(file)) {
319
+ if (typeof entry.timestamp !== "string" || !Number.isFinite(Date.parse(entry.timestamp))) continue;
320
+ startedAt ||= entry.timestamp;
321
+ updatedAt = entry.timestamp;
322
+ if (isClaudeUserTurn(entry)) turnTimes.push(entry.timestamp);
323
+ }
324
+ return updatedAt && turnTimes.length ? { sessionRef, startedAt, updatedAt, turnCount: turnTimes.length, turnTimes } : undefined;
325
+ }
326
+
327
+ function claudeReview(file, fromTurn, toTurn) {
328
+ const sessionRef = path.basename(file, ".jsonl");
329
+ let turn = 0;
330
+ const lastNoteByTurn = new Map();
331
+ for (const entry of claudeEntries(file)) {
332
+ if (isClaudeUserTurn(entry)) {
333
+ turn += 1;
334
+ continue;
335
+ }
336
+ if (entry.type !== "assistant" || entry.message?.role !== "assistant" || turn < fromTurn || turn > toTurn) continue;
337
+ const content = entry.message.content;
338
+ if (!Array.isArray(content) || content.some((part) => part.type === "tool_use")) continue;
339
+ const note = content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
340
+ if (note) lastNoteByTurn.set(turn, note);
341
+ }
342
+ return { sessionRef, turnCount: turn, notes: [...lastNoteByTurn].map(([t, note]) => ({ turn: t, note })) };
343
+ }
344
+
221
345
  function finalAssistantNote(file) {
222
346
  const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
223
347
  let sessionRef;
@@ -313,6 +437,16 @@ function opencodeReview(sessionRef, fromTurn, toTurn) {
313
437
  return { sessionRef, turnCount: turn, notes };
314
438
  }
315
439
 
440
+ function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
441
+ if (harness === "opencode") return opencodeReview(sessionRef, fromTurn, toTurn);
442
+ const files = harness === "codex" ? sessionFiles(codexSessionsRoot()) : sessionFiles(claudeSessionsRoot());
443
+ const selectedFile = files.map((file) => ({ file, session: harness === "codex" ? codexSession(file) : claudeSession(file) }))
444
+ .filter(({ session }) => session?.sessionRef === sessionRef)
445
+ .sort((left, right) => right.session.turnCount - left.session.turnCount || right.session.updatedAt.localeCompare(left.session.updatedAt))[0]?.file;
446
+ if (!selectedFile) throw new Error(`No local ${HARNESS_NAMES[harness]} session matches ${sessionRef}.`);
447
+ return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
448
+ }
449
+
316
450
  function reviewCandidates(notes) {
317
451
  const candidates = [];
318
452
  const seen = new Set();
@@ -333,13 +467,66 @@ function reviewCandidates(notes) {
333
467
  return candidates;
334
468
  }
335
469
 
470
+ function conciseTitle(statement, fallback) {
471
+ const value = (statement || fallback).trim();
472
+ if (value.length <= MAX_TITLE_LENGTH) return value;
473
+ return `${value.slice(0, MAX_TITLE_LENGTH - 1).trimEnd()}…`;
474
+ }
475
+
476
+ const ACTIVITY_CATEGORIES = [
477
+ { key: "interview_feedback", title: "Interview feedback", pattern: /\b(?:interview|candidate|hiring feedback)\b/i },
478
+ { key: "professional_writing", title: "Professional writing", pattern: /\b(?:linkedin|social post|article|newsletter|professional post|writing)\b/i },
479
+ { key: "product_discovery", title: "Product discovery", pattern: /\b(?:product discovery|user research|customer research|requirements?|roadmap)\b/i },
480
+ { key: "product_delivery", title: "Product and design delivery", pattern: /\b(?:landing page|website|user interface|user experience|design|prototype)\b/i },
481
+ { key: "research", title: "Research", pattern: /\b(?:research|investigat|compar|evaluat|analysis|analyz)\w*/i },
482
+ { key: "planning", title: "Planning", pattern: /\b(?:plan|strategy|prioriti|proposal|brief)\w*/i },
483
+ { key: "documentation", title: "Documentation", pattern: /\b(?:documentation|readme|guide|runbook)\b/i },
484
+ { key: "engineering", title: "Engineering", pattern: /\b(?:implement|code|bug|fix|test|api|database|frontend|backend|refactor|deploy)\w*/i },
485
+ ];
486
+
487
+ function safeActivityEvidence(notes, segment) {
488
+ const source = notes.map((note) => note.note).join("\n");
489
+ const categories = ACTIVITY_CATEGORIES.filter((category) => category.pattern.test(source));
490
+ if (categories.length === 0) return [];
491
+ return categories.map((category, index) => ({
492
+ kind: "activity",
493
+ title: category.title,
494
+ statement: `Used the harness for ${category.title.toLowerCase()}.`,
495
+ itemRef: `t${segment.fromTurn}_${category.key}_${index + 1}`,
496
+ }));
497
+ }
498
+
499
+ function payloadForSegment(harness, segment) {
500
+ const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
501
+ const fallback = `${HARNESS_NAMES[harness]} activity across turns ${segment.fromTurn}–${segment.toTurn}.`;
502
+ const evidence = safeActivityEvidence(notes, segment).slice(0, MAX_EVIDENCE_ITEMS);
503
+ const title = evidence.length === 1 ? evidence[0].title : evidence.length > 1 ? "Multiple harness activities" : fallback;
504
+ const summary = evidence.length ? `Used ${HARNESS_NAMES[harness]} for ${evidence.map((item) => item.title.toLowerCase()).join(", ")}.` : fallback;
505
+ return {
506
+ harness,
507
+ sessionRef: segment.sessionRef,
508
+ fromTurn: segment.fromTurn,
509
+ toTurn: segment.toTurn,
510
+ title: conciseTitle(title, fallback),
511
+ summary,
512
+ evidence,
513
+ verification: [],
514
+ decisions: [],
515
+ redactionVersion: REDACTION_VERSION,
516
+ };
517
+ }
518
+
336
519
  function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, selectedRef, includeOlder = false) {
337
520
  if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
338
521
  if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
339
522
  const since = Date.now() - hours * 3_600_000;
340
523
  const state = readState(harness);
524
+ const excluded = new Set(normalizedPolicy(readJSON(configPath(harness), {})).excludedSessionRefs);
525
+ const controlSession = environmentSessionRef(harness);
526
+ if (sessionRefIsSafe(controlSession)) excluded.add(controlSession);
341
527
  const sessions = new Map();
342
528
  for (const session of localSessions(harness)
529
+ .filter((session) => !excluded.has(session.sessionRef))
343
530
  .filter((session) => selectedRef ? session.sessionRef === selectedRef : Date.parse(session.updatedAt) >= since)) {
344
531
  const existing = sessions.get(session.sessionRef);
345
532
  sessions.set(session.sessionRef, existing ? {
@@ -365,8 +552,33 @@ function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, se
365
552
  .filter((session) => session.fromTurn <= session.toTurn && (includeOlder || Date.parse(session.oldestUnsyncedAt) >= since));
366
553
  }
367
554
 
555
+ function pendingSegments(harness) {
556
+ const state = readState(harness);
557
+ const policy = normalizedPolicy(readConfig(harness));
558
+ const excluded = new Set(policy.excludedSessionRefs);
559
+ const segments = [];
560
+ for (const session of localSessions(harness)
561
+ .filter((candidate) => !excluded.has(candidate.sessionRef))
562
+ .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))) {
563
+ let fromTurn = (state.sessions[session.sessionRef]?.lastSyncedTurn || 0) + 1;
564
+ while (fromTurn <= session.turnCount) {
565
+ let toTurn = Math.min(session.turnCount, fromTurn + MAX_TURNS_PER_SEGMENT - 1);
566
+ if (Date.parse(session.updatedAt) > Date.now() - SCHEDULER_TICK_MINUTES * 60_000) {
567
+ const completedTurns = new Set(reviewSegment(harness, session.sessionRef, fromTurn, toTurn).notes.map((note) => note.turn));
568
+ let completedTo = fromTurn - 1;
569
+ while (completedTurns.has(completedTo + 1)) completedTo += 1;
570
+ if (completedTo < fromTurn) break;
571
+ toTurn = completedTo;
572
+ }
573
+ segments.push({ ...session, fromTurn, toTurn });
574
+ fromTurn = toTurn + 1;
575
+ }
576
+ }
577
+ return segments;
578
+ }
579
+
368
580
  function prompt() {
369
- const input = process.stdin.isTTY ? process.stdin : fs.createReadStream("/dev/tty");
581
+ const input = process.stdin.isTTY ? process.stdin : fs.createReadStream(process.platform === "win32" ? "CONIN$" : "/dev/tty");
370
582
  const terminal = readline.createInterface({ input, output: process.stderr });
371
583
  terminal.shifuInput = input;
372
584
  return terminal;
@@ -383,9 +595,20 @@ async function confirm(question) {
383
595
  }
384
596
  }
385
597
 
598
+ async function ask(question, fallback) {
599
+ const terminal = prompt();
600
+ try {
601
+ const answer = (await terminal.question(`${question} [${fallback}] `)).trim();
602
+ return answer || fallback;
603
+ } finally {
604
+ terminal.close();
605
+ if (terminal.shifuInput !== process.stdin) terminal.shifuInput.destroy();
606
+ }
607
+ }
608
+
386
609
  async function readSecret(input = process.stdin) {
387
610
  const ownsInput = !input.isTTY;
388
- if (ownsInput) input = new tty.ReadStream(fs.openSync("/dev/tty", "r"));
611
+ if (ownsInput) input = new tty.ReadStream(fs.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"));
389
612
  const wasRaw = input.isRaw;
390
613
  process.stderr.write("Connection key (hidden): ");
391
614
  input.setRawMode(true);
@@ -463,7 +686,7 @@ function installDestination(harness) {
463
686
  return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode", "skills", "shifu-sync");
464
687
  }
465
688
 
466
- function install(harness) {
689
+ function install(harness, quiet = false) {
467
690
  copyRunner();
468
691
  if (harness === "opencode") {
469
692
  const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
@@ -490,25 +713,130 @@ function install(harness) {
490
713
  const content = fs.readFileSync(skill, "utf8")
491
714
  .replaceAll('"codex"', `"${harness}"`)
492
715
  .replaceAll("--harness codex", `--harness ${harness}`)
716
+ .replaceAll("Codex", HARNESS_NAMES[harness])
493
717
  .replaceAll("npx @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
494
718
  fs.writeFileSync(skill, content, { mode: 0o600 });
495
719
  }
496
720
  const removedHooks = harness === "codex" && removeCodexHooks();
497
- console.error(`Installed the Shifu sync instruction for ${HARNESS_NAMES[harness]}.`);
498
- if (removedHooks) console.error("Removed obsolete Codex lifecycle hooks.");
499
- console.error(`Next: coding-harness connect --harness ${harness}`);
721
+ if (!quiet) {
722
+ console.error(`Installed the Shifu sync instruction for ${HARNESS_NAMES[harness]}.`);
723
+ if (removedHooks) console.error("Removed obsolete Codex lifecycle hooks.");
724
+ console.error(`Next: coding-harness connect --harness ${harness}`);
725
+ }
726
+ }
727
+
728
+ function xml(value) {
729
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
730
+ }
731
+
732
+ function schedulerArtifact(harness, platform = process.platform) {
733
+ const runner = installedRunnerPath();
734
+ const label = `com.useshifu.coding-harness.${harness}`;
735
+ if (platform === "darwin") {
736
+ const file = path.join(os.homedir(), "Library", "LaunchAgents", `${label}.plist`);
737
+ return {
738
+ file,
739
+ content: `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${label}</string>\n<key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>${xml(runner)}</string><string>scheduled-sync</string><string>--harness</string><string>${harness}</string><string>--config-root</string><string>${xml(configRoot())}</string></array>\n<key>StartInterval</key><integer>${SCHEDULER_TICK_MINUTES * 60}</integer>\n<key>ProcessType</key><string>Background</string>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`,
740
+ activate() {
741
+ const domain = `gui/${process.getuid()}`;
742
+ try { execFileSync("launchctl", ["bootout", domain, file], { stdio: "ignore" }); } catch {}
743
+ execFileSync("launchctl", ["bootstrap", domain, file], { stdio: "ignore" });
744
+ },
745
+ };
746
+ }
747
+ if (platform === "linux") {
748
+ const systemd = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "systemd", "user");
749
+ const service = `shifu-coding-harness-${harness}.service`;
750
+ const timer = `shifu-coding-harness-${harness}.timer`;
751
+ return {
752
+ file: path.join(systemd, service),
753
+ content: `[Unit]\nDescription=Sync ${HARNESS_NAMES[harness]} activity to Shifu\n\n[Service]\nType=oneshot\nExecStart=${systemdQuote(process.execPath)} ${systemdQuote(runner)} scheduled-sync --harness ${harness} --config-root ${systemdQuote(configRoot())}\n`,
754
+ extraFiles: [{
755
+ file: path.join(systemd, timer),
756
+ content: `[Unit]\nDescription=Check whether ${HARNESS_NAMES[harness]} activity is due for Shifu sync\n\n[Timer]\nOnStartupSec=${SCHEDULER_TICK_MINUTES}min\nOnUnitActiveSec=${SCHEDULER_TICK_MINUTES}min\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`,
757
+ }],
758
+ activate() {
759
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
760
+ execFileSync("systemctl", ["--user", "enable", "--now", timer], { stdio: "ignore" });
761
+ },
762
+ };
763
+ }
764
+ if (platform === "win32") {
765
+ const file = path.join(configRoot(), `${harness}-scheduled-sync.cmd`);
766
+ const taskName = `Shifu coding harness ${harness}`;
767
+ return {
768
+ file,
769
+ content: `@echo off\r\n"${process.execPath}" "${runner}" scheduled-sync --harness ${harness} --config-root "${configRoot()}"\r\n`,
770
+ activate() {
771
+ execFileSync("schtasks.exe", ["/Create", "/TN", taskName, "/TR", file, "/SC", "MINUTE", "/MO", String(SCHEDULER_TICK_MINUTES), "/F"], { stdio: "ignore" });
772
+ },
773
+ };
774
+ }
775
+ throw new Error(`Scheduled sync is not supported on ${platform}. You can still run coding-harness sync on demand.`);
776
+ }
777
+
778
+ function systemdQuote(value) {
779
+ return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
780
+ }
781
+
782
+ function installSchedule(harness) {
783
+ const artifact = schedulerArtifact(harness, process.env.SHIFU_PLATFORM || process.platform);
784
+ for (const item of [artifact, ...(artifact.extraFiles || [])]) {
785
+ fs.mkdirSync(path.dirname(item.file), { recursive: true, mode: 0o700 });
786
+ fs.writeFileSync(item.file, item.content, { mode: 0o600 });
787
+ fs.chmodSync(item.file, 0o600);
788
+ }
789
+ if (process.env.SHIFU_SKIP_SCHEDULER_ACTIVATION !== "1") artifact.activate();
790
+ return artifact.file;
791
+ }
792
+
793
+ function scheduleNextRun(harness, intervalHours) {
794
+ const state = readState(harness);
795
+ state.schedule = { ...(state.schedule || {}), nextRunAt: Date.now() + intervalHours * 3_600_000 };
796
+ saveState(harness, state);
797
+ }
798
+
799
+ async function chooseSyncPolicy(existing) {
800
+ const intervalOption = option("--interval-hours");
801
+ const approvalOption = option("--approval-mode");
802
+ const intervalHours = validateIntervalHours(intervalOption ?? await ask("Sync every how many hours?", String(existing.intervalHours || DEFAULT_INTERVAL_HOURS)));
803
+ let approvalMode = approvalOption;
804
+ if (approvalMode === undefined) {
805
+ const answer = (await ask("Approval mode: automatic or manual?", existing.approvalMode || "automatic")).toLowerCase();
806
+ approvalMode = answer === "auto" ? "automatic" : answer;
807
+ }
808
+ return { intervalHours, approvalMode: validateApprovalMode(approvalMode) };
500
809
  }
501
810
 
502
811
  async function connect(harness) {
503
- const token = (await readSecret()).trim();
812
+ const existing = readJSON(configPath(harness), undefined);
813
+ const token = existing?.token || (await readSecret()).trim();
504
814
  if (!TOKEN_PATTERN.test(token)) throw new Error("The connection key is invalid.");
505
- const apiUrl = requireSafeApiUrl(option("--api-url") || process.env.SHIFU_API_URL || DEFAULT_API_URL);
506
- if (!(await confirm(`Save this ${HARNESS_NAMES[harness]} key locally for ${apiUrl}? No work content will be sent.`))) {
507
- console.error("Connection was not saved.");
508
- return;
509
- }
510
- writeJSON(configPath(harness), { apiUrl, token, harness });
511
- console.error(`${HARNESS_NAMES[harness]} is connected. Ask it to sync when you are ready to review recent work.`);
815
+ const apiUrl = requireSafeApiUrl(option("--api-url") || existing?.apiUrl || process.env.SHIFU_API_URL || DEFAULT_API_URL);
816
+ const savedPolicy = existing && normalizedPolicy(existing);
817
+ const currentPolicy = savedPolicy?.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic", excludedSessionRefs: savedPolicy?.excludedSessionRefs || [] };
818
+ const selected = await chooseSyncPolicy(currentPolicy);
819
+ const syncPolicy = configuredSyncPolicy(existing || {}, harness, selected.intervalHours, selected.approvalMode);
820
+ install(harness, true);
821
+ const scheduleFile = installSchedule(harness);
822
+ writeJSON(configPath(harness), { ...existing, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy });
823
+ scheduleNextRun(harness, syncPolicy.intervalHours);
824
+ console.error(`${HARNESS_NAMES[harness]} is connected. It will sync every ${syncPolicy.intervalHours} hours with ${syncPolicy.approvalMode} approval.`);
825
+ console.error(`Schedule installed at ${scheduleFile}. No session content was sent during setup.`);
826
+ }
827
+
828
+ async function updateConfig(harness) {
829
+ const config = readConfig(harness);
830
+ const savedPolicy = normalizedPolicy(config);
831
+ const selected = await chooseSyncPolicy(savedPolicy.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic" });
832
+ const scheduleEnabled = option("--schedule") !== "off";
833
+ config.version = CONFIG_VERSION;
834
+ config.syncPolicy = configuredSyncPolicy(config, harness, selected.intervalHours, selected.approvalMode, scheduleEnabled);
835
+ install(harness, true);
836
+ if (scheduleEnabled) installSchedule(harness);
837
+ writeJSON(configPath(harness), config);
838
+ scheduleNextRun(harness, config.syncPolicy.intervalHours);
839
+ console.error(`${HARNESS_NAMES[harness]} now syncs every ${config.syncPolicy.intervalHours} hours with ${config.syncPolicy.approvalMode} approval${scheduleEnabled ? "" : "; scheduling is off"}.`);
512
840
  }
513
841
 
514
842
  function textIsSafe(value, maximum) {
@@ -526,10 +854,15 @@ function validateSync(input, harness) {
526
854
  if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
527
855
  if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
528
856
  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.");
529
- if (input.redactionVersion === 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
857
+ if (input.redactionVersion >= 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
530
858
  if (!textIsSafe(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
531
- if (!Array.isArray(input.evidence) || input.evidence.length < 1 || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => item && EVIDENCE_KINDS.has(item.kind) && SCOPE_LEVELS.has(item.scope) && (input.redactionVersion !== 3 || textIsSafe(item.title, MAX_TITLE_LENGTH)) && textIsSafe(item.statement, MAX_WORK_DETAIL_LENGTH))) {
532
- throw new Error(`evidence must contain 1-${MAX_EVIDENCE_ITEMS} redacted work items with title, detail, kind, and scope.`);
859
+ const minimumEvidence = input.redactionVersion >= REDACTION_VERSION ? 0 : 1;
860
+ if (!Array.isArray(input.evidence) || input.evidence.length < minimumEvidence || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => {
861
+ 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;
862
+ if (item.kind === "activity" && input.redactionVersion >= REDACTION_VERSION) return item.scope === undefined || item.scope === "";
863
+ return SCOPE_LEVELS.has(item.scope);
864
+ })) {
865
+ throw new Error(`evidence must contain ${minimumEvidence}-${MAX_EVIDENCE_ITEMS} redacted activity or work items with valid titles, details, kinds, and scopes.`);
533
866
  }
534
867
  for (const field of ["verification", "decisions"]) {
535
868
  if (!Array.isArray(input[field]) || input[field].length > MAX_SYNC_ITEMS || !input[field].every((item) => textIsSafe(item, 240))) throw new Error(`${field} must contain at most ${MAX_SYNC_ITEMS} redacted statements.`);
@@ -549,7 +882,7 @@ function validateSync(input, harness) {
549
882
  if (identities.has(identity)) throw new Error("Each work item must have a distinct identity.");
550
883
  identities.add(identity);
551
884
  }
552
- if (input.redactionVersion !== 2 && input.redactionVersion !== 3) throw new Error("Use redactionVersion 3 for new reviewed syncs.");
885
+ if (input.redactionVersion !== 2 && input.redactionVersion !== 3 && input.redactionVersion !== REDACTION_VERSION) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs.`);
553
886
  }
554
887
 
555
888
  function validSyncReceipt(receipt, input) {
@@ -557,46 +890,249 @@ function validSyncReceipt(receipt, input) {
557
890
  (receipt.duplicate ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
558
891
  }
559
892
 
560
- async function sync(harness) {
561
- const config = readConfig(harness);
562
- const input = readJSON(0, undefined);
893
+ function sleep(milliseconds) {
894
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
895
+ }
896
+
897
+ function retryDelay(response, attempt) {
898
+ const retryAfter = response?.headers?.get?.("retry-after");
899
+ const seconds = Number(retryAfter);
900
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000);
901
+ const retryAt = Date.parse(retryAfter);
902
+ if (Number.isFinite(retryAt)) return Math.min(Math.max(0, retryAt - Date.now()), 30_000);
903
+ const base = Number(process.env.SHIFU_RETRY_BASE_MS || 1000);
904
+ return Math.min(base * (2 ** attempt), 30_000);
905
+ }
906
+
907
+ async function postSync(config, input) {
908
+ let lastError;
909
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
910
+ let response;
911
+ try {
912
+ response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
913
+ method: "POST",
914
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
915
+ body: JSON.stringify(input),
916
+ signal: AbortSignal.timeout(30_000),
917
+ });
918
+ } catch (error) {
919
+ lastError = new Error(`Could not reach Shifu at ${config.apiUrl}: ${error?.cause?.code || error?.message || "unknown transport error"}.`);
920
+ if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(undefined, attempt));
921
+ continue;
922
+ }
923
+ const payload = await response.json().catch(() => undefined);
924
+ if (response.ok) return payload?.data;
925
+ lastError = new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
926
+ if (response.status !== 429 && response.status < 500) throw lastError;
927
+ if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
928
+ }
929
+ throw lastError;
930
+ }
931
+
932
+ async function sendSync(harness, input, config = readConfig(harness)) {
563
933
  validateSync(input, harness);
564
934
  const state = readState(harness);
565
935
  const checkpoint = state.sessions[input.sessionRef];
566
936
  const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
567
937
  if (input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
568
- const preview = JSON.stringify(input, null, 2);
569
- console.error(`\nThis exact reviewed, redacted segment will be sent to Shifu:\n${preview}`);
570
- if (!(await confirm("Sync this segment"))) {
571
- console.error("Nothing was sent.");
572
- return;
573
- }
574
- let response;
575
- try {
576
- response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
577
- method: "POST",
578
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
579
- body: JSON.stringify(input),
580
- });
581
- } catch (error) {
582
- const reason = error?.cause?.code || error?.message || "unknown transport error";
583
- throw new Error(`Could not reach Shifu at ${config.apiUrl}: ${reason}.`);
584
- }
585
- const payload = await response.json().catch(() => undefined);
586
- if (!response.ok) throw new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
587
- const receipt = payload?.data;
938
+ const receipt = await postSync(config, input);
588
939
  if (!validSyncReceipt(receipt, input)) {
589
940
  throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
590
941
  }
591
942
  state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: receipt.lastSyncedTurn, syncDue: false };
592
943
  saveState(harness, state);
593
944
  console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : `Synced through turn ${receipt.lastSyncedTurn}.`);
945
+ return receipt;
946
+ }
947
+
948
+ async function withSyncLock(harness, action) {
949
+ fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
950
+ const file = lockPath(harness);
951
+ let descriptor;
952
+ try {
953
+ try {
954
+ descriptor = fs.openSync(file, "wx", 0o600);
955
+ } catch (error) {
956
+ if (error?.code !== "EEXIST") throw error;
957
+ const stale = Date.now() - fs.statSync(file).mtimeMs > 2 * 3_600_000;
958
+ if (!stale) return { skipped: true };
959
+ fs.unlinkSync(file);
960
+ descriptor = fs.openSync(file, "wx", 0o600);
961
+ }
962
+ fs.writeFileSync(descriptor, `${process.pid}\n`);
963
+ return await action();
964
+ } finally {
965
+ if (descriptor !== undefined) {
966
+ fs.closeSync(descriptor);
967
+ try { fs.unlinkSync(file); } catch {}
968
+ }
969
+ }
970
+ }
971
+
972
+ async function sync(harness) {
973
+ const config = readConfig(harness);
974
+ const input = readJSON(0, undefined);
975
+ validateSync(input, harness);
976
+ const policy = normalizedPolicy(config);
977
+ if (policy.approvalMode === "manual" && !hasFlag("--approved")) {
978
+ console.error(`\nThis exact redacted segment will be sent to Shifu:\n${JSON.stringify(input, null, 2)}`);
979
+ if (!(await confirm("Approve this sync"))) {
980
+ console.error("Nothing was sent.");
981
+ return;
982
+ }
983
+ }
984
+ const result = await withSyncLock(harness, () => sendSync(harness, input, config));
985
+ if (result?.skipped) throw new Error("Another Shifu sync is already running. This segment was not sent; retry later.");
986
+ }
987
+
988
+ function rebuildPendingQueue(harness) {
989
+ const segments = pendingSegments(harness).map((segment) => payloadForSegment(harness, segment));
990
+ const queue = { updatedAt: new Date().toISOString(), segments };
991
+ writeJSON(pendingPath(harness), queue);
992
+ return queue;
993
+ }
994
+
995
+ function approvalNotificationInvocation(harness, count, sessionRef) {
996
+ 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.`;
997
+ if (harness === "codex") {
998
+ return sessionRef ? { command: "codex", args: ["exec", "resume", "--all", "--json", sessionRef, prompt] } :
999
+ { command: "codex", args: ["exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "-C", os.homedir(), prompt] };
1000
+ }
1001
+ if (harness === "claude_code") {
1002
+ const args = ["-p", prompt, "--output-format", "json", "--max-turns", "1", "--permission-mode", "plan"];
1003
+ if (sessionRef) args.push("--resume", sessionRef);
1004
+ return { command: "claude", args };
1005
+ }
1006
+ const args = ["run", "--format", "json", "--title", "Shifu sync approval", prompt];
1007
+ if (sessionRef) args.splice(1, 0, "--session", sessionRef);
1008
+ return { command: "opencode", args };
1009
+ }
1010
+
1011
+ function notificationSessionRef(output) {
1012
+ for (const line of String(output).split("\n")) {
1013
+ if (!line.trim()) continue;
1014
+ try {
1015
+ const value = JSON.parse(line);
1016
+ const sessionRef = value.thread_id || value.session_id || value.sessionID || value.sessionId || value.data?.thread_id || value.data?.session_id || value.data?.sessionID;
1017
+ if (sessionRefIsSafe(sessionRef)) return sessionRef;
1018
+ } catch {}
1019
+ }
1020
+ return undefined;
1021
+ }
1022
+
1023
+ function notifyManualApproval(harness, count, existingSessionRef) {
1024
+ const invocation = approvalNotificationInvocation(harness, count, existingSessionRef);
1025
+ const before = existingSessionRef ? new Set() : new Set(localSessions(harness).map((session) => session.sessionRef));
1026
+ const output = execFileSync(invocation.command, invocation.args, {
1027
+ cwd: os.homedir(),
1028
+ encoding: "utf8",
1029
+ stdio: ["ignore", "pipe", "pipe"],
1030
+ timeout: 120_000,
1031
+ maxBuffer: 8 * 1024 * 1024,
1032
+ });
1033
+ if (existingSessionRef) return existingSessionRef;
1034
+ const created = notificationSessionRef(output) || localSessions(harness)
1035
+ .filter((session) => !before.has(session.sessionRef))
1036
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.sessionRef;
1037
+ if (!created) throw new Error("The approval session was created but its session reference could not be recorded safely.");
1038
+ return created;
1039
+ }
1040
+
1041
+ function showPending(harness) {
1042
+ readConfig(harness);
1043
+ const queue = rebuildPendingQueue(harness);
1044
+ console.log(JSON.stringify(queue, null, 2));
1045
+ }
1046
+
1047
+ async function approvePending(harness) {
1048
+ const config = readConfig(harness);
1049
+ const queue = rebuildPendingQueue(harness);
1050
+ if (queue.segments.length === 0) {
1051
+ console.error("There is no pending Shifu activity to approve.");
1052
+ return;
1053
+ }
1054
+ console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
1055
+ if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
1056
+ console.error("Nothing was sent. The pending list will keep accumulating.");
1057
+ return;
1058
+ }
1059
+ const result = await withSyncLock(harness, async () => {
1060
+ for (const input of queue.segments) await sendSync(harness, input, config);
1061
+ });
1062
+ if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
1063
+ rebuildPendingQueue(harness);
1064
+ }
1065
+
1066
+ async function scheduledSync(harness) {
1067
+ const config = readConfig(harness);
1068
+ const policy = normalizedPolicy(config);
1069
+ if (!policy.onboardingComplete || !policy.scheduleEnabled) return;
1070
+ const state = readState(harness);
1071
+ if (Number.isFinite(state.schedule?.nextRunAt) && state.schedule.nextRunAt > Date.now()) return;
1072
+ const result = await withSyncLock(harness, async () => {
1073
+ const startedAt = Date.now();
1074
+ let failures = 0;
1075
+ let synced = 0;
1076
+ if (policy.approvalMode === "manual") {
1077
+ const queue = rebuildPendingQueue(harness);
1078
+ const pendingFingerprint = createHash("sha256").update(queue.segments.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
1079
+ const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
1080
+ let approvalSessionRef = state.schedule?.approvalSessionRef;
1081
+ let notificationError;
1082
+ if (queue.segments.length > 0 && changed) {
1083
+ try {
1084
+ approvalSessionRef = notifyManualApproval(harness, queue.segments.length, approvalSessionRef);
1085
+ if (approvalSessionRef && !config.syncPolicy.excludedSessionRefs.includes(approvalSessionRef)) {
1086
+ config.syncPolicy.excludedSessionRefs.push(approvalSessionRef);
1087
+ writeJSON(configPath(harness), config);
1088
+ }
1089
+ } catch (error) {
1090
+ notificationError = error?.message || "Could not create the approval session.";
1091
+ }
1092
+ }
1093
+ state.schedule = {
1094
+ ...state.schedule,
1095
+ lastAttemptAt: startedAt,
1096
+ lastResult: notificationError ? "awaiting_approval_notification_failed" : "awaiting_approval",
1097
+ pendingSegments: queue.segments.length,
1098
+ pendingFingerprint,
1099
+ approvalSessionRef,
1100
+ notificationError,
1101
+ nextRunAt: startedAt + (notificationError ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
1102
+ };
1103
+ } else {
1104
+ const blockedSessions = new Set();
1105
+ for (const segment of pendingSegments(harness)) {
1106
+ if (blockedSessions.has(segment.sessionRef)) continue;
1107
+ try {
1108
+ await sendSync(harness, payloadForSegment(harness, segment), config);
1109
+ synced += 1;
1110
+ } catch (error) {
1111
+ failures += 1;
1112
+ blockedSessions.add(segment.sessionRef);
1113
+ console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
1114
+ }
1115
+ }
1116
+ state.schedule = {
1117
+ lastAttemptAt: startedAt,
1118
+ lastResult: failures ? "partial_failure" : "complete",
1119
+ syncedSegments: synced,
1120
+ failedSessions: failures,
1121
+ nextRunAt: startedAt + (failures ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
1122
+ };
1123
+ }
1124
+ const latest = readState(harness);
1125
+ latest.schedule = state.schedule;
1126
+ saveState(harness, latest);
1127
+ });
1128
+ if (result?.skipped) return;
594
1129
  }
595
1130
 
596
1131
  function status(harness) {
597
1132
  const config = readConfig(harness);
598
1133
  const state = readState(harness);
599
- console.log(JSON.stringify({ harness, apiUrl: config.apiUrl, sessions: state.sessions }, null, 2));
1134
+ const pending = readJSON(pendingPath(harness), { segments: [] });
1135
+ console.log(JSON.stringify({ harness, apiUrl: config.apiUrl, syncPolicy: normalizedPolicy(config), schedule: state.schedule || null, pendingSegments: pending.segments.length, sessions: state.sessions }, null, 2));
600
1136
  }
601
1137
 
602
1138
  function sessions(harness) {
@@ -631,7 +1167,7 @@ function review(harness) {
631
1167
  if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
632
1168
  const toTurn = Math.min(selected.turnCount, fromTurn + turns - 1);
633
1169
  if (fromTurn > toTurn) throw new Error(`This ${HARNESS_NAMES[harness]} session has no unsynced turns to review.`);
634
- const { notes } = harness === "codex" ? codexReview(sessionFiles(codexSessionsRoot()).map((file) => ({ file, session: codexSession(file) })).filter(({ session }) => session?.sessionRef === sessionRef).sort((left, right) => right.session.turnCount - left.session.turnCount)[0].file, fromTurn, toTurn) : opencodeReview(sessionRef, fromTurn, toTurn);
1170
+ const { notes } = reviewSegment(harness, sessionRef, fromTurn, toTurn);
635
1171
  const candidates = reviewCandidates(notes);
636
1172
  console.log(JSON.stringify({
637
1173
  sessionRef,
@@ -648,16 +1184,21 @@ function review(harness) {
648
1184
  }
649
1185
 
650
1186
  async function main() {
1187
+ if (option("--config-root")) process.env.SHIFU_CONFIG_ROOT = path.resolve(option("--config-root"));
651
1188
  const command = process.argv[2];
652
1189
  const subCommand = process.argv[3];
653
1190
  const harness = requireHarness(option("--harness"));
654
1191
  if (command === "install") return install(harness);
655
1192
  if (command === "connect") return connect(harness);
1193
+ if (command === "config" && subCommand === "update") return updateConfig(harness);
656
1194
  if (command === "sync") return sync(harness);
1195
+ if (command === "scheduled-sync") return scheduledSync(harness);
1196
+ if (command === "pending") return showPending(harness);
1197
+ if (command === "approve") return approvePending(harness);
657
1198
  if (command === "status") return status(harness);
658
1199
  if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
659
1200
  if (command === "review") return review(harness);
660
- throw new Error("Use install, connect, status, sessions, review, or sync.");
1201
+ throw new Error("Use install, connect, config update, status, sessions, review, sync, pending, approve, or scheduled-sync.");
661
1202
  }
662
1203
 
663
1204
  if (require.main === module) {
@@ -669,4 +1210,4 @@ if (require.main === module) {
669
1210
  });
670
1211
  }
671
1212
 
672
- module.exports = { codexReview, codexSession, configPath, finalAssistantNote, install, installDestination, localSessions, opencodeQuery, opencodeReview, opencodeSessions, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, statePath, textIsSafe, unsyncedCodexSessions, validSyncReceipt, validateSync };
1213
+ module.exports = { approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, schedulerArtifact, statePath, textIsSafe, unsyncedCodexSessions, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync };
@@ -1,47 +1,17 @@
1
1
  ---
2
- description: Prepare a reviewed, redacted incremental Shifu sync and ask for confirmation.
2
+ description: Inspect or sync redacted OpenCode activity using the saved Shifu policy.
3
3
  ---
4
4
 
5
- Use the Shifu reviewed-sync process for OpenCode only after an explicit user request. Never upload automatically or include raw transcript content. Before acting, say whether you will connect or sync and ask for confirmation.
5
+ Run `npx @useshifu/coding-harness status --harness opencode` first.
6
6
 
7
- For a sync request, use the time window the user names. If none is named, state that you will use the last 24 hours. The only supported windows are 24, 48, and 72 hours.
7
+ If manual approval has queued scheduled work, run `pending --harness opencode`, show the complete redacted list, and ask once. After approval, run `approve --approved --harness opencode`.
8
8
 
9
- 1. Run `npx @useshifu/coding-harness sessions --harness opencode --hours <24|48|72>`.
10
- 2. Use every returned opaque `sessionRef`, `fromTurn`, and `toTurn`. A segment contains at most 12 user turns.
11
- 3. Do not upload a `blockedSessions` entry automatically. Its next unsynced turn predates the selected window. Only if the user specifically chooses that session, run discovery and review with `--session-ref <sessionRef> --all`.
12
- 4. For each selected segment, run `npx @useshifu/coding-harness review --harness opencode --session-ref <sessionRef> --hours <24|48|72>`. Review returns local assistant notes and suggested candidates only. It never returns user prompts.
13
- 5. If review reports more than 64 candidates, rerun discovery and review with `--turns 1`. Repeat discovery after every accepted segment until the selected pending delta is exhausted.
14
- 6. Redact the reviewed assistant notes, show the exact JSON payload, and ask the user to confirm. Only after confirmation, pipe the payload to:
9
+ For an explicit sync-now request:
15
10
 
16
- ```sh
17
- npx @useshifu/coding-harness sync --harness opencode
18
- ```
11
+ 1. Run `sessions --harness opencode --hours 24`, using 48 or 72 only when requested.
12
+ 2. Review each returned segment with `review --harness opencode --session-ref <sessionRef> --hours <hours>`.
13
+ 3. Prepare a redaction-version-four payload from final assistant notes only. Use generic `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. An empty evidence list is valid when only the safe session summary remains.
14
+ 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`.
15
+ 5. Keep the returned session reference and turn range unchanged. Never upload prompts, raw transcripts, tool output, code, paths, commands, URLs, credentials, names, customer details, or proprietary identifiers.
19
16
 
20
- The payload must contain `harness: "opencode"`, an opaque `sessionRef`, the returned contiguous `fromTurn`/`toTurn` range, a concise redacted sync `title`, a redacted `summary`, 1–64 redacted `evidence` work items (`title`, `kind`, `statement`, `scope`), up to 16 redacted `verification` items, up to 16 redacted `decisions`, and `redactionVersion: 3`. Evidence describes the work itself; verification records how it was checked. Scope must be one of `unit`, `module`, `service`, `system`, or `product` and must be reviewed rather than guessed from the candidate. Replace names, repository identifiers, file paths, URLs, credentials, commands, prompts, source code, customer details, and proprietary identifiers with neutral descriptions. Never invent missing details or alter the returned turn range.
21
-
22
- Write a concise but useful durable technical record. Split architecture, implementation, security controls, configuration or rollout, and user-facing behaviour into separate evidence items when they have different boundaries. Each item should state the mechanism, responsible boundary, and material constraint. Record a material architecture, security, or rollout choice as `kind: "decision"` evidence when it should persist; do not treat the standalone `decisions` list as a durable work item. State known security limits plainly. Keep tests, builds, reviews, deployment checks, and other proof in `verification`, and cite relevant verification entries from evidence with `verificationRefs`. Use the smallest true scope and do not claim impact, ownership, or outcomes not supported by the reviewed work.
23
-
24
- Use this structure:
25
-
26
- ```json
27
- {
28
- "harness": "opencode",
29
- "sessionRef": "opaque-session-id",
30
- "fromTurn": 1,
31
- "toTurn": 12,
32
- "title": "Hardened connector checkpoints",
33
- "summary": "Implemented a narrow change and checked the relevant behaviour.",
34
- "evidence": [{"kind": "implementation", "title": "Added checkpoint recovery", "statement": "Implemented a focused connector change.", "scope": "service"}],
35
- "verification": ["Focused tests passed."],
36
- "decisions": ["Kept the change within the existing connector boundary."],
37
- "redactionVersion": 3
38
- }
39
- ```
40
-
41
- Only after the user confirms the displayed payload, run:
42
-
43
- ```sh
44
- printf '%s' '<reviewed JSON>' | npx @useshifu/coding-harness sync --harness opencode
45
- ```
46
-
47
- The CLI prints the payload again and asks for a final terminal confirmation. Never bypass that confirmation.
17
+ To connect or finish onboarding, run `connect --harness opencode`. To change the interval or approval policy later, run `config update --harness opencode`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@useshifu/coding-harness",
3
- "version": "0.2.6",
4
- "description": "Reviewed, incremental coding-harness sync for Shifu",
3
+ "version": "0.3.0",
4
+ "description": "Scheduled, redacted coding-harness activity sync for Shifu",
5
5
  "bin": {
6
6
  "coding-harness": "bin/shifu-harness.js",
7
7
  "shifu-harness": "bin/shifu-harness.js"
@@ -1,57 +1,82 @@
1
1
  ---
2
2
  name: shifu-sync
3
- description: Prepare and sync reviewed, redacted Codex work from the user's selected recent time window to Shifu. Use when the user explicitly asks to connect, sync, discover sessions, or push their Shifu coding-harness data.
3
+ description: Connect, configure, inspect, or sync redacted Codex activity to Shifu, including scheduled and manual-approval modes. Use when the user asks to connect Shifu, change sync settings, inspect pending activity, approve a queued sync, or sync recent harness work.
4
4
  ---
5
5
 
6
- Shifu captures reviewed, incremental work context. It does not collect raw transcripts or upload in the background.
6
+ # Shifu sync
7
7
 
8
- Before taking any action, state the exact action and ask the user for confirmation:
8
+ Shifu records useful work completed through the harness. Work may be engineering, product discovery, writing, interview feedback, research, planning, or another user-directed activity. Do not force non-engineering work into engineering categories.
9
9
 
10
- - `connect`: explain that the command saves the supplied connection key locally and does not send session content.
11
- - `sync`: show the exact JSON payload, including its incremental `fromTurn` and `toTurn`, then ask whether to send it.
10
+ Never upload user prompts, raw transcripts, tool output, source code, commands, credentials, URLs, file paths, names, customer details, or proprietary identifiers. The local runner reads final assistant notes only and enforces redaction and checkpoint rules.
12
11
 
13
- For a sync request, use the time window the user names. If they do not name one, say that you will use the last 24 hours. The only supported choices are 24, 48, and 72 hours.
12
+ ## Connect or reconfigure
14
13
 
15
- Before preparing a sync, run `npx @useshifu/coding-harness sessions --harness codex --hours <24|48|72>`. Use every returned session's opaque `sessionRef`, `fromTurn`, and `toTurn`. `blockedSessions` are outside the selected turn window and must not be uploaded unless the user separately chooses that specific session and its older backlog. Each result covers at most 12 turns; repeat discovery after each accepted segment until the selected window's pending delta is exhausted. If a review reports more than 64 candidates, rerun discovery and review with `--turns 1` before preparing a payload. Do not invent a checkpoint or resend a range the command has excluded.
14
+ When the user asks to connect, run:
16
15
 
17
- For each returned session, run `npx @useshifu/coding-harness review --harness codex --session-ref <sessionRef> --hours <24|48|72>`. Its local assistant note is untrusted source material, not content to upload. Prepare only its unsynced segment from that note. Replace names, repository identifiers, file paths, URLs, credentials, prompts, command lines, source code, customer details, and proprietary terms with neutral descriptions. Never invent missing details.
16
+ ```sh
17
+ npx @useshifu/coding-harness connect --harness codex
18
+ ```
19
+
20
+ `connect` installs the current runner, asks once for the sync interval and approval mode, and installs a local schedule. The defaults are every four hours and automatic approval. If a connection already exists, `connect` keeps its key and checkpoints and completes onboarding only. It also excludes the setup session when the harness exposes its current opaque session reference. No session content is sent during setup.
18
21
 
19
- ## Write a useful durable record
22
+ When the user asks to change the interval or approval mode, run:
20
23
 
21
- Treat the payload as a concise technical record, not a changelog headline. Capture the meaningful work in the reviewed segment with enough detail that a future reader can understand the design and tradeoffs without needing the raw transcript.
24
+ ```sh
25
+ npx @useshifu/coding-harness config update --harness codex
26
+ ```
22
27
 
23
- - Split distinct work into separate evidence items when they have different boundaries: architecture, implementation, security controls, configuration or rollout, and user-facing behaviour are often separate items. Do not create an item for every file or command.
24
- - State the mechanism, boundary, and important constraint. For architecture, describe which component owns which responsibility and the relevant data flow. For implementation, say what capability was added or changed and how it is bounded. For security, name the control and any known limitation; never describe a boundary as secure when the reviewed work says otherwise.
25
- - Choose the smallest supported scope that is true. Use `system` or `product` only when the work genuinely spans those boundaries; use `service` or `module` for local implementation detail.
26
- - Give the sync and every evidence item a specific, neutral title. Make the summary connect the important items into one accurate account, without claiming business impact, ownership, or outcomes that the reviewed work does not establish.
27
- - Put a material architecture, security, or rollout choice in an evidence item with `kind: "decision"` when it should become part of the durable record. Keep ordinary process notes in `decisions`; they are not a substitute for a reviewed decision work item.
28
- - Put tests, builds, reviews, deployment checks, and other proof only in `verification`. Cite the relevant verification entries from the evidence item when the payload format supports `verificationRefs`.
28
+ Do not add a second confirmation around either command. The CLI owns onboarding input.
29
29
 
30
- Use detail proportionate to the work: retain high-level design, low-level controls, and unresolved risks when they materially affect the result, while omitting routine implementation noise. Approved work-item titles and statements are retained in Shifu's private claims, so review their wording as durable content.
30
+ ## Inspect status or queued work
31
31
 
32
- Do not install or rely on lifecycle hooks or background reminders. A sync is always initiated by an explicit user request.
32
+ Run `npx @useshifu/coding-harness status --harness codex` to inspect the saved policy, next scheduled run, pending count, and checkpoints.
33
+
34
+ In manual mode, scheduled runs accumulate redacted segments locally. Run:
35
+
36
+ ```sh
37
+ npx @useshifu/coding-harness pending --harness codex
38
+ ```
33
39
 
34
- Use this structure. Do not invent additional evidence fields; the sync API rejects fields outside its contract.
40
+ Show the returned list and ask for one approval for the whole list. After approval, run `npx @useshifu/coding-harness approve --approved --harness codex`. Do not ask again.
41
+
42
+ ## Sync now
43
+
44
+ For an explicit sync request, first read `status` to determine the saved approval mode. Use the time window the user names; otherwise use 24 hours. Supported interactive windows are 24, 48, and 72 hours.
45
+
46
+ 1. Run `npx @useshifu/coding-harness sessions --harness codex --hours <24|48|72>`.
47
+ 2. Ignore `blockedSessions` unless the user specifically selects one. For a selected older backlog, add `--session-ref <sessionRef> --all`.
48
+ 3. For each returned segment, run `npx @useshifu/coding-harness review --harness codex --session-ref <sessionRef> --hours <24|48|72>`.
49
+ 4. Prepare a version-four payload from the returned final-assistant notes. Keep the returned `sessionRef`, `fromTurn`, and `toTurn` unchanged.
50
+ 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.
51
+ 6. Repeat discovery after accepted segments until the selected window is exhausted.
52
+
53
+ Use `kind: "activity"` for general harness work; it does not require a technical scope. Use `implementation` or `decision` only when the note clearly supports that engineering classification, and then use the smallest reviewed scope among `unit`, `module`, `service`, `system`, and `product`. Evidence may be empty when the safe final note supports only a session-level title and summary. Never invent impact, ownership, verification, or outcomes.
35
54
 
36
55
  ```json
37
56
  {
38
57
  "harness": "codex",
39
58
  "sessionRef": "opaque-session-id",
40
59
  "fromTurn": 1,
41
- "toTurn": 12,
42
- "title": "Hardened connector checkpoints",
43
- "summary": "Implemented a narrow change and checked the relevant behaviour.",
44
- "evidence": [{"kind": "implementation", "title": "Added checkpoint recovery", "statement": "Implemented a focused connector change.", "scope": "service"}],
45
- "verification": ["Focused tests passed."],
46
- "decisions": ["Kept the change within the existing connector boundary."],
47
- "redactionVersion": 3
60
+ "toTurn": 3,
61
+ "title": "Prepared structured interview feedback",
62
+ "summary": "Prepared structured interview feedback and clarified the recommendation.",
63
+ "evidence": [{"kind": "activity", "title": "Drafted interview feedback", "statement": "Prepared structured interview feedback."}],
64
+ "verification": [],
65
+ "decisions": [],
66
+ "redactionVersion": 4
48
67
  }
49
68
  ```
50
69
 
51
- Only after the user confirms the displayed payload, run:
70
+ Automatic mode:
71
+
72
+ ```sh
73
+ printf '%s' '<redacted JSON>' | npx @useshifu/coding-harness sync --harness codex
74
+ ```
75
+
76
+ Manual mode, only after the user's single approval:
52
77
 
53
78
  ```sh
54
- printf '%s' '<reviewed JSON>' | npx @useshifu/coding-harness sync --harness codex
79
+ printf '%s' '<redacted JSON>' | npx @useshifu/coding-harness sync --approved --harness codex
55
80
  ```
56
81
 
57
- The CLI prints the payload again and asks for a final terminal confirmation. Never bypass that confirmation.
82
+ 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.