@useshifu/coding-harness 0.3.5 → 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 CHANGED
@@ -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.
@@ -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 harness === "codex" ? codexReview(selectedFile, fromTurn, toTurn) : claudeReview(selectedFile, fromTurn, toTurn);
526
+ return selectedFile;
518
527
  }
519
528
 
520
529
  function reviewCandidates(notes) {
@@ -622,6 +631,129 @@ function payloadForSegment(harness, segment) {
622
631
  };
623
632
  }
624
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
+
625
757
  function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, selectedRef, includeOlder = false) {
626
758
  if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
627
759
  if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
@@ -1062,6 +1194,16 @@ function validateSync(input, harness) {
1062
1194
  if (input.redactionVersion !== 2 && input.redactionVersion !== 3 && input.redactionVersion !== REDACTION_VERSION) throw new Error(`Use redactionVersion ${REDACTION_VERSION} for new syncs.`);
1063
1195
  }
1064
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
+
1065
1207
  function validSyncReceipt(receipt, input) {
1066
1208
  return receipt?.sessionRef === input.sessionRef && receipt.acceptedToTurn === input.toTurn && Number.isInteger(receipt.lastSyncedTurn) &&
1067
1209
  (receipt.duplicate ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
@@ -1106,6 +1248,28 @@ async function postSync(config, input) {
1106
1248
  throw lastError;
1107
1249
  }
1108
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
+
1109
1273
  function scanWorkFiles(folder) {
1110
1274
  const entries = fs.readdirSync(validateInputFolder(folder), { withFileTypes: true })
1111
1275
  .filter((entry) => entry.isFile() && entry.name !== INPUT_FOLDER_README_NAME);
@@ -1289,12 +1453,16 @@ async function sendSync(harness, input, config = readConfig(harness)) {
1289
1453
  const checkpoint = state.sessions[input.sessionRef];
1290
1454
  const expected = (checkpoint?.lastSyncedTurn || 0) + 1;
1291
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);
1292
1457
  const receipt = await postSync(config, input);
1293
1458
  if (!validSyncReceipt(receipt, input)) {
1294
1459
  throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
1295
1460
  }
1461
+ state.profilePending ||= {};
1462
+ state.profilePending[`${profile.sessionRef}:${profile.fromTurn}:${profile.toTurn}`] = profile;
1296
1463
  state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: receipt.lastSyncedTurn, syncDue: false };
1297
1464
  saveState(harness, state);
1465
+ await flushPendingProfiles(harness, config);
1298
1466
  console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : `Synced through turn ${receipt.lastSyncedTurn}.`);
1299
1467
  return receipt;
1300
1468
  }
@@ -1449,6 +1617,7 @@ async function scheduledSync(harness) {
1449
1617
  console.error(`Shifu input folder remains pending: ${error.message}`);
1450
1618
  }
1451
1619
  }
1620
+ await flushPendingProfiles(harness, config);
1452
1621
  if (policy.approvalMode === "manual") {
1453
1622
  const queue = rebuildPendingQueue(harness);
1454
1623
  const pendingFingerprint = createHash("sha256").update(queue.segments.map((segment) => `${segment.sessionRef}:${segment.fromTurn}:${segment.toTurn}`).join("\n")).digest("hex");
@@ -1466,19 +1635,21 @@ async function scheduledSync(harness) {
1466
1635
  notificationError = error?.message || "Could not create the approval session.";
1467
1636
  }
1468
1637
  }
1638
+ const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
1469
1639
  state.schedule = {
1470
1640
  ...state.schedule,
1471
1641
  lastAttemptAt: startedAt,
1472
- lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles ? "partial_failure" : "awaiting_approval",
1642
+ lastResult: notificationError ? "awaiting_approval_notification_failed" : failedFiles || pendingProfiles ? "partial_failure" : "awaiting_approval",
1473
1643
  submittedFiles,
1474
1644
  pendingFiles,
1475
1645
  failedFiles,
1476
1646
  unsupportedFiles,
1647
+ pendingProfiles,
1477
1648
  pendingSegments: queue.segments.length,
1478
1649
  pendingFingerprint,
1479
1650
  approvalSessionRef,
1480
1651
  notificationError,
1481
- 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,
1482
1653
  };
1483
1654
  } else {
1484
1655
  const blockedSessions = new Set();
@@ -1493,16 +1664,18 @@ async function scheduledSync(harness) {
1493
1664
  console.error(`${HARNESS_NAMES[harness]} session ${segment.sessionRef} remains pending: ${error.message}`);
1494
1665
  }
1495
1666
  }
1667
+ const pendingProfiles = Object.keys(readState(harness).profilePending || {}).length;
1496
1668
  state.schedule = {
1497
1669
  lastAttemptAt: startedAt,
1498
- lastResult: failures || failedFiles ? "partial_failure" : "complete",
1670
+ lastResult: failures || failedFiles || pendingProfiles ? "partial_failure" : "complete",
1499
1671
  syncedSegments: synced,
1500
1672
  failedSessions: failures,
1501
1673
  submittedFiles,
1502
1674
  pendingFiles,
1503
1675
  failedFiles,
1504
1676
  unsupportedFiles,
1505
- nextRunAt: startedAt + (failures || failedFiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
1677
+ pendingProfiles,
1678
+ nextRunAt: startedAt + (failures || failedFiles || pendingProfiles ? SCHEDULER_TICK_MINUTES / 60 : policy.intervalHours) * 3_600_000,
1506
1679
  };
1507
1680
  }
1508
1681
  const latest = readState(harness);
@@ -1610,4 +1783,4 @@ if (require.main === module) {
1610
1783
  });
1611
1784
  }
1612
1785
 
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 };
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@useshifu/coding-harness",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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, 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: