@useshifu/coding-harness 0.3.0 → 0.3.2
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 +16 -6
- package/bin/shifu-harness.js +203 -29
- package/commands/shifu-sync.md +3 -1
- package/package.json +1 -1
- package/skills/shifu-sync/SKILL.md +10 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
4
|
|
|
5
5
|
## Connect
|
|
6
6
|
|
|
@@ -8,15 +8,22 @@
|
|
|
8
8
|
npx @useshifu/coding-harness connect --harness <codex|claude_code|opencode>
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Connect installs the local instruction and scheduled runner, saves the connection key with owner-only permissions, and
|
|
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
12
|
|
|
13
|
-
- sync interval:
|
|
14
|
-
-
|
|
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.
|
|
15
16
|
|
|
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.
|
|
22
|
+
|
|
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:
|
|
17
24
|
|
|
18
25
|
```sh
|
|
19
|
-
npx @useshifu/coding-harness config update --harness <codex|claude_code|opencode>
|
|
26
|
+
npx @useshifu/coding-harness config update --harness <codex|claude_code|opencode> --interval-hours 1 --approval-mode automatic --input-folder ~/Desktop/Shifu
|
|
20
27
|
```
|
|
21
28
|
|
|
22
29
|
Older configuration files remain on-demand and manual until either command completes onboarding; upgrading the package alone never enables background upload.
|
|
@@ -29,10 +36,13 @@ npx @useshifu/coding-harness sessions --harness codex --hours 24
|
|
|
29
36
|
npx @useshifu/coding-harness review --harness codex --session-ref opaque-session-id
|
|
30
37
|
npx @useshifu/coding-harness pending --harness codex
|
|
31
38
|
npx @useshifu/coding-harness approve --harness codex
|
|
39
|
+
npx @useshifu/coding-harness sync-files --harness codex
|
|
32
40
|
```
|
|
33
41
|
|
|
34
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.
|
|
35
43
|
|
|
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.
|
|
45
|
+
|
|
36
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.
|
|
37
47
|
|
|
38
48
|
## Sync a prepared payload
|
package/bin/shifu-harness.js
CHANGED
|
@@ -35,11 +35,24 @@ const MAX_TITLE_LENGTH = 160;
|
|
|
35
35
|
const MAX_WORK_DETAIL_LENGTH = 600;
|
|
36
36
|
const CONFIG_VERSION = 2;
|
|
37
37
|
const REDACTION_VERSION = 4;
|
|
38
|
-
const DEFAULT_INTERVAL_HOURS =
|
|
38
|
+
const DEFAULT_INTERVAL_HOURS = 1;
|
|
39
|
+
const LEGACY_INTERVAL_HOURS = 4;
|
|
39
40
|
const MIN_INTERVAL_HOURS = 0.25;
|
|
40
41
|
const MAX_INTERVAL_HOURS = 720;
|
|
41
42
|
const SCHEDULER_TICK_MINUTES = 15;
|
|
42
43
|
const MAX_RETRIES = 4;
|
|
44
|
+
const MAX_WORK_FILE_BYTES = 5 * 1024 * 1024;
|
|
45
|
+
const WORK_FILE_SYNC_VERSION = 2;
|
|
46
|
+
const WORK_FILE_EXTENSIONS = new Set([".txt", ".md", ".rtf", ".docx"]);
|
|
47
|
+
const INPUT_FOLDER_README_NAME = "README.md";
|
|
48
|
+
const INPUT_FOLDER_README = `# Shifu sync folder
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
Supported files: .txt, .md, .rtf, and .docx. Subfolders, links, images, and screenshots are not processed. This README is not synced.
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
`;
|
|
43
56
|
|
|
44
57
|
function configRoot() {
|
|
45
58
|
return process.env.SHIFU_CONFIG_ROOT || path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
|
|
@@ -204,7 +217,7 @@ function normalizedPolicy(config) {
|
|
|
204
217
|
(approvalMode === "automatic" || approvalMode === "manual");
|
|
205
218
|
return {
|
|
206
219
|
onboardingComplete: configured,
|
|
207
|
-
intervalHours: configured ? interval :
|
|
220
|
+
intervalHours: configured ? interval : LEGACY_INTERVAL_HOURS,
|
|
208
221
|
approvalMode: configured ? approvalMode : "manual",
|
|
209
222
|
scheduleEnabled: configured ? config.syncPolicy.scheduleEnabled !== false : false,
|
|
210
223
|
excludedSessionRefs: Array.isArray(config?.syncPolicy?.excludedSessionRefs) ? config.syncPolicy.excludedSessionRefs.filter(sessionRefIsSafe) : [],
|
|
@@ -252,6 +265,59 @@ function readState(harness) {
|
|
|
252
265
|
return readJSON(statePath(harness), { sessions: {} });
|
|
253
266
|
}
|
|
254
267
|
|
|
268
|
+
function defaultInputFolder(home = os.homedir()) {
|
|
269
|
+
return path.join(home, "Desktop", "Shifu");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function configuredInputFolder(config, selected = option("--input-folder")) {
|
|
273
|
+
const value = selected || config?.inputFolder || defaultInputFolder();
|
|
274
|
+
return path.resolve(value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function validateInputFolder(folder) {
|
|
278
|
+
let stat;
|
|
279
|
+
try {
|
|
280
|
+
stat = fs.statSync(folder);
|
|
281
|
+
} catch {
|
|
282
|
+
throw new Error(`The Shifu input folder is not accessible: ${folder}`);
|
|
283
|
+
}
|
|
284
|
+
if (!stat.isDirectory()) throw new Error(`The Shifu input path is not a folder: ${folder}`);
|
|
285
|
+
try {
|
|
286
|
+
fs.accessSync(folder, fs.constants.R_OK | fs.constants.X_OK);
|
|
287
|
+
} catch {
|
|
288
|
+
throw new Error(`The Shifu input folder is not accessible: ${folder}`);
|
|
289
|
+
}
|
|
290
|
+
return folder;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function prepareInputFolder(folder, isDefault = false, platform = process.env.SHIFU_PLATFORM || process.platform, home = os.homedir()) {
|
|
294
|
+
if (isDefault) fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
295
|
+
validateInputFolder(folder);
|
|
296
|
+
if (isDefault) {
|
|
297
|
+
try {
|
|
298
|
+
fs.writeFileSync(path.join(folder, INPUT_FOLDER_README_NAME), INPUT_FOLDER_README, { flag: "wx", mode: 0o600 });
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error?.code !== "EEXIST") throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const conflicts = [];
|
|
304
|
+
if (platform === "darwin") {
|
|
305
|
+
for (const location of ["Downloads", "Documents"]) {
|
|
306
|
+
const parent = path.join(home, location);
|
|
307
|
+
if (!fs.existsSync(parent)) continue;
|
|
308
|
+
const alias = path.join(parent, "Shifu");
|
|
309
|
+
try {
|
|
310
|
+
const existing = fs.lstatSync(alias);
|
|
311
|
+
if (!existing.isSymbolicLink() || path.resolve(parent, fs.readlinkSync(alias)) !== folder) conflicts.push(alias);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error?.code !== "ENOENT") throw error;
|
|
314
|
+
fs.symlinkSync(folder, alias, "dir");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return conflicts;
|
|
319
|
+
}
|
|
320
|
+
|
|
255
321
|
function saveState(harness, state) {
|
|
256
322
|
writeJSON(statePath(harness), state);
|
|
257
323
|
}
|
|
@@ -595,17 +661,6 @@ async function confirm(question) {
|
|
|
595
661
|
}
|
|
596
662
|
}
|
|
597
663
|
|
|
598
|
-
async function ask(question, fallback) {
|
|
599
|
-
const terminal = prompt();
|
|
600
|
-
try {
|
|
601
|
-
const answer = (await terminal.question(`${question} [${fallback}] `)).trim();
|
|
602
|
-
return answer || fallback;
|
|
603
|
-
} finally {
|
|
604
|
-
terminal.close();
|
|
605
|
-
if (terminal.shifuInput !== process.stdin) terminal.shifuInput.destroy();
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
664
|
async function readSecret(input = process.stdin) {
|
|
610
665
|
const ownsInput = !input.isTTY;
|
|
611
666
|
if (ownsInput) input = new tty.ReadStream(fs.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"));
|
|
@@ -799,13 +854,10 @@ function scheduleNextRun(harness, intervalHours) {
|
|
|
799
854
|
async function chooseSyncPolicy(existing) {
|
|
800
855
|
const intervalOption = option("--interval-hours");
|
|
801
856
|
const approvalOption = option("--approval-mode");
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
approvalMode = answer === "auto" ? "automatic" : answer;
|
|
807
|
-
}
|
|
808
|
-
return { intervalHours, approvalMode: validateApprovalMode(approvalMode) };
|
|
857
|
+
return {
|
|
858
|
+
intervalHours: validateIntervalHours(intervalOption ?? existing.intervalHours ?? DEFAULT_INTERVAL_HOURS),
|
|
859
|
+
approvalMode: validateApprovalMode(approvalOption ?? existing.approvalMode ?? "automatic"),
|
|
860
|
+
};
|
|
809
861
|
}
|
|
810
862
|
|
|
811
863
|
async function connect(harness) {
|
|
@@ -817,12 +869,16 @@ async function connect(harness) {
|
|
|
817
869
|
const currentPolicy = savedPolicy?.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic", excludedSessionRefs: savedPolicy?.excludedSessionRefs || [] };
|
|
818
870
|
const selected = await chooseSyncPolicy(currentPolicy);
|
|
819
871
|
const syncPolicy = configuredSyncPolicy(existing || {}, harness, selected.intervalHours, selected.approvalMode);
|
|
872
|
+
const inputFolder = configuredInputFolder(existing);
|
|
873
|
+
const conflicts = prepareInputFolder(inputFolder, inputFolder === defaultInputFolder());
|
|
820
874
|
install(harness, true);
|
|
821
875
|
const scheduleFile = installSchedule(harness);
|
|
822
|
-
writeJSON(configPath(harness), { ...existing, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy });
|
|
876
|
+
writeJSON(configPath(harness), { ...existing, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder });
|
|
823
877
|
scheduleNextRun(harness, syncPolicy.intervalHours);
|
|
824
878
|
console.error(`${HARNESS_NAMES[harness]} is connected. It will sync every ${syncPolicy.intervalHours} hours with ${syncPolicy.approvalMode} approval.`);
|
|
825
|
-
console.error(`
|
|
879
|
+
console.error(`Shifu sync folder: ${inputFolder}. Place .txt, .md, .rtf, or .docx files there. Only grounded contributions will become claims.`);
|
|
880
|
+
if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
|
|
881
|
+
console.error(`Schedule installed at ${scheduleFile}. No content was sent during setup.`);
|
|
826
882
|
}
|
|
827
883
|
|
|
828
884
|
async function updateConfig(harness) {
|
|
@@ -830,13 +886,18 @@ async function updateConfig(harness) {
|
|
|
830
886
|
const savedPolicy = normalizedPolicy(config);
|
|
831
887
|
const selected = await chooseSyncPolicy(savedPolicy.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic" });
|
|
832
888
|
const scheduleEnabled = option("--schedule") !== "off";
|
|
889
|
+
const inputFolder = configuredInputFolder(config);
|
|
890
|
+
const conflicts = prepareInputFolder(inputFolder, inputFolder === defaultInputFolder());
|
|
833
891
|
config.version = CONFIG_VERSION;
|
|
834
892
|
config.syncPolicy = configuredSyncPolicy(config, harness, selected.intervalHours, selected.approvalMode, scheduleEnabled);
|
|
893
|
+
config.inputFolder = inputFolder;
|
|
835
894
|
install(harness, true);
|
|
836
895
|
if (scheduleEnabled) installSchedule(harness);
|
|
837
896
|
writeJSON(configPath(harness), config);
|
|
838
897
|
scheduleNextRun(harness, config.syncPolicy.intervalHours);
|
|
839
898
|
console.error(`${HARNESS_NAMES[harness]} now syncs every ${config.syncPolicy.intervalHours} hours with ${config.syncPolicy.approvalMode} approval${scheduleEnabled ? "" : "; scheduling is off"}.`);
|
|
899
|
+
console.error(`Input folder: ${inputFolder}.`);
|
|
900
|
+
if (conflicts.length) console.error(`Existing Shifu shortcut(s) were left unchanged: ${conflicts.join(", ")}.`);
|
|
840
901
|
}
|
|
841
902
|
|
|
842
903
|
function textIsSafe(value, maximum) {
|
|
@@ -929,6 +990,89 @@ async function postSync(config, input) {
|
|
|
929
990
|
throw lastError;
|
|
930
991
|
}
|
|
931
992
|
|
|
993
|
+
function workFiles(folder) {
|
|
994
|
+
return fs.readdirSync(validateInputFolder(folder), { withFileTypes: true })
|
|
995
|
+
.filter((entry) => entry.isFile() && entry.name !== INPUT_FOLDER_README_NAME && WORK_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
|
|
996
|
+
.map((entry) => path.join(folder, entry.name))
|
|
997
|
+
.sort();
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function readWorkFile(file) {
|
|
1001
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
1002
|
+
try {
|
|
1003
|
+
const before = fs.fstatSync(descriptor);
|
|
1004
|
+
if (!before.isFile() || before.size > MAX_WORK_FILE_BYTES) throw new Error("Only regular files up to 5 MB are supported.");
|
|
1005
|
+
const bytes = fs.readFileSync(descriptor);
|
|
1006
|
+
const after = fs.fstatSync(descriptor);
|
|
1007
|
+
if (bytes.length > MAX_WORK_FILE_BYTES || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
1008
|
+
throw new Error("The file changed while being read; it will be retried later.");
|
|
1009
|
+
}
|
|
1010
|
+
return { bytes, modifiedAt: after.mtime.toISOString(), sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
1011
|
+
} finally {
|
|
1012
|
+
fs.closeSync(descriptor);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function postWorkFile(config, file, contents) {
|
|
1017
|
+
let lastError;
|
|
1018
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
|
|
1019
|
+
let response;
|
|
1020
|
+
try {
|
|
1021
|
+
const form = new FormData();
|
|
1022
|
+
form.append("file", new Blob([contents.bytes]), path.basename(file));
|
|
1023
|
+
form.append("sourceModifiedAt", contents.modifiedAt);
|
|
1024
|
+
response = await fetch(`${config.apiUrl}/v1/connectors/work-files`, {
|
|
1025
|
+
method: "POST",
|
|
1026
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
1027
|
+
body: form,
|
|
1028
|
+
signal: AbortSignal.timeout(15 * 60_000),
|
|
1029
|
+
});
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
lastError = new Error(`Could not send ${path.basename(file)} to Shifu: ${error?.cause?.code || error?.message || "unknown transport error"}.`);
|
|
1032
|
+
if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(undefined, attempt));
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const payload = await response.json().catch(() => undefined);
|
|
1036
|
+
if (response.ok) {
|
|
1037
|
+
const receipt = payload?.data;
|
|
1038
|
+
if (["processed", "no_claims", "duplicate"].includes(receipt?.status) && Number.isInteger(receipt?.claimsCreated) && receipt.claimsCreated >= 0) return receipt;
|
|
1039
|
+
throw new Error(`Shifu returned an invalid receipt for ${path.basename(file)}. Local state was not advanced.`);
|
|
1040
|
+
}
|
|
1041
|
+
lastError = new Error(payload?.error?.message || `Shifu rejected ${path.basename(file)} (${response.status}).`);
|
|
1042
|
+
if (response.status !== 429 && response.status < 500) throw lastError;
|
|
1043
|
+
if (attempt + 1 < MAX_RETRIES) await sleep(retryDelay(response, attempt));
|
|
1044
|
+
}
|
|
1045
|
+
throw lastError;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function syncWorkFiles(harness, config = readConfig(harness)) {
|
|
1049
|
+
if (typeof config.inputFolder !== "string") throw new Error("No input folder is configured. Run connect again to enable file sync.");
|
|
1050
|
+
const folder = validateInputFolder(configuredInputFolder(config));
|
|
1051
|
+
const state = readState(harness);
|
|
1052
|
+
const prior = state.workFiles?.folder === folder && state.workFiles.version === WORK_FILE_SYNC_VERSION ? state.workFiles.files || {} : {};
|
|
1053
|
+
let synced = 0;
|
|
1054
|
+
let failures = 0;
|
|
1055
|
+
for (const file of workFiles(folder)) {
|
|
1056
|
+
const name = path.basename(file);
|
|
1057
|
+
try {
|
|
1058
|
+
const contents = readWorkFile(file);
|
|
1059
|
+
if (prior[name] === contents.sha256) continue;
|
|
1060
|
+
const receipt = await postWorkFile(config, file, contents);
|
|
1061
|
+
const latest = readState(harness);
|
|
1062
|
+
const files = latest.workFiles?.folder === folder && latest.workFiles.version === WORK_FILE_SYNC_VERSION ? latest.workFiles.files || {} : {};
|
|
1063
|
+
latest.workFiles = { folder, version: WORK_FILE_SYNC_VERSION, files: { ...files, [name]: contents.sha256 } };
|
|
1064
|
+
saveState(harness, latest);
|
|
1065
|
+
prior[name] = contents.sha256;
|
|
1066
|
+
synced += 1;
|
|
1067
|
+
console.error(`${name}: ${receipt.status} (${receipt.claimsCreated} claim${receipt.claimsCreated === 1 ? "" : "s"}).`);
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
failures += 1;
|
|
1070
|
+
console.error(`${name} remains pending: ${error.message}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return { synced, failures };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
932
1076
|
async function sendSync(harness, input, config = readConfig(harness)) {
|
|
933
1077
|
validateSync(input, harness);
|
|
934
1078
|
const state = readState(harness);
|
|
@@ -949,6 +1093,7 @@ async function withSyncLock(harness, action) {
|
|
|
949
1093
|
fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
|
|
950
1094
|
const file = lockPath(harness);
|
|
951
1095
|
let descriptor;
|
|
1096
|
+
let heartbeat;
|
|
952
1097
|
try {
|
|
953
1098
|
try {
|
|
954
1099
|
descriptor = fs.openSync(file, "wx", 0o600);
|
|
@@ -960,8 +1105,13 @@ async function withSyncLock(harness, action) {
|
|
|
960
1105
|
descriptor = fs.openSync(file, "wx", 0o600);
|
|
961
1106
|
}
|
|
962
1107
|
fs.writeFileSync(descriptor, `${process.pid}\n`);
|
|
1108
|
+
heartbeat = setInterval(() => {
|
|
1109
|
+
try { fs.futimesSync(descriptor, new Date(), new Date()); } catch {}
|
|
1110
|
+
}, 60_000);
|
|
1111
|
+
heartbeat.unref();
|
|
963
1112
|
return await action();
|
|
964
1113
|
} finally {
|
|
1114
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
965
1115
|
if (descriptor !== undefined) {
|
|
966
1116
|
fs.closeSync(descriptor);
|
|
967
1117
|
try { fs.unlinkSync(file); } catch {}
|
|
@@ -1073,6 +1223,18 @@ async function scheduledSync(harness) {
|
|
|
1073
1223
|
const startedAt = Date.now();
|
|
1074
1224
|
let failures = 0;
|
|
1075
1225
|
let synced = 0;
|
|
1226
|
+
let syncedFiles = 0;
|
|
1227
|
+
let failedFiles = 0;
|
|
1228
|
+
if (config.inputFolder) {
|
|
1229
|
+
try {
|
|
1230
|
+
const files = await syncWorkFiles(harness, config);
|
|
1231
|
+
failedFiles = files.failures;
|
|
1232
|
+
syncedFiles = files.synced;
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
failedFiles = 1;
|
|
1235
|
+
console.error(`Shifu input folder remains pending: ${error.message}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1076
1238
|
if (policy.approvalMode === "manual") {
|
|
1077
1239
|
const queue = rebuildPendingQueue(harness);
|
|
1078
1240
|
const pendingFingerprint = createHash("sha256").update(queue.segments.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
|
|
@@ -1093,12 +1255,14 @@ async function scheduledSync(harness) {
|
|
|
1093
1255
|
state.schedule = {
|
|
1094
1256
|
...state.schedule,
|
|
1095
1257
|
lastAttemptAt: startedAt,
|
|
1096
|
-
lastResult: notificationError ? "awaiting_approval_notification_failed" : "awaiting_approval",
|
|
1258
|
+
lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles ? "partial_failure" : "awaiting_approval",
|
|
1259
|
+
syncedFiles,
|
|
1260
|
+
failedFiles,
|
|
1097
1261
|
pendingSegments: queue.segments.length,
|
|
1098
1262
|
pendingFingerprint,
|
|
1099
1263
|
approvalSessionRef,
|
|
1100
1264
|
notificationError,
|
|
1101
|
-
nextRunAt: startedAt + (notificationError ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1265
|
+
nextRunAt: startedAt + (notificationError || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1102
1266
|
};
|
|
1103
1267
|
} else {
|
|
1104
1268
|
const blockedSessions = new Set();
|
|
@@ -1115,10 +1279,12 @@ async function scheduledSync(harness) {
|
|
|
1115
1279
|
}
|
|
1116
1280
|
state.schedule = {
|
|
1117
1281
|
lastAttemptAt: startedAt,
|
|
1118
|
-
lastResult: failures ? "partial_failure" : "complete",
|
|
1282
|
+
lastResult: failures || failedFiles ? "partial_failure" : "complete",
|
|
1119
1283
|
syncedSegments: synced,
|
|
1120
1284
|
failedSessions: failures,
|
|
1121
|
-
|
|
1285
|
+
syncedFiles,
|
|
1286
|
+
failedFiles,
|
|
1287
|
+
nextRunAt: startedAt + (failures || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1122
1288
|
};
|
|
1123
1289
|
}
|
|
1124
1290
|
const latest = readState(harness);
|
|
@@ -1132,7 +1298,14 @@ function status(harness) {
|
|
|
1132
1298
|
const config = readConfig(harness);
|
|
1133
1299
|
const state = readState(harness);
|
|
1134
1300
|
const pending = readJSON(pendingPath(harness), { segments: [] });
|
|
1135
|
-
console.log(JSON.stringify({ harness, apiUrl: config.apiUrl, syncPolicy: normalizedPolicy(config), schedule: state.schedule || null, pendingSegments: pending.segments.length, sessions: state.sessions }, null, 2));
|
|
1301
|
+
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));
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function syncFilesNow(harness) {
|
|
1305
|
+
const result = await withSyncLock(harness, () => syncWorkFiles(harness));
|
|
1306
|
+
if (result?.skipped) throw new Error("Another Shifu sync is already running. Files remain pending.");
|
|
1307
|
+
console.error(`Checked the input folder: ${result.synced} sent, ${result.failures} pending.`);
|
|
1308
|
+
if (result.failures) throw new Error("Some input files remain pending. Retry sync-files after resolving the errors above.");
|
|
1136
1309
|
}
|
|
1137
1310
|
|
|
1138
1311
|
function sessions(harness) {
|
|
@@ -1192,13 +1365,14 @@ async function main() {
|
|
|
1192
1365
|
if (command === "connect") return connect(harness);
|
|
1193
1366
|
if (command === "config" && subCommand === "update") return updateConfig(harness);
|
|
1194
1367
|
if (command === "sync") return sync(harness);
|
|
1368
|
+
if (command === "sync-files") return syncFilesNow(harness);
|
|
1195
1369
|
if (command === "scheduled-sync") return scheduledSync(harness);
|
|
1196
1370
|
if (command === "pending") return showPending(harness);
|
|
1197
1371
|
if (command === "approve") return approvePending(harness);
|
|
1198
1372
|
if (command === "status") return status(harness);
|
|
1199
1373
|
if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
|
|
1200
1374
|
if (command === "review") return review(harness);
|
|
1201
|
-
throw new Error("Use install, connect, config update, status, sessions, review, sync, pending, approve, or scheduled-sync.");
|
|
1375
|
+
throw new Error("Use install, connect, config update, status, sessions, review, sync, sync-files, pending, approve, or scheduled-sync.");
|
|
1202
1376
|
}
|
|
1203
1377
|
|
|
1204
1378
|
if (require.main === module) {
|
|
@@ -1210,4 +1384,4 @@ if (require.main === module) {
|
|
|
1210
1384
|
});
|
|
1211
1385
|
}
|
|
1212
1386
|
|
|
1213
|
-
module.exports = { approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, schedulerArtifact, statePath, textIsSafe, unsyncedCodexSessions, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync };
|
|
1387
|
+
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
|
@@ -4,6 +4,8 @@ description: Inspect or sync redacted OpenCode activity using the saved Shifu po
|
|
|
4
4
|
|
|
5
5
|
Run `npx @useshifu/coding-harness status --harness opencode` first.
|
|
6
6
|
|
|
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
|
+
|
|
7
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`.
|
|
8
10
|
|
|
9
11
|
For an explicit sync-now request:
|
|
@@ -14,4 +16,4 @@ For an explicit sync-now request:
|
|
|
14
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`.
|
|
15
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.
|
|
16
18
|
|
|
17
|
-
To connect or finish onboarding, run `connect --harness opencode`. To change the interval
|
|
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,13 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: shifu-sync
|
|
3
|
-
description: Connect, configure, inspect, or sync
|
|
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
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
|
-
Never
|
|
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.
|
|
11
|
+
|
|
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.
|
|
11
13
|
|
|
12
14
|
## Connect or reconfigure
|
|
13
15
|
|
|
@@ -17,20 +19,22 @@ When the user asks to connect, run:
|
|
|
17
19
|
npx @useshifu/coding-harness connect --harness codex
|
|
18
20
|
```
|
|
19
21
|
|
|
20
|
-
`connect` installs the current runner
|
|
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.
|
|
21
23
|
|
|
22
|
-
When the user asks to change the interval
|
|
24
|
+
When the user asks to change the interval, approval mode, or input folder, run:
|
|
23
25
|
|
|
24
26
|
```sh
|
|
25
|
-
npx @useshifu/coding-harness config update --harness codex
|
|
27
|
+
npx @useshifu/coding-harness config update --harness codex --input-folder <existing-readable-folder>
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
Do not add a second confirmation around either command.
|
|
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.
|
|
29
31
|
|
|
30
32
|
## Inspect status or queued work
|
|
31
33
|
|
|
32
34
|
Run `npx @useshifu/coding-harness status --harness codex` to inspect the saved policy, next scheduled run, pending count, and checkpoints.
|
|
33
35
|
|
|
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.
|
|
37
|
+
|
|
34
38
|
In manual mode, scheduled runs accumulate redacted segments locally. Run:
|
|
35
39
|
|
|
36
40
|
```sh
|