@useshifu/coding-harness 0.3.4 → 0.3.6
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 +21 -11
- package/bin/shifu-harness.js +324 -38
- package/commands/shifu-sync.md +3 -3
- package/package.json +1 -1
- package/skills/shifu-sync/SKILL.md +19 -13
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
## Connect
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
npx @useshifu/coding-harness connect --harness <codex|claude_code|opencode>
|
|
8
|
+
npx --yes @useshifu/coding-harness connect --harness <codex|claude_code|opencode>
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Connect installs the local instruction and scheduled runner, saves the connection key with owner-only permissions, and uses these defaults without setup questions:
|
|
@@ -20,23 +20,25 @@ For named meeting transcripts, set your display name in Shifu Profile so your sp
|
|
|
20
20
|
|
|
21
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
22
|
|
|
23
|
-
No content is sent during setup.
|
|
23
|
+
No content is sent during setup. Every `connect` asks for a connection key and verifies it with Shifu for the chosen coding tool before saving it or reporting success. An invalid key or unreachable server leaves the existing connection unchanged. Replacing a key resets local checkpoints so existing work can be reconsidered for the new connection; the selected sync policy remains. If a coding tool cannot run `npx` or accept hidden input in its tool terminal, run the same command in your own interactive terminal and check the confirmation output. Change the choices later with:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
npx @useshifu/coding-harness config update --harness <codex|claude_code|opencode> --interval-hours 1 --approval-mode automatic --input-folder ~/Desktop/Shifu
|
|
26
|
+
npx --yes @useshifu/coding-harness config update --harness <codex|claude_code|opencode> --interval-hours 1 --approval-mode automatic --input-folder ~/Desktop/Shifu
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Older configuration files remain on-demand and manual until either command completes onboarding; upgrading the package alone never enables background upload.
|
|
30
30
|
|
|
31
|
+
To disconnect this device, run `npx --yes @useshifu/coding-harness disconnect --harness <codex|claude_code|opencode>`. This removes the local key, schedule, and checkpoints. It does not revoke the key on Shifu; revoke the old key there if it should no longer work.
|
|
32
|
+
|
|
31
33
|
## Operate
|
|
32
34
|
|
|
33
35
|
```sh
|
|
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
|
|
36
|
+
npx --yes @useshifu/coding-harness status --harness codex
|
|
37
|
+
npx --yes @useshifu/coding-harness sessions --harness codex --hours 24
|
|
38
|
+
npx --yes @useshifu/coding-harness review --harness codex --session-ref opaque-session-id
|
|
39
|
+
npx --yes @useshifu/coding-harness pending --harness codex
|
|
40
|
+
npx --yes @useshifu/coding-harness approve --harness codex
|
|
41
|
+
npx --yes @useshifu/coding-harness sync-files --harness codex
|
|
40
42
|
```
|
|
41
43
|
|
|
42
44
|
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.
|
|
@@ -45,12 +47,20 @@ The same scheduled run checks the input folder. `sync-files` checks it immediate
|
|
|
45
47
|
|
|
46
48
|
The file-job result reports published claims separately from needs-evidence suggestions. A `no_claims` result can still contain suggestions: these are possible contributions awaiting evidence or attribution, not verified claims. The runner reports the suggestion count but never labels those suggestions as established work. It does not print the file's text, candidate statements, or source excerpts to logs. Use Shifu to inspect the suggestions and their evidence status. A result with zero claims and zero suggestions means no user-attributable contribution was extracted; it does not mean the upload failed.
|
|
47
49
|
|
|
48
|
-
The connector captures
|
|
50
|
+
The connector captures supported harness work in engineering, product discovery, writing, interview feedback, research, and planning. Automatic sync turns completed actions and known work topics into separate controlled statements without sending source prose. If a segment has no supported work items, it advances the checkpoint without creating a claim. Version 1–3 clients remain supported.
|
|
49
51
|
|
|
50
52
|
## Sync a prepared payload
|
|
51
53
|
|
|
52
54
|
```sh
|
|
53
|
-
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
|
|
55
|
+
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 --yes @useshifu/coding-harness sync --harness codex
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
Automatic mode sends immediately. Manual mode asks once unless the calling harness already obtained approval and passes `--approved`.
|
|
59
|
+
|
|
60
|
+
## AI Profile observations
|
|
61
|
+
|
|
62
|
+
AI Profile observations are created automatically with each coding-session activity sync, under the same sync policy. The local runner reads user turns to detect a small set of behavioral signals, then sends only fixed, redacted statements and turn numbers. It never sends prompts, transcripts, tool output, code, file paths, names, or secrets for AI Profile. Model and thinking labels are included only when recognized in local harness metadata. If an observation fails, its redacted payload remains in a local retry queue while activity sync continues; an already accepted observation is kept.
|
|
63
|
+
|
|
64
|
+
The category is one of `system_design`, `detailed_design`, `implementation`, `debugging_testing`, `production_support`, `research_analysis`, `product_planning`, `writing_communication`, `marketing_growth`, `learning`, or `other`. Evidence dimensions are `task_selection`, `goal_clarity`, `context_sharing`, `constraint_setting`, `work_decomposition`, `iterative_steering`, `critical_judgment`, `verification`, `application`, and `responsible_use`. Automatic observations cite the relevant turn and never infer a limiting behavior from silence. Unsupported dimensions remain unscored.
|
|
65
|
+
|
|
66
|
+
The AI Profile page shows detected categories, model and thinking labels, and Jev scores supported by these limited observations. Local signals do not prove work quality or establish that another model setting would have performed better. Identical retries are safe.
|
package/bin/shifu-harness.js
CHANGED
|
@@ -25,6 +25,10 @@ const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
|
|
|
25
25
|
const EVIDENCE_KINDS = new Set(["implementation", "decision", "activity"]);
|
|
26
26
|
const EVIDENCE_FIELDS = new Set(["kind", "title", "statement", "scope", "area", "itemRef", "verificationRefs"]);
|
|
27
27
|
const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion"]);
|
|
28
|
+
const AI_PROFILE_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "category", "model", "thinking", "evidence"]);
|
|
29
|
+
const AI_PROFILE_EVIDENCE_FIELDS = new Set(["dimension", "statement", "signal", "turn"]);
|
|
30
|
+
const AI_PROFILE_DIMENSIONS = new Set(["task_selection", "goal_clarity", "context_sharing", "constraint_setting", "work_decomposition", "iterative_steering", "critical_judgment", "verification", "application", "responsible_use"]);
|
|
31
|
+
const AI_PROFILE_CATEGORIES = new Set(["system_design", "detailed_design", "implementation", "debugging_testing", "production_support", "research_analysis", "product_planning", "writing_communication", "marketing_growth", "learning", "other"]);
|
|
28
32
|
const SCOPE_LEVELS = new Set(["unit", "module", "service", "system", "product"]);
|
|
29
33
|
const WORK_AREAS = new Set(["architecture", "api", "backend", "connector", "database", "documentation", "frontend", "performance", "security", "testing", "tooling"]);
|
|
30
34
|
const ITEM_REF_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
|
|
@@ -509,12 +513,17 @@ function opencodeReview(sessionRef, fromTurn, toTurn) {
|
|
|
509
513
|
|
|
510
514
|
function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
|
|
511
515
|
if (harness === "opencode") return opencodeReview(sessionRef, fromTurn, toTurn);
|
|
516
|
+
const selectedFile = sessionFile(harness, sessionRef);
|
|
517
|
+
return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function sessionFile(harness, sessionRef) {
|
|
512
521
|
const files = harness === "codex" ? sessionFiles(codexSessionsRoot()) : sessionFiles(claudeSessionsRoot());
|
|
513
522
|
const selectedFile = files.map((file) => ({ file, session: harness === "codex" ? codexSession(file) : claudeSession(file) }))
|
|
514
523
|
.filter(({ session }) => session?.sessionRef === sessionRef)
|
|
515
524
|
.sort((left, right) => right.session.turnCount - left.session.turnCount || right.session.updatedAt.localeCompare(left.session.updatedAt))[0]?.file;
|
|
516
525
|
if (!selectedFile) throw new Error(`No local ${HARNESS_NAMES[harness]} session matches ${sessionRef}.`);
|
|
517
|
-
return
|
|
526
|
+
return selectedFile;
|
|
518
527
|
}
|
|
519
528
|
|
|
520
529
|
function reviewCandidates(notes) {
|
|
@@ -543,41 +552,77 @@ function conciseTitle(statement, fallback) {
|
|
|
543
552
|
return `${value.slice(0, MAX_TITLE_LENGTH - 1).trimEnd()}…`;
|
|
544
553
|
}
|
|
545
554
|
|
|
546
|
-
const
|
|
547
|
-
{ key: "
|
|
548
|
-
{ key: "
|
|
549
|
-
{ key: "
|
|
550
|
-
{ key: "
|
|
551
|
-
{ key: "
|
|
552
|
-
{ key: "
|
|
553
|
-
{ key: "
|
|
554
|
-
{ key: "
|
|
555
|
+
const WORK_ACTIONS = [
|
|
556
|
+
{ key: "implemented", title: "Implemented", pattern: /^(?:I\s+|We\s+)?(?:implemented|added|built|shipped)\b/i },
|
|
557
|
+
{ key: "fixed", title: "Fixed", pattern: /^(?:I\s+|We\s+)?(?:fixed|resolved|corrected)\b/i },
|
|
558
|
+
{ key: "improved", title: "Improved", pattern: /^(?:I\s+|We\s+)?(?:improved|optimized|updated)\b/i },
|
|
559
|
+
{ key: "refactored", title: "Refactored", pattern: /^(?:I\s+|We\s+)?refactored\b/i },
|
|
560
|
+
{ key: "designed", title: "Designed", pattern: /^(?:I\s+|We\s+)?designed\b/i },
|
|
561
|
+
{ key: "prepared", title: "Prepared", pattern: /^(?:I\s+|We\s+)?(?:prepared|drafted|wrote)\b/i },
|
|
562
|
+
{ key: "reviewed", title: "Reviewed", pattern: /^(?:I\s+|We\s+)?(?:reviewed|audited)\b/i },
|
|
563
|
+
{ key: "investigated", title: "Investigated", pattern: /^(?:I\s+|We\s+)?(?:investigated|researched|evaluated|compared)\b/i },
|
|
564
|
+
{ key: "deployed", title: "Deployed", pattern: /^(?:I\s+|We\s+)?(?:deployed|published)\b/i },
|
|
555
565
|
];
|
|
556
566
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
567
|
+
const WORK_OBJECTS = [
|
|
568
|
+
{ key: "interview_feedback", title: "interview feedback", pattern: /\b(?:interview feedback|candidate feedback|hiring feedback)\b/i },
|
|
569
|
+
{ key: "professional_post", title: "professional writing", pattern: /\b(?:linkedin|social post|professional post|newsletter|article)\b/i },
|
|
570
|
+
{ key: "home_review", title: "Home review", pattern: /\b(?:home review|work review|review on the home page)\b/i },
|
|
571
|
+
{ key: "connection_key", title: "connection key validation", pattern: /\b(?:connection key|connector key|key validation)\b/i },
|
|
572
|
+
{ key: "claim_extraction", title: "claim extraction", pattern: /\b(?:claim extraction|claim generation|claim matching)\b/i },
|
|
573
|
+
{ key: "retry", title: "retry handling", pattern: /\b(?:retries|retry)\b/i },
|
|
574
|
+
{ key: "cache", title: "cache behavior", pattern: /\b(?:cache|caching|ttl)\b/i },
|
|
575
|
+
{ key: "navigation", title: "navigation", pattern: /\b(?:navigation|sidebar|sub.?nav)\b/i },
|
|
576
|
+
{ key: "sync", title: "sync behavior", pattern: /\b(?:sync|synchronization|checkpoint)\b/i },
|
|
577
|
+
{ key: "auth", title: "authentication", pattern: /\b(?:authentication|sign.?in|login)\b/i },
|
|
578
|
+
{ key: "permissions", title: "access controls", pattern: /\b(?:authorization|permissions?|access control)\b/i },
|
|
579
|
+
{ key: "database", title: "database migration", pattern: /\b(?:database migration|schema migration|postgres migration)\b/i },
|
|
580
|
+
{ key: "api", title: "API endpoint", pattern: /\b(?:api|endpoint|http route)\b/i },
|
|
581
|
+
{ key: "frontend", title: "frontend component", pattern: /\b(?:frontend|react component|ui component|user interface)\b/i },
|
|
582
|
+
{ key: "test", title: "test coverage", pattern: /\b(?:test|tests|test suite|coverage)\b/i },
|
|
583
|
+
{ key: "docs", title: "documentation", pattern: /\b(?:documentation|readme|guide|runbook)\b/i },
|
|
584
|
+
{ key: "deploy", title: "deployment pipeline", pattern: /\b(?:deployment|deploy pipeline|ci pipeline|release pipeline)\b/i },
|
|
585
|
+
{ key: "performance", title: "performance", pattern: /\b(?:performance|latency|throughput)\b/i },
|
|
586
|
+
{ key: "security", title: "security controls", pattern: /\b(?:security|vulnerability|secret handling)\b/i },
|
|
587
|
+
{ key: "research", title: "product research", pattern: /\b(?:product discovery|user research|customer research)\b/i },
|
|
588
|
+
{ key: "planning", title: "project plan", pattern: /\b(?:roadmap|project plan|delivery plan)\b/i },
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
function safeActivityEvidence(notes) {
|
|
592
|
+
const evidence = [];
|
|
593
|
+
const seen = new Set();
|
|
594
|
+
for (const [noteIndex, { turn, note }] of notes.entries()) {
|
|
595
|
+
let inCodeBlock = false;
|
|
596
|
+
for (const [lineIndex, rawLine] of note.split("\n").entries()) {
|
|
597
|
+
if (rawLine.trim().startsWith("```")) { inCodeBlock = !inCodeBlock; continue; }
|
|
598
|
+
if (inCodeBlock) continue;
|
|
599
|
+
const line = rawLine.trim().replace(/^[-*]\s+/, "").split(/[.;—]/, 1)[0];
|
|
600
|
+
const action = WORK_ACTIONS.find((item) => item.pattern.test(line));
|
|
601
|
+
const object = WORK_OBJECTS.find((item) => item.pattern.test(line));
|
|
602
|
+
if (!action || !object) continue;
|
|
603
|
+
const key = `${action.key}:${object.key}`;
|
|
604
|
+
if (seen.has(key)) continue;
|
|
605
|
+
seen.add(key);
|
|
606
|
+
const title = `${action.title} ${object.title}`;
|
|
607
|
+
evidence.push({ kind: "activity", title, statement: `${title}.`, itemRef: `t${turn}_n${noteIndex + 1}_l${lineIndex + 1}` });
|
|
608
|
+
if (evidence.length === MAX_EVIDENCE_ITEMS) return evidence;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return evidence;
|
|
567
612
|
}
|
|
568
613
|
|
|
569
614
|
function payloadForSegment(harness, segment) {
|
|
570
615
|
const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
|
|
571
|
-
const
|
|
572
|
-
const
|
|
573
|
-
const
|
|
574
|
-
|
|
616
|
+
const evidence = safeActivityEvidence(notes);
|
|
617
|
+
const title = evidence.length ? evidence[0].title : "No supported work identified";
|
|
618
|
+
const summary = evidence.length > 1 ? `${evidence[0].statement} ${evidence.length - 1} more supported work items.` :
|
|
619
|
+
evidence.length === 1 ? evidence[0].statement : "This range had no supported work items.";
|
|
575
620
|
return {
|
|
576
621
|
harness,
|
|
577
622
|
sessionRef: segment.sessionRef,
|
|
578
623
|
fromTurn: segment.fromTurn,
|
|
579
624
|
toTurn: segment.toTurn,
|
|
580
|
-
title: conciseTitle(title,
|
|
625
|
+
title: conciseTitle(title, "No supported work identified"),
|
|
581
626
|
summary,
|
|
582
627
|
evidence,
|
|
583
628
|
verification: [],
|
|
@@ -586,6 +631,129 @@ function payloadForSegment(harness, segment) {
|
|
|
586
631
|
};
|
|
587
632
|
}
|
|
588
633
|
|
|
634
|
+
const PROFILE_SIGNALS = [
|
|
635
|
+
{ dimension: "task_selection", pattern: /\b(?:use (?:ai|an? agent) (?:to|for)|ask (?:ai|an? agent) to|delegate .+ to (?:ai|an? agent))\b/i, statement: "User explicitly chose a task to delegate to AI." },
|
|
636
|
+
{ dimension: "goal_clarity", pattern: /\b(?:build|create|write|draft|implement|fix|design|produce)\b/i, statement: "User named a concrete deliverable or change." },
|
|
637
|
+
{ dimension: "context_sharing", pattern: /\b(?:context|background|for example|here is)\b/i, statement: "User provided background or an example for the task." },
|
|
638
|
+
{ dimension: "constraint_setting", pattern: /\b(?:must|only|without|avoid|do not|don't|should not|cannot|can't)\b/i, statement: "User stated an explicit constraint on the work." },
|
|
639
|
+
{ dimension: "work_decomposition", pattern: /(?:^|\n)\s*(?:\d+[.)]|[-*])\s|\bfirst\b[\s\S]*\bthen\b/i, statement: "User broke the request into steps or listed parts." },
|
|
640
|
+
{ dimension: "critical_judgment", pattern: /\b(?:compare|alternative|tradeoffs?|why|challenge|question the assumption)\b/i, statement: "User asked to examine a reason or alternative." },
|
|
641
|
+
{ dimension: "verification", pattern: /\b(?:test|verify|validate|check|review the diff|prove)\b/i, statement: "User requested a check or test of the result." },
|
|
642
|
+
{ dimension: "application", pattern: /\b(?:deploy|ship|publish|merge|release)\b/i, statement: "User requested that the result be put into use." },
|
|
643
|
+
{ dimension: "responsible_use", pattern: /\b(?:redact|privacy|private data|raw data|secret|credential|permission)\b/i, statement: "User called out a privacy, secret, or permission boundary." },
|
|
644
|
+
];
|
|
645
|
+
const ITERATION_PATTERN = /\b(?:instead|change|revise|adjust|fix|more|less|not what|actually)\b/i;
|
|
646
|
+
const PROFILE_CATEGORIZATION = [
|
|
647
|
+
{ category: "production_support", pattern: /\b(?:incident|outage|production support|on.call)\b/i },
|
|
648
|
+
{ category: "debugging_testing", pattern: /\b(?:debug|bug|failing test|regression|troubleshoot)\b/i },
|
|
649
|
+
{ category: "system_design", pattern: /\b(?:system design|architecture|high.level design|hld)\b/i },
|
|
650
|
+
{ category: "detailed_design", pattern: /\b(?:detailed design|low.level design|lld|api design|schema design)\b/i },
|
|
651
|
+
{ category: "marketing_growth", pattern: /\b(?:marketing|growth|campaign|conversion)\b/i },
|
|
652
|
+
{ category: "writing_communication", pattern: /\b(?:write|draft|article|post|email|feedback|documentation)\b/i },
|
|
653
|
+
{ category: "research_analysis", pattern: /\b(?:research|analy[sz]e|compare|investigate)\b/i },
|
|
654
|
+
{ category: "product_planning", pattern: /\b(?:roadmap|product plan|feature plan|prioriti[sz]e)\b/i },
|
|
655
|
+
{ category: "learning", pattern: /\b(?:learn|explain|teach|tutorial)\b/i },
|
|
656
|
+
{ category: "implementation", pattern: /\b(?:implement|build|code|refactor|frontend|backend|api|test)\b/i },
|
|
657
|
+
];
|
|
658
|
+
|
|
659
|
+
function canonicalModel(value) {
|
|
660
|
+
if (typeof value !== "string") return undefined;
|
|
661
|
+
const known = value.match(/^(gpt-(?:6-(?:astra|sol|luna)|5\.6-(?:sol|terra|luna)|5\.5)|o[134](?:-mini)?)(?:$|[-.])/i);
|
|
662
|
+
if (known) return known[1].toLowerCase();
|
|
663
|
+
const claude = value.match(/^claude-(opus|sonnet|haiku)-(\d+(?:-\d+)?)(?:$|-)/i);
|
|
664
|
+
return claude ? `claude-${claude[1].toLowerCase()}-${claude[2]}` : undefined;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function canonicalThinking(value) {
|
|
668
|
+
return ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"].includes(value) ? value : undefined;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function localProfileTurns(harness, segment) {
|
|
672
|
+
const turns = [];
|
|
673
|
+
if (harness === "opencode") {
|
|
674
|
+
const rows = opencodeQuery(`SELECT m.id AS messageId, json_extract(m.data, '$.role') AS role, json_extract(m.data, '$.modelID') AS model, p.data AS part FROM message m LEFT JOIN part p ON p.message_id = m.id WHERE m.session_id = ${JSON.stringify(segment.sessionRef)} ORDER BY m.time_created, p.id`);
|
|
675
|
+
let lastMessage;
|
|
676
|
+
let turn = 0;
|
|
677
|
+
for (const row of rows) {
|
|
678
|
+
if (row.messageId !== lastMessage) {
|
|
679
|
+
lastMessage = row.messageId;
|
|
680
|
+
if (row.role === "user") { turn += 1; turns.push({ turn, text: "" }); }
|
|
681
|
+
if (row.role === "assistant" && turn > 0) turns[turn - 1].model ||= canonicalModel(row.model);
|
|
682
|
+
}
|
|
683
|
+
if (row.role !== "user" || turn === 0 || typeof row.part !== "string") continue;
|
|
684
|
+
try { const part = JSON.parse(row.part); if (part.type === "text" && typeof part.text === "string") turns[turn - 1].text += part.text; } catch {}
|
|
685
|
+
}
|
|
686
|
+
} else if (harness === "claude_code") {
|
|
687
|
+
let turn = 0;
|
|
688
|
+
for (const entry of claudeEntries(sessionFile(harness, segment.sessionRef))) {
|
|
689
|
+
if (isClaudeUserTurn(entry)) {
|
|
690
|
+
turn += 1;
|
|
691
|
+
const content = entry.message.content;
|
|
692
|
+
turns.push({ turn, text: typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("\n") });
|
|
693
|
+
} else if (entry.type === "assistant" && turn > 0) {
|
|
694
|
+
turns[turn - 1].model ||= canonicalModel(entry.message?.model);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
let turn = 0;
|
|
699
|
+
for (const line of fs.readFileSync(sessionFile(harness, segment.sessionRef), "utf8").split("\n")) {
|
|
700
|
+
if (!line) continue;
|
|
701
|
+
const entry = JSON.parse(line);
|
|
702
|
+
if (entry.type === "response_item" && entry.payload?.type === "message" && entry.payload.role === "user") {
|
|
703
|
+
turn += 1;
|
|
704
|
+
turns.push({ turn, text: entry.payload.content?.filter((part) => part.type === "input_text").map((part) => part.text).join("\n") || "" });
|
|
705
|
+
} else if (entry.type === "turn_context" && turn > 0) {
|
|
706
|
+
turns[turn - 1].model ||= canonicalModel(entry.payload?.model);
|
|
707
|
+
turns[turn - 1].thinking ||= canonicalThinking(entry.payload?.effort);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return turns.filter(({ turn }) => turn >= segment.fromTurn && turn <= segment.toTurn);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function profileForSegment(harness, segment) {
|
|
715
|
+
const turns = localProfileTurns(harness, segment);
|
|
716
|
+
if (turns.length !== segment.toTurn - segment.fromTurn + 1) throw new Error("The AI Profile source turns were not found locally; the segment remains pending.");
|
|
717
|
+
const evidence = [];
|
|
718
|
+
const observed = new Set();
|
|
719
|
+
const categories = [];
|
|
720
|
+
for (const { turn, text } of turns) {
|
|
721
|
+
for (const signal of PROFILE_SIGNALS) {
|
|
722
|
+
if (!observed.has(signal.dimension) && signal.pattern.test(text)) {
|
|
723
|
+
evidence.push({ dimension: signal.dimension, statement: signal.statement, signal: "demonstrated", turn });
|
|
724
|
+
observed.add(signal.dimension);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (turn > 1 && !observed.has("iterative_steering") && ITERATION_PATTERN.test(text)) {
|
|
728
|
+
evidence.push({ dimension: "iterative_steering", statement: "User gave a follow-up direction to change the work.", signal: "demonstrated", turn });
|
|
729
|
+
observed.add("iterative_steering");
|
|
730
|
+
}
|
|
731
|
+
const category = PROFILE_CATEGORIZATION.find((item) => item.pattern.test(text))?.category;
|
|
732
|
+
if (category) categories.push(category);
|
|
733
|
+
}
|
|
734
|
+
const models = [...new Set(turns.map((turn) => turn.model).filter(Boolean))];
|
|
735
|
+
const thinking = [...new Set(turns.map((turn) => turn.thinking).filter(Boolean))];
|
|
736
|
+
const observation = {
|
|
737
|
+
harness, sessionRef: segment.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn,
|
|
738
|
+
category: categories[0] || "other", evidence,
|
|
739
|
+
};
|
|
740
|
+
if (models.length === 1 && turns.every((turn) => turn.model)) observation.model = models[0];
|
|
741
|
+
if (thinking.length === 1 && turns.every((turn) => turn.thinking)) observation.thinking = thinking[0];
|
|
742
|
+
validateGeneratedProfile(observation, harness);
|
|
743
|
+
return observation;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function validateGeneratedProfile(profile, harness) {
|
|
747
|
+
validateAIProfileObservation(profile, harness);
|
|
748
|
+
const statements = new Map(PROFILE_SIGNALS.map((signal) => [signal.dimension, signal.statement]));
|
|
749
|
+
statements.set("iterative_steering", "User gave a follow-up direction to change the work.");
|
|
750
|
+
if (profile.evidence.some((item) => item.signal !== "demonstrated" || statements.get(item.dimension) !== item.statement) ||
|
|
751
|
+
(profile.model !== undefined && canonicalModel(profile.model) !== profile.model) ||
|
|
752
|
+
(profile.thinking !== undefined && canonicalThinking(profile.thinking) !== profile.thinking)) {
|
|
753
|
+
throw new Error("Only locally generated AI Profile signals may be synced.");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
589
757
|
function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, selectedRef, includeOlder = false) {
|
|
590
758
|
if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
|
|
591
759
|
if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
|
|
@@ -667,7 +835,12 @@ async function confirm(question) {
|
|
|
667
835
|
|
|
668
836
|
async function readSecret(input = process.stdin) {
|
|
669
837
|
const ownsInput = !input.isTTY;
|
|
670
|
-
if (ownsInput)
|
|
838
|
+
if (ownsInput) {
|
|
839
|
+
let descriptor;
|
|
840
|
+
try { descriptor = fs.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"); }
|
|
841
|
+
catch { throw new Error("This terminal cannot accept a hidden connection key. Run connect in your own interactive terminal."); }
|
|
842
|
+
input = new tty.ReadStream(descriptor);
|
|
843
|
+
}
|
|
671
844
|
const wasRaw = input.isRaw;
|
|
672
845
|
process.stderr.write("Connection key (hidden): ");
|
|
673
846
|
input.setRawMode(true);
|
|
@@ -755,11 +928,11 @@ function install(harness, quiet = false) {
|
|
|
755
928
|
.replaceAll('"codex"', '"opencode"')
|
|
756
929
|
.replaceAll("--harness codex", "--harness opencode")
|
|
757
930
|
.replaceAll("Codex", "OpenCode")
|
|
758
|
-
.replaceAll("npx @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
|
|
931
|
+
.replaceAll("npx --yes @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
|
|
759
932
|
fs.writeFileSync(path.join(skillDest, "SKILL.md"), skillContent, { mode: 0o600 });
|
|
760
933
|
|
|
761
934
|
const commandContent = fs.readFileSync(path.join(__dirname, "..", "commands", "shifu-sync.md"), "utf8")
|
|
762
|
-
.replaceAll("npx @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
|
|
935
|
+
.replaceAll("npx --yes @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
|
|
763
936
|
for (const folder of ["command", "commands"]) {
|
|
764
937
|
const commandDir = path.join(configDir, "opencode", folder);
|
|
765
938
|
fs.mkdirSync(commandDir, { recursive: true, mode: 0o700 });
|
|
@@ -773,14 +946,14 @@ function install(harness, quiet = false) {
|
|
|
773
946
|
.replaceAll('"codex"', `"${harness}"`)
|
|
774
947
|
.replaceAll("--harness codex", `--harness ${harness}`)
|
|
775
948
|
.replaceAll("Codex", HARNESS_NAMES[harness])
|
|
776
|
-
.replaceAll("npx @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
|
|
949
|
+
.replaceAll("npx --yes @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
|
|
777
950
|
fs.writeFileSync(skill, content, { mode: 0o600 });
|
|
778
951
|
}
|
|
779
952
|
const removedHooks = harness === "codex" && removeCodexHooks();
|
|
780
953
|
if (!quiet) {
|
|
781
954
|
console.error(`Installed the Shifu sync instruction for ${HARNESS_NAMES[harness]}.`);
|
|
782
955
|
if (removedHooks) console.error("Removed obsolete Codex lifecycle hooks.");
|
|
783
|
-
console.error(`Next: coding-harness connect --harness ${harness}`);
|
|
956
|
+
console.error(`Next: npx --yes @useshifu/coding-harness connect --harness ${harness}`);
|
|
784
957
|
}
|
|
785
958
|
}
|
|
786
959
|
|
|
@@ -849,6 +1022,45 @@ function installSchedule(harness) {
|
|
|
849
1022
|
return artifact.file;
|
|
850
1023
|
}
|
|
851
1024
|
|
|
1025
|
+
function removeSchedule(harness) {
|
|
1026
|
+
const artifact = schedulerArtifact(harness, process.env.SHIFU_PLATFORM || process.platform);
|
|
1027
|
+
if (process.env.SHIFU_SKIP_SCHEDULER_ACTIVATION !== "1") {
|
|
1028
|
+
if (process.platform === "darwin") {
|
|
1029
|
+
const domain = `gui/${process.getuid()}`;
|
|
1030
|
+
const label = `com.useshifu.coding-harness.${harness}`;
|
|
1031
|
+
const scheduleIsLoaded = () => {
|
|
1032
|
+
let listing;
|
|
1033
|
+
try { listing = execFileSync("launchctl", ["list"], { encoding: "utf8" }); }
|
|
1034
|
+
catch { throw new Error("Could not check the Shifu schedule. Disconnect was cancelled."); }
|
|
1035
|
+
return listing.split(/\r?\n/).some((line) => line.trim().split(/\s+/).at(-1) === label);
|
|
1036
|
+
};
|
|
1037
|
+
if (scheduleIsLoaded()) {
|
|
1038
|
+
try { execFileSync("launchctl", ["bootout", domain, artifact.file], { stdio: "ignore" }); }
|
|
1039
|
+
catch { throw new Error("Could not stop the Shifu schedule. Disconnect was cancelled."); }
|
|
1040
|
+
if (scheduleIsLoaded()) throw new Error("The Shifu schedule is still running. Disconnect was cancelled.");
|
|
1041
|
+
}
|
|
1042
|
+
} else if (process.platform === "linux") {
|
|
1043
|
+
if (fs.existsSync(artifact.file)) {
|
|
1044
|
+
const timer = `shifu-coding-harness-${harness}.timer`;
|
|
1045
|
+
try { execFileSync("systemctl", ["--user", "disable", "--now", timer], { stdio: "ignore" }); }
|
|
1046
|
+
catch { throw new Error("Could not stop the Shifu schedule. Disconnect was cancelled."); }
|
|
1047
|
+
let state;
|
|
1048
|
+
try { state = execFileSync("systemctl", ["--user", "show", "--property=ActiveState", "--value", timer], { encoding: "utf8" }).trim(); }
|
|
1049
|
+
catch { throw new Error("Could not verify that the Shifu schedule stopped. Disconnect was cancelled."); }
|
|
1050
|
+
if (state === "active" || state === "activating") throw new Error("The Shifu schedule is still running. Disconnect was cancelled.");
|
|
1051
|
+
}
|
|
1052
|
+
} else if (process.platform === "win32") {
|
|
1053
|
+
if (fs.existsSync(artifact.file)) {
|
|
1054
|
+
try { execFileSync("schtasks.exe", ["/Delete", "/TN", `Shifu coding harness ${harness}`, "/F"], { stdio: "ignore" }); }
|
|
1055
|
+
catch { throw new Error("Could not stop the Shifu schedule. Disconnect was cancelled."); }
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
for (const item of [artifact, ...(artifact.extraFiles || [])]) {
|
|
1060
|
+
try { fs.unlinkSync(item.file); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
852
1064
|
function scheduleNextRun(harness, intervalHours) {
|
|
853
1065
|
const state = readState(harness);
|
|
854
1066
|
state.schedule = { ...(state.schedule || {}), nextRunAt: Date.now() + intervalHours * 3_600_000 };
|
|
@@ -864,11 +1076,28 @@ async function chooseSyncPolicy(existing) {
|
|
|
864
1076
|
};
|
|
865
1077
|
}
|
|
866
1078
|
|
|
867
|
-
async function
|
|
1079
|
+
async function validateConnection(apiUrl, token, harness) {
|
|
1080
|
+
let response;
|
|
1081
|
+
try {
|
|
1082
|
+
response = await fetch(`${apiUrl}/v1/connectors/coding-sessions/connection?harness=${encodeURIComponent(harness)}`, {
|
|
1083
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1084
|
+
signal: AbortSignal.timeout(10_000),
|
|
1085
|
+
});
|
|
1086
|
+
} catch {
|
|
1087
|
+
throw new Error("Could not reach Shifu to verify the connection key. Nothing was changed.");
|
|
1088
|
+
}
|
|
1089
|
+
if (response.status === 401 || response.status === 422) throw new Error("Shifu rejected this connection key for the selected coding tool. Nothing was changed.");
|
|
1090
|
+
if (!response.ok) throw new Error(`Shifu could not verify the connection key (HTTP ${response.status}). Nothing was changed.`);
|
|
1091
|
+
const result = await response.json().catch(() => null);
|
|
1092
|
+
if (result?.data?.connected !== true) throw new Error("Shifu returned an invalid connection check. Nothing was changed.");
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function connect(harness, readKey = readSecret) {
|
|
868
1096
|
const existing = readJSON(configPath(harness), undefined);
|
|
869
|
-
const token =
|
|
1097
|
+
const token = (await readKey()).trim();
|
|
870
1098
|
if (!TOKEN_PATTERN.test(token)) throw new Error("The connection key is invalid.");
|
|
871
1099
|
const apiUrl = requireSafeApiUrl(option("--api-url") || existing?.apiUrl || process.env.SHIFU_API_URL || DEFAULT_API_URL);
|
|
1100
|
+
await validateConnection(apiUrl, token, harness);
|
|
872
1101
|
const savedPolicy = existing && normalizedPolicy(existing);
|
|
873
1102
|
const currentPolicy = savedPolicy?.onboardingComplete ? savedPolicy : { intervalHours: DEFAULT_INTERVAL_HOURS, approvalMode: "automatic", excludedSessionRefs: savedPolicy?.excludedSessionRefs || [] };
|
|
874
1103
|
const selected = await chooseSyncPolicy(currentPolicy);
|
|
@@ -878,6 +1107,11 @@ async function connect(harness) {
|
|
|
878
1107
|
install(harness, true);
|
|
879
1108
|
const scheduleFile = installSchedule(harness);
|
|
880
1109
|
writeJSON(configPath(harness), { ...existing, version: CONFIG_VERSION, apiUrl, token, harness, syncPolicy, inputFolder });
|
|
1110
|
+
if (existing?.token && existing.token !== token) {
|
|
1111
|
+
for (const file of [statePath(harness), pendingPath(harness)]) {
|
|
1112
|
+
try { fs.unlinkSync(file); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
881
1115
|
scheduleNextRun(harness, syncPolicy.intervalHours);
|
|
882
1116
|
console.error(`${HARNESS_NAMES[harness]} is connected. It will sync every ${syncPolicy.intervalHours} hours with ${syncPolicy.approvalMode} approval.`);
|
|
883
1117
|
console.error(`Shifu sync folder: ${inputFolder}. Place .txt, .md, .rtf, or .docx files there. Only grounded contributions will become claims.`);
|
|
@@ -885,6 +1119,16 @@ async function connect(harness) {
|
|
|
885
1119
|
console.error(`Schedule installed at ${scheduleFile}. No content was sent during setup.`);
|
|
886
1120
|
}
|
|
887
1121
|
|
|
1122
|
+
function disconnect(harness) {
|
|
1123
|
+
readConfig(harness);
|
|
1124
|
+
if (fs.existsSync(lockPath(harness))) throw new Error("A Shifu sync is running. Try disconnecting again after it finishes.");
|
|
1125
|
+
removeSchedule(harness);
|
|
1126
|
+
for (const file of [configPath(harness), statePath(harness), pendingPath(harness)]) {
|
|
1127
|
+
try { fs.unlinkSync(file); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
1128
|
+
}
|
|
1129
|
+
console.error(`${HARNESS_NAMES[harness]} is disconnected on this device. Revoke the old key in Shifu if you no longer want it usable.`);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
888
1132
|
async function updateConfig(harness) {
|
|
889
1133
|
const config = readConfig(harness);
|
|
890
1134
|
const savedPolicy = normalizedPolicy(config);
|
|
@@ -950,6 +1194,16 @@ function validateSync(input, harness) {
|
|
|
950
1194
|
if (input.redactionVersion !== 2 && input.redactionVersion !== 3 && input.redactionVersion !== REDACTION_VERSION) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs.`);
|
|
951
1195
|
}
|
|
952
1196
|
|
|
1197
|
+
function validateAIProfileObservation(input, harness) {
|
|
1198
|
+
if (!input || typeof input !== "object" || Object.keys(input).some((field) => !AI_PROFILE_FIELDS.has(field)) || input.harness !== harness || typeof input.sessionRef !== "string" || !/^[A-Za-z0-9_-]{8,200}$/.test(input.sessionRef)) throw new Error("Use a reviewed AI Profile observation for this harness and an opaque session reference.");
|
|
1199
|
+
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 range of at most 12 turns.");
|
|
1200
|
+
if (!AI_PROFILE_CATEGORIES.has(input.category)) throw new Error("Choose a supported AI Profile category.");
|
|
1201
|
+
for (const field of ["model", "thinking"]) {
|
|
1202
|
+
if (input[field] !== undefined && (typeof input[field] !== "string" || input[field].length > (field === "model" ? 100 : 40) || /[\x00-\x1f\x7f\\@`<>]/.test(input[field]) || input[field].includes("://") || ["secret", "sk-", "ghp_"].some((part) => input[field].toLowerCase().includes(part)))) throw new Error(`Use safe ${field} metadata or omit it.`);
|
|
1203
|
+
}
|
|
1204
|
+
if (!Array.isArray(input.evidence) || input.evidence.length > 20 || !input.evidence.every((item) => item && typeof item === "object" && Object.keys(item).every((field) => AI_PROFILE_EVIDENCE_FIELDS.has(field)) && AI_PROFILE_DIMENSIONS.has(item.dimension) && ["demonstrated", "limiting"].includes(item.signal) && Number.isInteger(item.turn) && item.turn >= input.fromTurn && item.turn <= input.toTurn && textIsSafe(item.statement, 240))) throw new Error("Use at most 20 redacted, turn-cited AI-use observations; omit unsupported claims.");
|
|
1205
|
+
}
|
|
1206
|
+
|
|
953
1207
|
function validSyncReceipt(receipt, input) {
|
|
954
1208
|
return receipt?.sessionRef === input.sessionRef && receipt.acceptedToTurn === input.toTurn && Number.isInteger(receipt.lastSyncedTurn) &&
|
|
955
1209
|
(receipt.duplicate ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
|
|
@@ -994,6 +1248,28 @@ async function postSync(config, input) {
|
|
|
994
1248
|
throw lastError;
|
|
995
1249
|
}
|
|
996
1250
|
|
|
1251
|
+
async function flushPendingProfiles(harness, config) {
|
|
1252
|
+
const pending = readState(harness).profilePending || {};
|
|
1253
|
+
for (const [key, profile] of Object.entries(pending)) {
|
|
1254
|
+
try {
|
|
1255
|
+
validateGeneratedProfile(profile, harness);
|
|
1256
|
+
const response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/ai-profile-observations`, {
|
|
1257
|
+
method: "POST",
|
|
1258
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
|
|
1259
|
+
body: JSON.stringify(profile),
|
|
1260
|
+
signal: AbortSignal.timeout(30_000),
|
|
1261
|
+
});
|
|
1262
|
+
const receipt = await response.json().catch(() => undefined);
|
|
1263
|
+
if (response.status !== 409 && (!response.ok || receipt?.data?.accepted !== true)) throw new Error(`HTTP ${response.status}`);
|
|
1264
|
+
const state = readState(harness);
|
|
1265
|
+
delete state.profilePending[key];
|
|
1266
|
+
saveState(harness, state);
|
|
1267
|
+
} catch {
|
|
1268
|
+
console.error(`AI Profile observation for ${profile.sessionRef} turns ${profile.fromTurn}–${profile.toTurn} remains pending.`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
997
1273
|
function scanWorkFiles(folder) {
|
|
998
1274
|
const entries = fs.readdirSync(validateInputFolder(folder), { withFileTypes: true })
|
|
999
1275
|
.filter((entry) => entry.isFile() && entry.name !== INPUT_FOLDER_README_NAME);
|
|
@@ -1177,12 +1453,16 @@ async function sendSync(harness, input, config = readConfig(harness)) {
|
|
|
1177
1453
|
const checkpoint = state.sessions[input.sessionRef];
|
|
1178
1454
|
const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
|
|
1179
1455
|
if (input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
|
|
1456
|
+
const profile = profileForSegment(harness, input);
|
|
1180
1457
|
const receipt = await postSync(config, input);
|
|
1181
1458
|
if (!validSyncReceipt(receipt, input)) {
|
|
1182
1459
|
throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
|
|
1183
1460
|
}
|
|
1461
|
+
state.profilePending ||= {};
|
|
1462
|
+
state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
|
|
1184
1463
|
state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: receipt.lastSyncedTurn, syncDue: false };
|
|
1185
1464
|
saveState(harness, state);
|
|
1465
|
+
await flushPendingProfiles(harness, config);
|
|
1186
1466
|
console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : `Synced through turn ${receipt.lastSyncedTurn}.`);
|
|
1187
1467
|
return receipt;
|
|
1188
1468
|
}
|
|
@@ -1337,6 +1617,7 @@ async function scheduledSync(harness) {
|
|
|
1337
1617
|
console.error(`Shifu input folder remains pending: ${error.message}`);
|
|
1338
1618
|
}
|
|
1339
1619
|
}
|
|
1620
|
+
await flushPendingProfiles(harness, config);
|
|
1340
1621
|
if (policy.approvalMode === "manual") {
|
|
1341
1622
|
const queue = rebuildPendingQueue(harness);
|
|
1342
1623
|
const pendingFingerprint = createHash("sha256").update(queue.segments.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
|
|
@@ -1354,19 +1635,21 @@ async function scheduledSync(harness) {
|
|
|
1354
1635
|
notificationError = error?.message || "Could not create the approval session.";
|
|
1355
1636
|
}
|
|
1356
1637
|
}
|
|
1638
|
+
const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
|
|
1357
1639
|
state.schedule = {
|
|
1358
1640
|
...state.schedule,
|
|
1359
1641
|
lastAttemptAt: startedAt,
|
|
1360
|
-
lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles ? "partial_failure" : "awaiting_approval",
|
|
1642
|
+
lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles || pendingProfiles ? "partial_failure" : "awaiting_approval",
|
|
1361
1643
|
submittedFiles,
|
|
1362
1644
|
pendingFiles,
|
|
1363
1645
|
failedFiles,
|
|
1364
1646
|
unsupportedFiles,
|
|
1647
|
+
pendingProfiles,
|
|
1365
1648
|
pendingSegments: queue.segments.length,
|
|
1366
1649
|
pendingFingerprint,
|
|
1367
1650
|
approvalSessionRef,
|
|
1368
1651
|
notificationError,
|
|
1369
|
-
nextRunAt: startedAt + (notificationError || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1652
|
+
nextRunAt: startedAt + (notificationError || failedFiles || pendingProfiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1370
1653
|
};
|
|
1371
1654
|
} else {
|
|
1372
1655
|
const blockedSessions = new Set();
|
|
@@ -1381,16 +1664,18 @@ async function scheduledSync(harness) {
|
|
|
1381
1664
|
console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
|
|
1382
1665
|
}
|
|
1383
1666
|
}
|
|
1667
|
+
const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
|
|
1384
1668
|
state.schedule = {
|
|
1385
1669
|
lastAttemptAt: startedAt,
|
|
1386
|
-
lastResult: failures || failedFiles ? "partial_failure" : "complete",
|
|
1670
|
+
lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : "complete",
|
|
1387
1671
|
syncedSegments: synced,
|
|
1388
1672
|
failedSessions: failures,
|
|
1389
1673
|
submittedFiles,
|
|
1390
1674
|
pendingFiles,
|
|
1391
1675
|
failedFiles,
|
|
1392
1676
|
unsupportedFiles,
|
|
1393
|
-
|
|
1677
|
+
pendingProfiles,
|
|
1678
|
+
nextRunAt: startedAt + (failures || failedFiles || pendingProfiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
|
|
1394
1679
|
};
|
|
1395
1680
|
}
|
|
1396
1681
|
const latest = readState(harness);
|
|
@@ -1476,6 +1761,7 @@ async function main() {
|
|
|
1476
1761
|
const harness = requireHarness(option("--harness"));
|
|
1477
1762
|
if (command === "install") return install(harness);
|
|
1478
1763
|
if (command === "connect") return connect(harness);
|
|
1764
|
+
if (command === "disconnect") return disconnect(harness);
|
|
1479
1765
|
if (command === "config" && subCommand === "update") return updateConfig(harness);
|
|
1480
1766
|
if (command === "sync") return sync(harness);
|
|
1481
1767
|
if (command === "sync-files") return syncFilesNow(harness);
|
|
@@ -1485,7 +1771,7 @@ async function main() {
|
|
|
1485
1771
|
if (command === "status") return status(harness);
|
|
1486
1772
|
if (command === "sessions" || command === "discover" || (command === "session" && (subCommand === "discover" || subCommand === "list" || !subCommand || subCommand.startsWith("-")))) return sessions(harness);
|
|
1487
1773
|
if (command === "review") return review(harness);
|
|
1488
|
-
throw new Error("Use install, connect, config update, status, sessions, review, sync, sync-files, pending, approve, or scheduled-sync.");
|
|
1774
|
+
throw new Error("Use install, connect, disconnect, config update, status, sessions, review, sync, sync-files, pending, approve, or scheduled-sync.");
|
|
1489
1775
|
}
|
|
1490
1776
|
|
|
1491
1777
|
if (require.main === module) {
|
|
@@ -1497,4 +1783,4 @@ if (require.main === module) {
|
|
|
1497
1783
|
});
|
|
1498
1784
|
}
|
|
1499
1785
|
|
|
1500
|
-
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 };
|
|
1786
|
+
module.exports = { approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, connect, defaultInputFolder, disconnect, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, prepareInputFolder, profileForSegment, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateAIProfileObservation, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
|
package/commands/shifu-sync.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Inspect or sync redacted OpenCode activity using the saved Shifu policy.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Run `npx @useshifu/coding-harness status --harness opencode` first.
|
|
5
|
+
Run `npx --yes @useshifu/coding-harness status --harness opencode` first.
|
|
6
6
|
|
|
7
7
|
For an explicit Shifu input-folder sync, run `sync-files --harness opencode`. It polls unfinished jobs before submitting new or changed `.txt`, `.md`, `.rtf`, and `.docx` files up to 20 MB. Upload starts asynchronous server processing; check status on the next manual or scheduled sync. Only a final `processed`, `no_claims`, or `duplicate` result completes the local checkpoint; a `partial` result remains retryable. Completed file checkpoints from the previous runner are replayed once; existing in-flight jobs are polled before replay. Report published claims and needs-evidence suggestions separately. `no_claims` may still include suggestions, which are not verified user contributions. Unsupported files are counted but not uploaded. No claim-review step is needed. 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, and file text or excerpts must not appear in logs.
|
|
8
8
|
|
|
@@ -12,8 +12,8 @@ For an explicit sync-now request:
|
|
|
12
12
|
|
|
13
13
|
1. Run `sessions --harness opencode --hours 24`, using 48 or 72 only when requested.
|
|
14
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
|
|
15
|
+
3. Prepare a redaction-version-four payload from final assistant notes only. Use `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 advances the checkpoint without creating a claim.
|
|
16
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
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.
|
|
18
18
|
|
|
19
|
-
To connect or
|
|
19
|
+
To connect or replace a key, run `npx --yes @useshifu/coding-harness connect --harness opencode`. Connect asks for a key and verifies it with Shifu before reporting success. If this tool terminal cannot run the command or accept hidden input, ask the user to run it in their own interactive terminal and report the output; do not claim the connection succeeded. To disconnect locally, run `npx --yes @useshifu/coding-harness disconnect --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,57 +1,63 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: shifu-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.
|
|
3
|
+
description: Connect, configure, inspect, or sync coding-harness activity, AI Profile observations, 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, AI-use evidence, 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 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
|
|
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 for career activity and scans user turns locally for fixed AI Profile signals; it sends neither raw source. 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
11
|
|
|
12
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.
|
|
13
13
|
|
|
14
|
+
## AI Profile observations
|
|
15
|
+
|
|
16
|
+
Every accepted coding-session sync also queues a locally derived AI Profile observation for the same turns. No separate command or approval is needed. The runner sends only fixed statements for detected behavior and recognized model metadata, with no raw prompt text. Unsupported dimensions remain unscored. If profile scoring is unavailable, the redacted observation remains local for the next sync while activity sync continues.
|
|
17
|
+
|
|
14
18
|
## Connect or reconfigure
|
|
15
19
|
|
|
16
20
|
When the user asks to connect, run:
|
|
17
21
|
|
|
18
22
|
```sh
|
|
19
|
-
npx @useshifu/coding-harness connect --harness codex
|
|
23
|
+
npx --yes @useshifu/coding-harness connect --harness codex
|
|
20
24
|
```
|
|
21
25
|
|
|
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` up to 20 MB directly inside the folder; images and screenshots are not supported. Unknown formats are counted but never uploaded.
|
|
26
|
+
`connect` asks for a key every time and verifies it against Shifu for this harness before reporting success. If this tool terminal cannot run npx or accept hidden input, ask the user to run the command in their own interactive terminal and report its output; do not claim the connection succeeded. It 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` up to 20 MB directly inside the folder; images and screenshots are not supported. Unknown formats are counted but never uploaded. Replacing a key resets local checkpoints so work can sync to the new connection. It also excludes the setup session when the harness exposes its current opaque session reference. No content is sent during setup.
|
|
27
|
+
|
|
28
|
+
To disconnect locally, run `npx --yes @useshifu/coding-harness disconnect --harness codex`. Tell the user to revoke the old key in Shifu if they no longer want it usable.
|
|
23
29
|
|
|
24
30
|
When the user asks to change the interval, approval mode, or input folder, run:
|
|
25
31
|
|
|
26
32
|
```sh
|
|
27
|
-
npx @useshifu/coding-harness config update --harness codex --input-folder <existing-readable-folder>
|
|
33
|
+
npx --yes @useshifu/coding-harness config update --harness codex --input-folder <existing-readable-folder>
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
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.
|
|
31
37
|
|
|
32
38
|
## Inspect status or queued work
|
|
33
39
|
|
|
34
|
-
Run `npx @useshifu/coding-harness status --harness codex` to inspect the saved policy, next scheduled run, pending count, and checkpoints.
|
|
40
|
+
Run `npx --yes @useshifu/coding-harness status --harness codex` to inspect the saved policy, next scheduled run, pending count, and checkpoints.
|
|
35
41
|
|
|
36
|
-
For an explicit input-folder sync, run `npx @useshifu/coding-harness sync-files --harness codex`. It first polls previously accepted file jobs, then submits new or changed supported files. Uploading starts background processing; it does not wait for extraction. A later manual or scheduled sync polls status again. Only `processed`, `no_claims`, or `duplicate` advances a file's completed checkpoint; `partial` is retried on the next sync. The upgraded runner replays old completed checkpoints once, while preserving and polling in-flight jobs before their replay. No claim review or manual approval is needed for files. Report submitted, processing, retry-needed, unsupported, published-claim, and needs-evidence suggestion counts separately. Do not claim a submitted file was fully processed. If a file fails, leave it pending and report the error. Never print file text, candidate statements, or excerpts into logs.
|
|
42
|
+
For an explicit input-folder sync, run `npx --yes @useshifu/coding-harness sync-files --harness codex`. It first polls previously accepted file jobs, then submits new or changed supported files. Uploading starts background processing; it does not wait for extraction. A later manual or scheduled sync polls status again. Only `processed`, `no_claims`, or `duplicate` advances a file's completed checkpoint; `partial` is retried on the next sync. The upgraded runner replays old completed checkpoints once, while preserving and polling in-flight jobs before their replay. No claim review or manual approval is needed for files. Report submitted, processing, retry-needed, unsupported, published-claim, and needs-evidence suggestion counts separately. Do not claim a submitted file was fully processed. If a file fails, leave it pending and report the error. Never print file text, candidate statements, or excerpts into logs.
|
|
37
43
|
|
|
38
44
|
The folder runner accepts nonempty `.txt`, `.md`, `.rtf`, and `.docx` files up to 20 MB; the server accepts at most 500,000 extractable characters per file and splits that text into bounded chunks. Empty, malformed, binary, and renamed unsupported files cannot yield claims. A `no_claims` result means processing finished with no published claims; it may still have retained needs-evidence suggestions. Suggestions are possible contributions with unresolved evidence or attribution, not verified claims. If both counts are zero, no attributable contribution was extracted; upload itself may still have succeeded. For a named transcript, confirm the user's Shifu Profile display name matches their speaker label before expecting automated attribution. Never promise a claim from every file or infer one from an action item. The upload has a transport deadline, but model processing has no upload-request timeout; failed chunks back off and can be retried without republishing completed chunks.
|
|
39
45
|
|
|
40
46
|
In manual mode, scheduled runs accumulate redacted segments locally. Run:
|
|
41
47
|
|
|
42
48
|
```sh
|
|
43
|
-
npx @useshifu/coding-harness pending --harness codex
|
|
49
|
+
npx --yes @useshifu/coding-harness pending --harness codex
|
|
44
50
|
```
|
|
45
51
|
|
|
46
|
-
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.
|
|
52
|
+
Show the returned list and ask for one approval for the whole list. After approval, run `npx --yes @useshifu/coding-harness approve --approved --harness codex`. Do not ask again.
|
|
47
53
|
|
|
48
54
|
## Sync now
|
|
49
55
|
|
|
50
56
|
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.
|
|
51
57
|
|
|
52
|
-
1. Run `npx @useshifu/coding-harness sessions --harness codex --hours <24|48|72>`.
|
|
58
|
+
1. Run `npx --yes @useshifu/coding-harness sessions --harness codex --hours <24|48|72>`.
|
|
53
59
|
2. Ignore `blockedSessions` unless the user specifically selects one. For a selected older backlog, add `--session-ref <sessionRef> --all`.
|
|
54
|
-
3. For each returned segment, run `npx @useshifu/coding-harness review --harness codex --session-ref <sessionRef> --hours <24|48|72>`.
|
|
60
|
+
3. For each returned segment, run `npx --yes @useshifu/coding-harness review --harness codex --session-ref <sessionRef> --hours <24|48|72>`.
|
|
55
61
|
4. Prepare a version-four payload from the returned final-assistant notes. Keep the returned `sessionRef`, `fromTurn`, and `toTurn` unchanged.
|
|
56
62
|
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.
|
|
57
63
|
6. Repeat discovery after accepted segments until the selected window is exhausted.
|
|
@@ -76,13 +82,13 @@ Use `kind: "activity"` for general harness work; it does not require a technical
|
|
|
76
82
|
Automatic mode:
|
|
77
83
|
|
|
78
84
|
```sh
|
|
79
|
-
printf '%s' '<redacted JSON>' | npx @useshifu/coding-harness sync --harness codex
|
|
85
|
+
printf '%s' '<redacted JSON>' | npx --yes @useshifu/coding-harness sync --harness codex
|
|
80
86
|
```
|
|
81
87
|
|
|
82
88
|
Manual mode, only after the user's single approval:
|
|
83
89
|
|
|
84
90
|
```sh
|
|
85
|
-
printf '%s' '<redacted JSON>' | npx @useshifu/coding-harness sync --approved --harness codex
|
|
91
|
+
printf '%s' '<redacted JSON>' | npx --yes @useshifu/coding-harness sync --approved --harness codex
|
|
86
92
|
```
|
|
87
93
|
|
|
88
94
|
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.
|