@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 +4 -4
- package/bin/shifu-harness.js +75 -5
- package/package.json +1 -1
- package/skills/shifu-sync/SKILL.md +1 -1
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
|
|
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.
|
package/bin/shifu-harness.js
CHANGED
|
@@ -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("
|
|
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 ||
|
|
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
|
|
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
|
@@ -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
|
{
|