@useshifu/coding-harness 0.2.7 → 0.3.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 +35 -14
- package/bin/shifu-harness.js +709 -50
- package/commands/shifu-sync.md +11 -39
- package/package.json +2 -2
- package/skills/shifu-sync/SKILL.md +58 -29
package/README.md
CHANGED
|
@@ -1,33 +1,54 @@
|
|
|
1
1
|
# Shifu coding-harness connector
|
|
2
2
|
|
|
3
|
-
`@useshifu/coding-harness` connects Codex, Claude Code, or OpenCode to Shifu
|
|
3
|
+
`@useshifu/coding-harness` connects Codex, Claude Code, or OpenCode to Shifu and watches one local input folder. Coding-session sync reads final-assistant notes, never user prompts or raw transcripts, and sends incremental redacted activity through durable checkpoints. Folder files are sent over TLS for server-side parsing, sanitization, and user-attribution checks; the local runner does not redact file contents.
|
|
4
|
+
|
|
5
|
+
## Connect
|
|
4
6
|
|
|
5
7
|
```sh
|
|
6
|
-
npx @useshifu/coding-harness
|
|
7
|
-
npx @useshifu/coding-harness connect --harness codex
|
|
8
|
+
npx @useshifu/coding-harness connect --harness <codex|claude_code|opencode>
|
|
8
9
|
```
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
Connect installs the local instruction and scheduled runner, saves the connection key with owner-only permissions, and uses these defaults without setup questions:
|
|
12
|
+
|
|
13
|
+
- sync interval: one hour, configurable from 0.25 to 720 hours;
|
|
14
|
+
- automatic coding-session sync (manual approval remains available for coding sessions);
|
|
15
|
+
- one input folder created at `~/Desktop/Shifu` on macOS, with a `README.md` explaining what it is for and shortcuts at `~/Downloads/Shifu` and `~/Documents/Shifu` when those parent folders exist.
|
|
16
|
+
|
|
17
|
+
Place `.txt`, `.md`, `.rtf`, or `.docx` files directly in the input folder. Its `README.md` is never synced and an existing one is not overwritten. Screenshots and images are not processed. Existing files are picked up on the next scheduled run; unchanged files are not sent again. A failed file stays pending for retry. Folder files never require a claim-review step, even when coding-session sync is in manual mode. Shifu creates claims only when the file supports a specific contribution by the connected user; meeting action items or another person's work are not treated as the user's claims.
|
|
18
|
+
|
|
19
|
+
For named meeting transcripts, set your display name in Shifu Profile so your speaker turns can be identified. If the speaker cannot be matched, uncertain contributions are skipped.
|
|
20
|
+
|
|
21
|
+
The shortcuts are symlinks to the same folder, not additional watched folders. An existing conflicting `Shifu` item is left untouched and reported. To choose another existing, readable folder, use `--input-folder <path>` with `connect` or `config update`; Shifu checks the path before saving the change. No macOS permission flow is required by the CLI.
|
|
11
22
|
|
|
12
|
-
|
|
23
|
+
No content is sent during setup. Re-running `connect` on an older installation keeps its key, checkpoints, and selected sync policy. Change the choices later with:
|
|
13
24
|
|
|
14
25
|
```sh
|
|
15
|
-
npx @useshifu/coding-harness
|
|
16
|
-
npx @useshifu/coding-harness review --harness <codex|claude_code|opencode> --session-ref opaque-session-id
|
|
26
|
+
npx @useshifu/coding-harness config update --harness <codex|claude_code|opencode> --interval-hours 1 --approval-mode automatic --input-folder ~/Desktop/Shifu
|
|
17
27
|
```
|
|
18
28
|
|
|
19
|
-
|
|
29
|
+
Older configuration files remain on-demand and manual until either command completes onboarding; upgrading the package alone never enables background upload.
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
## Operate
|
|
22
32
|
|
|
23
33
|
```sh
|
|
24
|
-
|
|
34
|
+
npx @useshifu/coding-harness status --harness codex
|
|
35
|
+
npx @useshifu/coding-harness sessions --harness codex --hours 24
|
|
36
|
+
npx @useshifu/coding-harness review --harness codex --session-ref opaque-session-id
|
|
37
|
+
npx @useshifu/coding-harness pending --harness codex
|
|
38
|
+
npx @useshifu/coding-harness approve --harness codex
|
|
39
|
+
npx @useshifu/coding-harness sync-files --harness codex
|
|
25
40
|
```
|
|
26
41
|
|
|
27
|
-
|
|
42
|
+
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.
|
|
28
43
|
|
|
29
|
-
|
|
44
|
+
The same scheduled run checks the input folder. `sync-files` checks it immediately without changing the schedule. It sends supported file bytes over the configured HTTPS connection (or localhost HTTP for development); the server keeps the raw bytes transient and returns a final processing receipt before the local file checkpoint advances. Files larger than 5 MB are left pending with an error. The scanner reads regular files directly in the chosen folder and ignores symlinks and subfolders.
|
|
30
45
|
|
|
31
|
-
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
## Sync a prepared payload
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
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
|
|
52
|
+
```
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
Automatic mode sends immediately. Manual mode asks once unless the calling harness already obtained approval and passes `--approved`.
|
package/bin/shifu-harness.js
CHANGED
|
@@ -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,28 @@ 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 = 1;
|
|
39
|
+
const LEGACY_INTERVAL_HOURS = 4;
|
|
40
|
+
const MIN_INTERVAL_HOURS = 0.25;
|
|
41
|
+
const MAX_INTERVAL_HOURS = 720;
|
|
42
|
+
const SCHEDULER_TICK_MINUTES = 15;
|
|
43
|
+
const MAX_RETRIES = 4;
|
|
44
|
+
const MAX_WORK_FILE_BYTES = 5 * 1024 * 1024;
|
|
45
|
+
const WORK_FILE_EXTENSIONS = new Set([".txt", ".md", ".rtf", ".docx"]);
|
|
46
|
+
const INPUT_FOLDER_README_NAME = "README.md";
|
|
47
|
+
const INPUT_FOLDER_README = `# Shifu sync folder
|
|
48
|
+
|
|
49
|
+
Put work logs, meeting transcripts, and other work documents directly in this folder. Shifu checks it every hour by default and keeps claims only when the file supports a contribution you made. Other people's work and meeting action items alone are not your claims.
|
|
50
|
+
|
|
51
|
+
Supported files: .txt, .md, .rtf, and .docx. Subfolders, links, images, and screenshots are not processed. This README is not synced.
|
|
52
|
+
|
|
53
|
+
Files you add are sent to Shifu for parsing and sanitization; they are not redacted on this device. Only place files here if you want them processed. For named meeting transcripts, set your display name in Shifu Profile so your speaker turns can be matched.
|
|
54
|
+
`;
|
|
35
55
|
|
|
36
56
|
function configRoot() {
|
|
37
|
-
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
|
|
57
|
+
return process.env.SHIFU_CONFIG_ROOT || path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
function configPath(harness) {
|
|
@@ -45,6 +65,14 @@ function statePath(harness) {
|
|
|
45
65
|
return path.join(configRoot(), `${harness}-checkpoints.json`);
|
|
46
66
|
}
|
|
47
67
|
|
|
68
|
+
function pendingPath(harness) {
|
|
69
|
+
return path.join(configRoot(), `${harness}-pending.json`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lockPath(harness) {
|
|
73
|
+
return path.join(configRoot(), `${harness}.lock`);
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
function installedRunnerPath() {
|
|
49
77
|
return path.join(configRoot(), "runner.js");
|
|
50
78
|
}
|
|
@@ -165,6 +193,63 @@ function readConfig(harness) {
|
|
|
165
193
|
return config;
|
|
166
194
|
}
|
|
167
195
|
|
|
196
|
+
function environmentSessionRef(harness) {
|
|
197
|
+
return harness === "codex" ? process.env.CODEX_THREAD_ID || process.env.CODEX_SESSION_ID :
|
|
198
|
+
harness === "claude_code" ? process.env.CLAUDE_CODE_SESSION_ID : process.env.OPENCODE_SESSION_ID;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function currentSessionRef(harness) {
|
|
202
|
+
const fromEnvironment = environmentSessionRef(harness);
|
|
203
|
+
if (sessionRefIsSafe(fromEnvironment)) return fromEnvironment;
|
|
204
|
+
try {
|
|
205
|
+
const recent = localSessions(harness).filter((session) => Date.parse(session.updatedAt) >= Date.now() - 5 * 60_000);
|
|
206
|
+
if (recent.length === 1) return recent[0].sessionRef;
|
|
207
|
+
} catch {}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizedPolicy(config) {
|
|
212
|
+
const interval = Number(config?.syncPolicy?.intervalHours);
|
|
213
|
+
const approvalMode = config?.syncPolicy?.approvalMode;
|
|
214
|
+
const configured = config?.version === CONFIG_VERSION && config.syncPolicy?.onboardingComplete === true &&
|
|
215
|
+
Number.isFinite(interval) && interval >= MIN_INTERVAL_HOURS && interval <= MAX_INTERVAL_HOURS &&
|
|
216
|
+
(approvalMode === "automatic" || approvalMode === "manual");
|
|
217
|
+
return {
|
|
218
|
+
onboardingComplete: configured,
|
|
219
|
+
intervalHours: configured ? interval : LEGACY_INTERVAL_HOURS,
|
|
220
|
+
approvalMode: configured ? approvalMode : "manual",
|
|
221
|
+
scheduleEnabled: configured ? config.syncPolicy.scheduleEnabled !== false : false,
|
|
222
|
+
excludedSessionRefs: Array.isArray(config?.syncPolicy?.excludedSessionRefs) ? config.syncPolicy.excludedSessionRefs.filter(sessionRefIsSafe) : [],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function validateIntervalHours(value) {
|
|
227
|
+
const interval = Number(value);
|
|
228
|
+
if (!Number.isFinite(interval) || interval < MIN_INTERVAL_HOURS || interval > MAX_INTERVAL_HOURS) {
|
|
229
|
+
throw new Error(`Choose a sync interval between ${MIN_INTERVAL_HOURS} and ${MAX_INTERVAL_HOURS} hours.`);
|
|
230
|
+
}
|
|
231
|
+
return interval;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validateApprovalMode(value) {
|
|
235
|
+
if (value !== "automatic" && value !== "manual") throw new Error("Choose automatic or manual approval.");
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function configuredSyncPolicy(config, harness, intervalHours, approvalMode, scheduleEnabled = true) {
|
|
240
|
+
const existing = normalizedPolicy(config);
|
|
241
|
+
const excludedSessionRefs = new Set(existing.excludedSessionRefs);
|
|
242
|
+
const current = currentSessionRef(harness);
|
|
243
|
+
if (sessionRefIsSafe(current)) excludedSessionRefs.add(current.trim());
|
|
244
|
+
return {
|
|
245
|
+
onboardingComplete: true,
|
|
246
|
+
intervalHours: validateIntervalHours(intervalHours),
|
|
247
|
+
approvalMode: validateApprovalMode(approvalMode),
|
|
248
|
+
scheduleEnabled,
|
|
249
|
+
excludedSessionRefs: [...excludedSessionRefs],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
168
253
|
function requireSafeApiUrl(value) {
|
|
169
254
|
let url;
|
|
170
255
|
try { url = new URL(value); } catch { throw new Error("Use a valid Shifu API URL."); }
|
|
@@ -179,6 +264,59 @@ function readState(harness) {
|
|
|
179
264
|
return readJSON(statePath(harness), { sessions: {} });
|
|
180
265
|
}
|
|
181
266
|
|
|
267
|
+
function defaultInputFolder(home = os.homedir()) {
|
|
268
|
+
return path.join(home, "Desktop", "Shifu");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function configuredInputFolder(config, selected = option("--input-folder")) {
|
|
272
|
+
const value = selected || config?.inputFolder || defaultInputFolder();
|
|
273
|
+
return path.resolve(value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateInputFolder(folder) {
|
|
277
|
+
let stat;
|
|
278
|
+
try {
|
|
279
|
+
stat = fs.statSync(folder);
|
|
280
|
+
} catch {
|
|
281
|
+
throw new Error(`The Shifu input folder is not accessible: ${folder}`);
|
|
282
|
+
}
|
|
283
|
+
if (!stat.isDirectory()) throw new Error(`The Shifu input path is not a folder: ${folder}`);
|
|
284
|
+
try {
|
|
285
|
+
fs.accessSync(folder, fs.constants.R_OK | fs.constants.X_OK);
|
|
286
|
+
} catch {
|
|
287
|
+
throw new Error(`The Shifu input folder is not accessible: ${folder}`);
|
|
288
|
+
}
|
|
289
|
+
return folder;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function prepareInputFolder(folder, isDefault = false, platform = process.env.SHIFU_PLATFORM || process.platform, home = os.homedir()) {
|
|
293
|
+
if (isDefault) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
294
|
+
validateInputFolder(folder);
|
|
295
|
+
if (isDefault) {
|
|
296
|
+
try {
|
|
297
|
+
fs.writeFileSync(path.join(folder, INPUT_FOLDER_README_NAME), INPUT_FOLDER_README, { flag: "wx", mode: 0o600 });
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error?.code !== "EEXIST") throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const conflicts = [];
|
|
303
|
+
if (platform === "darwin") {
|
|
304
|
+
for (const location of ["Downloads", "Documents"]) {
|
|
305
|
+
const parent = path.join(home, location);
|
|
306
|
+
if (!fs.existsSync(parent)) continue;
|
|
307
|
+
const alias = path.join(parent, "Shifu");
|
|
308
|
+
try {
|
|
309
|
+
const existing = fs.lstatSync(alias);
|
|
310
|
+
if (!existing.isSymbolicLink() || path.resolve(parent, fs.readlinkSync(alias)) !== folder) conflicts.push(alias);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error?.code !== "ENOENT") throw error;
|
|
313
|
+
fs.symlinkSync(folder, alias, "dir");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return conflicts;
|
|
318
|
+
}
|
|
319
|
+
|
|
182
320
|
function saveState(harness, state) {
|
|
183
321
|
writeJSON(statePath(harness), state);
|
|
184
322
|
}
|
|
@@ -364,6 +502,16 @@ function opencodeReview(sessionRef, fromTurn, toTurn) {
|
|
|
364
502
|
return { sessionRef, turnCount: turn, notes };
|
|
365
503
|
}
|
|
366
504
|
|
|
505
|
+
function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
|
|
506
|
+
if (harness === "opencode") return opencodeReview(sessionRef, fromTurn, toTurn);
|
|
507
|
+
const files = harness === "codex" ? sessionFiles(codexSessionsRoot()) : sessionFiles(claudeSessionsRoot());
|
|
508
|
+
const selectedFile = files.map((file) => ({ file, session: harness === "codex" ? codexSession(file) : claudeSession(file) }))
|
|
509
|
+
.filter(({ session }) => session?.sessionRef === sessionRef)
|
|
510
|
+
.sort((left, right) => right.session.turnCount - left.session.turnCount || right.session.updatedAt.localeCompare(left.session.updatedAt))[0]?.file;
|
|
511
|
+
if (!selectedFile) throw new Error(`No local ${HARNESS_NAMES[harness]} session matches ${sessionRef}.`);
|
|
512
|
+
return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
|
|
513
|
+
}
|
|
514
|
+
|
|
367
515
|
function reviewCandidates(notes) {
|
|
368
516
|
const candidates = [];
|
|
369
517
|
const seen = new Set();
|
|
@@ -384,13 +532,66 @@ function reviewCandidates(notes) {
|
|
|
384
532
|
return candidates;
|
|
385
533
|
}
|
|
386
534
|
|
|
535
|
+
function conciseTitle(statement, fallback) {
|
|
536
|
+
const value = (statement || fallback).trim();
|
|
537
|
+
if (value.length <= MAX_TITLE_LENGTH) return value;
|
|
538
|
+
return `${value.slice(0, MAX_TITLE_LENGTH - 1).trimEnd()}…`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const ACTIVITY_CATEGORIES = [
|
|
542
|
+
{ key: "interview_feedback", title: "Interview feedback", pattern: /\b(?:interview|candidate|hiring feedback)\b/i },
|
|
543
|
+
{ key: "professional_writing", title: "Professional writing", pattern: /\b(?:linkedin|social post|article|newsletter|professional post|writing)\b/i },
|
|
544
|
+
{ key: "product_discovery", title: "Product discovery", pattern: /\b(?:product discovery|user research|customer research|requirements?|roadmap)\b/i },
|
|
545
|
+
{ key: "product_delivery", title: "Product and design delivery", pattern: /\b(?:landing page|website|user interface|user experience|design|prototype)\b/i },
|
|
546
|
+
{ key: "research", title: "Research", pattern: /\b(?:research|investigat|compar|evaluat|analysis|analyz)\w*/i },
|
|
547
|
+
{ key: "planning", title: "Planning", pattern: /\b(?:plan|strategy|prioriti|proposal|brief)\w*/i },
|
|
548
|
+
{ key: "documentation", title: "Documentation", pattern: /\b(?:documentation|readme|guide|runbook)\b/i },
|
|
549
|
+
{ key: "engineering", title: "Engineering", pattern: /\b(?:implement|code|bug|fix|test|api|database|frontend|backend|refactor|deploy)\w*/i },
|
|
550
|
+
];
|
|
551
|
+
|
|
552
|
+
function safeActivityEvidence(notes, segment) {
|
|
553
|
+
const source = notes.map((note) => note.note).join("\n");
|
|
554
|
+
const categories = ACTIVITY_CATEGORIES.filter((category) => category.pattern.test(source));
|
|
555
|
+
if (categories.length === 0) return [];
|
|
556
|
+
return categories.map((category, index) => ({
|
|
557
|
+
kind: "activity",
|
|
558
|
+
title: category.title,
|
|
559
|
+
statement: `Used the harness for ${category.title.toLowerCase()}.`,
|
|
560
|
+
itemRef: `t${segment.fromTurn}_${category.key}_${index + 1}`,
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function payloadForSegment(harness, segment) {
|
|
565
|
+
const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
|
|
566
|
+
const fallback = `${HARNESS_NAMES[harness]} activity across turns ${segment.fromTurn}–${segment.toTurn}.`;
|
|
567
|
+
const evidence = safeActivityEvidence(notes, segment).slice(0, MAX_EVIDENCE_ITEMS);
|
|
568
|
+
const title = evidence.length === 1 ? evidence[0].title : evidence.length > 1 ? "Multiple harness activities" : fallback;
|
|
569
|
+
const summary = evidence.length ? `Used ${HARNESS_NAMES[harness]} for ${evidence.map((item) => item.title.toLowerCase()).join(", ")}.` : fallback;
|
|
570
|
+
return {
|
|
571
|
+
harness,
|
|
572
|
+
sessionRef: segment.sessionRef,
|
|
573
|
+
fromTurn: segment.fromTurn,
|
|
574
|
+
toTurn: segment.toTurn,
|
|
575
|
+
title: conciseTitle(title, fallback),
|
|
576
|
+
summary,
|
|
577
|
+
evidence,
|
|
578
|
+
verification: [],
|
|
579
|
+
decisions: [],
|
|
580
|
+
redactionVersion: REDACTION_VERSION,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
387
584
|
function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, selectedRef, includeOlder = false) {
|
|
388
585
|
if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
|
|
389
586
|
if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
|
|
390
587
|
const since = Date.now() - hours * 3_600_000;
|
|
391
588
|
const state = readState(harness);
|
|
589
|
+
const excluded = new Set(normalizedPolicy(readJSON(configPath(harness), {})).excludedSessionRefs);
|
|
590
|
+
const controlSession = environmentSessionRef(harness);
|
|
591
|
+
if (sessionRefIsSafe(controlSession)) excluded.add(controlSession);
|
|
392
592
|
const sessions = new Map();
|
|
393
593
|
for (const session of localSessions(harness)
|
|
594
|
+
.filter((session) => !excluded.has(session.sessionRef))
|
|
394
595
|
.filter((session) => selectedRef ? session.sessionRef === selectedRef : Date.parse(session.updatedAt) >= since)) {
|
|
395
596
|
const existing = sessions.get(session.sessionRef);
|
|
396
597
|
sessions.set(session.sessionRef, existing ? {
|
|
@@ -416,8 +617,33 @@ function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, se
|
|
|
416
617
|
.filter((session) => session.fromTurn <= session.toTurn && (includeOlder || Date.parse(session.oldestUnsyncedAt) >= since));
|
|
417
618
|
}
|
|
418
619
|
|
|
620
|
+
function pendingSegments(harness) {
|
|
621
|
+
const state = readState(harness);
|
|
622
|
+
const policy = normalizedPolicy(readConfig(harness));
|
|
623
|
+
const excluded = new Set(policy.excludedSessionRefs);
|
|
624
|
+
const segments = [];
|
|
625
|
+
for (const session of localSessions(harness)
|
|
626
|
+
.filter((candidate) => !excluded.has(candidate.sessionRef))
|
|
627
|
+
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))) {
|
|
628
|
+
let fromTurn = (state.sessions[session.sessionRef]?.lastSyncedTurn || 0) + 1;
|
|
629
|
+
while (fromTurn <= session.turnCount) {
|
|
630
|
+
let toTurn = Math.min(session.turnCount, fromTurn + MAX_TURNS_PER_SEGMENT - 1);
|
|
631
|
+
if (Date.parse(session.updatedAt) > Date.now() - SCHEDULER_TICK_MINUTES * 60_000) {
|
|
632
|
+
const completedTurns = new Set(reviewSegment(harness, session.sessionRef, fromTurn, toTurn).notes.map((note) => note.turn));
|
|
633
|
+
let completedTo = fromTurn - 1;
|
|
634
|
+
while (completedTurns.has(completedTo + 1)) completedTo += 1;
|
|
635
|
+
if (completedTo < fromTurn) break;
|
|
636
|
+
toTurn = completedTo;
|
|
637
|
+
}
|
|
638
|
+
segments.push({ ...session, fromTurn, toTurn });
|
|
639
|
+
fromTurn = toTurn + 1;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return segments;
|
|
643
|
+
}
|
|
644
|
+
|
|
419
645
|
function prompt() {
|
|
420
|
-
const input = process.stdin.isTTY ? process.stdin : fs.createReadStream("/dev/tty");
|
|
646
|
+
const input = process.stdin.isTTY ? process.stdin : fs.createReadStream(process.platform === "win32" ? "CONIN$" : "/dev/tty");
|
|
421
647
|
const terminal = readline.createInterface({ input, output: process.stderr });
|
|
422
648
|
terminal.shifuInput = input;
|
|
423
649
|
return terminal;
|
|
@@ -436,7 +662,7 @@ async function confirm(question) {
|
|
|
436
662
|
|
|
437
663
|
async function readSecret(input = process.stdin) {
|
|
438
664
|
const ownsInput = !input.isTTY;
|
|
439
|
-
if (ownsInput) input = new tty.ReadStream(fs.openSync("/dev/tty", "r"));
|
|
665
|
+
if (ownsInput) input = new tty.ReadStream(fs.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"));
|
|
440
666
|
const wasRaw = input.isRaw;
|
|
441
667
|
process.stderr.write("Connection key (hidden): ");
|
|
442
668
|
input.setRawMode(true);
|
|
@@ -514,7 +740,7 @@ function installDestination(harness) {
|
|
|
514
740
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode", "skills", "shifu-sync");
|
|
515
741
|
}
|
|
516
742
|
|
|
517
|
-
function install(harness) {
|
|
743
|
+
function install(harness, quiet = false) {
|
|
518
744
|
copyRunner();
|
|
519
745
|
if (harness === "opencode") {
|
|
520
746
|
const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
@@ -546,21 +772,131 @@ function install(harness) {
|
|
|
546
772
|
fs.writeFileSync(skill, content, { mode: 0o600 });
|
|
547
773
|
}
|
|
548
774
|
const removedHooks = harness === "codex" && removeCodexHooks();
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
775
|
+
if (!quiet) {
|
|
776
|
+
console.error(`Installed the Shifu sync instruction for ${HARNESS_NAMES[harness]}.`);
|
|
777
|
+
if (removedHooks) console.error("Removed obsolete Codex lifecycle hooks.");
|
|
778
|
+
console.error(`Next: coding-harness connect --harness ${harness}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function xml(value) {
|
|
783
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function schedulerArtifact(harness, platform = process.platform) {
|
|
787
|
+
const runner = installedRunnerPath();
|
|
788
|
+
const label = `com.useshifu.coding-harness.${harness}`;
|
|
789
|
+
if (platform === "darwin") {
|
|
790
|
+
const file = path.join(os.homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
791
|
+
return {
|
|
792
|
+
file,
|
|
793
|
+
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`,
|
|
794
|
+
activate() {
|
|
795
|
+
const domain = `gui/${process.getuid()}`;
|
|
796
|
+
try { execFileSync("launchctl", ["bootout", domain, file], { stdio: "ignore" }); } catch {}
|
|
797
|
+
execFileSync("launchctl", ["bootstrap", domain, file], { stdio: "ignore" });
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
if (platform === "linux") {
|
|
802
|
+
const systemd = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "systemd", "user");
|
|
803
|
+
const service = `shifu-coding-harness-${harness}.service`;
|
|
804
|
+
const timer = `shifu-coding-harness-${harness}.timer`;
|
|
805
|
+
return {
|
|
806
|
+
file: path.join(systemd, service),
|
|
807
|
+
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`,
|
|
808
|
+
extraFiles: [{
|
|
809
|
+
file: path.join(systemd, timer),
|
|
810
|
+
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`,
|
|
811
|
+
}],
|
|
812
|
+
activate() {
|
|
813
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
814
|
+
execFileSync("systemctl", ["--user", "enable", "--now", timer], { stdio: "ignore" });
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
if (platform === "win32") {
|
|
819
|
+
const file = path.join(configRoot(), `${harness}-scheduled-sync.cmd`);
|
|
820
|
+
const taskName = `Shifu coding harness ${harness}`;
|
|
821
|
+
return {
|
|
822
|
+
file,
|
|
823
|
+
content: `@echo off\r\n"${process.execPath}" "${runner}" scheduled-sync --harness ${harness} --config-root "${configRoot()}"\r\n`,
|
|
824
|
+
activate() {
|
|
825
|
+
execFileSync("schtasks.exe", ["/Create", "/TN", taskName, "/TR", file, "/SC", "MINUTE", "/MO", String(SCHEDULER_TICK_MINUTES), "/F"], { stdio: "ignore" });
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
throw new Error(`Scheduled sync is not supported on ${platform}. You can still run coding-harness sync on demand.`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function systemdQuote(value) {
|
|
833
|
+
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function installSchedule(harness) {
|
|
837
|
+
const artifact = schedulerArtifact(harness, process.env.SHIFU_PLATFORM || process.platform);
|
|
838
|
+
for (const item of [artifact, ...(artifact.extraFiles || [])]) {
|
|
839
|
+
fs.mkdirSync(path.dirname(item.file), { recursive: true, mode: 0o700 });
|
|
840
|
+
fs.writeFileSync(item.file, item.content, { mode: 0o600 });
|
|
841
|
+
fs.chmodSync(item.file, 0o600);
|
|
842
|
+
}
|
|
843
|
+
if (process.env.SHIFU_SKIP_SCHEDULER_ACTIVATION !== "1") artifact.activate();
|
|
844
|
+
return artifact.file;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function scheduleNextRun(harness, intervalHours) {
|
|
848
|
+
const state = readState(harness);
|
|
849
|
+
state.schedule = { ...(state.schedule || {}), nextRunAt: Date.now() + intervalHours * 3_600_000 };
|
|
850
|
+
saveState(harness, state);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function chooseSyncPolicy(existing) {
|
|
854
|
+
const intervalOption = option("--interval-hours");
|
|
855
|
+
const approvalOption = option("--approval-mode");
|
|
856
|
+
return {
|
|
857
|
+
intervalHours: validateIntervalHours(intervalOption ?? existing.intervalHours ?? DEFAULT_INTERVAL_HOURS),
|
|
858
|
+
approvalMode: validateApprovalMode(approvalOption ?? existing.approvalMode ?? "automatic"),
|
|
859
|
+
};
|
|
552
860
|
}
|
|
553
861
|
|
|
554
862
|
async function connect(harness) {
|
|
555
|
-
const
|
|
863
|
+
const existing = readJSON(configPath(harness), undefined);
|
|
864
|
+
const token = existing?.token || (await readSecret()).trim();
|
|
556
865
|
if (!TOKEN_PATTERN.test(token)) throw new Error("The connection key is invalid.");
|
|
557
|
-
const apiUrl = requireSafeApiUrl(option("--api-url") || process.env.SHIFU_API_URL || DEFAULT_API_URL);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
|
|
866
|
+
const apiUrl = requireSafeApiUrl(option("--api-url") || existing?.apiUrl || process.env.SHIFU_API_URL || DEFAULT_API_URL);
|
|
867
|
+
const savedPolicy = existing && normalizedPolicy(existing);
|
|
868
|
+
const currentPolicy = savedPolicy?.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic", excludedSessionRefs: savedPolicy?.excludedSessionRefs || [] };
|
|
869
|
+
const selected = await chooseSyncPolicy(currentPolicy);
|
|
870
|
+
const syncPolicy = configuredSyncPolicy(existing || {}, harness, selected.intervalHours, selected.approvalMode);
|
|
871
|
+
const inputFolder = configuredInputFolder(existing);
|
|
872
|
+
const conflicts = prepareInputFolder(inputFolder, inputFolder === defaultInputFolder());
|
|
873
|
+
install(harness, true);
|
|
874
|
+
const scheduleFile = installSchedule(harness);
|
|
875
|
+
writeJSON(configPath(harness), { ...existing, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder });
|
|
876
|
+
scheduleNextRun(harness, syncPolicy.intervalHours);
|
|
877
|
+
console.error(`${HARNESS_NAMES[harness]} is connected. It will sync every ${syncPolicy.intervalHours} hours with ${syncPolicy.approvalMode} approval.`);
|
|
878
|
+
console.error(`Shifu sync folder: ${inputFolder}. Place .txt, .md, .rtf, or .docx files there. Only grounded contributions will become claims.`);
|
|
879
|
+
if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
|
|
880
|
+
console.error(`Schedule installed at ${scheduleFile}. No content was sent during setup.`);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function updateConfig(harness) {
|
|
884
|
+
const config = readConfig(harness);
|
|
885
|
+
const savedPolicy = normalizedPolicy(config);
|
|
886
|
+
const selected = await chooseSyncPolicy(savedPolicy.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic" });
|
|
887
|
+
const scheduleEnabled = option("--schedule") !== "off";
|
|
888
|
+
const inputFolder = configuredInputFolder(config);
|
|
889
|
+
const conflicts = prepareInputFolder(inputFolder, inputFolder === defaultInputFolder());
|
|
890
|
+
config.version = CONFIG_VERSION;
|
|
891
|
+
config.syncPolicy = configuredSyncPolicy(config, harness, selected.intervalHours, selected.approvalMode, scheduleEnabled);
|
|
892
|
+
config.inputFolder = inputFolder;
|
|
893
|
+
install(harness, true);
|
|
894
|
+
if (scheduleEnabled) installSchedule(harness);
|
|
895
|
+
writeJSON(configPath(harness), config);
|
|
896
|
+
scheduleNextRun(harness, config.syncPolicy.intervalHours);
|
|
897
|
+
console.error(`${HARNESS_NAMES[harness]} now syncs every ${config.syncPolicy.intervalHours} hours with ${config.syncPolicy.approvalMode} approval${scheduleEnabled ? "" : "; scheduling is off"}.`);
|
|
898
|
+
console.error(`Input folder: ${inputFolder}.`);
|
|
899
|
+
if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
|
|
564
900
|
}
|
|
565
901
|
|
|
566
902
|
function textIsSafe(value, maximum) {
|
|
@@ -578,10 +914,15 @@ function validateSync(input, harness) {
|
|
|
578
914
|
if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
|
|
579
915
|
if (!input || input.harness !== harness || !sessionRefIsSafe(input.sessionRef)) throw new Error("Add the current opaque sessionRef before syncing.");
|
|
580
916
|
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.");
|
|
581
|
-
if (input.redactionVersion
|
|
917
|
+
if (input.redactionVersion >= 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
|
|
582
918
|
if (!textIsSafe(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
|
|
583
|
-
|
|
584
|
-
|
|
919
|
+
const minimumEvidence = input.redactionVersion >= REDACTION_VERSION ? 0 : 1;
|
|
920
|
+
if (!Array.isArray(input.evidence) || input.evidence.length < minimumEvidence || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => {
|
|
921
|
+
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;
|
|
922
|
+
if (item.kind === "activity" && input.redactionVersion >= REDACTION_VERSION) return item.scope === undefined || item.scope === "";
|
|
923
|
+
return SCOPE_LEVELS.has(item.scope);
|
|
924
|
+
})) {
|
|
925
|
+
throw new Error(`evidence must contain ${minimumEvidence}-${MAX_EVIDENCE_ITEMS} redacted activity or work items with valid titles, details, kinds, and scopes.`);
|
|
585
926
|
}
|
|
586
927
|
for (const field of ["verification", "decisions"]) {
|
|
587
928
|
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.`);
|
|
@@ -601,7 +942,7 @@ function validateSync(input, harness) {
|
|
|
601
942
|
if (identities.has(identity)) throw new Error("Each work item must have a distinct identity.");
|
|
602
943
|
identities.add(identity);
|
|
603
944
|
}
|
|
604
|
-
if (input.redactionVersion !== 2 && input.redactionVersion !== 3) throw new Error(
|
|
945
|
+
if (input.redactionVersion !== 2 && input.redactionVersion !== 3 && input.redactionVersion !== REDACTION_VERSION) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs.`);
|
|
605
946
|
}
|
|
606
947
|
|
|
607
948
|
function validSyncReceipt(receipt, input) {
|
|
@@ -609,46 +950,361 @@ function validSyncReceipt(receipt, input) {
|
|
|
609
950
|
(receipt.duplicate ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
|
|
610
951
|
}
|
|
611
952
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
953
|
+
function sleep(milliseconds) {
|
|
954
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function retryDelay(response, attempt) {
|
|
958
|
+
const retryAfter = response?.headers?.get?.("retry-after");
|
|
959
|
+
const seconds = Number(retryAfter);
|
|
960
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000);
|
|
961
|
+
const retryAt = Date.parse(retryAfter);
|
|
962
|
+
if (Number.isFinite(retryAt)) return Math.min(Math.max(0, retryAt - Date.now()), 30_000);
|
|
963
|
+
const base = Number(process.env.SHIFU_RETRY_BASE_MS || 1000);
|
|
964
|
+
return Math.min(base * (2 ** attempt), 30_000);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function postSync(config, input) {
|
|
968
|
+
let lastError;
|
|
969
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
|
|
970
|
+
let response;
|
|
971
|
+
try {
|
|
972
|
+
response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
|
|
973
|
+
method: "POST",
|
|
974
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
|
|
975
|
+
body: JSON.stringify(input),
|
|
976
|
+
signal: AbortSignal.timeout(30_000),
|
|
977
|
+
});
|
|
978
|
+
} catch (error) {
|
|
979
|
+
lastError = new Error(`Could not reach Shifu at ${config.apiUrl}: ${error?.cause?.code || error?.message || "unknown transport error"}.`);
|
|
980
|
+
if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(undefined, attempt));
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
const payload = await response.json().catch(() => undefined);
|
|
984
|
+
if (response.ok) return payload?.data;
|
|
985
|
+
lastError = new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
|
|
986
|
+
if (response.status !== 429 && response.status < 500) throw lastError;
|
|
987
|
+
if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
|
|
988
|
+
}
|
|
989
|
+
throw lastError;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function workFiles(folder) {
|
|
993
|
+
return fs.readdirSync(validateInputFolder(folder), { withFileTypes: true })
|
|
994
|
+
.filter((entry) => entry.isFile() && entry.name !== INPUT_FOLDER_README_NAME && WORK_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
|
|
995
|
+
.map((entry) => path.join(folder, entry.name))
|
|
996
|
+
.sort();
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function readWorkFile(file) {
|
|
1000
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
1001
|
+
try {
|
|
1002
|
+
const before = fs.fstatSync(descriptor);
|
|
1003
|
+
if (!before.isFile() || before.size > MAX_WORK_FILE_BYTES) throw new Error("Only regular files up to 5 MB are supported.");
|
|
1004
|
+
const bytes = fs.readFileSync(descriptor);
|
|
1005
|
+
const after = fs.fstatSync(descriptor);
|
|
1006
|
+
if (bytes.length > MAX_WORK_FILE_BYTES || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
1007
|
+
throw new Error("The file changed while being read; it will be retried later.");
|
|
1008
|
+
}
|
|
1009
|
+
return { bytes, modifiedAt: after.mtime.toISOString(), sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
1010
|
+
} finally {
|
|
1011
|
+
fs.closeSync(descriptor);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function postWorkFile(config, file, contents) {
|
|
1016
|
+
let lastError;
|
|
1017
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
|
|
1018
|
+
let response;
|
|
1019
|
+
try {
|
|
1020
|
+
const form = new FormData();
|
|
1021
|
+
form.append("file", new Blob([contents.bytes]), path.basename(file));
|
|
1022
|
+
form.append("sourceModifiedAt", contents.modifiedAt);
|
|
1023
|
+
response = await fetch(`${config.apiUrl}/v1/connectors/work-files`, {
|
|
1024
|
+
method: "POST",
|
|
1025
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
1026
|
+
body: form,
|
|
1027
|
+
signal: AbortSignal.timeout(15 * 60_000),
|
|
1028
|
+
});
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
lastError = new Error(`Could not send ${path.basename(file)} to Shifu: ${error?.cause?.code || error?.message || "unknown transport error"}.`);
|
|
1031
|
+
if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(undefined, attempt));
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const payload = await response.json().catch(() => undefined);
|
|
1035
|
+
if (response.ok) {
|
|
1036
|
+
const receipt = payload?.data;
|
|
1037
|
+
if (["processed", "no_claims", "duplicate"].includes(receipt?.status) && Number.isInteger(receipt?.claimsCreated) && receipt.claimsCreated >= 0) return receipt;
|
|
1038
|
+
throw new Error(`Shifu returned an invalid receipt for ${path.basename(file)}. Local state was not advanced.`);
|
|
1039
|
+
}
|
|
1040
|
+
lastError = new Error(payload?.error?.message || `Shifu rejected ${path.basename(file)} (${response.status}).`);
|
|
1041
|
+
if (response.status !== 429 && response.status < 500) throw lastError;
|
|
1042
|
+
if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
|
|
1043
|
+
}
|
|
1044
|
+
throw lastError;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async function syncWorkFiles(harness, config = readConfig(harness)) {
|
|
1048
|
+
if (typeof config.inputFolder !== "string") throw new Error("No input folder is configured. Run connect again to enable file sync.");
|
|
1049
|
+
const folder = validateInputFolder(configuredInputFolder(config));
|
|
1050
|
+
const state = readState(harness);
|
|
1051
|
+
const prior = state.workFiles?.folder === folder ? state.workFiles.files || {} : {};
|
|
1052
|
+
let synced = 0;
|
|
1053
|
+
let failures = 0;
|
|
1054
|
+
for (const file of workFiles(folder)) {
|
|
1055
|
+
const name = path.basename(file);
|
|
1056
|
+
try {
|
|
1057
|
+
const contents = readWorkFile(file);
|
|
1058
|
+
if (prior[name] === contents.sha256) continue;
|
|
1059
|
+
const receipt = await postWorkFile(config, file, contents);
|
|
1060
|
+
const latest = readState(harness);
|
|
1061
|
+
const files = latest.workFiles?.folder === folder ? latest.workFiles.files || {} : {};
|
|
1062
|
+
latest.workFiles = { folder, files: { ...files, [name]: contents.sha256 } };
|
|
1063
|
+
saveState(harness, latest);
|
|
1064
|
+
prior[name] = contents.sha256;
|
|
1065
|
+
synced += 1;
|
|
1066
|
+
console.error(`${name}: ${receipt.status} (${receipt.claimsCreated} claim${receipt.claimsCreated === 1 ? "" : "s"}).`);
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
failures += 1;
|
|
1069
|
+
console.error(`${name} remains pending: ${error.message}`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return { synced, failures };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function sendSync(harness, input, config = readConfig(harness)) {
|
|
615
1076
|
validateSync(input, harness);
|
|
616
1077
|
const state = readState(harness);
|
|
617
1078
|
const checkpoint = state.sessions[input.sessionRef];
|
|
618
1079
|
const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
|
|
619
1080
|
if (input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
|
|
620
|
-
const
|
|
621
|
-
console.error(`\nThis exact reviewed, redacted segment will be sent to Shifu:\n${preview}`);
|
|
622
|
-
if (!(await confirm("Sync this segment"))) {
|
|
623
|
-
console.error("Nothing was sent.");
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
let response;
|
|
627
|
-
try {
|
|
628
|
-
response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
|
|
629
|
-
method: "POST",
|
|
630
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
|
|
631
|
-
body: JSON.stringify(input),
|
|
632
|
-
});
|
|
633
|
-
} catch (error) {
|
|
634
|
-
const reason = error?.cause?.code || error?.message || "unknown transport error";
|
|
635
|
-
throw new Error(`Could not reach Shifu at ${config.apiUrl}: ${reason}.`);
|
|
636
|
-
}
|
|
637
|
-
const payload = await response.json().catch(() => undefined);
|
|
638
|
-
if (!response.ok) throw new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
|
|
639
|
-
const receipt = payload?.data;
|
|
1081
|
+
const receipt = await postSync(config, input);
|
|
640
1082
|
if (!validSyncReceipt(receipt, input)) {
|
|
641
1083
|
throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
|
|
642
1084
|
}
|
|
643
1085
|
state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: receipt.lastSyncedTurn, syncDue: false };
|
|
644
1086
|
saveState(harness, state);
|
|
645
1087
|
console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : `Synced through turn ${receipt.lastSyncedTurn}.`);
|
|
1088
|
+
return receipt;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function withSyncLock(harness, action) {
|
|
1092
|
+
fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
|
|
1093
|
+
const file = lockPath(harness);
|
|
1094
|
+
let descriptor;
|
|
1095
|
+
let heartbeat;
|
|
1096
|
+
try {
|
|
1097
|
+
try {
|
|
1098
|
+
descriptor = fs.openSync(file, "wx", 0o600);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1101
|
+
const stale = Date.now() - fs.statSync(file).mtimeMs > 2 * 3_600_000;
|
|
1102
|
+
if (!stale) return { skipped: true };
|
|
1103
|
+
fs.unlinkSync(file);
|
|
1104
|
+
descriptor = fs.openSync(file, "wx", 0o600);
|
|
1105
|
+
}
|
|
1106
|
+
fs.writeFileSync(descriptor, `${process.pid}\n`);
|
|
1107
|
+
heartbeat = setInterval(() => {
|
|
1108
|
+
try { fs.futimesSync(descriptor, new Date(), new Date()); } catch {}
|
|
1109
|
+
}, 60_000);
|
|
1110
|
+
heartbeat.unref();
|
|
1111
|
+
return await action();
|
|
1112
|
+
} finally {
|
|
1113
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
1114
|
+
if (descriptor !== undefined) {
|
|
1115
|
+
fs.closeSync(descriptor);
|
|
1116
|
+
try { fs.unlinkSync(file); } catch {}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function sync(harness) {
|
|
1122
|
+
const config = readConfig(harness);
|
|
1123
|
+
const input = readJSON(0, undefined);
|
|
1124
|
+
validateSync(input, harness);
|
|
1125
|
+
const policy = normalizedPolicy(config);
|
|
1126
|
+
if (policy.approvalMode === "manual" && !hasFlag("--approved")) {
|
|
1127
|
+
console.error(`\nThis exact redacted segment will be sent to Shifu:\n${JSON.stringify(input, null, 2)}`);
|
|
1128
|
+
if (!(await confirm("Approve this sync"))) {
|
|
1129
|
+
console.error("Nothing was sent.");
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const result = await withSyncLock(harness, () => sendSync(harness, input, config));
|
|
1134
|
+
if (result?.skipped) throw new Error("Another Shifu sync is already running. This segment was not sent; retry later.");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function rebuildPendingQueue(harness) {
|
|
1138
|
+
const segments = pendingSegments(harness).map((segment) => payloadForSegment(harness, segment));
|
|
1139
|
+
const queue = { updatedAt: new Date().toISOString(), segments };
|
|
1140
|
+
writeJSON(pendingPath(harness), queue);
|
|
1141
|
+
return queue;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function approvalNotificationInvocation(harness, count, sessionRef) {
|
|
1145
|
+
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.`;
|
|
1146
|
+
if (harness === "codex") {
|
|
1147
|
+
return sessionRef ? { command: "codex", args: ["exec", "resume", "--all", "--json", sessionRef, prompt] } :
|
|
1148
|
+
{ command: "codex", args: ["exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "-C", os.homedir(), prompt] };
|
|
1149
|
+
}
|
|
1150
|
+
if (harness === "claude_code") {
|
|
1151
|
+
const args = ["-p", prompt, "--output-format", "json", "--max-turns", "1", "--permission-mode", "plan"];
|
|
1152
|
+
if (sessionRef) args.push("--resume", sessionRef);
|
|
1153
|
+
return { command: "claude", args };
|
|
1154
|
+
}
|
|
1155
|
+
const args = ["run", "--format", "json", "--title", "Shifu sync approval", prompt];
|
|
1156
|
+
if (sessionRef) args.splice(1, 0, "--session", sessionRef);
|
|
1157
|
+
return { command: "opencode", args };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function notificationSessionRef(output) {
|
|
1161
|
+
for (const line of String(output).split("\n")) {
|
|
1162
|
+
if (!line.trim()) continue;
|
|
1163
|
+
try {
|
|
1164
|
+
const value = JSON.parse(line);
|
|
1165
|
+
const sessionRef = value.thread_id || value.session_id || value.sessionID || value.sessionId || value.data?.thread_id || value.data?.session_id || value.data?.sessionID;
|
|
1166
|
+
if (sessionRefIsSafe(sessionRef)) return sessionRef;
|
|
1167
|
+
} catch {}
|
|
1168
|
+
}
|
|
1169
|
+
return undefined;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function notifyManualApproval(harness, count, existingSessionRef) {
|
|
1173
|
+
const invocation = approvalNotificationInvocation(harness, count, existingSessionRef);
|
|
1174
|
+
const before = existingSessionRef ? new Set() : new Set(localSessions(harness).map((session) => session.sessionRef));
|
|
1175
|
+
const output = execFileSync(invocation.command, invocation.args, {
|
|
1176
|
+
cwd: os.homedir(),
|
|
1177
|
+
encoding: "utf8",
|
|
1178
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1179
|
+
timeout: 120_000,
|
|
1180
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1181
|
+
});
|
|
1182
|
+
if (existingSessionRef) return existingSessionRef;
|
|
1183
|
+
const created = notificationSessionRef(output) || localSessions(harness)
|
|
1184
|
+
.filter((session) => !before.has(session.sessionRef))
|
|
1185
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.sessionRef;
|
|
1186
|
+
if (!created) throw new Error("The approval session was created but its session reference could not be recorded safely.");
|
|
1187
|
+
return created;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function showPending(harness) {
|
|
1191
|
+
readConfig(harness);
|
|
1192
|
+
const queue = rebuildPendingQueue(harness);
|
|
1193
|
+
console.log(JSON.stringify(queue, null, 2));
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function approvePending(harness) {
|
|
1197
|
+
const config = readConfig(harness);
|
|
1198
|
+
const queue = rebuildPendingQueue(harness);
|
|
1199
|
+
if (queue.segments.length === 0) {
|
|
1200
|
+
console.error("There is no pending Shifu activity to approve.");
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
console.error(`\nThese ${queue.segments.length} redacted segment(s) will be sent to Shifu:\n${JSON.stringify(queue.segments, null, 2)}`);
|
|
1204
|
+
if (!hasFlag("--approved") && !(await confirm("Approve all pending segments"))) {
|
|
1205
|
+
console.error("Nothing was sent. The pending list will keep accumulating.");
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const result = await withSyncLock(harness, async () => {
|
|
1209
|
+
for (const input of queue.segments) await sendSync(harness, input, config);
|
|
1210
|
+
});
|
|
1211
|
+
if (result?.skipped) throw new Error("Another Shifu sync is already running. Nothing was sent; retry later.");
|
|
1212
|
+
rebuildPendingQueue(harness);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function scheduledSync(harness) {
|
|
1216
|
+
const config = readConfig(harness);
|
|
1217
|
+
const policy = normalizedPolicy(config);
|
|
1218
|
+
if (!policy.onboardingComplete || !policy.scheduleEnabled) return;
|
|
1219
|
+
const state = readState(harness);
|
|
1220
|
+
if (Number.isFinite(state.schedule?.nextRunAt) && state.schedule.nextRunAt > Date.now()) return;
|
|
1221
|
+
const result = await withSyncLock(harness, async () => {
|
|
1222
|
+
const startedAt = Date.now();
|
|
1223
|
+
let failures = 0;
|
|
1224
|
+
let synced = 0;
|
|
1225
|
+
let syncedFiles = 0;
|
|
1226
|
+
let failedFiles = 0;
|
|
1227
|
+
if (config.inputFolder) {
|
|
1228
|
+
try {
|
|
1229
|
+
const files = await syncWorkFiles(harness, config);
|
|
1230
|
+
failedFiles = files.failures;
|
|
1231
|
+
syncedFiles = files.synced;
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
failedFiles = 1;
|
|
1234
|
+
console.error(`Shifu input folder remains pending: ${error.message}`);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
if (policy.approvalMode === "manual") {
|
|
1238
|
+
const queue = rebuildPendingQueue(harness);
|
|
1239
|
+
const pendingFingerprint = createHash("sha256").update(queue.segments.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
|
|
1240
|
+
const changed = state.schedule?.pendingFingerprint !== pendingFingerprint;
|
|
1241
|
+
let approvalSessionRef = state.schedule?.approvalSessionRef;
|
|
1242
|
+
let notificationError;
|
|
1243
|
+
if (queue.segments.length > 0 && changed) {
|
|
1244
|
+
try {
|
|
1245
|
+
approvalSessionRef = notifyManualApproval(harness, queue.segments.length, approvalSessionRef);
|
|
1246
|
+
if (approvalSessionRef && !config.syncPolicy.excludedSessionRefs.includes(approvalSessionRef)) {
|
|
1247
|
+
config.syncPolicy.excludedSessionRefs.push(approvalSessionRef);
|
|
1248
|
+
writeJSON(configPath(harness), config);
|
|
1249
|
+
}
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
notificationError = error?.message || "Could not create the approval session.";
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
state.schedule = {
|
|
1255
|
+
...state.schedule,
|
|
1256
|
+
lastAttemptAt: startedAt,
|
|
1257
|
+
lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles ? "partial_failure" : "awaiting_approval",
|
|
1258
|
+
syncedFiles,
|
|
1259
|
+
failedFiles,
|
|
1260
|
+
pendingSegments: queue.segments.length,
|
|
1261
|
+
pendingFingerprint,
|
|
1262
|
+
approvalSessionRef,
|
|
1263
|
+
notificationError,
|
|
1264
|
+
nextRunAt: startedAt + (notificationError || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1265
|
+
};
|
|
1266
|
+
} else {
|
|
1267
|
+
const blockedSessions = new Set();
|
|
1268
|
+
for (const segment of pendingSegments(harness)) {
|
|
1269
|
+
if (blockedSessions.has(segment.sessionRef)) continue;
|
|
1270
|
+
try {
|
|
1271
|
+
await sendSync(harness, payloadForSegment(harness, segment), config);
|
|
1272
|
+
synced += 1;
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
failures += 1;
|
|
1275
|
+
blockedSessions.add(segment.sessionRef);
|
|
1276
|
+
console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
state.schedule = {
|
|
1280
|
+
lastAttemptAt: startedAt,
|
|
1281
|
+
lastResult: failures || failedFiles ? "partial_failure" : "complete",
|
|
1282
|
+
syncedSegments: synced,
|
|
1283
|
+
failedSessions: failures,
|
|
1284
|
+
syncedFiles,
|
|
1285
|
+
failedFiles,
|
|
1286
|
+
nextRunAt: startedAt + (failures || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
const latest = readState(harness);
|
|
1290
|
+
latest.schedule = state.schedule;
|
|
1291
|
+
saveState(harness, latest);
|
|
1292
|
+
});
|
|
1293
|
+
if (result?.skipped) return;
|
|
646
1294
|
}
|
|
647
1295
|
|
|
648
1296
|
function status(harness) {
|
|
649
1297
|
const config = readConfig(harness);
|
|
650
1298
|
const state = readState(harness);
|
|
651
|
-
|
|
1299
|
+
const pending = readJSON(pendingPath(harness), { segments: [] });
|
|
1300
|
+
console.log(JSON.stringify({ harness, apiUrl: config.apiUrl, inputFolder: config.inputFolder || null, syncPolicy: normalizedPolicy(config), schedule: state.schedule || null, pendingSegments: pending.segments.length, syncedFiles: Object.keys(state.workFiles?.files || {}).length, sessions: state.sessions }, null, 2));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
async function syncFilesNow(harness) {
|
|
1304
|
+
const result = await withSyncLock(harness, () => syncWorkFiles(harness));
|
|
1305
|
+
if (result?.skipped) throw new Error("Another Shifu sync is already running. Files remain pending.");
|
|
1306
|
+
console.error(`Checked the input folder: ${result.synced} sent, ${result.failures} pending.`);
|
|
1307
|
+
if (result.failures) throw new Error("Some input files remain pending. Retry sync-files after resolving the errors above.");
|
|
652
1308
|
}
|
|
653
1309
|
|
|
654
1310
|
function sessions(harness) {
|
|
@@ -683,10 +1339,7 @@ function review(harness) {
|
|
|
683
1339
|
if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
|
|
684
1340
|
const toTurn = Math.min(selected.turnCount, fromTurn + turns - 1);
|
|
685
1341
|
if (fromTurn > toTurn) throw new Error(`This ${HARNESS_NAMES[harness]} session has no unsynced turns to review.`);
|
|
686
|
-
const
|
|
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);
|
|
1342
|
+
const { notes } = reviewSegment(harness, sessionRef, fromTurn, toTurn);
|
|
690
1343
|
const candidates = reviewCandidates(notes);
|
|
691
1344
|
console.log(JSON.stringify({
|
|
692
1345
|
sessionRef,
|
|
@@ -703,16 +1356,22 @@ function review(harness) {
|
|
|
703
1356
|
}
|
|
704
1357
|
|
|
705
1358
|
async function main() {
|
|
1359
|
+
if (option("--config-root")) process.env.SHIFU_CONFIG_ROOT = path.resolve(option("--config-root"));
|
|
706
1360
|
const command = process.argv[2];
|
|
707
1361
|
const subCommand = process.argv[3];
|
|
708
1362
|
const harness = requireHarness(option("--harness"));
|
|
709
1363
|
if (command === "install") return install(harness);
|
|
710
1364
|
if (command === "connect") return connect(harness);
|
|
1365
|
+
if (command === "config" && subCommand === "update") return updateConfig(harness);
|
|
711
1366
|
if (command === "sync") return sync(harness);
|
|
1367
|
+
if (command === "sync-files") return syncFilesNow(harness);
|
|
1368
|
+
if (command === "scheduled-sync") return scheduledSync(harness);
|
|
1369
|
+
if (command === "pending") return showPending(harness);
|
|
1370
|
+
if (command === "approve") return approvePending(harness);
|
|
712
1371
|
if (command === "status") return status(harness);
|
|
713
1372
|
if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
|
|
714
1373
|
if (command === "review") return review(harness);
|
|
715
|
-
throw new Error("Use install, connect, status, sessions, review, or sync.");
|
|
1374
|
+
throw new Error("Use install, connect, config update, status, sessions, review, sync, sync-files, pending, approve, or scheduled-sync.");
|
|
716
1375
|
}
|
|
717
1376
|
|
|
718
1377
|
if (require.main === module) {
|
|
@@ -724,4 +1383,4 @@ if (require.main === module) {
|
|
|
724
1383
|
});
|
|
725
1384
|
}
|
|
726
1385
|
|
|
727
|
-
module.exports = { claudeReview, claudeSession, codexReview, codexSession, configPath, finalAssistantNote, install, installDestination, localSessions, opencodeQuery, opencodeReview, opencodeSessions, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, statePath, textIsSafe, unsyncedCodexSessions, validSyncReceipt, validateSync };
|
|
1386
|
+
module.exports = { approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, defaultInputFolder, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, prepareInputFolder, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
|
package/commands/shifu-sync.md
CHANGED
|
@@ -1,47 +1,19 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Inspect or sync redacted OpenCode activity using the saved Shifu policy.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Run `npx @useshifu/coding-harness status --harness opencode` first.
|
|
6
6
|
|
|
7
|
-
For
|
|
7
|
+
For an explicit Shifu input-folder sync, run `sync-files --harness opencode`. It processes supported `.txt`, `.md`, `.rtf`, and `.docx` files in the configured folder without a claim-review step. Do not treat another speaker's work or a meeting action item as the user's contribution. The server parses and sanitizes files; the local runner does not redact file contents.
|
|
8
8
|
|
|
9
|
-
|
|
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
|
+
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`.
|
|
15
10
|
|
|
16
|
-
|
|
17
|
-
npx @useshifu/coding-harness sync --harness opencode
|
|
18
|
-
```
|
|
11
|
+
For an explicit sync-now request:
|
|
19
12
|
|
|
20
|
-
|
|
13
|
+
1. Run `sessions --harness opencode --hours 24`, using 48 or 72 only when requested.
|
|
14
|
+
2. Review each returned segment with `review --harness opencode --session-ref <sessionRef> --hours <hours>`.
|
|
15
|
+
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.
|
|
16
|
+
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`.
|
|
17
|
+
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.
|
|
21
18
|
|
|
22
|
-
|
|
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.
|
|
19
|
+
To connect or finish onboarding, run `connect --harness opencode`. New connections use one-hour automatic sync and `~/Desktop/Shifu` on macOS by default. To change the interval, approval policy, or folder later, run `config update --harness opencode` with the desired flags (for example, `--input-folder <existing-readable-folder>`).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@useshifu/coding-harness",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.1",
|
|
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,86 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: shifu-sync
|
|
3
|
-
description:
|
|
3
|
+
description: Connect, configure, inspect, or sync coding-harness activity and local work files to Shifu. Use when the user asks to connect Shifu, change sync settings, inspect pending activity, approve a queued coding sync, or sync recent harness work or input-folder files.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Shifu
|
|
6
|
+
# Shifu sync
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
-
|
|
11
|
-
- `sync`: show the exact JSON payload, including its incremental `fromTurn` and `toTurn`, then ask whether to send it.
|
|
10
|
+
Never include user prompts, raw transcripts, tool output, source code, commands, credentials, URLs, file paths, names, customer details, or proprietary identifiers in a coding-session payload. The local coding runner reads final assistant notes only and enforces redaction and checkpoint rules. Files placed in the user's chosen Shifu input folder are a separate path: the runner sends supported files over TLS for server-side parsing and sanitization. Do not describe folder files as locally redacted.
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
Only describe work attributable to the user. A team action item, meeting discussion, or another person's contribution is not evidence that the user did it. If a final note does not support a specific user contribution, do not invent one.
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
## Connect or reconfigure
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
When the user asks to connect, run:
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
```sh
|
|
19
|
+
npx @useshifu/coding-harness connect --harness codex
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`connect` installs the current runner and a local schedule with no policy questions. New connections default to hourly automatic sync and `~/Desktop/Shifu` as the single input folder on macOS. It creates shortcuts at `~/Downloads/Shifu` and `~/Documents/Shifu` when possible without replacing conflicting items. Supported files are `.txt`, `.md`, `.rtf`, and `.docx` directly inside the folder; images and screenshots are not supported. If a connection already exists, `connect` keeps its key, checkpoints, and chosen policy. It also excludes the setup session when the harness exposes its current opaque session reference. No content is sent during setup.
|
|
23
|
+
|
|
24
|
+
When the user asks to change the interval, approval mode, or input folder, run:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npx @useshifu/coding-harness config update --harness codex --input-folder <existing-readable-folder>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Use only the flags the user wants to change; existing settings are preserved. The CLI validates a custom folder before saving. Do not add a second confirmation around either command.
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
## Inspect status or queued work
|
|
22
33
|
|
|
23
|
-
|
|
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`.
|
|
34
|
+
Run `npx @useshifu/coding-harness status --harness codex` to inspect the saved policy, next scheduled run, pending count, and checkpoints.
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
For an explicit input-folder sync, run `npx @useshifu/coding-harness sync-files --harness codex`. It sends supported files automatically and advances each local checkpoint only after a final server receipt. No claim review or manual approval is needed for files. If a file fails, leave it pending and report the error; do not claim it was synced.
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
In manual mode, scheduled runs accumulate redacted segments locally. Run:
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
```sh
|
|
41
|
+
npx @useshifu/coding-harness pending --harness codex
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
## Sync now
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
1. Run `npx @useshifu/coding-harness sessions --harness codex --hours <24|48|72>`.
|
|
51
|
+
2. Ignore `blockedSessions` unless the user specifically selects one. For a selected older backlog, add `--session-ref <sessionRef> --all`.
|
|
52
|
+
3. For each returned segment, run `npx @useshifu/coding-harness review --harness codex --session-ref <sessionRef> --hours <24|48|72>`.
|
|
53
|
+
4. Prepare a version-four payload from the returned final-assistant notes. Keep the returned `sessionRef`, `fromTurn`, and `toTurn` unchanged.
|
|
54
|
+
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.
|
|
55
|
+
6. Repeat discovery after accepted segments until the selected window is exhausted.
|
|
56
|
+
|
|
57
|
+
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
58
|
|
|
36
59
|
```json
|
|
37
60
|
{
|
|
38
61
|
"harness": "codex",
|
|
39
62
|
"sessionRef": "opaque-session-id",
|
|
40
63
|
"fromTurn": 1,
|
|
41
|
-
"toTurn":
|
|
42
|
-
"title": "
|
|
43
|
-
"summary": "
|
|
44
|
-
"evidence": [{"kind": "
|
|
45
|
-
"verification": [
|
|
46
|
-
"decisions": [
|
|
47
|
-
"redactionVersion":
|
|
64
|
+
"toTurn": 3,
|
|
65
|
+
"title": "Prepared structured interview feedback",
|
|
66
|
+
"summary": "Prepared structured interview feedback and clarified the recommendation.",
|
|
67
|
+
"evidence": [{"kind": "activity", "title": "Drafted interview feedback", "statement": "Prepared structured interview feedback."}],
|
|
68
|
+
"verification": [],
|
|
69
|
+
"decisions": [],
|
|
70
|
+
"redactionVersion": 4
|
|
48
71
|
}
|
|
49
72
|
```
|
|
50
73
|
|
|
51
|
-
|
|
74
|
+
Automatic mode:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
printf '%s' '<redacted JSON>' | npx @useshifu/coding-harness sync --harness codex
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Manual mode, only after the user's single approval:
|
|
52
81
|
|
|
53
82
|
```sh
|
|
54
|
-
printf '%s' '<
|
|
83
|
+
printf '%s' '<redacted JSON>' | npx @useshifu/coding-harness sync --approved --harness codex
|
|
55
84
|
```
|
|
56
85
|
|
|
57
|
-
|
|
86
|
+
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.
|