@useshifu/coding-harness 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ 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. 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:
23
+ No content is sent during setup. Every `connect` asks for a connection key using a normal terminal prompt, so the key is visible as you type or paste it. The key is verified 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 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
26
  npx --yes @useshifu/coding-harness config update --harness <codex|claude_code|opencode> --interval-hours 1 --approval-mode automatic --input-folder ~/Desktop/Shifu
@@ -47,7 +47,7 @@ The same scheduled run checks the input folder. `sync-files` checks it immediate
47
47
 
48
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.
49
49
 
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.
50
+ The connector captures supported harness work in engineering, product discovery, writing, interview feedback, research, and planning. Automatic sync turns completed actions, known work topics, descriptive feature or project types, and supported problem or approach details into separate controlled statements without sending source prose or raw names. If a segment has no supported work items, it advances the checkpoint without creating a claim. Version 1–3 clients remain supported.
51
51
 
52
52
  ## Sync a prepared payload
53
53
 
@@ -56,3 +56,11 @@ printf '%s' '{"harness":"codex","sessionRef":"opaque-session-id","fromTurn":1,"t
56
56
  ```
57
57
 
58
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.
@@ -6,7 +6,6 @@ const os = require("node:os");
6
6
  const path = require("node:path");
7
7
  const { execFileSync } = require("node:child_process");
8
8
  const readline = require("node:readline/promises");
9
- const tty = require("node:tty");
10
9
 
11
10
  const DEFAULT_API_URL = "https://api.useshifu.com";
12
11
  const HARNESS_NAMES = {
@@ -25,6 +24,10 @@ const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
25
24
  const EVIDENCE_KINDS = new Set(["implementation", "decision", "activity"]);
26
25
  const EVIDENCE_FIELDS = new Set(["kind", "title", "statement", "scope", "area", "itemRef", "verificationRefs"]);
27
26
  const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion"]);
27
+ const AI_PROFILE_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "category", "model", "thinking", "evidence"]);
28
+ const AI_PROFILE_EVIDENCE_FIELDS = new Set(["dimension", "statement", "signal", "turn"]);
29
+ const AI_PROFILE_DIMENSIONS = new Set(["task_selection", "goal_clarity", "context_sharing", "constraint_setting", "work_decomposition", "iterative_steering", "critical_judgment", "verification", "application", "responsible_use"]);
30
+ 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
31
  const SCOPE_LEVELS = new Set(["unit", "module", "service", "system", "product"]);
29
32
  const WORK_AREAS = new Set(["architecture", "api", "backend", "connector", "database", "documentation", "frontend", "performance", "security", "testing", "tooling"]);
30
33
  const ITEM_REF_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
@@ -509,12 +512,17 @@ function opencodeReview(sessionRef, fromTurn, toTurn) {
509
512
 
510
513
  function reviewSegment(harness, sessionRef, fromTurn, toTurn) {
511
514
  if (harness === "opencode") return opencodeReview(sessionRef, fromTurn, toTurn);
515
+ const selectedFile = sessionFile(harness, sessionRef);
516
+ return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
517
+ }
518
+
519
+ function sessionFile(harness, sessionRef) {
512
520
  const files = harness === "codex" ? sessionFiles(codexSessionsRoot()) : sessionFiles(claudeSessionsRoot());
513
521
  const selectedFile = files.map((file) => ({ file, session: harness === "codex" ? codexSession(file) : claudeSession(file) }))
514
522
  .filter(({ session }) => session?.sessionRef === sessionRef)
515
523
  .sort((left, right) => right.session.turnCount - left.session.turnCount || right.session.updatedAt.localeCompare(left.session.updatedAt))[0]?.file;
516
524
  if (!selectedFile) throw new Error(`No local ${HARNESS_NAMES[harness]} session matches ${sessionRef}.`);
517
- return harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
525
+ return selectedFile;
518
526
  }
519
527
 
520
528
  function reviewCandidates(notes) {
@@ -579,23 +587,107 @@ const WORK_OBJECTS = [
579
587
  { key: "planning", title: "project plan", pattern: /\b(?:roadmap|project plan|delivery plan)\b/i },
580
588
  ];
581
589
 
590
+ // Only controlled feature descriptions leave the device during unattended sync.
591
+ const WORK_CONTEXTS = [
592
+ { key: "payment_reconciliation", title: "payment reconciliation flow", phrase: "a payment reconciliation flow", pattern: /\bpayment reconciliation\b/i },
593
+ { key: "payment", title: "payment flow", phrase: "a payment flow", pattern: /\b(?:payment|payments|payout|payouts)\b/i },
594
+ { key: "checkout", title: "checkout flow", phrase: "a checkout flow", pattern: /\bcheckout\b/i },
595
+ { key: "billing", title: "billing flow", phrase: "a billing flow", pattern: /\b(?:billing|invoic(?:e|es|ing)|subscription|subscriptions)\b/i },
596
+ { key: "reconciliation", title: "reconciliation flow", phrase: "a reconciliation flow", pattern: /\breconciliation\b/i },
597
+ { key: "onboarding", title: "onboarding flow", phrase: "an onboarding flow", pattern: /\bonboarding\b/i },
598
+ { key: "search", title: "search experience", phrase: "a search experience", pattern: /\bsearch\b/i },
599
+ { key: "notification", title: "notification flow", phrase: "a notification flow", pattern: /\bnotifications?\b/i },
600
+ { key: "reporting", title: "reporting feature", phrase: "a reporting feature", pattern: /\b(?:reporting|reports)\b/i },
601
+ { key: "messaging", title: "messaging feature", phrase: "a messaging feature", pattern: /\bmessaging\b/i },
602
+ { key: "file_upload", title: "file upload flow", phrase: "a file upload flow", pattern: /\b(?:file uploads?|document uploads?)\b/i },
603
+ { key: "scheduling", title: "scheduling flow", phrase: "a scheduling flow", pattern: /\b(?:scheduling|booking)\b/i },
604
+ { key: "analytics", title: "analytics feature", phrase: "an analytics feature", pattern: /\banalytics\b/i },
605
+ { key: "data_import", title: "data import flow", phrase: "a data import flow", pattern: /\b(?:data import|csv import|bulk import)\b/i },
606
+ { key: "document_processing", title: "document processing flow", phrase: "a document processing flow", pattern: /\bdocument processing\b/i },
607
+ ];
608
+
609
+ const WORK_DETAILS = [
610
+ { title: "timeouts", pattern: /\btimeouts?\b/i },
611
+ { title: "duplicates", pattern: /\bduplicat(?:e|es|ed|ion)\b/i },
612
+ { title: "stale data", pattern: /\bstale (?:data|results?|cache)\b/i },
613
+ { title: "race conditions", pattern: /\brace conditions?\b/i },
614
+ { title: "data loss", pattern: /\bdata loss\b/i },
615
+ { title: "latency", pattern: /\b(?:latency|slow responses?)\b/i },
616
+ { title: "idempotency", pattern: /\bidempoten(?:t|cy)\b/i },
617
+ { title: "backoff", pattern: /\bbackoff\b/i },
618
+ { title: "validation", pattern: /\bvalidat(?:e|ed|ion|ing)\b/i },
619
+ { title: "pagination", pattern: /\bpaginat(?:e|ed|ion|ing)\b/i },
620
+ { title: "batching", pattern: /\bbatch(?:es|ed|ing)?\b/i },
621
+ { title: "rate limiting", pattern: /\brate limit(?:s|ing|ed)?\b/i },
622
+ { title: "deduplication", pattern: /\bdedupl?icat(?:e|ed|ion|ing)\b/i },
623
+ { title: "fallback behavior", pattern: /\bfallback\b/i },
624
+ { title: "accessibility", pattern: /\baccessibility\b/i },
625
+ { title: "mobile layout", pattern: /\bmobile layout\b/i },
626
+ ];
627
+
628
+ function controlledContexts(text) {
629
+ const matches = WORK_CONTEXTS.flatMap((item) => {
630
+ const match = item.pattern.exec(text);
631
+ if (!match) return [];
632
+ const type = text.slice(match.index + match[0].length).match(/^\s+(service|platform|system|feature|project|workflow|flow|experience)\b/i)?.[1];
633
+ if (!type) return [item];
634
+ const title = `${item.title.replace(/ (?:flow|feature|experience)$/, "")} ${type.toLowerCase()}`;
635
+ return [{ ...item, title, phrase: `${item.phrase.startsWith("an ") ? "an" : "a"} ${title}` }];
636
+ });
637
+ const contexts = matches.some((item) => item.key === "payment_reconciliation") ? matches.filter((item) => item.key !== "payment" && item.key !== "reconciliation") : matches;
638
+ return contexts.length <= 2 ? contexts : null;
639
+ }
640
+
641
+ function controlledDetails(text) {
642
+ return WORK_DETAILS.map((item) => ({ item, index: item.pattern.exec(text)?.index }))
643
+ .filter(({ index }) => index !== undefined)
644
+ .sort((left, right) => left.index - right.index)
645
+ .slice(0, 3)
646
+ .map(({ item }) => item.title);
647
+ }
648
+
649
+ function withoutNamedLabels(text) {
650
+ return text.replace(/\b(?:Project|Client|Customer|Company)\s+[A-Z][A-Za-z0-9_-]*/g, "");
651
+ }
652
+
582
653
  function safeActivityEvidence(notes) {
583
654
  const evidence = [];
584
655
  const seen = new Set();
585
656
  for (const [noteIndex, { turn, note }] of notes.entries()) {
586
657
  let inCodeBlock = false;
658
+ let sectionContexts = [];
587
659
  for (const [lineIndex, rawLine] of note.split("\n").entries()) {
588
660
  if (rawLine.trim().startsWith("```")) { inCodeBlock = !inCodeBlock; continue; }
589
661
  if (inCodeBlock) continue;
662
+ const heading = rawLine.trim().match(/^(?:#{1,6}\s+(.+)|\*\*(.+)\*\*:?\s*)$/);
663
+ if (heading) {
664
+ const label = withoutNamedLabels(heading[1] || heading[2]);
665
+ sectionContexts = /\b(?:project|feature|flow|service|system|product)\b/i.test(label) ? controlledContexts(label) || [] : [];
666
+ continue;
667
+ }
590
668
  const line = rawLine.trim().replace(/^[-*]\s+/, "").split(/[.;—]/, 1)[0];
669
+ const clauses = line.split(/\s+(?:and|but|while)\s+/i);
670
+ const nextAction = clauses.findIndex((clause, index) => index > 0 && WORK_ACTIONS.some((item) => item.pattern.test(clause)));
671
+ const workClause = withoutNamedLabels(nextAction > 0 ? clauses.slice(0, nextAction).join(" and ") : line);
591
672
  const action = WORK_ACTIONS.find((item) => item.pattern.test(line));
592
- const object = WORK_OBJECTS.find((item) => item.pattern.test(line));
673
+ const detailSource = workClause.split(/\b(?:about|titled)\b/i, 1)[0];
674
+ const contexts = controlledContexts(detailSource);
675
+ const featureContexts = contexts === null ? [] : contexts.length ? contexts : sectionContexts;
676
+ let object;
677
+ let objectPosition = Infinity;
678
+ for (const item of WORK_OBJECTS) {
679
+ const match = item.pattern.exec(workClause);
680
+ if (match && match.index < objectPosition) { object = item; objectPosition = match.index; }
681
+ }
682
+ object ||= featureContexts[0];
593
683
  if (!action || !object) continue;
594
- const key = `${action.key}:${object.key}`;
595
- if (seen.has(key)) continue;
596
- seen.add(key);
597
- const title = `${action.title} ${object.title}`;
598
- evidence.push({ kind: "activity", title, statement: `${title}.`, itemRef: `t${turn}_n${noteIndex + 1}_l${lineIndex + 1}` });
684
+ const context = ["interview_feedback", "professional_post"].includes(object.key) ? [] : featureContexts.filter((item) => item !== object);
685
+ const details = controlledDetails(detailSource).filter((detail) => !object.title.includes(detail));
686
+ const title = `${action.title} ${object.title}${context.length ? ` for ${context.map((item) => item.phrase).join(" and ")}` : ""}`;
687
+ const statement = `${title}${details.length ? ` (${details.join(", ")})` : ""}.`;
688
+ if (seen.has(statement)) continue;
689
+ seen.add(statement);
690
+ evidence.push({ kind: "activity", title, statement, itemRef: `t${turn}_n${noteIndex + 1}_l${lineIndex + 1}` });
599
691
  if (evidence.length === MAX_EVIDENCE_ITEMS) return evidence;
600
692
  }
601
693
  }
@@ -606,8 +698,8 @@ function payloadForSegment(harness, segment) {
606
698
  const { notes } = reviewSegment(harness, segment.sessionRef, segment.fromTurn, segment.toTurn);
607
699
  const evidence = safeActivityEvidence(notes);
608
700
  const title = evidence.length ? evidence[0].title : "No supported work identified";
609
- const summary = evidence.length > 1 ? `${evidence[0].statement} ${evidence.length - 1} more supported work items.` :
610
- evidence.length === 1 ? evidence[0].statement : "This range had no supported work items.";
701
+ const summary = evidence.length ? `${evidence.slice(0, 3).map((item) => item.statement).join(" ")}${evidence.length > 3 ? ` ${evidence.length - 3} more supported work items.` : ""}` :
702
+ "This range had no supported work items.";
611
703
  return {
612
704
  harness,
613
705
  sessionRef: segment.sessionRef,
@@ -622,6 +714,129 @@ function payloadForSegment(harness, segment) {
622
714
  };
623
715
  }
624
716
 
717
+ const PROFILE_SIGNALS = [
718
+ { 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." },
719
+ { dimension: "goal_clarity", pattern: /\b(?:build|create|write|draft|implement|fix|design|produce)\b/i, statement: "User named a concrete deliverable or change." },
720
+ { dimension: "context_sharing", pattern: /\b(?:context|background|for example|here is)\b/i, statement: "User provided background or an example for the task." },
721
+ { 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." },
722
+ { 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." },
723
+ { dimension: "critical_judgment", pattern: /\b(?:compare|alternative|tradeoffs?|why|challenge|question the assumption)\b/i, statement: "User asked to examine a reason or alternative." },
724
+ { dimension: "verification", pattern: /\b(?:test|verify|validate|check|review the diff|prove)\b/i, statement: "User requested a check or test of the result." },
725
+ { dimension: "application", pattern: /\b(?:deploy|ship|publish|merge|release)\b/i, statement: "User requested that the result be put into use." },
726
+ { 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." },
727
+ ];
728
+ const ITERATION_PATTERN = /\b(?:instead|change|revise|adjust|fix|more|less|not what|actually)\b/i;
729
+ const PROFILE_CATEGORIZATION = [
730
+ { category: "production_support", pattern: /\b(?:incident|outage|production support|on.call)\b/i },
731
+ { category: "debugging_testing", pattern: /\b(?:debug|bug|failing test|regression|troubleshoot)\b/i },
732
+ { category: "system_design", pattern: /\b(?:system design|architecture|high.level design|hld)\b/i },
733
+ { category: "detailed_design", pattern: /\b(?:detailed design|low.level design|lld|api design|schema design)\b/i },
734
+ { category: "marketing_growth", pattern: /\b(?:marketing|growth|campaign|conversion)\b/i },
735
+ { category: "writing_communication", pattern: /\b(?:write|draft|article|post|email|feedback|documentation)\b/i },
736
+ { category: "research_analysis", pattern: /\b(?:research|analy[sz]e|compare|investigate)\b/i },
737
+ { category: "product_planning", pattern: /\b(?:roadmap|product plan|feature plan|prioriti[sz]e)\b/i },
738
+ { category: "learning", pattern: /\b(?:learn|explain|teach|tutorial)\b/i },
739
+ { category: "implementation", pattern: /\b(?:implement|build|code|refactor|frontend|backend|api|test)\b/i },
740
+ ];
741
+
742
+ function canonicalModel(value) {
743
+ if (typeof value !== "string") return undefined;
744
+ const known = value.match(/^(gpt-(?:6-(?:astra|sol|luna)|5\.6-(?:sol|terra|luna)|5\.5)|o[134](?:-mini)?)(?:$|[-.])/i);
745
+ if (known) return known[1].toLowerCase();
746
+ const claude = value.match(/^claude-(opus|sonnet|haiku)-(\d+(?:-\d+)?)(?:$|-)/i);
747
+ return claude ? `claude-${claude[1].toLowerCase()}-${claude[2]}` : undefined;
748
+ }
749
+
750
+ function canonicalThinking(value) {
751
+ return ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"].includes(value) ? value : undefined;
752
+ }
753
+
754
+ function localProfileTurns(harness, segment) {
755
+ const turns = [];
756
+ if (harness === "opencode") {
757
+ 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`);
758
+ let lastMessage;
759
+ let turn = 0;
760
+ for (const row of rows) {
761
+ if (row.messageId !== lastMessage) {
762
+ lastMessage = row.messageId;
763
+ if (row.role === "user") { turn += 1; turns.push({ turn, text: "" }); }
764
+ if (row.role === "assistant" && turn > 0) turns[turn - 1].model ||= canonicalModel(row.model);
765
+ }
766
+ if (row.role !== "user" || turn === 0 || typeof row.part !== "string") continue;
767
+ try { const part = JSON.parse(row.part); if (part.type === "text" && typeof part.text === "string") turns[turn - 1].text += part.text; } catch {}
768
+ }
769
+ } else if (harness === "claude_code") {
770
+ let turn = 0;
771
+ for (const entry of claudeEntries(sessionFile(harness, segment.sessionRef))) {
772
+ if (isClaudeUserTurn(entry)) {
773
+ turn += 1;
774
+ const content = entry.message.content;
775
+ turns.push({ turn, text: typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("\n") });
776
+ } else if (entry.type === "assistant" && turn > 0) {
777
+ turns[turn - 1].model ||= canonicalModel(entry.message?.model);
778
+ }
779
+ }
780
+ } else {
781
+ let turn = 0;
782
+ for (const line of fs.readFileSync(sessionFile(harness, segment.sessionRef), "utf8").split("\n")) {
783
+ if (!line) continue;
784
+ const entry = JSON.parse(line);
785
+ if (entry.type === "response_item" && entry.payload?.type === "message" && entry.payload.role === "user") {
786
+ turn += 1;
787
+ turns.push({ turn, text: entry.payload.content?.filter((part) => part.type === "input_text").map((part) => part.text).join("\n") || "" });
788
+ } else if (entry.type === "turn_context" && turn > 0) {
789
+ turns[turn - 1].model ||= canonicalModel(entry.payload?.model);
790
+ turns[turn - 1].thinking ||= canonicalThinking(entry.payload?.effort);
791
+ }
792
+ }
793
+ }
794
+ return turns.filter(({ turn }) => turn >= segment.fromTurn && turn <= segment.toTurn);
795
+ }
796
+
797
+ function profileForSegment(harness, segment) {
798
+ const turns = localProfileTurns(harness, segment);
799
+ if (turns.length !== segment.toTurn - segment.fromTurn + 1) throw new Error("The AI Profile source turns were not found locally; the segment remains pending.");
800
+ const evidence = [];
801
+ const observed = new Set();
802
+ const categories = [];
803
+ for (const { turn, text } of turns) {
804
+ for (const signal of PROFILE_SIGNALS) {
805
+ if (!observed.has(signal.dimension) && signal.pattern.test(text)) {
806
+ evidence.push({ dimension: signal.dimension, statement: signal.statement, signal: "demonstrated", turn });
807
+ observed.add(signal.dimension);
808
+ }
809
+ }
810
+ if (turn > 1 && !observed.has("iterative_steering") && ITERATION_PATTERN.test(text)) {
811
+ evidence.push({ dimension: "iterative_steering", statement: "User gave a follow-up direction to change the work.", signal: "demonstrated", turn });
812
+ observed.add("iterative_steering");
813
+ }
814
+ const category = PROFILE_CATEGORIZATION.find((item) => item.pattern.test(text))?.category;
815
+ if (category) categories.push(category);
816
+ }
817
+ const models = [...new Set(turns.map((turn) => turn.model).filter(Boolean))];
818
+ const thinking = [...new Set(turns.map((turn) => turn.thinking).filter(Boolean))];
819
+ const observation = {
820
+ harness, sessionRef: segment.sessionRef, fromTurn: segment.fromTurn, toTurn: segment.toTurn,
821
+ category: categories[0] || "other", evidence,
822
+ };
823
+ if (models.length === 1 && turns.every((turn) => turn.model)) observation.model = models[0];
824
+ if (thinking.length === 1 && turns.every((turn) => turn.thinking)) observation.thinking = thinking[0];
825
+ validateGeneratedProfile(observation, harness);
826
+ return observation;
827
+ }
828
+
829
+ function validateGeneratedProfile(profile, harness) {
830
+ validateAIProfileObservation(profile, harness);
831
+ const statements = new Map(PROFILE_SIGNALS.map((signal) => [signal.dimension, signal.statement]));
832
+ statements.set("iterative_steering", "User gave a follow-up direction to change the work.");
833
+ if (profile.evidence.some((item) => item.signal !== "demonstrated" || statements.get(item.dimension) !== item.statement) ||
834
+ (profile.model !== undefined && canonicalModel(profile.model) !== profile.model) ||
835
+ (profile.thinking !== undefined && canonicalThinking(profile.thinking) !== profile.thinking)) {
836
+ throw new Error("Only locally generated AI Profile signals may be synced.");
837
+ }
838
+ }
839
+
625
840
  function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, selectedRef, includeOlder = false) {
626
841
  if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
627
842
  if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
@@ -701,41 +916,17 @@ async function confirm(question) {
701
916
  }
702
917
  }
703
918
 
704
- async function readSecret(input = process.stdin) {
705
- const ownsInput = !input.isTTY;
706
- if (ownsInput) {
707
- let descriptor;
708
- try { descriptor = fs.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r"); }
709
- catch { throw new Error("This terminal cannot accept a hidden connection key. Run connect in your own interactive terminal."); }
710
- input = new tty.ReadStream(descriptor);
919
+ async function readSecret(input = process.stdin, output = process.stderr) {
920
+ const terminal = readline.createInterface({ input, output });
921
+ try {
922
+ const answer = terminal.question("Connection key: ");
923
+ const closed = new Promise((_, reject) => terminal.once("close", () => reject(new Error("Connection key input closed."))));
924
+ return await Promise.race([answer, closed]);
925
+ } catch {
926
+ throw new Error("Could not read a connection key. Run connect in an interactive terminal.");
927
+ } finally {
928
+ terminal.close();
711
929
  }
712
- const wasRaw = input.isRaw;
713
- process.stderr.write("Connection key (hidden): ");
714
- input.setRawMode(true);
715
- input.setEncoding("utf8");
716
- return new Promise((resolve, reject) => {
717
- let value = "";
718
- const finish = (error) => {
719
- input.off("data", onData);
720
- input.off("error", onError);
721
- input.setRawMode(Boolean(wasRaw));
722
- if (ownsInput) input.destroy();
723
- process.stderr.write("\n");
724
- if (error) reject(error);
725
- else resolve(value);
726
- };
727
- const onError = (error) => finish(error);
728
- const onData = (chunk) => {
729
- for (const character of chunk) {
730
- if (character === "\r" || character === "\n") return finish();
731
- if (character === "\u0003") return finish(new Error("Connection was cancelled."));
732
- if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
733
- else if (character >= " " && character !== "\u007f") value += character;
734
- }
735
- };
736
- input.on("data", onData);
737
- input.on("error", onError);
738
- });
739
930
  }
740
931
 
741
932
  function copyRunner() {
@@ -1062,6 +1253,16 @@ function validateSync(input, harness) {
1062
1253
  if (input.redactionVersion !== 2 && input.redactionVersion !== 3 && input.redactionVersion !== REDACTION_VERSION) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs.`);
1063
1254
  }
1064
1255
 
1256
+ function validateAIProfileObservation(input, harness) {
1257
+ 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.");
1258
+ 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.");
1259
+ if (!AI_PROFILE_CATEGORIES.has(input.category)) throw new Error("Choose a supported AI Profile category.");
1260
+ for (const field of ["model", "thinking"]) {
1261
+ 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.`);
1262
+ }
1263
+ 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.");
1264
+ }
1265
+
1065
1266
  function validSyncReceipt(receipt, input) {
1066
1267
  return receipt?.sessionRef === input.sessionRef && receipt.acceptedToTurn === input.toTurn && Number.isInteger(receipt.lastSyncedTurn) &&
1067
1268
  (receipt.duplicate ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
@@ -1106,6 +1307,28 @@ async function postSync(config, input) {
1106
1307
  throw lastError;
1107
1308
  }
1108
1309
 
1310
+ async function flushPendingProfiles(harness, config) {
1311
+ const pending = readState(harness).profilePending || {};
1312
+ for (const [key, profile] of Object.entries(pending)) {
1313
+ try {
1314
+ validateGeneratedProfile(profile, harness);
1315
+ const response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/ai-profile-observations`, {
1316
+ method: "POST",
1317
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
1318
+ body: JSON.stringify(profile),
1319
+ signal: AbortSignal.timeout(30_000),
1320
+ });
1321
+ const receipt = await response.json().catch(() => undefined);
1322
+ if (response.status !== 409 && (!response.ok || receipt?.data?.accepted !== true)) throw new Error(`HTTP ${response.status}`);
1323
+ const state = readState(harness);
1324
+ delete state.profilePending[key];
1325
+ saveState(harness, state);
1326
+ } catch {
1327
+ console.error(`AI Profile observation for ${profile.sessionRef} turns ${profile.fromTurn}–${profile.toTurn} remains pending.`);
1328
+ }
1329
+ }
1330
+ }
1331
+
1109
1332
  function scanWorkFiles(folder) {
1110
1333
  const entries = fs.readdirSync(validateInputFolder(folder), { withFileTypes: true })
1111
1334
  .filter((entry) => entry.isFile() && entry.name !== INPUT_FOLDER_README_NAME);
@@ -1289,12 +1512,16 @@ async function sendSync(harness, input, config = readConfig(harness)) {
1289
1512
  const checkpoint = state.sessions[input.sessionRef];
1290
1513
  const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
1291
1514
  if (input.fromTurn !== expected) throw new Error(`This local checkpoint expects turn ${expected}. Review the pending segment before retrying.`);
1515
+ const profile = profileForSegment(harness, input);
1292
1516
  const receipt = await postSync(config, input);
1293
1517
  if (!validSyncReceipt(receipt, input)) {
1294
1518
  throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
1295
1519
  }
1520
+ state.profilePending ||= {};
1521
+ state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
1296
1522
  state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: receipt.lastSyncedTurn, syncDue: false };
1297
1523
  saveState(harness, state);
1524
+ await flushPendingProfiles(harness, config);
1298
1525
  console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : `Synced through turn ${receipt.lastSyncedTurn}.`);
1299
1526
  return receipt;
1300
1527
  }
@@ -1449,6 +1676,7 @@ async function scheduledSync(harness) {
1449
1676
  console.error(`Shifu input folder remains pending: ${error.message}`);
1450
1677
  }
1451
1678
  }
1679
+ await flushPendingProfiles(harness, config);
1452
1680
  if (policy.approvalMode === "manual") {
1453
1681
  const queue = rebuildPendingQueue(harness);
1454
1682
  const pendingFingerprint = createHash("sha256").update(queue.segments.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
@@ -1466,19 +1694,21 @@ async function scheduledSync(harness) {
1466
1694
  notificationError = error?.message || "Could not create the approval session.";
1467
1695
  }
1468
1696
  }
1697
+ const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
1469
1698
  state.schedule = {
1470
1699
  ...state.schedule,
1471
1700
  lastAttemptAt: startedAt,
1472
- lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles ? "partial_failure" : "awaiting_approval",
1701
+ lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles || pendingProfiles ? "partial_failure" : "awaiting_approval",
1473
1702
  submittedFiles,
1474
1703
  pendingFiles,
1475
1704
  failedFiles,
1476
1705
  unsupportedFiles,
1706
+ pendingProfiles,
1477
1707
  pendingSegments: queue.segments.length,
1478
1708
  pendingFingerprint,
1479
1709
  approvalSessionRef,
1480
1710
  notificationError,
1481
- nextRunAt: startedAt + (notificationError || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
1711
+ nextRunAt: startedAt + (notificationError || failedFiles || pendingProfiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
1482
1712
  };
1483
1713
  } else {
1484
1714
  const blockedSessions = new Set();
@@ -1493,16 +1723,18 @@ async function scheduledSync(harness) {
1493
1723
  console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
1494
1724
  }
1495
1725
  }
1726
+ const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
1496
1727
  state.schedule = {
1497
1728
  lastAttemptAt: startedAt,
1498
- lastResult: failures || failedFiles ? "partial_failure" : "complete",
1729
+ lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : "complete",
1499
1730
  syncedSegments: synced,
1500
1731
  failedSessions: failures,
1501
1732
  submittedFiles,
1502
1733
  pendingFiles,
1503
1734
  failedFiles,
1504
1735
  unsupportedFiles,
1505
- nextRunAt: startedAt + (failures || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
1736
+ pendingProfiles,
1737
+ nextRunAt: startedAt + (failures || failedFiles || pendingProfiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
1506
1738
  };
1507
1739
  }
1508
1740
  const latest = readState(harness);
@@ -1610,4 +1842,4 @@ if (require.main === module) {
1610
1842
  });
1611
1843
  }
1612
1844
 
1613
- module.exports = { approvalNotificationInvocation, claudeReview, claudeSession, codexReview, codexSession, configPath, configuredSyncPolicy, connect, defaultInputFolder, disconnect, finalAssistantNote, install, installDestination, localSessions, normalizedPolicy, notificationSessionRef, opencodeQuery, opencodeReview, opencodeSessions, payloadForSegment, pendingSegments, prepareInputFolder, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, schedulerArtifact, statePath, syncWorkFiles, textIsSafe, unsyncedCodexSessions, validateConnection, validateInputFolder, validSyncReceipt, validateApprovalMode, validateIntervalHours, validateSync, workFiles };
1845
+ 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 };
@@ -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 `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.
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. Preserve the supported action, feature or project purpose, specific problem, approach, and verified result. Synthesize a descriptive project label (such as "payment reconciliation service") from its purpose; never copy a raw label that could identify a company or person. Keep distinct contributions separate, including different fixes within one feature. 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
- 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.
17
+ 5. Keep the returned session reference and turn range unchanged. Never upload prompts, raw transcripts, tool output, code, paths, commands, URLs, credentials, company or person names, customer details, or proprietary identifiers.
18
18
 
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>`).
19
+ To connect or replace a key, run `npx --yes @useshifu/coding-harness connect --harness opencode`. Connect asks for a key with a normal visible terminal prompt and verifies it with Shifu before reporting success. Do not ask the user to paste the key into chat. If this tool terminal cannot run the command or accept 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@useshifu/coding-harness",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Scheduled, redacted coding-harness activity sync for Shifu",
5
5
  "bin": {
6
6
  "coding-harness": "bin/shifu-harness.js",
@@ -1,16 +1,20 @@
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 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.
10
+ Never include user prompts, raw transcripts, tool output, source code, commands, credentials, URLs, file paths, company or person names, customer details, or proprietary identifiers in a coding-session payload. Synthesize descriptive project labels from their purpose rather than copying a raw name. 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:
@@ -19,7 +23,7 @@ When the user asks to connect, run:
19
23
  npx --yes @useshifu/coding-harness connect --harness codex
20
24
  ```
21
25
 
22
- `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.
26
+ `connect` asks for a key with a normal visible terminal prompt every time and verifies it against Shifu for this harness before reporting success. Do not ask the user to paste the key into chat. If this tool terminal cannot run npx or accept 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.
23
27
 
24
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.
25
29
 
@@ -60,6 +64,8 @@ For an explicit sync request, first read `status` to determine the saved approva
60
64
 
61
65
  Use `kind: "activity"` for general harness work; it does not require a technical scope. Use `implementation` or `decision` only when the note clearly supports that engineering classification, and then use the smallest reviewed scope among `unit`, `module`, `service`, `system`, and `product`. Evidence may be empty when the safe final note supports only a session-level title and summary. Never invent impact, ownership, verification, or outcomes.
62
66
 
67
+ Make each supported item useful for later recall: retain the user's action, the feature or project purpose, the specific problem, the approach, and a verified result when the final note supports each part. A descriptive project label such as "payment reconciliation service" is useful; synthesize it from the work's purpose instead of copying a raw project label that might contain a company or person's name. Keep distinct contributions separate, including two fixes in one feature that addressed different problems. Omit names of companies and people, customer details, proprietary identifiers, paths, code snippets, and unsupported impact. If a detail cannot be safely described, keep the supported parts. Prefer a summary that names the main distinct work items over a generic count alone.
68
+
63
69
  ```json
64
70
  {
65
71
  "harness": "codex",