@useshifu/coding-harness 0.1.0 → 0.2.1

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
@@ -5,16 +5,25 @@
5
5
  ```sh
6
6
  npx @useshifu/coding-harness install --harness codex
7
7
  npx @useshifu/coding-harness connect --harness codex
8
- npx @useshifu/coding-harness schedule --harness codex --after-turns 12 --after-hours 4
9
- npx @useshifu/coding-harness activate --harness codex
10
8
  ```
11
9
 
12
- Replace `codex` with `claude_code` or `opencode` as needed. `connect`, `schedule`, `activate`, and `sync` each ask for confirmation. Activation writes only local harness configuration; it can mark a segment as due but cannot upload it. A due reminder appears when the harness next becomes active.
10
+ `connect` and `sync` each ask for confirmation. The Codex connector has no lifecycle hooks and never uploads in the background. Claude Code and OpenCode time-window discovery are not available yet.
11
+
12
+ When asked to sync, Codex discovers 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`.
13
+
14
+ ```sh
15
+ npx @useshifu/coding-harness sessions --harness codex --hours 24
16
+ npx @useshifu/coding-harness review --harness codex --session-ref opaque-session-id
17
+ ```
18
+
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.
13
20
 
14
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.
15
22
 
16
23
  ```sh
17
- printf '%s' '{"harness":"codex","sessionRef":"opaque-session-id","fromTurn":1,"toTurn":8,"summary":"Implemented a focused change and checked the result.","evidence":["Focused tests passed."],"decisions":[],"redactionVersion":1}' | npx @useshifu/coding-harness sync --harness codex
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
18
25
  ```
19
26
 
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).
28
+
20
29
  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`.
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const crypto = require("node:crypto");
4
3
  const fs = require("node:fs");
5
4
  const os = require("node:os");
6
5
  const path = require("node:path");
7
6
  const readline = require("node:readline/promises");
7
+ const tty = require("node:tty");
8
8
 
9
9
  const DEFAULT_API_URL = "https://api.useshifu.com";
10
10
  const HARNESS_NAMES = {
@@ -13,7 +13,22 @@ const HARNESS_NAMES = {
13
13
  opencode: "OpenCode",
14
14
  };
15
15
  const TOKEN_PATTERN = /^cs_sk_[a-f0-9]{16}_[a-f0-9]{64}$/;
16
- const SENSITIVE_PATTERNS = ["-----begin", "api_key", "authorization", "password=", "sk-", "ghp_", "@", "://", "`", "/"];
16
+ const SENSITIVE_PATTERNS = ["-----begin", "authorization", "sk-", "sk_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_", "xox", "github_pat_", "glpat-", "ignore previous instructions", "ignore all previous instructions", "system prompt", "@", "://", "`", "/"];
17
+ const PHONE_NUMBER_PATTERN = /(?:\+?\d[\d .()-]{6,}\d)/i;
18
+ 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;
19
+ const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+/i;
20
+ const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
21
+ const EVIDENCE_KINDS = new Set(["implementation", "decision"]);
22
+ const EVIDENCE_FIELDS = new Set(["kind", "title", "statement", "scope", "area", "itemRef", "verificationRefs"]);
23
+ const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion"]);
24
+ const SCOPE_LEVELS = new Set(["unit", "module", "service", "system", "product"]);
25
+ const WORK_AREAS = new Set(["architecture", "api", "backend", "connector", "database", "documentation", "frontend", "performance", "security", "testing", "tooling"]);
26
+ const ITEM_REF_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
27
+ const MAX_EVIDENCE_ITEMS = 64;
28
+ const MAX_SYNC_ITEMS = 16;
29
+ const MAX_TURNS_PER_SEGMENT = 12;
30
+ const MAX_TITLE_LENGTH = 160;
31
+ const MAX_WORK_DETAIL_LENGTH = 600;
17
32
 
18
33
  function configRoot() {
19
34
  return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
@@ -31,6 +46,10 @@ function installedRunnerPath() {
31
46
  return path.join(configRoot(), "runner.js");
32
47
  }
33
48
 
49
+ function codexSessionsRoot() {
50
+ return process.env.SHIFU_CODEX_SESSIONS_ROOT || path.join(os.homedir(), ".codex", "sessions");
51
+ }
52
+
34
53
  function requireHarness(value) {
35
54
  if (!Object.hasOwn(HARNESS_NAMES, value)) throw new Error("Choose --harness codex, claude_code, or opencode.");
36
55
  return value;
@@ -67,9 +86,20 @@ function readConfig(harness) {
67
86
  if (!config || typeof config.token !== "string" || typeof config.apiUrl !== "string") {
68
87
  throw new Error(`No ${HARNESS_NAMES[harness]} connection is configured. Run connect first.`);
69
88
  }
89
+ requireSafeApiUrl(config.apiUrl);
70
90
  return config;
71
91
  }
72
92
 
93
+ function requireSafeApiUrl(value) {
94
+ let url;
95
+ try { url = new URL(value); } catch { throw new Error("Use a valid Shifu API URL."); }
96
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
97
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password || url.search || url.hash) {
98
+ throw new Error("Use HTTPS for the Shifu API, or HTTP on localhost for development.");
99
+ }
100
+ return url.toString().replace(/\/$/, "");
101
+ }
102
+
73
103
  function readState(harness) {
74
104
  return readJSON(statePath(harness), { sessions: {} });
75
105
  }
@@ -78,15 +108,204 @@ function saveState(harness, state) {
78
108
  writeJSON(statePath(harness), state);
79
109
  }
80
110
 
111
+ function numberOption(name, fallback) {
112
+ const value = option(name);
113
+ return value === undefined ? fallback : Number(value);
114
+ }
115
+
116
+ function sessionFiles(root) {
117
+ if (!fs.existsSync(root)) return [];
118
+ return fs.readdirSync(root, { recursive: true, withFileTypes: true })
119
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
120
+ .map((entry) => path.join(entry.parentPath || entry.path, entry.name));
121
+ }
122
+
123
+ function codexSession(file) {
124
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
125
+ let sessionRef;
126
+ let startedAt;
127
+ let updatedAt;
128
+ let turnCount = 0;
129
+ const turnTimes = [];
130
+ for (const line of lines) {
131
+ let entry;
132
+ try {
133
+ entry = JSON.parse(line);
134
+ } catch {
135
+ throw new Error(`Could not read Codex session metadata from ${file}.`);
136
+ }
137
+ if (entry.type === "session_meta") {
138
+ sessionRef = entry.payload?.id || entry.payload?.session_id;
139
+ startedAt ||= entry.timestamp;
140
+ }
141
+ if (entry.type === "response_item" && entry.payload?.type === "message" && entry.payload?.role === "user") {
142
+ turnCount += 1;
143
+ turnTimes.push(typeof entry.timestamp === "string" ? entry.timestamp : null);
144
+ }
145
+ if (typeof entry.timestamp === "string") updatedAt = entry.timestamp;
146
+ }
147
+ if (typeof sessionRef !== "string" || sessionRef.length < 8 || !updatedAt || turnCount === 0) return undefined;
148
+ return { sessionRef, startedAt: startedAt || updatedAt, updatedAt, turnCount, turnTimes };
149
+ }
150
+
151
+ function finalAssistantNote(file) {
152
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
153
+ let sessionRef;
154
+ let updatedAt;
155
+ let note;
156
+ for (const line of lines) {
157
+ let entry;
158
+ try {
159
+ entry = JSON.parse(line);
160
+ } catch {
161
+ throw new Error(`Could not read Codex session metadata from ${file}.`);
162
+ }
163
+ if (entry.type === "session_meta") sessionRef = entry.payload?.id || entry.payload?.session_id;
164
+ if (entry.type === "response_item" && entry.payload?.type === "message" && entry.payload?.role === "assistant") {
165
+ const text = entry.payload.content
166
+ ?.filter((content) => content.type === "output_text" && typeof content.text === "string")
167
+ .map((content) => content.text)
168
+ .join("\n")
169
+ .trim();
170
+ if (text) note = text;
171
+ }
172
+ if (typeof entry.timestamp === "string") updatedAt = entry.timestamp;
173
+ }
174
+ return typeof sessionRef === "string" && note && updatedAt ? { sessionRef, updatedAt, note } : undefined;
175
+ }
176
+
177
+ function codexReview(file, fromTurn, toTurn) {
178
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
179
+ let sessionRef;
180
+ let turn = 0;
181
+ const notes = [];
182
+ for (const line of lines) {
183
+ let entry;
184
+ try {
185
+ entry = JSON.parse(line);
186
+ } catch {
187
+ throw new Error(`Could not read Codex session metadata from ${file}.`);
188
+ }
189
+ if (entry.type === "session_meta") sessionRef = entry.payload?.id || entry.payload?.session_id;
190
+ if (entry.type !== "response_item" || entry.payload?.type !== "message") continue;
191
+ if (entry.payload.role === "user") {
192
+ turn += 1;
193
+ continue;
194
+ }
195
+ if (entry.payload.role !== "assistant" || (entry.payload.phase && entry.payload.phase !== "final_answer") || turn < fromTurn || turn > toTurn) continue;
196
+ const note = entry.payload.content
197
+ ?.filter((content) => content.type === "output_text" && typeof content.text === "string")
198
+ .map((content) => content.text)
199
+ .join("\n")
200
+ .trim();
201
+ if (note) notes.push({ turn, note });
202
+ }
203
+ return { sessionRef, turnCount: turn, notes };
204
+ }
205
+
206
+ function reviewCandidates(notes) {
207
+ const candidates = [];
208
+ const seen = new Set();
209
+ for (const [noteIndex, { turn, note }] of notes.entries()) {
210
+ for (const [lineIndex, line] of note.split("\n").entries()) {
211
+ const statement = line.trim().replace(/^[-*]\s+/, "");
212
+ if (!textIsSafe(statement, MAX_WORK_DETAIL_LENGTH) || /\b(?:tests?|checks?|build|lint|review)\b.*\b(?:passed|pass|completed|succeeded)\.?$/i.test(statement)) continue;
213
+ const kind = /^(decided|chose|selected|kept|rejected)\b/i.test(statement) ? "decision" : "implementation";
214
+ if (seen.has(`${kind}:${statement}`)) continue;
215
+ seen.add(`${kind}:${statement}`);
216
+ const itemRef = `t${turn}_n${noteIndex + 1}_l${lineIndex + 1}`;
217
+ const areas = [...WORK_AREAS].filter((area) => new RegExp(`\\b${area}\\b`, "i").test(statement));
218
+ const candidate = { kind, statement, scope: null, itemRef };
219
+ if (areas.length === 1) candidate.area = areas[0];
220
+ candidates.push(candidate);
221
+ }
222
+ }
223
+ return candidates;
224
+ }
225
+
226
+ function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, selectedRef, includeOlder = false) {
227
+ if (harness !== "codex") throw new Error("Session discovery is currently available for Codex only.");
228
+ if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
229
+ if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
230
+ const since = Date.now() - hours * 3_600_000;
231
+ const state = readState(harness);
232
+ const sessions = new Map();
233
+ for (const session of sessionFiles(codexSessionsRoot())
234
+ .map(codexSession)
235
+ .filter(Boolean)
236
+ .filter((session) => selectedRef ? session.sessionRef === selectedRef : Date.parse(session.updatedAt) >= since)) {
237
+ const existing = sessions.get(session.sessionRef);
238
+ sessions.set(session.sessionRef, existing ? {
239
+ ...session,
240
+ startedAt: existing.startedAt < session.startedAt ? existing.startedAt : session.startedAt,
241
+ updatedAt: existing.updatedAt > session.updatedAt ? existing.updatedAt : session.updatedAt,
242
+ turnCount: Math.max(existing.turnCount, session.turnCount),
243
+ turnTimes: session.turnCount >= existing.turnCount ? session.turnTimes : existing.turnTimes,
244
+ } : session);
245
+ }
246
+ return [...sessions.values()]
247
+ .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))
248
+ .map(({ turnTimes, ...session }) => {
249
+ const checkpoint = state.sessions[session.sessionRef]?.lastSyncedTurn || 0;
250
+ return {
251
+ ...session,
252
+ fromTurn: checkpoint + 1,
253
+ toTurn: Math.min(session.turnCount, checkpoint + turns),
254
+ remainingTurns: Math.max(0, session.turnCount - checkpoint - turns),
255
+ oldestUnsyncedAt: turnTimes[checkpoint] || null,
256
+ };
257
+ })
258
+ .filter((session) => session.fromTurn <= session.toTurn && (includeOlder || Date.parse(session.oldestUnsyncedAt) >= since));
259
+ }
260
+
81
261
  function prompt() {
82
- return readline.createInterface({ input: process.stdin, output: process.stderr });
262
+ const input = process.stdin.isTTY ? process.stdin : fs.createReadStream("/dev/tty");
263
+ const terminal = readline.createInterface({ input, output: process.stderr });
264
+ terminal.shifuInput = input;
265
+ return terminal;
83
266
  }
84
267
 
85
268
  async function confirm(question) {
86
269
  const terminal = prompt();
87
- const answer = (await terminal.question(`${question} [y/N] `)).trim().toLowerCase();
88
- terminal.close();
89
- return answer === "y" || answer === "yes";
270
+ try {
271
+ const answer = (await terminal.question(`${question} [y/N] `)).trim().toLowerCase();
272
+ return answer === "y" || answer === "yes";
273
+ } finally {
274
+ terminal.close();
275
+ if (terminal.shifuInput !== process.stdin) terminal.shifuInput.destroy();
276
+ }
277
+ }
278
+
279
+ async function readSecret(input = process.stdin) {
280
+ const ownsInput = !input.isTTY;
281
+ if (ownsInput) input = new tty.ReadStream(fs.openSync("/dev/tty", "r"));
282
+ const wasRaw = input.isRaw;
283
+ process.stderr.write("Connection key (hidden): ");
284
+ input.setRawMode(true);
285
+ input.setEncoding("utf8");
286
+ return new Promise((resolve, reject) => {
287
+ let value = "";
288
+ const finish = (error) => {
289
+ input.off("data", onData);
290
+ input.off("error", onError);
291
+ input.setRawMode(Boolean(wasRaw));
292
+ if (ownsInput) input.destroy();
293
+ process.stderr.write("\n");
294
+ if (error) reject(error);
295
+ else resolve(value);
296
+ };
297
+ const onError = (error) => finish(error);
298
+ const onData = (chunk) => {
299
+ for (const character of chunk) {
300
+ if (character === "\r" || character === "\n") return finish();
301
+ if (character === "\u0003") return finish(new Error("Connection was cancelled."));
302
+ if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
303
+ else if (character >= " " && character !== "\u007f") value += character;
304
+ }
305
+ };
306
+ input.on("data", onData);
307
+ input.on("error", onError);
308
+ });
90
309
  }
91
310
 
92
311
  function copyRunner() {
@@ -100,42 +319,35 @@ function copyDirectory(source, destination) {
100
319
  fs.cpSync(source, destination, { recursive: true, force: true });
101
320
  }
102
321
 
103
- function shellCommand(harness, event) {
104
- return `\"${process.execPath}\" \"${installedRunnerPath()}\" hook --harness ${harness} --event ${event}`;
105
- }
106
-
107
- function addHook(config, event, command, timeout) {
108
- config.hooks ||= {};
109
- config.hooks[event] ||= [];
110
- const present = config.hooks[event].some((group) => group && Array.isArray(group.hooks) && group.hooks.some((hook) => hook.command === command));
111
- if (!present) config.hooks[event].push({ hooks: [{ type: "command", command, timeout }] });
322
+ function removeHooks(config, commandFragment) {
323
+ if (!config.hooks || typeof config.hooks !== "object") return false;
324
+ let changed = false;
325
+ for (const [event, groups] of Object.entries(config.hooks)) {
326
+ if (!Array.isArray(groups)) continue;
327
+ const retainedGroups = groups
328
+ .map((group) => {
329
+ if (!group || !Array.isArray(group.hooks)) return group;
330
+ const hooks = group.hooks.filter((hook) => {
331
+ const remove = hook?.type === "command" && typeof hook.command === "string" && hook.command.includes(commandFragment);
332
+ changed ||= remove;
333
+ return !remove;
334
+ });
335
+ return hooks.length === 0 ? undefined : { ...group, hooks };
336
+ })
337
+ .filter(Boolean);
338
+ if (retainedGroups.length === 0) delete config.hooks[event];
339
+ else config.hooks[event] = retainedGroups;
340
+ }
341
+ return changed;
112
342
  }
113
343
 
114
- function enableCodex(harness) {
344
+ function removeCodexHooks() {
115
345
  const file = path.join(os.homedir(), ".codex", "hooks.json");
116
- const config = readJSON(file, { description: "Shifu reviewed sync reminders" });
117
- addHook(config, "Stop", shellCommand(harness, "stop"), 3);
118
- addHook(config, "SessionEnd", shellCommand(harness, "session_end"), 3);
119
- writeJSON(file, config);
120
- return file;
121
- }
122
-
123
- function enableClaude(harness) {
124
- const file = path.join(os.homedir(), ".claude", "settings.json");
346
+ if (!fs.existsSync(file)) return false;
125
347
  const config = readJSON(file, {});
126
- addHook(config, "Stop", shellCommand(harness, "stop"), 3);
127
- addHook(config, "SessionEnd", shellCommand(harness, "session_end"), 3);
348
+ if (!removeHooks(config, `${installedRunnerPath()}\" hook --harness codex`)) return false;
128
349
  writeJSON(file, config);
129
- return file;
130
- }
131
-
132
- function enableOpenCode(harness) {
133
- const file = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode", "plugins", "shifu-sync.js");
134
- const runner = JSON.stringify(installedRunnerPath());
135
- const source = `import { spawn } from "node:child_process";\n\nconst runner = ${runner};\n\nfunction notify(event) {\n const child = spawn(process.execPath, [runner, "hook", "--harness", "${harness}", "--event", "idle"], { stdio: ["pipe", "ignore", "ignore"], detached: true });\n child.stdin.end(JSON.stringify({ event }));\n child.unref();\n}\n\nexport const ShifuSync = async () => ({\n event: async ({ event }) => {\n if (event.type === "session.idle" || event.type === "session.deleted") notify(event);\n },\n});\n`;
136
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
137
- fs.writeFileSync(file, source, { mode: 0o600 });
138
- return file;
350
+ return true;
139
351
  }
140
352
 
141
353
  function installDestination(harness) {
@@ -155,93 +367,59 @@ function install(harness) {
155
367
  const skill = path.join(destination, "SKILL.md");
156
368
  const content = fs.readFileSync(skill, "utf8")
157
369
  .replaceAll('"codex"', `"${harness}"`)
158
- .replaceAll("--harness codex", `--harness ${harness}`);
370
+ .replaceAll("--harness codex", `--harness ${harness}`)
371
+ .replaceAll("npx @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
159
372
  fs.writeFileSync(skill, content, { mode: 0o600 });
160
373
  }
374
+ const removedHooks = harness === "codex" && removeCodexHooks();
161
375
  console.error(`Installed the Shifu sync instruction for ${HARNESS_NAMES[harness]}.`);
162
- console.error(`Next: shifu-harness connect --harness ${harness}`);
376
+ if (removedHooks) console.error("Removed obsolete Codex lifecycle hooks.");
377
+ console.error(`Next: coding-harness connect --harness ${harness}`);
163
378
  }
164
379
 
165
380
  async function connect(harness) {
166
- const terminal = prompt();
167
- const token = (await terminal.question("Connection key: ")).trim();
168
- terminal.close();
381
+ const token = (await readSecret()).trim();
169
382
  if (!TOKEN_PATTERN.test(token)) throw new Error("The connection key is invalid.");
170
- const apiUrl = (option("--api-url") || process.env.SHIFU_API_URL || DEFAULT_API_URL).replace(/\/$/, "");
383
+ const apiUrl = requireSafeApiUrl(option("--api-url") || process.env.SHIFU_API_URL || DEFAULT_API_URL);
171
384
  if (!(await confirm(`Save this ${HARNESS_NAMES[harness]} key locally for ${apiUrl}? No work content will be sent.`))) {
172
385
  console.error("Connection was not saved.");
173
386
  return;
174
387
  }
175
- const existing = readJSON(configPath(harness), {});
176
- writeJSON(configPath(harness), { ...existing, apiUrl, token, harness, schedule: existing.schedule || { afterTurns: 12, afterHours: 4 } });
177
- console.error(`${HARNESS_NAMES[harness]} is connected. Run activate only when you want reminders.`);
178
- }
179
-
180
- async function activate(harness) {
181
- readConfig(harness);
182
- if (!(await confirm(`Activate Shifu reminders for ${HARNESS_NAMES[harness]}? This adds local lifecycle hooks. They only mark syncs as due; they never upload automatically.`))) {
183
- console.error("Activation was cancelled.");
184
- return;
185
- }
186
- copyRunner();
187
- const file = harness === "codex" ? enableCodex(harness) : harness === "claude_code" ? enableClaude(harness) : enableOpenCode(harness);
188
- console.error(`Shifu reminders are active. Updated ${file}.`);
189
- }
190
-
191
- async function schedule(harness) {
192
- const config = readConfig(harness);
193
- const afterTurns = Number(option("--after-turns") || config.schedule?.afterTurns || 12);
194
- const afterHours = Number(option("--after-hours") || config.schedule?.afterHours || 4);
195
- if (!Number.isInteger(afterTurns) || afterTurns < 1 || afterTurns > 1000 || !Number.isFinite(afterHours) || afterHours <= 0 || afterHours > 168) {
196
- throw new Error("Choose --after-turns from 1 to 1000 and --after-hours greater than 0 and at most 168.");
197
- }
198
- if (!(await confirm(`Save a reminder after ${afterTurns} turns or ${afterHours} hours without a sync? It will request review, never upload automatically.`))) {
199
- console.error("Schedule was not changed.");
200
- return;
201
- }
202
- writeJSON(configPath(harness), { ...config, schedule: { afterTurns, afterHours } });
203
- console.error("Reminder schedule saved. Activate the connector to receive harness lifecycle reminders.");
204
- }
205
-
206
- function sessionID(input) {
207
- return input.session_id || input.sessionId || input?.event?.properties?.sessionID || input?.event?.properties?.id;
208
- }
209
-
210
- function hook(harness, event) {
211
- const config = readConfig(harness);
212
- const input = readJSON(0, {});
213
- const reference = sessionID(input);
214
- if (typeof reference !== "string" || reference.length < 8) return;
215
- const state = readState(harness);
216
- state.lastSessionRef = reference;
217
- const session = state.sessions[reference] || { turnsSinceSync: 0, lastSyncedAt: 0, syncDue: false };
218
- if (event === "stop" || event === "idle") session.turnsSinceSync += 1;
219
- if (event === "session_end") session.syncDue = true;
220
- const hoursSinceSync = session.lastSyncedAt ? (Date.now() - session.lastSyncedAt) / 3_600_000 : 0;
221
- const schedule = config.schedule || { afterTurns: 12, afterHours: 4 };
222
- const due = session.turnsSinceSync >= schedule.afterTurns || hoursSinceSync >= schedule.afterHours || session.syncDue;
223
- session.syncDue = due;
224
- state.sessions[reference] = session;
225
- saveState(harness, state);
226
- if (event === "stop" && harness === "codex") {
227
- process.stdout.write(JSON.stringify(due
228
- ? { systemMessage: "A Shifu sync is due. Prepare an incremental, redacted summary, show exactly what would be sent, and ask the user to confirm before running sync." }
229
- : { continue: true }));
230
- }
388
+ writeJSON(configPath(harness), { apiUrl, token, harness });
389
+ console.error(`${HARNESS_NAMES[harness]} is connected. Ask it to sync when you are ready to review recent work.`);
231
390
  }
232
391
 
233
392
  function textIsSafe(value, maximum) {
234
- return typeof value === "string" && value.trim().length > 0 && value.length <= maximum && !SENSITIVE_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern));
393
+ return typeof value === "string" && value.trim().length > 0 && value.length <= maximum && !/[\x00-\x1f\x7f]/.test(value) && !PHONE_NUMBER_PATTERN.test(value) && !UNSAFE_CODE_PATTERN.test(value) && !CREDENTIAL_ASSIGNMENT_PATTERN.test(value) && !CLOUD_CREDENTIAL_PATTERN.test(value) && !SENSITIVE_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern));
235
394
  }
236
395
 
237
396
  function validateSync(input, harness) {
397
+ if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
238
398
  if (!input || input.harness !== harness || typeof input.sessionRef !== "string" || !textIsSafe(input.sessionRef, 200) || input.sessionRef.length < 8) throw new Error("Add the current opaque sessionRef before syncing.");
239
- if (!Number.isInteger(input.fromTurn) || !Number.isInteger(input.toTurn) || input.fromTurn < 1 || input.toTurn < input.fromTurn) throw new Error("Use a valid incremental turn range.");
399
+ if (!Number.isInteger(input.fromTurn) || !Number.isInteger(input.toTurn) || input.fromTurn < 1 || input.toTurn < input.fromTurn || input.toTurn - input.fromTurn >= MAX_TURNS_PER_SEGMENT) throw new Error("Use a valid incremental range of at most 12 turns.");
400
+ if (input.redactionVersion === 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
240
401
  if (!textIsSafe(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
241
- for (const field of ["evidence", "decisions"]) {
242
- if (!Array.isArray(input[field]) || input[field].length > 4 || !input[field].every((item) => textIsSafe(item, 240))) throw new Error(`${field} must contain at most four redacted statements.`);
402
+ 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))) {
403
+ throw new Error(`evidence must contain 1-${MAX_EVIDENCE_ITEMS} redacted work items with title, detail, kind, and scope.`);
243
404
  }
244
- if (input.redactionVersion !== 1) throw new Error("Use redactionVersion 1.");
405
+ for (const field of ["verification", "decisions"]) {
406
+ 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.`);
407
+ }
408
+ if (!input.evidence.every((item) =>
409
+ Object.keys(item).every((field) => EVIDENCE_FIELDS.has(field)) &&
410
+ (!Object.hasOwn(item, "area") || item.area === "" || WORK_AREAS.has(item.area)) &&
411
+ (!Object.hasOwn(item, "itemRef") || item.itemRef === "" || (typeof item.itemRef === "string" && ITEM_REF_PATTERN.test(item.itemRef))) &&
412
+ (!Object.hasOwn(item, "verificationRefs") || (Array.isArray(item.verificationRefs) &&
413
+ new Set(item.verificationRefs).size === item.verificationRefs.length &&
414
+ item.verificationRefs.every((ref) => Number.isInteger(ref) && ref >= 0 && ref < input.verification.length))))) {
415
+ throw new Error("Each work item must use a supported area, safe itemRef, and distinct indexes into verification.");
416
+ }
417
+ if (input.redactionVersion !== 2 && input.redactionVersion !== 3) throw new Error("Use redactionVersion 3 for new reviewed syncs.");
418
+ }
419
+
420
+ function validSyncReceipt(receipt, input) {
421
+ return receipt?.sessionRef === input.sessionRef && receipt.acceptedToTurn === input.toTurn && Number.isInteger(receipt.lastSyncedTurn) &&
422
+ (receipt.duplicate ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
245
423
  }
246
424
 
247
425
  async function sync(harness) {
@@ -258,15 +436,23 @@ async function sync(harness) {
258
436
  console.error("Nothing was sent.");
259
437
  return;
260
438
  }
261
- const response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
262
- method: "POST",
263
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
264
- body: JSON.stringify(input),
265
- });
439
+ let response;
440
+ try {
441
+ response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
442
+ method: "POST",
443
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
444
+ body: JSON.stringify(input),
445
+ });
446
+ } catch (error) {
447
+ const reason = error?.cause?.code || error?.message || "unknown transport error";
448
+ throw new Error(`Could not reach Shifu at ${config.apiUrl}: ${reason}.`);
449
+ }
266
450
  const payload = await response.json().catch(() => undefined);
267
451
  if (!response.ok) throw new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
268
452
  const receipt = payload?.data;
269
- if (!receipt || !Number.isInteger(receipt.lastSyncedTurn)) throw new Error("Shifu did not return a sync checkpoint.");
453
+ if (!validSyncReceipt(receipt, input)) {
454
+ throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
455
+ }
270
456
  state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: receipt.lastSyncedTurn, syncDue: false };
271
457
  saveState(harness, state);
272
458
  console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : `Synced through turn ${receipt.lastSyncedTurn}.`);
@@ -275,7 +461,59 @@ async function sync(harness) {
275
461
  function status(harness) {
276
462
  const config = readConfig(harness);
277
463
  const state = readState(harness);
278
- console.log(JSON.stringify({ harness, apiUrl: config.apiUrl, schedule: config.schedule, sessions: state.sessions }, null, 2));
464
+ console.log(JSON.stringify({ harness, apiUrl: config.apiUrl, sessions: state.sessions }, null, 2));
465
+ }
466
+
467
+ function sessions(harness) {
468
+ readConfig(harness);
469
+ const hours = numberOption("--hours", 24);
470
+ const turns = numberOption("--turns", MAX_TURNS_PER_SEGMENT);
471
+ const sessionRef = option("--session-ref");
472
+ const all = hasFlag("--all");
473
+ if (all && !sessionRef) throw new Error("Use --all only with an explicitly selected --session-ref.");
474
+ const pending = unsyncedCodexSessions(harness, hours, turns, sessionRef, all);
475
+ const blocked = all ? [] : unsyncedCodexSessions(harness, hours, turns, sessionRef, true)
476
+ .filter((session) => !pending.some((item) => item.sessionRef === session.sessionRef))
477
+ .map(({ sessionRef, oldestUnsyncedAt }) => ({ sessionRef, oldestUnsyncedAt }));
478
+ console.log(JSON.stringify({ harness, hours, turns, sessions: pending, blockedSessions: blocked }, null, 2));
479
+ }
480
+
481
+ function review(harness) {
482
+ if (harness !== "codex") throw new Error("Session review is currently available for Codex only.");
483
+ readConfig(harness);
484
+ const sessionRef = option("--session-ref");
485
+ if (typeof sessionRef !== "string" || sessionRef.length < 8) throw new Error("Provide an opaque Codex --session-ref from the sessions command.");
486
+ const state = readState(harness);
487
+ const fromTurn = (state.sessions[sessionRef]?.lastSyncedTurn || 0) + 1;
488
+ const matchingFiles = sessionFiles(codexSessionsRoot())
489
+ .map((file) => ({ file, session: codexSession(file) }))
490
+ .filter(({ session }) => session?.sessionRef === sessionRef)
491
+ .sort((left, right) => right.session.turnCount - left.session.turnCount || right.session.updatedAt.localeCompare(left.session.updatedAt));
492
+ const selected = matchingFiles[0];
493
+ if (!selected) throw new Error("No local Codex session matches this reference.");
494
+ const hours = numberOption("--hours", 24);
495
+ if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
496
+ if (!hasFlag("--all") && !(Date.parse(selected.session.turnTimes[fromTurn - 1]) >= Date.now() - hours * 3_600_000)) {
497
+ throw new Error("This session's next unsynced turn predates the selected window. Ask for this specific session and use --all to review its backlog.");
498
+ }
499
+ const turns = numberOption("--turns", MAX_TURNS_PER_SEGMENT);
500
+ if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
501
+ const toTurn = Math.min(selected.session.turnCount, fromTurn + turns - 1);
502
+ if (fromTurn > toTurn) throw new Error("This Codex session has no unsynced turns to review.");
503
+ const { notes } = codexReview(selected.file, fromTurn, toTurn);
504
+ const candidates = reviewCandidates(notes);
505
+ console.log(JSON.stringify({
506
+ sessionRef,
507
+ fromTurn,
508
+ toTurn,
509
+ oldestUnsyncedAt: selected.session.turnTimes[fromTurn - 1] || null,
510
+ source: "Local assistant final notes for unsynced user turns. Candidates are suggestions, not verified or guaranteed redacted; inspect them before constructing a Shifu payload.",
511
+ notes,
512
+ candidates,
513
+ draft: { title: null, summary: null, evidence: candidates.map((candidate) => ({ ...candidate, title: null })) },
514
+ candidateOverflow: candidates.length > MAX_EVIDENCE_ITEMS,
515
+ remainingTurns: selected.session.turnCount - toTurn,
516
+ }, null, 2));
279
517
  }
280
518
 
281
519
  async function main() {
@@ -283,19 +521,20 @@ async function main() {
283
521
  const harness = requireHarness(option("--harness"));
284
522
  if (command === "install") return install(harness);
285
523
  if (command === "connect") return connect(harness);
286
- if (command === "activate") return activate(harness);
287
- if (command === "schedule") return schedule(harness);
288
524
  if (command === "sync") return sync(harness);
289
525
  if (command === "status") return status(harness);
290
- if (command === "hook") return hook(harness, option("--event"));
291
- throw new Error("Use install, connect, activate, schedule, sync, status, or hook.");
526
+ if (command === "sessions") return sessions(harness);
527
+ if (command === "review") return review(harness);
528
+ throw new Error("Use install, connect, status, sessions, review, or sync.");
292
529
  }
293
530
 
294
531
  if (require.main === module) {
295
- main().catch((error) => {
296
- console.error(error.message);
297
- process.exitCode = 1;
298
- });
532
+ main()
533
+ .then(() => process.exit(0))
534
+ .catch((error) => {
535
+ console.error(error.message);
536
+ process.exit(1);
537
+ });
299
538
  }
300
539
 
301
- module.exports = { addHook, configPath, statePath, textIsSafe, validateSync };
540
+ module.exports = { codexReview, codexSession, configPath, finalAssistantNote, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, statePath, textIsSafe, unsyncedCodexSessions, validSyncReceipt, validateSync };
@@ -8,4 +8,4 @@ Use the Shifu reviewed-sync process for the current OpenCode session. Never uplo
8
8
  npx @useshifu/coding-harness sync --harness opencode
9
9
  ```
10
10
 
11
- The payload must contain `harness`, an opaque `sessionRef`, `fromTurn`, `toTurn`, a concise redacted `summary`, up to four redacted `evidence` items, up to four redacted `decisions`, and `redactionVersion: 1`. Replace names, paths, URLs, credentials, commands, prompts, source code, customer details, and proprietary identifiers with neutral descriptions.
11
+ The payload must contain `harness`, an opaque `sessionRef`, a contiguous `fromTurn`/`toTurn` range of at most 12 turns, 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, paths, URLs, credentials, commands, prompts, source code, customer details, and proprietary identifiers with neutral descriptions.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@useshifu/coding-harness",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Reviewed, incremental coding-harness sync for Shifu",
5
5
  "bin": {
6
+ "coding-harness": "bin/shifu-harness.js",
6
7
  "shifu-harness": "bin/shifu-harness.js"
7
8
  },
8
9
  "files": [
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: shifu-sync
3
- description: Prepare and sync a reviewed, redacted incremental summary of the current coding session to Shifu. Use only when the user explicitly asks to connect, activate, schedule, sync, or push their Shifu coding-harness data.
3
+ description: Prepare and sync reviewed, redacted Codex work from the user's selected recent time window to Shifu. Use only when the user explicitly asks to connect, sync, or push their Shifu coding-harness data.
4
4
  ---
5
5
 
6
6
  Shifu captures reviewed, incremental work context. It does not collect raw transcripts or upload in the background.
@@ -8,15 +8,15 @@ Shifu captures reviewed, incremental work context. It does not collect raw trans
8
8
  Before taking any action, state the exact action and ask the user for confirmation:
9
9
 
10
10
  - `connect`: explain that the command saves the supplied connection key locally and does not send session content.
11
- - `activate`: explain that it adds local lifecycle hooks. Hooks can mark a sync as due, but never upload automatically.
12
- - `schedule`: explain the turn and time thresholds. A threshold asks for review on the next active harness turn; it cannot interrupt an ended session with a dialog.
13
11
  - `sync`: show the exact JSON payload, including its incremental `fromTurn` and `toTurn`, then ask whether to send it.
14
12
 
15
- Before preparing a sync, run `npx @useshifu/coding-harness status --harness codex` to see the local checkpoint. Use its most recent opaque `lastSessionRef` and begin at `lastSyncedTurn + 1`. Do not guess a checkpoint or claim to be syncing the whole conversation when an earlier segment is still pending.
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.
16
14
 
17
- For a manual request such as “push this conversation,” prepare only the unsynced segment. Make the content concise and useful, preserving technical decisions, outcome, evidence, and verification. 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.
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.
18
16
 
19
- When a turn or time reminder appears, first decide whether the unsynced work forms a coherent, useful segment. If it does not, keep the segment pending and continue the session without prompting or sending anything. A reminder is never permission to upload.
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. Each `evidence` item must be a factual, redacted work item describing what was created, changed, or deliberately decided and which component was involved. Choose its smallest supported scope: `unit`, `module`, `service`, `system`, or `product`; the candidate's null scope is not a classification. Put tests, builds, reviews, and other checks only in `verification`. Do not claim impact, ownership, outcome, or a broader scope without direct support. 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. Approved work-item statements are retained in Shifu's private claims, so review their wording as durable content.
18
+
19
+ Do not install or rely on lifecycle hooks or background reminders. A sync is always initiated by an explicit user request.
20
20
 
21
21
  Use this structure:
22
22
 
@@ -26,10 +26,12 @@ Use this structure:
26
26
  "sessionRef": "opaque-session-id",
27
27
  "fromTurn": 1,
28
28
  "toTurn": 12,
29
+ "title": "Hardened connector checkpoints",
29
30
  "summary": "Implemented a narrow change and checked the relevant behaviour.",
30
- "evidence": ["Focused tests passed."],
31
+ "evidence": [{"kind": "implementation", "title": "Added checkpoint recovery", "statement": "Implemented a focused connector change.", "scope": "service"}],
32
+ "verification": ["Focused tests passed."],
31
33
  "decisions": ["Kept the change within the existing connector boundary."],
32
- "redactionVersion": 1
34
+ "redactionVersion": 3
33
35
  }
34
36
  ```
35
37