@useshifu/coding-harness 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/bin/shifu-harness.js +359 -120
- package/commands/shifu-sync.md +1 -1
- package/package.json +2 -1
- package/skills/shifu-sync/SKILL.md +10 -8
package/README.md
CHANGED
|
@@ -5,16 +5,25 @@
|
|
|
5
5
|
```sh
|
|
6
6
|
npx @useshifu/coding-harness install --harness codex
|
|
7
7
|
npx @useshifu/coding-harness connect --harness codex
|
|
8
|
-
npx @useshifu/coding-harness schedule --harness codex --after-turns 12 --after-hours 4
|
|
9
|
-
npx @useshifu/coding-harness activate --harness codex
|
|
10
8
|
```
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
`connect` and `sync` each ask for confirmation. The Codex connector has no lifecycle hooks and never uploads in the background. Claude Code and OpenCode time-window discovery are not available yet.
|
|
11
|
+
|
|
12
|
+
When asked to sync, Codex discovers unsynced user turns from the last 24 hours by default. A user can choose 48 or 72 hours instead. Discovery reports only opaque session references, timestamps, and unsynced turn ranges; it does not send transcript content. `blockedSessions` lists recently changed sessions whose next unsynced turn is older than the chosen window. Those older contiguous backlogs are not silently included; the user can explicitly select a session and review it with `--all`.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx @useshifu/coding-harness sessions --harness codex --hours 24
|
|
16
|
+
npx @useshifu/coding-harness review --harness codex --session-ref opaque-session-id
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`review` reads the next contiguous segment of at most 12 unsynced turns locally. Pass the selected `--hours` value to `review` as well; `--all` is reserved for a specifically selected session's older backlog. It shows assistant final notes, candidate work items, and a draft that leaves required sync and work-item titles blank for the reviewer. Candidates are suggestions, not proof or guaranteed redaction. Candidate scope is left unset until a reviewer chooses the smallest supported level. If a segment has more than 64 candidates, rerun `sessions` and `review` with `--turns 1` to narrow it before syncing. Review each work title, detail, and area, prepare any verification, and preserve an item's `itemRef` when a later segment revises the same work. User-message records are omitted, but assistant notes may quote sensitive material. Nothing is uploaded by this command. Repeat discovery after each accepted segment to continue the delta.
|
|
13
20
|
|
|
14
21
|
To submit a reviewed segment, pipe the exact payload to `sync`. The CLI prints it, asks again, and only then sends it to Shifu. Successful responses advance a local checkpoint; the server rejects out-of-order or changed retries.
|
|
15
22
|
|
|
16
23
|
```sh
|
|
17
|
-
printf '%s' '{"harness":"codex","sessionRef":"opaque-session-id","fromTurn":1,"toTurn":8,"summary":"Implemented a focused change and checked the result.","evidence":["Focused tests passed."],"decisions":[],"redactionVersion":
|
|
24
|
+
printf '%s' '{"harness":"codex","sessionRef":"opaque-session-id","fromTurn":1,"toTurn":8,"title":"Hardened connector checkpoints","summary":"Implemented a focused change and checked the result.","evidence":[{"kind":"implementation","title":"Added checkpoint recovery","statement":"Implemented a focused connector change.","scope":"service","area":"connector","itemRef":"work_1","verificationRefs":[0]}],"verification":["Focused tests passed."],"decisions":[],"redactionVersion":3}' | npx @useshifu/coding-harness sync --harness codex
|
|
18
25
|
```
|
|
19
26
|
|
|
27
|
+
The server stores each approved sync title and summary, plus every redacted work-item title and statement as a separate claim with its scope, optional area and item reference, cited verification, and source receipt. Uncited verification wording, standalone decisions, raw assistant notes, and transcripts are not retained as claim text. `area`, `itemRef`, and `verificationRefs` are optional; citing a verification item applies only to that work item and is displayed separately from its work statement. A decision becomes a claim only when it is included as an `evidence` item with `kind: "decision"`. At least one reviewed work item is required to advance a checkpoint. The complete cross-component contract is in [coding-session-sync-contract.md](../../docs/coding-session-sync-contract.md).
|
|
28
|
+
|
|
20
29
|
The default API URL is `https://api.useshifu.com`. Override it during local or self-hosted use with `--api-url` on `connect`, or `SHIFU_API_URL`.
|
package/bin/shifu-harness.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const crypto = require("node:crypto");
|
|
4
3
|
const fs = require("node:fs");
|
|
5
4
|
const os = require("node:os");
|
|
6
5
|
const path = require("node:path");
|
|
7
6
|
const readline = require("node:readline/promises");
|
|
7
|
+
const tty = require("node:tty");
|
|
8
8
|
|
|
9
9
|
const DEFAULT_API_URL = "https://api.useshifu.com";
|
|
10
10
|
const HARNESS_NAMES = {
|
|
@@ -13,7 +13,22 @@ const HARNESS_NAMES = {
|
|
|
13
13
|
opencode: "OpenCode",
|
|
14
14
|
};
|
|
15
15
|
const TOKEN_PATTERN = /^cs_sk_[a-f0-9]{16}_[a-f0-9]{64}$/;
|
|
16
|
-
const SENSITIVE_PATTERNS = ["-----begin", "
|
|
16
|
+
const SENSITIVE_PATTERNS = ["-----begin", "authorization", "sk-", "sk_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_", "xox", "github_pat_", "glpat-", "ignore previous instructions", "ignore all previous instructions", "system prompt", "@", "://", "`", "/"];
|
|
17
|
+
const PHONE_NUMBER_PATTERN = /(?:\+?\d[\d .()-]{6,}\d)/i;
|
|
18
|
+
const UNSAFE_CODE_PATTERN = /\b(?:func|class|interface|struct|package|import|select|insert|update|delete|create\s+table)\s+[a-z_][a-z0-9_]*\s*(?:\(|\{|=|$)/i;
|
|
19
|
+
const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+/i;
|
|
20
|
+
const CLOUD_CREDENTIAL_PATTERN = /\bAKIA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{20,}/;
|
|
21
|
+
const EVIDENCE_KINDS = new Set(["implementation", "decision"]);
|
|
22
|
+
const EVIDENCE_FIELDS = new Set(["kind", "title", "statement", "scope", "area", "itemRef", "verificationRefs"]);
|
|
23
|
+
const SYNC_FIELDS = new Set(["harness", "sessionRef", "fromTurn", "toTurn", "title", "summary", "evidence", "verification", "decisions", "redactionVersion"]);
|
|
24
|
+
const SCOPE_LEVELS = new Set(["unit", "module", "service", "system", "product"]);
|
|
25
|
+
const WORK_AREAS = new Set(["architecture", "api", "backend", "connector", "database", "documentation", "frontend", "performance", "security", "testing", "tooling"]);
|
|
26
|
+
const ITEM_REF_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
|
|
27
|
+
const MAX_EVIDENCE_ITEMS = 64;
|
|
28
|
+
const MAX_SYNC_ITEMS = 16;
|
|
29
|
+
const MAX_TURNS_PER_SEGMENT = 12;
|
|
30
|
+
const MAX_TITLE_LENGTH = 160;
|
|
31
|
+
const MAX_WORK_DETAIL_LENGTH = 600;
|
|
17
32
|
|
|
18
33
|
function configRoot() {
|
|
19
34
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "shifu", "coding-harness");
|
|
@@ -31,6 +46,10 @@ function installedRunnerPath() {
|
|
|
31
46
|
return path.join(configRoot(), "runner.js");
|
|
32
47
|
}
|
|
33
48
|
|
|
49
|
+
function codexSessionsRoot() {
|
|
50
|
+
return process.env.SHIFU_CODEX_SESSIONS_ROOT || path.join(os.homedir(), ".codex", "sessions");
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
function requireHarness(value) {
|
|
35
54
|
if (!Object.hasOwn(HARNESS_NAMES, value)) throw new Error("Choose --harness codex, claude_code, or opencode.");
|
|
36
55
|
return value;
|
|
@@ -67,9 +86,20 @@ function readConfig(harness) {
|
|
|
67
86
|
if (!config || typeof config.token !== "string" || typeof config.apiUrl !== "string") {
|
|
68
87
|
throw new Error(`No ${HARNESS_NAMES[harness]} connection is configured. Run connect first.`);
|
|
69
88
|
}
|
|
89
|
+
requireSafeApiUrl(config.apiUrl);
|
|
70
90
|
return config;
|
|
71
91
|
}
|
|
72
92
|
|
|
93
|
+
function requireSafeApiUrl(value) {
|
|
94
|
+
let url;
|
|
95
|
+
try { url = new URL(value); } catch { throw new Error("Use a valid Shifu API URL."); }
|
|
96
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
97
|
+
if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password || url.search || url.hash) {
|
|
98
|
+
throw new Error("Use HTTPS for the Shifu API, or HTTP on localhost for development.");
|
|
99
|
+
}
|
|
100
|
+
return url.toString().replace(/\/$/, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
function readState(harness) {
|
|
74
104
|
return readJSON(statePath(harness), { sessions: {} });
|
|
75
105
|
}
|
|
@@ -78,15 +108,204 @@ function saveState(harness, state) {
|
|
|
78
108
|
writeJSON(statePath(harness), state);
|
|
79
109
|
}
|
|
80
110
|
|
|
111
|
+
function numberOption(name, fallback) {
|
|
112
|
+
const value = option(name);
|
|
113
|
+
return value === undefined ? fallback : Number(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sessionFiles(root) {
|
|
117
|
+
if (!fs.existsSync(root)) return [];
|
|
118
|
+
return fs.readdirSync(root, { recursive: true, withFileTypes: true })
|
|
119
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
120
|
+
.map((entry) => path.join(entry.parentPath || entry.path, entry.name));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function codexSession(file) {
|
|
124
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
125
|
+
let sessionRef;
|
|
126
|
+
let startedAt;
|
|
127
|
+
let updatedAt;
|
|
128
|
+
let turnCount = 0;
|
|
129
|
+
const turnTimes = [];
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
let entry;
|
|
132
|
+
try {
|
|
133
|
+
entry = JSON.parse(line);
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(`Could not read Codex session metadata from ${file}.`);
|
|
136
|
+
}
|
|
137
|
+
if (entry.type === "session_meta") {
|
|
138
|
+
sessionRef = entry.payload?.id || entry.payload?.session_id;
|
|
139
|
+
startedAt ||= entry.timestamp;
|
|
140
|
+
}
|
|
141
|
+
if (entry.type === "response_item" && entry.payload?.type === "message" && entry.payload?.role === "user") {
|
|
142
|
+
turnCount += 1;
|
|
143
|
+
turnTimes.push(typeof entry.timestamp === "string" ? entry.timestamp : null);
|
|
144
|
+
}
|
|
145
|
+
if (typeof entry.timestamp === "string") updatedAt = entry.timestamp;
|
|
146
|
+
}
|
|
147
|
+
if (typeof sessionRef !== "string" || sessionRef.length < 8 || !updatedAt || turnCount === 0) return undefined;
|
|
148
|
+
return { sessionRef, startedAt: startedAt || updatedAt, updatedAt, turnCount, turnTimes };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finalAssistantNote(file) {
|
|
152
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
153
|
+
let sessionRef;
|
|
154
|
+
let updatedAt;
|
|
155
|
+
let note;
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
let entry;
|
|
158
|
+
try {
|
|
159
|
+
entry = JSON.parse(line);
|
|
160
|
+
} catch {
|
|
161
|
+
throw new Error(`Could not read Codex session metadata from ${file}.`);
|
|
162
|
+
}
|
|
163
|
+
if (entry.type === "session_meta") sessionRef = entry.payload?.id || entry.payload?.session_id;
|
|
164
|
+
if (entry.type === "response_item" && entry.payload?.type === "message" && entry.payload?.role === "assistant") {
|
|
165
|
+
const text = entry.payload.content
|
|
166
|
+
?.filter((content) => content.type === "output_text" && typeof content.text === "string")
|
|
167
|
+
.map((content) => content.text)
|
|
168
|
+
.join("\n")
|
|
169
|
+
.trim();
|
|
170
|
+
if (text) note = text;
|
|
171
|
+
}
|
|
172
|
+
if (typeof entry.timestamp === "string") updatedAt = entry.timestamp;
|
|
173
|
+
}
|
|
174
|
+
return typeof sessionRef === "string" && note && updatedAt ? { sessionRef, updatedAt, note } : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function codexReview(file, fromTurn, toTurn) {
|
|
178
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
179
|
+
let sessionRef;
|
|
180
|
+
let turn = 0;
|
|
181
|
+
const notes = [];
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
let entry;
|
|
184
|
+
try {
|
|
185
|
+
entry = JSON.parse(line);
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error(`Could not read Codex session metadata from ${file}.`);
|
|
188
|
+
}
|
|
189
|
+
if (entry.type === "session_meta") sessionRef = entry.payload?.id || entry.payload?.session_id;
|
|
190
|
+
if (entry.type !== "response_item" || entry.payload?.type !== "message") continue;
|
|
191
|
+
if (entry.payload.role === "user") {
|
|
192
|
+
turn += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (entry.payload.role !== "assistant" || (entry.payload.phase && entry.payload.phase !== "final_answer") || turn < fromTurn || turn > toTurn) continue;
|
|
196
|
+
const note = entry.payload.content
|
|
197
|
+
?.filter((content) => content.type === "output_text" && typeof content.text === "string")
|
|
198
|
+
.map((content) => content.text)
|
|
199
|
+
.join("\n")
|
|
200
|
+
.trim();
|
|
201
|
+
if (note) notes.push({ turn, note });
|
|
202
|
+
}
|
|
203
|
+
return { sessionRef, turnCount: turn, notes };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function reviewCandidates(notes) {
|
|
207
|
+
const candidates = [];
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
for (const [noteIndex, { turn, note }] of notes.entries()) {
|
|
210
|
+
for (const [lineIndex, line] of note.split("\n").entries()) {
|
|
211
|
+
const statement = line.trim().replace(/^[-*]\s+/, "");
|
|
212
|
+
if (!textIsSafe(statement, MAX_WORK_DETAIL_LENGTH) || /\b(?:tests?|checks?|build|lint|review)\b.*\b(?:passed|pass|completed|succeeded)\.?$/i.test(statement)) continue;
|
|
213
|
+
const kind = /^(decided|chose|selected|kept|rejected)\b/i.test(statement) ? "decision" : "implementation";
|
|
214
|
+
if (seen.has(`${kind}:${statement}`)) continue;
|
|
215
|
+
seen.add(`${kind}:${statement}`);
|
|
216
|
+
const itemRef = `t${turn}_n${noteIndex + 1}_l${lineIndex + 1}`;
|
|
217
|
+
const areas = [...WORK_AREAS].filter((area) => new RegExp(`\\b${area}\\b`, "i").test(statement));
|
|
218
|
+
const candidate = { kind, statement, scope: null, itemRef };
|
|
219
|
+
if (areas.length === 1) candidate.area = areas[0];
|
|
220
|
+
candidates.push(candidate);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return candidates;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function unsyncedCodexSessions(harness, hours, turns = MAX_TURNS_PER_SEGMENT, selectedRef, includeOlder = false) {
|
|
227
|
+
if (harness !== "codex") throw new Error("Session discovery is currently available for Codex only.");
|
|
228
|
+
if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
|
|
229
|
+
if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
|
|
230
|
+
const since = Date.now() - hours * 3_600_000;
|
|
231
|
+
const state = readState(harness);
|
|
232
|
+
const sessions = new Map();
|
|
233
|
+
for (const session of sessionFiles(codexSessionsRoot())
|
|
234
|
+
.map(codexSession)
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
.filter((session) => selectedRef ? session.sessionRef === selectedRef : Date.parse(session.updatedAt) >= since)) {
|
|
237
|
+
const existing = sessions.get(session.sessionRef);
|
|
238
|
+
sessions.set(session.sessionRef, existing ? {
|
|
239
|
+
...session,
|
|
240
|
+
startedAt: existing.startedAt < session.startedAt ? existing.startedAt : session.startedAt,
|
|
241
|
+
updatedAt: existing.updatedAt > session.updatedAt ? existing.updatedAt : session.updatedAt,
|
|
242
|
+
turnCount: Math.max(existing.turnCount, session.turnCount),
|
|
243
|
+
turnTimes: session.turnCount >= existing.turnCount ? session.turnTimes : existing.turnTimes,
|
|
244
|
+
} : session);
|
|
245
|
+
}
|
|
246
|
+
return [...sessions.values()]
|
|
247
|
+
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))
|
|
248
|
+
.map(({ turnTimes, ...session }) => {
|
|
249
|
+
const checkpoint = state.sessions[session.sessionRef]?.lastSyncedTurn || 0;
|
|
250
|
+
return {
|
|
251
|
+
...session,
|
|
252
|
+
fromTurn: checkpoint + 1,
|
|
253
|
+
toTurn: Math.min(session.turnCount, checkpoint + turns),
|
|
254
|
+
remainingTurns: Math.max(0, session.turnCount - checkpoint - turns),
|
|
255
|
+
oldestUnsyncedAt: turnTimes[checkpoint] || null,
|
|
256
|
+
};
|
|
257
|
+
})
|
|
258
|
+
.filter((session) => session.fromTurn <= session.toTurn && (includeOlder || Date.parse(session.oldestUnsyncedAt) >= since));
|
|
259
|
+
}
|
|
260
|
+
|
|
81
261
|
function prompt() {
|
|
82
|
-
|
|
262
|
+
const input = process.stdin.isTTY ? process.stdin : fs.createReadStream("/dev/tty");
|
|
263
|
+
const terminal = readline.createInterface({ input, output: process.stderr });
|
|
264
|
+
terminal.shifuInput = input;
|
|
265
|
+
return terminal;
|
|
83
266
|
}
|
|
84
267
|
|
|
85
268
|
async function confirm(question) {
|
|
86
269
|
const terminal = prompt();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
270
|
+
try {
|
|
271
|
+
const answer = (await terminal.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
272
|
+
return answer === "y" || answer === "yes";
|
|
273
|
+
} finally {
|
|
274
|
+
terminal.close();
|
|
275
|
+
if (terminal.shifuInput !== process.stdin) terminal.shifuInput.destroy();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function readSecret(input = process.stdin) {
|
|
280
|
+
const ownsInput = !input.isTTY;
|
|
281
|
+
if (ownsInput) input = new tty.ReadStream(fs.openSync("/dev/tty", "r"));
|
|
282
|
+
const wasRaw = input.isRaw;
|
|
283
|
+
process.stderr.write("Connection key (hidden): ");
|
|
284
|
+
input.setRawMode(true);
|
|
285
|
+
input.setEncoding("utf8");
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
let value = "";
|
|
288
|
+
const finish = (error) => {
|
|
289
|
+
input.off("data", onData);
|
|
290
|
+
input.off("error", onError);
|
|
291
|
+
input.setRawMode(Boolean(wasRaw));
|
|
292
|
+
if (ownsInput) input.destroy();
|
|
293
|
+
process.stderr.write("\n");
|
|
294
|
+
if (error) reject(error);
|
|
295
|
+
else resolve(value);
|
|
296
|
+
};
|
|
297
|
+
const onError = (error) => finish(error);
|
|
298
|
+
const onData = (chunk) => {
|
|
299
|
+
for (const character of chunk) {
|
|
300
|
+
if (character === "\r" || character === "\n") return finish();
|
|
301
|
+
if (character === "\u0003") return finish(new Error("Connection was cancelled."));
|
|
302
|
+
if (character === "\u007f" || character === "\b") value = value.slice(0, -1);
|
|
303
|
+
else if (character >= " " && character !== "\u007f") value += character;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
input.on("data", onData);
|
|
307
|
+
input.on("error", onError);
|
|
308
|
+
});
|
|
90
309
|
}
|
|
91
310
|
|
|
92
311
|
function copyRunner() {
|
|
@@ -100,42 +319,35 @@ function copyDirectory(source, destination) {
|
|
|
100
319
|
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
101
320
|
}
|
|
102
321
|
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
322
|
+
function removeHooks(config, commandFragment) {
|
|
323
|
+
if (!config.hooks || typeof config.hooks !== "object") return false;
|
|
324
|
+
let changed = false;
|
|
325
|
+
for (const [event, groups] of Object.entries(config.hooks)) {
|
|
326
|
+
if (!Array.isArray(groups)) continue;
|
|
327
|
+
const retainedGroups = groups
|
|
328
|
+
.map((group) => {
|
|
329
|
+
if (!group || !Array.isArray(group.hooks)) return group;
|
|
330
|
+
const hooks = group.hooks.filter((hook) => {
|
|
331
|
+
const remove = hook?.type === "command" && typeof hook.command === "string" && hook.command.includes(commandFragment);
|
|
332
|
+
changed ||= remove;
|
|
333
|
+
return !remove;
|
|
334
|
+
});
|
|
335
|
+
return hooks.length === 0 ? undefined : { ...group, hooks };
|
|
336
|
+
})
|
|
337
|
+
.filter(Boolean);
|
|
338
|
+
if (retainedGroups.length === 0) delete config.hooks[event];
|
|
339
|
+
else config.hooks[event] = retainedGroups;
|
|
340
|
+
}
|
|
341
|
+
return changed;
|
|
112
342
|
}
|
|
113
343
|
|
|
114
|
-
function
|
|
344
|
+
function removeCodexHooks() {
|
|
115
345
|
const file = path.join(os.homedir(), ".codex", "hooks.json");
|
|
116
|
-
|
|
117
|
-
addHook(config, "Stop", shellCommand(harness, "stop"), 3);
|
|
118
|
-
addHook(config, "SessionEnd", shellCommand(harness, "session_end"), 3);
|
|
119
|
-
writeJSON(file, config);
|
|
120
|
-
return file;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function enableClaude(harness) {
|
|
124
|
-
const file = path.join(os.homedir(), ".claude", "settings.json");
|
|
346
|
+
if (!fs.existsSync(file)) return false;
|
|
125
347
|
const config = readJSON(file, {});
|
|
126
|
-
|
|
127
|
-
addHook(config, "SessionEnd", shellCommand(harness, "session_end"), 3);
|
|
348
|
+
if (!removeHooks(config, `${installedRunnerPath()}\" hook --harness codex`)) return false;
|
|
128
349
|
writeJSON(file, config);
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function enableOpenCode(harness) {
|
|
133
|
-
const file = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode", "plugins", "shifu-sync.js");
|
|
134
|
-
const runner = JSON.stringify(installedRunnerPath());
|
|
135
|
-
const source = `import { spawn } from "node:child_process";\n\nconst runner = ${runner};\n\nfunction notify(event) {\n const child = spawn(process.execPath, [runner, "hook", "--harness", "${harness}", "--event", "idle"], { stdio: ["pipe", "ignore", "ignore"], detached: true });\n child.stdin.end(JSON.stringify({ event }));\n child.unref();\n}\n\nexport const ShifuSync = async () => ({\n event: async ({ event }) => {\n if (event.type === "session.idle" || event.type === "session.deleted") notify(event);\n },\n});\n`;
|
|
136
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
137
|
-
fs.writeFileSync(file, source, { mode: 0o600 });
|
|
138
|
-
return file;
|
|
350
|
+
return true;
|
|
139
351
|
}
|
|
140
352
|
|
|
141
353
|
function installDestination(harness) {
|
|
@@ -155,93 +367,59 @@ function install(harness) {
|
|
|
155
367
|
const skill = path.join(destination, "SKILL.md");
|
|
156
368
|
const content = fs.readFileSync(skill, "utf8")
|
|
157
369
|
.replaceAll('"codex"', `"${harness}"`)
|
|
158
|
-
.replaceAll("--harness codex", `--harness ${harness}`)
|
|
370
|
+
.replaceAll("--harness codex", `--harness ${harness}`)
|
|
371
|
+
.replaceAll("npx @useshifu/coding-harness", `"${process.execPath}" "${installedRunnerPath()}"`);
|
|
159
372
|
fs.writeFileSync(skill, content, { mode: 0o600 });
|
|
160
373
|
}
|
|
374
|
+
const removedHooks = harness === "codex" && removeCodexHooks();
|
|
161
375
|
console.error(`Installed the Shifu sync instruction for ${HARNESS_NAMES[harness]}.`);
|
|
162
|
-
console.error(
|
|
376
|
+
if (removedHooks) console.error("Removed obsolete Codex lifecycle hooks.");
|
|
377
|
+
console.error(`Next: coding-harness connect --harness ${harness}`);
|
|
163
378
|
}
|
|
164
379
|
|
|
165
380
|
async function connect(harness) {
|
|
166
|
-
const
|
|
167
|
-
const token = (await terminal.question("Connection key: ")).trim();
|
|
168
|
-
terminal.close();
|
|
381
|
+
const token = (await readSecret()).trim();
|
|
169
382
|
if (!TOKEN_PATTERN.test(token)) throw new Error("The connection key is invalid.");
|
|
170
|
-
const apiUrl = (option("--api-url") || process.env.SHIFU_API_URL || DEFAULT_API_URL)
|
|
383
|
+
const apiUrl = requireSafeApiUrl(option("--api-url") || process.env.SHIFU_API_URL || DEFAULT_API_URL);
|
|
171
384
|
if (!(await confirm(`Save this ${HARNESS_NAMES[harness]} key locally for ${apiUrl}? No work content will be sent.`))) {
|
|
172
385
|
console.error("Connection was not saved.");
|
|
173
386
|
return;
|
|
174
387
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
console.error(`${HARNESS_NAMES[harness]} is connected. Run activate only when you want reminders.`);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function activate(harness) {
|
|
181
|
-
readConfig(harness);
|
|
182
|
-
if (!(await confirm(`Activate Shifu reminders for ${HARNESS_NAMES[harness]}? This adds local lifecycle hooks. They only mark syncs as due; they never upload automatically.`))) {
|
|
183
|
-
console.error("Activation was cancelled.");
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
copyRunner();
|
|
187
|
-
const file = harness === "codex" ? enableCodex(harness) : harness === "claude_code" ? enableClaude(harness) : enableOpenCode(harness);
|
|
188
|
-
console.error(`Shifu reminders are active. Updated ${file}.`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async function schedule(harness) {
|
|
192
|
-
const config = readConfig(harness);
|
|
193
|
-
const afterTurns = Number(option("--after-turns") || config.schedule?.afterTurns || 12);
|
|
194
|
-
const afterHours = Number(option("--after-hours") || config.schedule?.afterHours || 4);
|
|
195
|
-
if (!Number.isInteger(afterTurns) || afterTurns < 1 || afterTurns > 1000 || !Number.isFinite(afterHours) || afterHours <= 0 || afterHours > 168) {
|
|
196
|
-
throw new Error("Choose --after-turns from 1 to 1000 and --after-hours greater than 0 and at most 168.");
|
|
197
|
-
}
|
|
198
|
-
if (!(await confirm(`Save a reminder after ${afterTurns} turns or ${afterHours} hours without a sync? It will request review, never upload automatically.`))) {
|
|
199
|
-
console.error("Schedule was not changed.");
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
writeJSON(configPath(harness), { ...config, schedule: { afterTurns, afterHours } });
|
|
203
|
-
console.error("Reminder schedule saved. Activate the connector to receive harness lifecycle reminders.");
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function sessionID(input) {
|
|
207
|
-
return input.session_id || input.sessionId || input?.event?.properties?.sessionID || input?.event?.properties?.id;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function hook(harness, event) {
|
|
211
|
-
const config = readConfig(harness);
|
|
212
|
-
const input = readJSON(0, {});
|
|
213
|
-
const reference = sessionID(input);
|
|
214
|
-
if (typeof reference !== "string" || reference.length < 8) return;
|
|
215
|
-
const state = readState(harness);
|
|
216
|
-
state.lastSessionRef = reference;
|
|
217
|
-
const session = state.sessions[reference] || { turnsSinceSync: 0, lastSyncedAt: 0, syncDue: false };
|
|
218
|
-
if (event === "stop" || event === "idle") session.turnsSinceSync += 1;
|
|
219
|
-
if (event === "session_end") session.syncDue = true;
|
|
220
|
-
const hoursSinceSync = session.lastSyncedAt ? (Date.now() - session.lastSyncedAt) / 3_600_000 : 0;
|
|
221
|
-
const schedule = config.schedule || { afterTurns: 12, afterHours: 4 };
|
|
222
|
-
const due = session.turnsSinceSync >= schedule.afterTurns || hoursSinceSync >= schedule.afterHours || session.syncDue;
|
|
223
|
-
session.syncDue = due;
|
|
224
|
-
state.sessions[reference] = session;
|
|
225
|
-
saveState(harness, state);
|
|
226
|
-
if (event === "stop" && harness === "codex") {
|
|
227
|
-
process.stdout.write(JSON.stringify(due
|
|
228
|
-
? { systemMessage: "A Shifu sync is due. Prepare an incremental, redacted summary, show exactly what would be sent, and ask the user to confirm before running sync." }
|
|
229
|
-
: { continue: true }));
|
|
230
|
-
}
|
|
388
|
+
writeJSON(configPath(harness), { apiUrl, token, harness });
|
|
389
|
+
console.error(`${HARNESS_NAMES[harness]} is connected. Ask it to sync when you are ready to review recent work.`);
|
|
231
390
|
}
|
|
232
391
|
|
|
233
392
|
function textIsSafe(value, maximum) {
|
|
234
|
-
return typeof value === "string" && value.trim().length > 0 && value.length <= maximum && !SENSITIVE_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern));
|
|
393
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= maximum && !/[\x00-\x1f\x7f]/.test(value) && !PHONE_NUMBER_PATTERN.test(value) && !UNSAFE_CODE_PATTERN.test(value) && !CREDENTIAL_ASSIGNMENT_PATTERN.test(value) && !CLOUD_CREDENTIAL_PATTERN.test(value) && !SENSITIVE_PATTERNS.some((pattern) => value.toLowerCase().includes(pattern));
|
|
235
394
|
}
|
|
236
395
|
|
|
237
396
|
function validateSync(input, harness) {
|
|
397
|
+
if (!input || Object.keys(input).some((field) => !SYNC_FIELDS.has(field))) throw new Error("Remove unsupported fields from the reviewed sync payload.");
|
|
238
398
|
if (!input || input.harness !== harness || typeof input.sessionRef !== "string" || !textIsSafe(input.sessionRef, 200) || input.sessionRef.length < 8) throw new Error("Add the current opaque sessionRef before syncing.");
|
|
239
|
-
if (!Number.isInteger(input.fromTurn) || !Number.isInteger(input.toTurn) || input.fromTurn < 1 || input.toTurn < input.fromTurn) throw new Error("Use a valid incremental
|
|
399
|
+
if (!Number.isInteger(input.fromTurn) || !Number.isInteger(input.toTurn) || input.fromTurn < 1 || input.toTurn < input.fromTurn || input.toTurn - input.fromTurn >= MAX_TURNS_PER_SEGMENT) throw new Error("Use a valid incremental range of at most 12 turns.");
|
|
400
|
+
if (input.redactionVersion === 3 && !textIsSafe(input.title, MAX_TITLE_LENGTH)) throw new Error("Add a concise, redacted title for this reviewed sync.");
|
|
240
401
|
if (!textIsSafe(input.summary, 1200)) throw new Error("The summary is empty, too long, or contains sensitive content. Redact it before syncing.");
|
|
241
|
-
|
|
242
|
-
|
|
402
|
+
if (!Array.isArray(input.evidence) || input.evidence.length < 1 || input.evidence.length > MAX_EVIDENCE_ITEMS || !input.evidence.every((item) => item && EVIDENCE_KINDS.has(item.kind) && SCOPE_LEVELS.has(item.scope) && (input.redactionVersion !== 3 || textIsSafe(item.title, MAX_TITLE_LENGTH)) && textIsSafe(item.statement, MAX_WORK_DETAIL_LENGTH))) {
|
|
403
|
+
throw new Error(`evidence must contain 1-${MAX_EVIDENCE_ITEMS} redacted work items with title, detail, kind, and scope.`);
|
|
243
404
|
}
|
|
244
|
-
|
|
405
|
+
for (const field of ["verification", "decisions"]) {
|
|
406
|
+
if (!Array.isArray(input[field]) || input[field].length > MAX_SYNC_ITEMS || !input[field].every((item) => textIsSafe(item, 240))) throw new Error(`${field} must contain at most ${MAX_SYNC_ITEMS} redacted statements.`);
|
|
407
|
+
}
|
|
408
|
+
if (!input.evidence.every((item) =>
|
|
409
|
+
Object.keys(item).every((field) => EVIDENCE_FIELDS.has(field)) &&
|
|
410
|
+
(!Object.hasOwn(item, "area") || item.area === "" || WORK_AREAS.has(item.area)) &&
|
|
411
|
+
(!Object.hasOwn(item, "itemRef") || item.itemRef === "" || (typeof item.itemRef === "string" && ITEM_REF_PATTERN.test(item.itemRef))) &&
|
|
412
|
+
(!Object.hasOwn(item, "verificationRefs") || (Array.isArray(item.verificationRefs) &&
|
|
413
|
+
new Set(item.verificationRefs).size === item.verificationRefs.length &&
|
|
414
|
+
item.verificationRefs.every((ref) => Number.isInteger(ref) && ref >= 0 && ref < input.verification.length))))) {
|
|
415
|
+
throw new Error("Each work item must use a supported area, safe itemRef, and distinct indexes into verification.");
|
|
416
|
+
}
|
|
417
|
+
if (input.redactionVersion !== 2 && input.redactionVersion !== 3) throw new Error("Use redactionVersion 3 for new reviewed syncs.");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function validSyncReceipt(receipt, input) {
|
|
421
|
+
return receipt?.sessionRef === input.sessionRef && receipt.acceptedToTurn === input.toTurn && Number.isInteger(receipt.lastSyncedTurn) &&
|
|
422
|
+
(receipt.duplicate ? receipt.lastSyncedTurn >= input.toTurn : receipt.lastSyncedTurn === input.toTurn);
|
|
245
423
|
}
|
|
246
424
|
|
|
247
425
|
async function sync(harness) {
|
|
@@ -258,15 +436,23 @@ async function sync(harness) {
|
|
|
258
436
|
console.error("Nothing was sent.");
|
|
259
437
|
return;
|
|
260
438
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
439
|
+
let response;
|
|
440
|
+
try {
|
|
441
|
+
response = await fetch(`${config.apiUrl}/v1/connectors/coding-sessions/syncs`, {
|
|
442
|
+
method: "POST",
|
|
443
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.token}` },
|
|
444
|
+
body: JSON.stringify(input),
|
|
445
|
+
});
|
|
446
|
+
} catch (error) {
|
|
447
|
+
const reason = error?.cause?.code || error?.message || "unknown transport error";
|
|
448
|
+
throw new Error(`Could not reach Shifu at ${config.apiUrl}: ${reason}.`);
|
|
449
|
+
}
|
|
266
450
|
const payload = await response.json().catch(() => undefined);
|
|
267
451
|
if (!response.ok) throw new Error(payload?.error?.message || `Shifu rejected the sync (${response.status}).`);
|
|
268
452
|
const receipt = payload?.data;
|
|
269
|
-
if (!receipt
|
|
453
|
+
if (!validSyncReceipt(receipt, input)) {
|
|
454
|
+
throw new Error("Shifu returned a checkpoint that does not match this reviewed segment. Local state was not advanced.");
|
|
455
|
+
}
|
|
270
456
|
state.sessions[input.sessionRef] = { turnsSinceSync: 0, lastSyncedAt: Date.now(), lastSyncedTurn: receipt.lastSyncedTurn, syncDue: false };
|
|
271
457
|
saveState(harness, state);
|
|
272
458
|
console.error(receipt.duplicate ? "Shifu already had this exact segment. Local checkpoint recovered." : `Synced through turn ${receipt.lastSyncedTurn}.`);
|
|
@@ -275,7 +461,59 @@ async function sync(harness) {
|
|
|
275
461
|
function status(harness) {
|
|
276
462
|
const config = readConfig(harness);
|
|
277
463
|
const state = readState(harness);
|
|
278
|
-
console.log(JSON.stringify({ harness, apiUrl: config.apiUrl,
|
|
464
|
+
console.log(JSON.stringify({ harness, apiUrl: config.apiUrl, sessions: state.sessions }, null, 2));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function sessions(harness) {
|
|
468
|
+
readConfig(harness);
|
|
469
|
+
const hours = numberOption("--hours", 24);
|
|
470
|
+
const turns = numberOption("--turns", MAX_TURNS_PER_SEGMENT);
|
|
471
|
+
const sessionRef = option("--session-ref");
|
|
472
|
+
const all = hasFlag("--all");
|
|
473
|
+
if (all && !sessionRef) throw new Error("Use --all only with an explicitly selected --session-ref.");
|
|
474
|
+
const pending = unsyncedCodexSessions(harness, hours, turns, sessionRef, all);
|
|
475
|
+
const blocked = all ? [] : unsyncedCodexSessions(harness, hours, turns, sessionRef, true)
|
|
476
|
+
.filter((session) => !pending.some((item) => item.sessionRef === session.sessionRef))
|
|
477
|
+
.map(({ sessionRef, oldestUnsyncedAt }) => ({ sessionRef, oldestUnsyncedAt }));
|
|
478
|
+
console.log(JSON.stringify({ harness, hours, turns, sessions: pending, blockedSessions: blocked }, null, 2));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function review(harness) {
|
|
482
|
+
if (harness !== "codex") throw new Error("Session review is currently available for Codex only.");
|
|
483
|
+
readConfig(harness);
|
|
484
|
+
const sessionRef = option("--session-ref");
|
|
485
|
+
if (typeof sessionRef !== "string" || sessionRef.length < 8) throw new Error("Provide an opaque Codex --session-ref from the sessions command.");
|
|
486
|
+
const state = readState(harness);
|
|
487
|
+
const fromTurn = (state.sessions[sessionRef]?.lastSyncedTurn || 0) + 1;
|
|
488
|
+
const matchingFiles = sessionFiles(codexSessionsRoot())
|
|
489
|
+
.map((file) => ({ file, session: codexSession(file) }))
|
|
490
|
+
.filter(({ session }) => session?.sessionRef === sessionRef)
|
|
491
|
+
.sort((left, right) => right.session.turnCount - left.session.turnCount || right.session.updatedAt.localeCompare(left.session.updatedAt));
|
|
492
|
+
const selected = matchingFiles[0];
|
|
493
|
+
if (!selected) throw new Error("No local Codex session matches this reference.");
|
|
494
|
+
const hours = numberOption("--hours", 24);
|
|
495
|
+
if (![24, 48, 72].includes(hours)) throw new Error("Choose --hours 24, 48, or 72.");
|
|
496
|
+
if (!hasFlag("--all") && !(Date.parse(selected.session.turnTimes[fromTurn - 1]) >= Date.now() - hours * 3_600_000)) {
|
|
497
|
+
throw new Error("This session's next unsynced turn predates the selected window. Ask for this specific session and use --all to review its backlog.");
|
|
498
|
+
}
|
|
499
|
+
const turns = numberOption("--turns", MAX_TURNS_PER_SEGMENT);
|
|
500
|
+
if (!Number.isInteger(turns) || turns < 1 || turns > MAX_TURNS_PER_SEGMENT) throw new Error("Choose --turns between 1 and 12.");
|
|
501
|
+
const toTurn = Math.min(selected.session.turnCount, fromTurn + turns - 1);
|
|
502
|
+
if (fromTurn > toTurn) throw new Error("This Codex session has no unsynced turns to review.");
|
|
503
|
+
const { notes } = codexReview(selected.file, fromTurn, toTurn);
|
|
504
|
+
const candidates = reviewCandidates(notes);
|
|
505
|
+
console.log(JSON.stringify({
|
|
506
|
+
sessionRef,
|
|
507
|
+
fromTurn,
|
|
508
|
+
toTurn,
|
|
509
|
+
oldestUnsyncedAt: selected.session.turnTimes[fromTurn - 1] || null,
|
|
510
|
+
source: "Local assistant final notes for unsynced user turns. Candidates are suggestions, not verified or guaranteed redacted; inspect them before constructing a Shifu payload.",
|
|
511
|
+
notes,
|
|
512
|
+
candidates,
|
|
513
|
+
draft: { title: null, summary: null, evidence: candidates.map((candidate) => ({ ...candidate, title: null })) },
|
|
514
|
+
candidateOverflow: candidates.length > MAX_EVIDENCE_ITEMS,
|
|
515
|
+
remainingTurns: selected.session.turnCount - toTurn,
|
|
516
|
+
}, null, 2));
|
|
279
517
|
}
|
|
280
518
|
|
|
281
519
|
async function main() {
|
|
@@ -283,19 +521,20 @@ async function main() {
|
|
|
283
521
|
const harness = requireHarness(option("--harness"));
|
|
284
522
|
if (command === "install") return install(harness);
|
|
285
523
|
if (command === "connect") return connect(harness);
|
|
286
|
-
if (command === "activate") return activate(harness);
|
|
287
|
-
if (command === "schedule") return schedule(harness);
|
|
288
524
|
if (command === "sync") return sync(harness);
|
|
289
525
|
if (command === "status") return status(harness);
|
|
290
|
-
if (command === "
|
|
291
|
-
|
|
526
|
+
if (command === "sessions") return sessions(harness);
|
|
527
|
+
if (command === "review") return review(harness);
|
|
528
|
+
throw new Error("Use install, connect, status, sessions, review, or sync.");
|
|
292
529
|
}
|
|
293
530
|
|
|
294
531
|
if (require.main === module) {
|
|
295
|
-
main()
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
532
|
+
main()
|
|
533
|
+
.then(() => process.exit(0))
|
|
534
|
+
.catch((error) => {
|
|
535
|
+
console.error(error.message);
|
|
536
|
+
process.exit(1);
|
|
537
|
+
});
|
|
299
538
|
}
|
|
300
539
|
|
|
301
|
-
module.exports = {
|
|
540
|
+
module.exports = { codexReview, codexSession, configPath, finalAssistantNote, readSecret, removeHooks, requireSafeApiUrl, reviewCandidates, statePath, textIsSafe, unsyncedCodexSessions, validSyncReceipt, validateSync };
|
package/commands/shifu-sync.md
CHANGED
|
@@ -8,4 +8,4 @@ Use the Shifu reviewed-sync process for the current OpenCode session. Never uplo
|
|
|
8
8
|
npx @useshifu/coding-harness sync --harness opencode
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The payload must contain `harness`, an opaque `sessionRef`, `fromTurn
|
|
11
|
+
The payload must contain `harness`, an opaque `sessionRef`, a contiguous `fromTurn`/`toTurn` range of at most 12 turns, a concise redacted sync `title`, a redacted `summary`, 1–64 redacted `evidence` work items (`title`, `kind`, `statement`, `scope`), up to 16 redacted `verification` items, up to 16 redacted `decisions`, and `redactionVersion: 3`. Evidence describes the work itself; verification records how it was checked. Scope must be one of `unit`, `module`, `service`, `system`, or `product` and must be reviewed rather than guessed from the candidate. Replace names, paths, URLs, credentials, commands, prompts, source code, customer details, and proprietary identifiers with neutral descriptions.
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@useshifu/coding-harness",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Reviewed, incremental coding-harness sync for Shifu",
|
|
5
5
|
"bin": {
|
|
6
|
+
"coding-harness": "bin/shifu-harness.js",
|
|
6
7
|
"shifu-harness": "bin/shifu-harness.js"
|
|
7
8
|
},
|
|
8
9
|
"files": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: shifu-sync
|
|
3
|
-
description: Prepare and sync
|
|
3
|
+
description: Prepare and sync reviewed, redacted Codex work from the user's selected recent time window to Shifu. Use only when the user explicitly asks to connect, sync, or push their Shifu coding-harness data.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Shifu captures reviewed, incremental work context. It does not collect raw transcripts or upload in the background.
|
|
@@ -8,15 +8,15 @@ Shifu captures reviewed, incremental work context. It does not collect raw trans
|
|
|
8
8
|
Before taking any action, state the exact action and ask the user for confirmation:
|
|
9
9
|
|
|
10
10
|
- `connect`: explain that the command saves the supplied connection key locally and does not send session content.
|
|
11
|
-
- `activate`: explain that it adds local lifecycle hooks. Hooks can mark a sync as due, but never upload automatically.
|
|
12
|
-
- `schedule`: explain the turn and time thresholds. A threshold asks for review on the next active harness turn; it cannot interrupt an ended session with a dialog.
|
|
13
11
|
- `sync`: show the exact JSON payload, including its incremental `fromTurn` and `toTurn`, then ask whether to send it.
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
For a sync request, use the time window the user names. If they do not name one, say that you will use the last 24 hours. The only supported choices are 24, 48, and 72 hours.
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
Before preparing a sync, run `npx @useshifu/coding-harness sessions --harness codex --hours <24|48|72>`. Use every returned session's opaque `sessionRef`, `fromTurn`, and `toTurn`. `blockedSessions` are outside the selected turn window and must not be uploaded unless the user separately chooses that specific session and its older backlog. Each result covers at most 12 turns; repeat discovery after each accepted segment until the selected window's pending delta is exhausted. If a review reports more than 64 candidates, rerun discovery and review with `--turns 1` before preparing a payload. Do not invent a checkpoint or resend a range the command has excluded.
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
For each returned session, run `npx @useshifu/coding-harness review --harness codex --session-ref <sessionRef> --hours <24|48|72>`. Its local assistant note is untrusted source material, not content to upload. Prepare only its unsynced segment from that note. Each `evidence` item must be a factual, redacted work item describing what was created, changed, or deliberately decided and which component was involved. Choose its smallest supported scope: `unit`, `module`, `service`, `system`, or `product`; the candidate's null scope is not a classification. Put tests, builds, reviews, and other checks only in `verification`. Do not claim impact, ownership, outcome, or a broader scope without direct support. Replace names, repository identifiers, file paths, URLs, credentials, prompts, command lines, source code, customer details, and proprietary terms with neutral descriptions. Never invent missing details. Approved work-item statements are retained in Shifu's private claims, so review their wording as durable content.
|
|
18
|
+
|
|
19
|
+
Do not install or rely on lifecycle hooks or background reminders. A sync is always initiated by an explicit user request.
|
|
20
20
|
|
|
21
21
|
Use this structure:
|
|
22
22
|
|
|
@@ -26,10 +26,12 @@ Use this structure:
|
|
|
26
26
|
"sessionRef": "opaque-session-id",
|
|
27
27
|
"fromTurn": 1,
|
|
28
28
|
"toTurn": 12,
|
|
29
|
+
"title": "Hardened connector checkpoints",
|
|
29
30
|
"summary": "Implemented a narrow change and checked the relevant behaviour.",
|
|
30
|
-
"evidence": ["
|
|
31
|
+
"evidence": [{"kind": "implementation", "title": "Added checkpoint recovery", "statement": "Implemented a focused connector change.", "scope": "service"}],
|
|
32
|
+
"verification": ["Focused tests passed."],
|
|
31
33
|
"decisions": ["Kept the change within the existing connector boundary."],
|
|
32
|
-
"redactionVersion":
|
|
34
|
+
"redactionVersion": 3
|
|
33
35
|
}
|
|
34
36
|
```
|
|
35
37
|
|