@useshifu/coding-harness 0.2.5 → 0.2.7

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
@@ -7,13 +7,13 @@ npx @useshifu/coding-harness install --harness codex
7
7
  npx @useshifu/coding-harness connect --harness codex
8
8
  ```
9
9
 
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.
10
+ `connect` and `sync` each ask for confirmation. The connector has no lifecycle hooks and never uploads in the background. Codex, Claude Code, and OpenCode support local time-window discovery and review. Claude Code discovery reads local sessions under `~/.claude/projects`; review surfaces assistant text for inspection, never raw prompts or tool results.
11
11
 
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`.
12
+ When asked to sync, Codex, Claude Code, 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`.
13
13
 
14
14
  ```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
15
+ npx @useshifu/coding-harness sessions --harness <codex|claude_code|opencode> --hours 24
16
+ npx @useshifu/coding-harness review --harness <codex|claude_code|opencode> --session-ref opaque-session-id
17
17
  ```
18
18
 
19
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.
@@ -16,6 +16,8 @@ const HARNESS_NAMES = {
16
16
  const TOKEN_PATTERN = /^cs_sk_[a-f0-9]{16}_[a-f0-9]{64}$/;
17
17
  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", "@", "://", "`", "/"];
18
18
  const PHONE_NUMBER_PATTERN = /(?:\+?\d[\d .()-]{6,}\d)/i;
19
+ const OPAQUE_SESSION_REF_PATTERN = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|(?:ses|thread)_[a-z0-9_-]+)$/i;
20
+ const UNSAFE_SESSION_REF_PATTERNS = ["api_key", "authorization", "password", "@", "://", "`", "/"];
19
21
  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;
20
22
  const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+/i;
21
23
  const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
@@ -51,6 +53,10 @@ function codexSessionsRoot() {
51
53
  return process.env.SHIFU_CODEX_SESSIONS_ROOT || path.join(os.homedir(), ".codex", "sessions");
52
54
  }
53
55
 
56
+ function claudeSessionsRoot() {
57
+ return process.env.SHIFU_CLAUDE_SESSIONS_ROOT || path.join(os.homedir(), ".claude", "projects");
58
+ }
59
+
54
60
  function opencodeQuery(query) {
55
61
  if (process.env.SHIFU_OPENCODE_DB && !process.env.SHIFU_OPENCODE_BIN) {
56
62
  try {
@@ -114,8 +120,9 @@ function opencodeSessions(rows) {
114
120
 
115
121
  function localSessions(harness) {
116
122
  if (harness === "codex") return sessionFiles(codexSessionsRoot()).map(codexSession).filter(Boolean);
123
+ if (harness === "claude_code") return sessionFiles(claudeSessionsRoot()).map(claudeSession).filter(Boolean);
117
124
  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"));
118
- throw new Error("Session discovery is currently available for Codex and OpenCode only.");
125
+ throw new Error("Choose a supported coding harness.");
119
126
  }
120
127
 
121
128
  function requireHarness(value) {
@@ -216,6 +223,52 @@ function codexSession(file) {
216
223
  return { sessionRef, startedAt: startedAt || updatedAt, updatedAt, turnCount, turnTimes };
217
224
  }
218
225
 
226
+ function claudeEntries(file) {
227
+ return fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => {
228
+ try { return JSON.parse(line); }
229
+ catch { throw new Error(`Could not read Claude Code session metadata from ${file}.`); }
230
+ }).filter((entry) => !entry.isSidechain);
231
+ }
232
+
233
+ function isClaudeUserTurn(entry) {
234
+ if (entry.type !== "user" || entry.message?.role !== "user") return false;
235
+ const content = entry.message.content;
236
+ return typeof content === "string" || (Array.isArray(content) && content.some((part) => part.type === "text"));
237
+ }
238
+
239
+ function claudeSession(file) {
240
+ const sessionRef = path.basename(file, ".jsonl");
241
+ if (!OPAQUE_SESSION_REF_PATTERN.test(sessionRef)) return undefined;
242
+ let startedAt;
243
+ let updatedAt;
244
+ const turnTimes = [];
245
+ for (const entry of claudeEntries(file)) {
246
+ if (typeof entry.timestamp !== "string" || !Number.isFinite(Date.parse(entry.timestamp))) continue;
247
+ startedAt ||= entry.timestamp;
248
+ updatedAt = entry.timestamp;
249
+ if (isClaudeUserTurn(entry)) turnTimes.push(entry.timestamp);
250
+ }
251
+ return updatedAt && turnTimes.length ? { sessionRef, startedAt, updatedAt, turnCount: turnTimes.length, turnTimes } : undefined;
252
+ }
253
+
254
+ function claudeReview(file, fromTurn, toTurn) {
255
+ const sessionRef = path.basename(file, ".jsonl");
256
+ let turn = 0;
257
+ const lastNoteByTurn = new Map();
258
+ for (const entry of claudeEntries(file)) {
259
+ if (isClaudeUserTurn(entry)) {
260
+ turn += 1;
261
+ continue;
262
+ }
263
+ if (entry.type !== "assistant" || entry.message?.role !== "assistant" || turn < fromTurn || turn > toTurn) continue;
264
+ const content = entry.message.content;
265
+ if (!Array.isArray(content) || content.some((part) => part.type === "tool_use")) continue;
266
+ const note = content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
267
+ if (note) lastNoteByTurn.set(turn, note);
268
+ }
269
+ return { sessionRef, turnCount: turn, notes: [...lastNoteByTurn].map(([t, note]) => ({ turn: t, note })) };
270
+ }
271
+
219
272
  function finalAssistantNote(file) {
220
273
  const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
221
274
  let sessionRef;
@@ -488,6 +541,7 @@ function install(harness) {
488
541
  const content = fs.readFileSync(skill, "utf8")
489
542
  .replaceAll('"codex"', `"${harness}"`)
490
543
  .replaceAll("--harness codex", `--harness ${harness}`)
544
+ .replaceAll("Codex", HARNESS_NAMES[harness])
491
545
  .replaceAll("npx @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
492
546
  fs.writeFileSync(skill, content, { mode: 0o600 });
493
547
  }
@@ -513,10 +567,17 @@ function textIsSafe(value, maximum) {
513
567
  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));
514
568
  }
515
569
 
570
+ function sessionRefIsSafe(value) {
571
+ if (typeof value !== "string") return false;
572
+ const trimmed = value.trim();
573
+ if (UNSAFE_SESSION_REF_PATTERNS.some((pattern) => trimmed.toLowerCase().includes(pattern))) return false;
574
+ return trimmed.length >= 8 && trimmed.length <= 200 && (OPAQUE_SESSION_REF_PATTERN.test(trimmed) || textIsSafe(trimmed, 200));
575
+ }
576
+
516
577
  function validateSync(input, harness) {
517
578
  if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
518
- 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.");
519
- 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.");
579
+ if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
580
+ 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.");
520
581
  if (input.redactionVersion === 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
521
582
  if (!textIsSafe(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
522
583
  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))) {
@@ -534,6 +595,12 @@ function validateSync(input, harness) {
534
595
  item.verificationRefs.every((ref) => Number.isInteger(ref) && ref >= 0 && ref < input.verification.length))))) {
535
596
  throw new Error("Each work item must use a supported area, safe itemRef, and distinct indexes into verification.");
536
597
  }
598
+ const identities = new Set();
599
+ for (const item of input.evidence) {
600
+ const identity = item.itemRef || `${item.kind}:${item.scope}:${item.statement.trim()}`;
601
+ if (identities.has(identity)) throw new Error("Each work item must have a distinct identity.");
602
+ identities.add(identity);
603
+ }
537
604
  if (input.redactionVersion !== 2 && input.redactionVersion !== 3) throw new Error("Use redactionVersion 3 for new reviewed syncs.");
538
605
  }
539
606
 
@@ -616,7 +683,10 @@ function review(harness) {
616
683
  if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
617
684
  const toTurn = Math.min(selected.turnCount, fromTurn + turns - 1);
618
685
  if (fromTurn > toTurn) throw new Error(`This ${HARNESS_NAMES[harness]} session has no unsynced turns to review.`);
619
- 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);
686
+ const files = harness === "codex" ? sessionFiles(codexSessionsRoot()) : harness === "claude_code" ? sessionFiles(claudeSessionsRoot()) : [];
687
+ const selectedFile = files.map((file) => ({ file, session: harness === "codex" ? codexSession(file) : claudeSession(file) }))
688
+ .filter(({ session }) => session?.sessionRef === sessionRef).sort((left, right) => right.session.turnCount - left.session.turnCount)[0]?.file;
689
+ const { notes } = harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : harness === "claude_code" ? claudeReview(selectedFile, fromTurn, toTurn) : opencodeReview(sessionRef, fromTurn, toTurn);
620
690
  const candidates = reviewCandidates(notes);
621
691
  console.log(JSON.stringify({
622
692
  sessionRef,
@@ -654,4 +724,4 @@ if (require.main === module) {
654
724
  });
655
725
  }
656
726
 
657
- module.exports = { codexReview, codexSession, configPath, finalAssistantNote, install, installDestination, localSessions, opencodeQuery, opencodeReview, opencodeSessions, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, statePath, textIsSafe, unsyncedCodexSessions, validSyncReceipt, validateSync };
727
+ module.exports = { claudeReview, claudeSession, codexReview, codexSession, configPath, finalAssistantNote, install, installDestination, localSessions, opencodeQuery, opencodeReview, opencodeSessions, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, statePath, textIsSafe, unsyncedCodexSessions, validSyncReceipt, validateSync };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@useshifu/coding-harness",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Reviewed, incremental coding-harness sync for Shifu",
5
5
  "bin": {
6
6
  "coding-harness": "bin/shifu-harness.js",
@@ -31,7 +31,7 @@ Use detail proportionate to the work: retain high-level design, low-level contro
31
31
 
32
32
  Do not install or rely on lifecycle hooks or background reminders. A sync is always initiated by an explicit user request.
33
33
 
34
- Use this structure:
34
+ Use this structure. Do not invent additional evidence fields; the sync API rejects fields outside its contract.
35
35
 
36
36
  ```json
37
37
  {