@useshifu/coding-harness 0.3.0 → 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 +16 -6
- package/bin/shifu-harness.js +202 -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,23 @@ 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_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
|
+
`;
|
|
43
55
|
|
|
44
56
|
function configRoot() {
|
|
45
57
|
return process.env.SHIFU_CONFIG_ROOT || path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
|
|
@@ -204,7 +216,7 @@ function normalizedPolicy(config) {
|
|
|
204
216
|
(approvalMode === "automatic" || approvalMode === "manual");
|
|
205
217
|
return {
|
|
206
218
|
onboardingComplete: configured,
|
|
207
|
-
intervalHours: configured ? interval :
|
|
219
|
+
intervalHours: configured ? interval : LEGACY_INTERVAL_HOURS,
|
|
208
220
|
approvalMode: configured ? approvalMode : "manual",
|
|
209
221
|
scheduleEnabled: configured ? config.syncPolicy.scheduleEnabled !== false : false,
|
|
210
222
|
excludedSessionRefs: Array.isArray(config?.syncPolicy?.excludedSessionRefs) ? config.syncPolicy.excludedSessionRefs.filter(sessionRefIsSafe) : [],
|
|
@@ -252,6 +264,59 @@ function readState(harness) {
|
|
|
252
264
|
return readJSON(statePath(harness), { sessions: {} });
|
|
253
265
|
}
|
|
254
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
|
+
|
|
255
320
|
function saveState(harness, state) {
|
|
256
321
|
writeJSON(statePath(harness), state);
|
|
257
322
|
}
|
|
@@ -595,17 +660,6 @@ async function confirm(question) {
|
|
|
595
660
|
}
|
|
596
661
|
}
|
|
597
662
|
|
|
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
663
|
async function readSecret(input = process.stdin) {
|
|
610
664
|
const ownsInput = !input.isTTY;
|
|
611
665
|
if (ownsInput) input = new tty.ReadStream(fs.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"));
|
|
@@ -799,13 +853,10 @@ function scheduleNextRun(harness, intervalHours) {
|
|
|
799
853
|
async function chooseSyncPolicy(existing) {
|
|
800
854
|
const intervalOption = option("--interval-hours");
|
|
801
855
|
const approvalOption = option("--approval-mode");
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
approvalMode = answer === "auto" ? "automatic" : answer;
|
|
807
|
-
}
|
|
808
|
-
return { intervalHours, approvalMode: validateApprovalMode(approvalMode) };
|
|
856
|
+
return {
|
|
857
|
+
intervalHours: validateIntervalHours(intervalOption ?? existing.intervalHours ?? DEFAULT_INTERVAL_HOURS),
|
|
858
|
+
approvalMode: validateApprovalMode(approvalOption ?? existing.approvalMode ?? "automatic"),
|
|
859
|
+
};
|
|
809
860
|
}
|
|
810
861
|
|
|
811
862
|
async function connect(harness) {
|
|
@@ -817,12 +868,16 @@ async function connect(harness) {
|
|
|
817
868
|
const currentPolicy = savedPolicy?.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic", excludedSessionRefs: savedPolicy?.excludedSessionRefs || [] };
|
|
818
869
|
const selected = await chooseSyncPolicy(currentPolicy);
|
|
819
870
|
const syncPolicy = configuredSyncPolicy(existing || {}, harness, selected.intervalHours, selected.approvalMode);
|
|
871
|
+
const inputFolder = configuredInputFolder(existing);
|
|
872
|
+
const conflicts = prepareInputFolder(inputFolder, inputFolder === defaultInputFolder());
|
|
820
873
|
install(harness, true);
|
|
821
874
|
const scheduleFile = installSchedule(harness);
|
|
822
|
-
writeJSON(configPath(harness), { ...existing, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy });
|
|
875
|
+
writeJSON(configPath(harness), { ...existing, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder });
|
|
823
876
|
scheduleNextRun(harness, syncPolicy.intervalHours);
|
|
824
877
|
console.error(`${HARNESS_NAMES[harness]} is connected. It will sync every ${syncPolicy.intervalHours} hours with ${syncPolicy.approvalMode} approval.`);
|
|
825
|
-
console.error(`
|
|
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.`);
|
|
826
881
|
}
|
|
827
882
|
|
|
828
883
|
async function updateConfig(harness) {
|
|
@@ -830,13 +885,18 @@ async function updateConfig(harness) {
|
|
|
830
885
|
const savedPolicy = normalizedPolicy(config);
|
|
831
886
|
const selected = await chooseSyncPolicy(savedPolicy.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic" });
|
|
832
887
|
const scheduleEnabled = option("--schedule") !== "off";
|
|
888
|
+
const inputFolder = configuredInputFolder(config);
|
|
889
|
+
const conflicts = prepareInputFolder(inputFolder, inputFolder === defaultInputFolder());
|
|
833
890
|
config.version = CONFIG_VERSION;
|
|
834
891
|
config.syncPolicy = configuredSyncPolicy(config, harness, selected.intervalHours, selected.approvalMode, scheduleEnabled);
|
|
892
|
+
config.inputFolder = inputFolder;
|
|
835
893
|
install(harness, true);
|
|
836
894
|
if (scheduleEnabled) installSchedule(harness);
|
|
837
895
|
writeJSON(configPath(harness), config);
|
|
838
896
|
scheduleNextRun(harness, config.syncPolicy.intervalHours);
|
|
839
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(", ")}.`);
|
|
840
900
|
}
|
|
841
901
|
|
|
842
902
|
function textIsSafe(value, maximum) {
|
|
@@ -929,6 +989,89 @@ async function postSync(config, input) {
|
|
|
929
989
|
throw lastError;
|
|
930
990
|
}
|
|
931
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
|
+
|
|
932
1075
|
async function sendSync(harness, input, config = readConfig(harness)) {
|
|
933
1076
|
validateSync(input, harness);
|
|
934
1077
|
const state = readState(harness);
|
|
@@ -949,6 +1092,7 @@ async function withSyncLock(harness, action) {
|
|
|
949
1092
|
fs.mkdirSync(configRoot(), { recursive: true, mode: 0o700 });
|
|
950
1093
|
const file = lockPath(harness);
|
|
951
1094
|
let descriptor;
|
|
1095
|
+
let heartbeat;
|
|
952
1096
|
try {
|
|
953
1097
|
try {
|
|
954
1098
|
descriptor = fs.openSync(file, "wx", 0o600);
|
|
@@ -960,8 +1104,13 @@ async function withSyncLock(harness, action) {
|
|
|
960
1104
|
descriptor = fs.openSync(file, "wx", 0o600);
|
|
961
1105
|
}
|
|
962
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();
|
|
963
1111
|
return await action();
|
|
964
1112
|
} finally {
|
|
1113
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
965
1114
|
if (descriptor !== undefined) {
|
|
966
1115
|
fs.closeSync(descriptor);
|
|
967
1116
|
try { fs.unlinkSync(file); } catch {}
|
|
@@ -1073,6 +1222,18 @@ async function scheduledSync(harness) {
|
|
|
1073
1222
|
const startedAt = Date.now();
|
|
1074
1223
|
let failures = 0;
|
|
1075
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
|
+
}
|
|
1076
1237
|
if (policy.approvalMode === "manual") {
|
|
1077
1238
|
const queue = rebuildPendingQueue(harness);
|
|
1078
1239
|
const pendingFingerprint = createHash("sha256").update(queue.segments.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
|
|
@@ -1093,12 +1254,14 @@ async function scheduledSync(harness) {
|
|
|
1093
1254
|
state.schedule = {
|
|
1094
1255
|
...state.schedule,
|
|
1095
1256
|
lastAttemptAt: startedAt,
|
|
1096
|
-
lastResult: notificationError ? "awaiting_approval_notification_failed" : "awaiting_approval",
|
|
1257
|
+
lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles ? "partial_failure" : "awaiting_approval",
|
|
1258
|
+
syncedFiles,
|
|
1259
|
+
failedFiles,
|
|
1097
1260
|
pendingSegments: queue.segments.length,
|
|
1098
1261
|
pendingFingerprint,
|
|
1099
1262
|
approvalSessionRef,
|
|
1100
1263
|
notificationError,
|
|
1101
|
-
nextRunAt: startedAt + (notificationError ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1264
|
+
nextRunAt: startedAt + (notificationError || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1102
1265
|
};
|
|
1103
1266
|
} else {
|
|
1104
1267
|
const blockedSessions = new Set();
|
|
@@ -1115,10 +1278,12 @@ async function scheduledSync(harness) {
|
|
|
1115
1278
|
}
|
|
1116
1279
|
state.schedule = {
|
|
1117
1280
|
lastAttemptAt: startedAt,
|
|
1118
|
-
lastResult: failures ? "partial_failure" : "complete",
|
|
1281
|
+
lastResult: failures || failedFiles ? "partial_failure" : "complete",
|
|
1119
1282
|
syncedSegments: synced,
|
|
1120
1283
|
failedSessions: failures,
|
|
1121
|
-
|
|
1284
|
+
syncedFiles,
|
|
1285
|
+
failedFiles,
|
|
1286
|
+
nextRunAt: startedAt + (failures || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1122
1287
|
};
|
|
1123
1288
|
}
|
|
1124
1289
|
const latest = readState(harness);
|
|
@@ -1132,7 +1297,14 @@ function status(harness) {
|
|
|
1132
1297
|
const config = readConfig(harness);
|
|
1133
1298
|
const state = readState(harness);
|
|
1134
1299
|
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));
|
|
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.");
|
|
1136
1308
|
}
|
|
1137
1309
|
|
|
1138
1310
|
function sessions(harness) {
|
|
@@ -1192,13 +1364,14 @@ async function main() {
|
|
|
1192
1364
|
if (command === "connect") return connect(harness);
|
|
1193
1365
|
if (command === "config" && subCommand === "update") return updateConfig(harness);
|
|
1194
1366
|
if (command === "sync") return sync(harness);
|
|
1367
|
+
if (command === "sync-files") return syncFilesNow(harness);
|
|
1195
1368
|
if (command === "scheduled-sync") return scheduledSync(harness);
|
|
1196
1369
|
if (command === "pending") return showPending(harness);
|
|
1197
1370
|
if (command === "approve") return approvePending(harness);
|
|
1198
1371
|
if (command === "status") return status(harness);
|
|
1199
1372
|
if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
|
|
1200
1373
|
if (command === "review") return review(harness);
|
|
1201
|
-
throw new Error("Use install, connect, config update, status, sessions, review, sync, pending, approve, or scheduled-sync.");
|
|
1374
|
+
throw new Error("Use install, connect, config update, status, sessions, review, sync, sync-files, pending, approve, or scheduled-sync.");
|
|
1202
1375
|
}
|
|
1203
1376
|
|
|
1204
1377
|
if (require.main === module) {
|
|
@@ -1210,4 +1383,4 @@ if (require.main === module) {
|
|
|
1210
1383
|
});
|
|
1211
1384
|
}
|
|
1212
1385
|
|
|
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 };
|
|
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
|
@@ -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
|