@nexus-cortex/core 4.52.0 → 4.54.0
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/dist/adapters/node/GitHistoryStore.d.ts +80 -0
- package/dist/adapters/node/GitHistoryStore.d.ts.map +1 -0
- package/dist/adapters/node/GitHistoryStore.js +179 -0
- package/dist/adapters/node/GitHistoryStore.js.map +1 -0
- package/dist/adapters/node/index.d.ts +2 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/canon/canonArtifacts.d.ts +14 -0
- package/dist/canon/canonArtifacts.d.ts.map +1 -0
- package/dist/canon/canonArtifacts.js +269 -0
- package/dist/canon/canonArtifacts.js.map +1 -0
- package/dist/canon/canonGraph.d.ts +30 -0
- package/dist/canon/canonGraph.d.ts.map +1 -0
- package/dist/canon/canonGraph.js +216 -0
- package/dist/canon/canonGraph.js.map +1 -0
- package/dist/canon/canonPull.d.ts +38 -0
- package/dist/canon/canonPull.d.ts.map +1 -0
- package/dist/canon/canonPull.js +133 -0
- package/dist/canon/canonPull.js.map +1 -0
- package/dist/canon/canonSync.d.ts +20 -0
- package/dist/canon/canonSync.d.ts.map +1 -0
- package/dist/canon/canonSync.js +243 -0
- package/dist/canon/canonSync.js.map +1 -0
- package/dist/canon/canonTranslate.d.ts +19 -0
- package/dist/canon/canonTranslate.d.ts.map +1 -0
- package/dist/canon/canonTranslate.js +719 -0
- package/dist/canon/canonTranslate.js.map +1 -0
- package/dist/canon/index.d.ts +19 -0
- package/dist/canon/index.d.ts.map +1 -0
- package/dist/canon/index.js +14 -0
- package/dist/canon/index.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +17 -4
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canonTranslate — the canon gateway leg, graduated from
|
|
3
|
+
* `scripts/canon/canon-translate.ts` (Phase C part 2; the script is now a thin
|
|
4
|
+
* wrapper over this module, so the cron and the CLI run ONE implementation).
|
|
5
|
+
*
|
|
6
|
+
* Reads native session files in the canon store and maintains the canonical
|
|
7
|
+
* line: /native/claude-code/** → /canon/claude-code/**, /native/nexus-cortex/**
|
|
8
|
+
* → /canon/nexus-cortex/** (≈ identity), plus /projections refs and the
|
|
9
|
+
* self-documenting MAPPING/TRANSLATED/PROJECTIONS docs.
|
|
10
|
+
*
|
|
11
|
+
* The transform bodies are the script's PROVEN logic, transplanted verbatim
|
|
12
|
+
* (byte-identical output is the graduation's regression gate). The canonical
|
|
13
|
+
* record schema authority is `@nexus-cortex/types` / MessageTypes.ts — the
|
|
14
|
+
* transforms operate structurally on raw JSONL records BY DESIGN (canon =
|
|
15
|
+
* verbatim superset; `message` bodies pass through untouched), so they neither
|
|
16
|
+
* construct nor need typed Message values.
|
|
17
|
+
*
|
|
18
|
+
* @module canon/canonTranslate
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
import * as readline from 'node:readline';
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
export async function canonTranslate(o = {}) {
|
|
25
|
+
const HOME = o.home ?? process.env.HOME ?? '/home/runner/workspace';
|
|
26
|
+
const DRY = o.dryRun ?? false;
|
|
27
|
+
const STORE = o.store ?? '/tmp/canon-store';
|
|
28
|
+
const MANIFEST_PATH = path.join(HOME, '.canon', 'translate-manifest.json');
|
|
29
|
+
const MAX_BYTES = 50 * 1024 * 1024;
|
|
30
|
+
const PART_BYTES = 25 * 1024 * 1024;
|
|
31
|
+
const SCRIPT_VERSION = 'a3.13'; // bump to force full re-translate
|
|
32
|
+
const MESSAGE_TYPES = new Set(['user', 'assistant', 'system', 'file-history-snapshot']);
|
|
33
|
+
const manifest = fs.existsSync(MANIFEST_PATH)
|
|
34
|
+
? JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'))
|
|
35
|
+
: {};
|
|
36
|
+
const errors = [];
|
|
37
|
+
const stats = {};
|
|
38
|
+
const eventTypeCounts = {};
|
|
39
|
+
let unchanged = 0;
|
|
40
|
+
let dupResults = 0;
|
|
41
|
+
let orphanRepairs = 0;
|
|
42
|
+
let blobs = new Map(); // set in main, before discovery
|
|
43
|
+
/** path (store-relative) → git blob SHA. Stable across clones/mtimes; the
|
|
44
|
+
* Merkle anchor for both provenance and the incremental manifest. */
|
|
45
|
+
function blobMap() {
|
|
46
|
+
const out = new Map();
|
|
47
|
+
const ls = execFileSync('git', ['ls-files', '-s', '--', 'native'], { cwd: STORE, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
48
|
+
for (const line of ls.split('\n')) {
|
|
49
|
+
const m = line.match(/^\d+ ([0-9a-f]{40}) \d\t(.+)$/);
|
|
50
|
+
if (m)
|
|
51
|
+
out.set(m[2], m[1].slice(0, 12));
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function discover(rootAbs, relPrefix) {
|
|
56
|
+
const groups = new Map();
|
|
57
|
+
const walk = (dir) => {
|
|
58
|
+
let entries = [];
|
|
59
|
+
try {
|
|
60
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
const p = path.join(dir, e.name);
|
|
67
|
+
if (e.isDirectory()) {
|
|
68
|
+
walk(p);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!e.isFile())
|
|
72
|
+
continue;
|
|
73
|
+
const m = e.name.match(/^(.*\.jsonl)\.part-\d{4}$/);
|
|
74
|
+
const logical = m ? path.join(dir, m[1]) : p;
|
|
75
|
+
if (!logical.endsWith('.jsonl'))
|
|
76
|
+
continue;
|
|
77
|
+
const g = groups.get(logical) ?? [];
|
|
78
|
+
g.push(p);
|
|
79
|
+
groups.set(logical, g);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
walk(rootAbs);
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const [logical, parts] of groups) {
|
|
85
|
+
parts.sort();
|
|
86
|
+
// Signature = git blob SHAs (content-stable across clones; mtime:size only
|
|
87
|
+
// as a fallback for not-yet-committed files, which then retry next run).
|
|
88
|
+
const sig = SCRIPT_VERSION + '|' + parts
|
|
89
|
+
.map((p) => blobs.get(path.relative(STORE, p))
|
|
90
|
+
?? (() => { const st = fs.statSync(p); return `${st.mtimeMs}:${st.size}`; })())
|
|
91
|
+
.join('|');
|
|
92
|
+
out.push({ rel: path.join(relPrefix, path.relative(rootAbs, logical)), parts, sig });
|
|
93
|
+
}
|
|
94
|
+
return out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
95
|
+
}
|
|
96
|
+
/** yields [line, blobShaOfThePartContainingIt] */
|
|
97
|
+
async function* logicalLines(parts) {
|
|
98
|
+
for (const p of parts) {
|
|
99
|
+
const blob = blobs.get(path.relative(STORE, p)) ?? 'untracked';
|
|
100
|
+
const rl = readline.createInterface({ input: fs.createReadStream(p), crlfDelay: Infinity });
|
|
101
|
+
for await (const line of rl)
|
|
102
|
+
if (line.trim())
|
|
103
|
+
yield [line, blob];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// ── chunk-aware output: single file ≤50MB, else 25MB .part-NNNN files ──────
|
|
107
|
+
function finalizeOutput(destAbs, tmpAbs) {
|
|
108
|
+
const size = fs.statSync(tmpAbs).size;
|
|
109
|
+
const staleParts = () => {
|
|
110
|
+
const dir = path.dirname(destAbs);
|
|
111
|
+
const base = path.basename(destAbs);
|
|
112
|
+
let names = [];
|
|
113
|
+
try {
|
|
114
|
+
names = fs.readdirSync(dir);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
return names.filter((n) => n.startsWith(base + '.part-')).map((n) => path.join(dir, n));
|
|
120
|
+
};
|
|
121
|
+
if (size <= MAX_BYTES) {
|
|
122
|
+
for (const p of staleParts())
|
|
123
|
+
fs.unlinkSync(p);
|
|
124
|
+
fs.renameSync(tmpAbs, destAbs);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// split at line boundaries into parts; remove single-file form + extra parts
|
|
128
|
+
const content = fs.readFileSync(tmpAbs, 'utf8');
|
|
129
|
+
let offset = 0, part = 0;
|
|
130
|
+
while (offset < content.length) {
|
|
131
|
+
let end = Math.min(offset + PART_BYTES, content.length);
|
|
132
|
+
if (end < content.length) {
|
|
133
|
+
const nl = content.lastIndexOf('\n', end);
|
|
134
|
+
if (nl > offset)
|
|
135
|
+
end = nl + 1;
|
|
136
|
+
}
|
|
137
|
+
fs.writeFileSync(`${destAbs}.part-${String(part).padStart(4, '0')}`, content.slice(offset, end));
|
|
138
|
+
offset = end;
|
|
139
|
+
part++;
|
|
140
|
+
}
|
|
141
|
+
for (const p of staleParts()) {
|
|
142
|
+
const idx = Number(p.slice(-4));
|
|
143
|
+
if (idx >= part)
|
|
144
|
+
fs.unlinkSync(p);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
fs.unlinkSync(destAbs);
|
|
148
|
+
}
|
|
149
|
+
catch { /* none */ }
|
|
150
|
+
fs.unlinkSync(tmpAbs);
|
|
151
|
+
}
|
|
152
|
+
function writeIfChanged(abs, content) {
|
|
153
|
+
if (fs.existsSync(abs) && fs.readFileSync(abs, 'utf8') === content)
|
|
154
|
+
return;
|
|
155
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
156
|
+
fs.writeFileSync(abs, content);
|
|
157
|
+
}
|
|
158
|
+
// ── claude-code fragment merging ───────────────────────────────────────────
|
|
159
|
+
// Claude Code's writer emits ONE RECORD PER CONTENT BLOCK: a response with
|
|
160
|
+
// parallel tool calls becomes N assistant records (same requestId/message.id),
|
|
161
|
+
// and their results N separate user records. Canon's contract is one canonical
|
|
162
|
+
// message per logical API message — strict providers (ChatCompletions) reject
|
|
163
|
+
// assistant→assistant→tool sequences, and orphan-repair heuristics misfire on
|
|
164
|
+
// fragments. Reconstruct true boundaries here so EVERY consumer is healed.
|
|
165
|
+
// parentUuid links to merged-away fragments are remapped (children always
|
|
166
|
+
// follow parents in an append-only log, so a forward-pass map suffices).
|
|
167
|
+
// Native granularity is fully retained in /native; merged canon records carry
|
|
168
|
+
// mergedFrom (absorbed uuids) for traceability.
|
|
169
|
+
function asBlocks(content) {
|
|
170
|
+
if (Array.isArray(content))
|
|
171
|
+
return content;
|
|
172
|
+
if (typeof content === 'string')
|
|
173
|
+
return [{ type: 'text', text: content }];
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
function isToolResultOnly(rec) {
|
|
177
|
+
const c = rec?.message?.content;
|
|
178
|
+
return Array.isArray(c) && c.length > 0 && c.every((b) => b?.type === 'tool_result');
|
|
179
|
+
}
|
|
180
|
+
function asstGroupKey(rec) {
|
|
181
|
+
return rec.requestId ?? rec.message?.id ?? rec.uuid;
|
|
182
|
+
}
|
|
183
|
+
function mergeFragments(frags) {
|
|
184
|
+
if (frags.length === 1)
|
|
185
|
+
return frags[0];
|
|
186
|
+
const merged = { ...frags[0] };
|
|
187
|
+
merged.message = { ...frags[0].message };
|
|
188
|
+
merged.message.content = frags.flatMap((f) => asBlocks(f.message?.content));
|
|
189
|
+
// usage/model: the last fragment carrying them describes the full response
|
|
190
|
+
for (const f of frags) {
|
|
191
|
+
if (f.message?.usage)
|
|
192
|
+
merged.message.usage = f.message.usage;
|
|
193
|
+
if (f.message?.model)
|
|
194
|
+
merged.message.model = f.message.model;
|
|
195
|
+
}
|
|
196
|
+
merged.mergedFrom = frags.slice(1).map((f) => f.uuid).filter(Boolean);
|
|
197
|
+
return merged;
|
|
198
|
+
}
|
|
199
|
+
function toCanonClaude(rec, ctx) {
|
|
200
|
+
const t = rec.type;
|
|
201
|
+
if (!MESSAGE_TYPES.has(t))
|
|
202
|
+
return { event: rec };
|
|
203
|
+
if (t === 'user') {
|
|
204
|
+
const c = rec.message?.content;
|
|
205
|
+
const isToolResult = Array.isArray(c) && c.some((b) => b?.type === 'tool_result');
|
|
206
|
+
if (!isToolResult)
|
|
207
|
+
ctx.turn++;
|
|
208
|
+
}
|
|
209
|
+
const canon = { ...rec };
|
|
210
|
+
if (t === 'file-history-snapshot') {
|
|
211
|
+
canon.uuid = canon.uuid ?? `fhs-${rec.messageId}`;
|
|
212
|
+
canon.timestamp = canon.timestamp ?? rec.snapshot?.timestamp;
|
|
213
|
+
}
|
|
214
|
+
// Canon SystemMessage.content is a required string; Claude Code emits
|
|
215
|
+
// metadata-only system events (turn_duration, stop_hook_summary) without one.
|
|
216
|
+
if (t === 'system' && canon.content === undefined)
|
|
217
|
+
canon.content = '';
|
|
218
|
+
const sid = rec.sessionId ?? ctx.sessionId;
|
|
219
|
+
canon.timeline = {
|
|
220
|
+
sessionId: sid,
|
|
221
|
+
conversationId: sid, // claude-code has no sub-session conversations
|
|
222
|
+
turnNumber: Math.max(0, ctx.turn),
|
|
223
|
+
};
|
|
224
|
+
if (t === 'assistant' && rec.message?.model) {
|
|
225
|
+
canon.model = { id: rec.message.model, provider: 'anthropic', apiPattern: 'messages' };
|
|
226
|
+
}
|
|
227
|
+
const u = t === 'assistant' ? rec.message?.usage : undefined;
|
|
228
|
+
if (u) {
|
|
229
|
+
canon.usage = {
|
|
230
|
+
inputTokens: u.input_tokens ?? 0,
|
|
231
|
+
outputTokens: u.output_tokens ?? 0,
|
|
232
|
+
cache: {
|
|
233
|
+
cacheCreationTokens: u.cache_creation_input_tokens,
|
|
234
|
+
cacheReadTokens: u.cache_read_input_tokens,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
canon.provenance = { harness: 'claude-code', native: ctx.nativeRel, line: ctx.line, ref: ctx.ref };
|
|
239
|
+
return { canon };
|
|
240
|
+
}
|
|
241
|
+
// ── translate one logical native file ──────────────────────────────────────
|
|
242
|
+
async function translateFile(lf, harness) {
|
|
243
|
+
if (manifest[lf.rel] === lf.sig) {
|
|
244
|
+
unchanged++;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const st = (stats[harness] ??= { files: 0, messages: 0, events: 0 });
|
|
248
|
+
const destRel = path.join('canon', lf.rel);
|
|
249
|
+
const destAbs = path.join(STORE, destRel);
|
|
250
|
+
const eventsAbs = destAbs.replace(/\.jsonl$/, '.events.jsonl');
|
|
251
|
+
const ctx = {
|
|
252
|
+
sessionId: path.basename(lf.rel, '.jsonl'),
|
|
253
|
+
nativeRel: path.join('native', lf.rel),
|
|
254
|
+
ref: '', line: 0, turn: -1,
|
|
255
|
+
};
|
|
256
|
+
let msgCount = 0;
|
|
257
|
+
const events = [];
|
|
258
|
+
const fileErrors = [];
|
|
259
|
+
if (DRY) {
|
|
260
|
+
st.files++;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
264
|
+
const tmpAbs = destAbs + '.tmp';
|
|
265
|
+
const out = fs.createWriteStream(tmpAbs);
|
|
266
|
+
const remap = new Map();
|
|
267
|
+
// Sidecarred events (attachments, snapshots) can sit INSIDE the parentUuid
|
|
268
|
+
// chain. Claude Code's renderer walks that chain and silently EXCLUDES
|
|
269
|
+
// everything upstream of a broken link (measured: a 555KB skill doc vanished
|
|
270
|
+
// from rendered context). Re-parent chain children through sidecarred
|
|
271
|
+
// events to the nearest surviving message ancestor.
|
|
272
|
+
const eventParent = new Map();
|
|
273
|
+
const emittedUuids = new Set();
|
|
274
|
+
let lastEmittedUuid = null;
|
|
275
|
+
const resolveParent = (p) => {
|
|
276
|
+
const seen = new Set();
|
|
277
|
+
while (typeof p === 'string' && !seen.has(p)) {
|
|
278
|
+
seen.add(p);
|
|
279
|
+
if (remap.has(p)) {
|
|
280
|
+
p = remap.get(p);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (eventParent.has(p)) {
|
|
284
|
+
p = eventParent.get(p);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
return p ?? null;
|
|
290
|
+
};
|
|
291
|
+
const usesSeen = new Set();
|
|
292
|
+
const cortexUses = new Set();
|
|
293
|
+
const cortexAnswered = new Set();
|
|
294
|
+
let cortexOpen = []; // pending calls from the last assistant
|
|
295
|
+
// claude-code abandoned-call repair: tool_use ids emitted by flushAsst but
|
|
296
|
+
// not yet answered by a flushRes. When a non-result MESSAGE record interposes
|
|
297
|
+
// (a user interrupted a long tool call mid-conversation), the call is
|
|
298
|
+
// abandoned — synthesize an error-marked result so the A4 pairing lint and
|
|
299
|
+
// strict providers accept the history. Mirror of the nexus-cortex path's
|
|
300
|
+
// repair; only true tail calls (EOF) stay open ("next sync completes them").
|
|
301
|
+
// Fixes the A4 red streak: the claude-code path had orphan-RESULT repair but
|
|
302
|
+
// no abandoned-CALL repair — built on the wrong assumption that Claude Code
|
|
303
|
+
// always writes "[interrupted]" results (this session is the counterexample).
|
|
304
|
+
let ccOpen = [];
|
|
305
|
+
// last-seen valid native timestamp — fallback for synthetic repair records
|
|
306
|
+
// whose stranding/current source record carries none (e.g. Claude Code's
|
|
307
|
+
// file-history-snapshot records: {type,messageId,snapshot,isSnapshotUpdate},
|
|
308
|
+
// no timestamp field). Without this, drainAbandoned wrote timestamp:undefined
|
|
309
|
+
// → JSON.stringify dropped the key → verify's 'missing timestamp' failure.
|
|
310
|
+
let lastTs = undefined;
|
|
311
|
+
let pendAsst = [];
|
|
312
|
+
let pendRes = [];
|
|
313
|
+
const emitCanon = (rec, line, ref) => {
|
|
314
|
+
if (rec.parentUuid)
|
|
315
|
+
rec.parentUuid = resolveParent(rec.parentUuid);
|
|
316
|
+
// Chain integrity is an ABSOLUTE invariant of the canonical line (lint-
|
|
317
|
+
// enforced): a parent that still doesn't resolve (dropped upstream for any
|
|
318
|
+
// reason) re-parents to the previous emitted record.
|
|
319
|
+
if (typeof rec.parentUuid === 'string' && !emittedUuids.has(rec.parentUuid)) {
|
|
320
|
+
rec.parentUuid = lastEmittedUuid;
|
|
321
|
+
}
|
|
322
|
+
const bs = Array.isArray(rec.message?.content) ? rec.message.content : [];
|
|
323
|
+
for (const b of bs)
|
|
324
|
+
if (b?.type === 'tool_use' && b.id)
|
|
325
|
+
usesSeen.add(b.id);
|
|
326
|
+
ctx.line = line;
|
|
327
|
+
ctx.ref = ref;
|
|
328
|
+
const { canon } = toCanonClaude(rec, ctx);
|
|
329
|
+
if (canon.uuid) {
|
|
330
|
+
emittedUuids.add(canon.uuid);
|
|
331
|
+
lastEmittedUuid = canon.uuid;
|
|
332
|
+
}
|
|
333
|
+
out.write(JSON.stringify(canon) + '\n');
|
|
334
|
+
msgCount++;
|
|
335
|
+
};
|
|
336
|
+
const flushAsst = () => {
|
|
337
|
+
if (!pendAsst.length)
|
|
338
|
+
return;
|
|
339
|
+
const first = pendAsst[0];
|
|
340
|
+
const merged = mergeFragments(pendAsst.map((g) => g.rec));
|
|
341
|
+
for (const g of pendAsst.slice(1))
|
|
342
|
+
if (g.rec.uuid)
|
|
343
|
+
remap.set(g.rec.uuid, first.rec.uuid);
|
|
344
|
+
emitCanon(merged, first.line, first.ref);
|
|
345
|
+
for (const b of asBlocks(merged.message?.content))
|
|
346
|
+
if (b?.type === 'tool_use' && b.id)
|
|
347
|
+
ccOpen.push({ id: b.id, name: b.name });
|
|
348
|
+
pendAsst = [];
|
|
349
|
+
};
|
|
350
|
+
// Emit synthetic error results for any calls abandoned mid-conversation
|
|
351
|
+
// (called only when a non-result record definitively strands them). True
|
|
352
|
+
// tail calls are never drained — the loop's final flush leaves ccOpen intact.
|
|
353
|
+
const drainAbandoned = (ts, line, blob) => {
|
|
354
|
+
if (!ccOpen.length)
|
|
355
|
+
return;
|
|
356
|
+
orphanRepairs += ccOpen.length;
|
|
357
|
+
out.write(JSON.stringify({
|
|
358
|
+
uuid: `synth-result-${ccOpen[0].id}`,
|
|
359
|
+
timestamp: ts,
|
|
360
|
+
type: 'user',
|
|
361
|
+
synthetic: 'canon-abandoned-call-repair',
|
|
362
|
+
message: {
|
|
363
|
+
role: 'user',
|
|
364
|
+
content: ccOpen.map((o) => ({
|
|
365
|
+
type: 'tool_result', tool_use_id: o.id, is_error: true,
|
|
366
|
+
content: '[canon repair: no result was recorded for this call — the harness turn was interrupted]',
|
|
367
|
+
})),
|
|
368
|
+
},
|
|
369
|
+
provenance: { harness, native: ctx.nativeRel, line, ref: blob },
|
|
370
|
+
}) + '\n');
|
|
371
|
+
msgCount++;
|
|
372
|
+
ccOpen = [];
|
|
373
|
+
};
|
|
374
|
+
// Orphan-result repair happens at result-group flush, preserving STRICT pair
|
|
375
|
+
// interleaving (assistant-uses → its results ; synth-use → orphan results).
|
|
376
|
+
// Observed source: user-backgrounded tools — Claude Code skips the assistant
|
|
377
|
+
// tool_use record on mid-turn interruption but still writes the result,
|
|
378
|
+
// sometimes interleaved INSIDE another pair. Emitting the synthetic use
|
|
379
|
+
// adjacent to the orphan result (and only after the real pair closes) keeps
|
|
380
|
+
// the canonical line valid for every consumer. Explicit, never silent.
|
|
381
|
+
const flushRes = () => {
|
|
382
|
+
if (!pendRes.length)
|
|
383
|
+
return;
|
|
384
|
+
const first = pendRes[0];
|
|
385
|
+
const merged = mergeFragments(pendRes.map((g) => g.rec));
|
|
386
|
+
for (const g of pendRes.slice(1))
|
|
387
|
+
if (g.rec.uuid)
|
|
388
|
+
remap.set(g.rec.uuid, first.rec.uuid);
|
|
389
|
+
const bs = asBlocks(merged.message?.content);
|
|
390
|
+
for (const b of bs)
|
|
391
|
+
if (b?.type === 'tool_result' && b.tool_use_id)
|
|
392
|
+
ccOpen = ccOpen.filter((o) => o.id !== b.tool_use_id);
|
|
393
|
+
const orphans = bs.filter((b) => b?.type === 'tool_result' && b.tool_use_id && !usesSeen.has(b.tool_use_id));
|
|
394
|
+
if (!orphans.length) {
|
|
395
|
+
emitCanon(merged, first.line, first.ref);
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
const paired = bs.filter((b) => !orphans.includes(b));
|
|
399
|
+
if (paired.length) {
|
|
400
|
+
emitCanon({ ...merged, message: { ...merged.message, content: paired } }, first.line, first.ref);
|
|
401
|
+
}
|
|
402
|
+
emitCanon({
|
|
403
|
+
uuid: `synth-use-${orphans[0].tool_use_id}`,
|
|
404
|
+
timestamp: merged.timestamp,
|
|
405
|
+
type: 'assistant',
|
|
406
|
+
synthetic: 'canon-orphan-result-repair',
|
|
407
|
+
sessionId: merged.sessionId,
|
|
408
|
+
message: {
|
|
409
|
+
role: 'assistant',
|
|
410
|
+
content: orphans.map((b) => ({
|
|
411
|
+
type: 'tool_use', id: b.tool_use_id, name: b.tool_name ?? 'unknown_tool', input: {},
|
|
412
|
+
})),
|
|
413
|
+
},
|
|
414
|
+
}, first.line, first.ref);
|
|
415
|
+
emitCanon({
|
|
416
|
+
...merged,
|
|
417
|
+
uuid: paired.length ? `${merged.uuid}-orphan-results` : merged.uuid,
|
|
418
|
+
message: { ...merged.message, content: orphans },
|
|
419
|
+
}, first.line, first.ref);
|
|
420
|
+
}
|
|
421
|
+
pendRes = [];
|
|
422
|
+
};
|
|
423
|
+
let lineNo = 0;
|
|
424
|
+
for await (const [line, blob] of logicalLines(lf.parts)) {
|
|
425
|
+
lineNo++;
|
|
426
|
+
let rec;
|
|
427
|
+
try {
|
|
428
|
+
rec = JSON.parse(line);
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
fileErrors.push(`${ctx.nativeRel}:${lineNo} — unparseable JSON: ${String(e).slice(0, 120)}`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (rec && rec.timestamp)
|
|
435
|
+
lastTs = rec.timestamp; // track for synthetic-record fallback
|
|
436
|
+
if (harness === 'nexus-cortex') {
|
|
437
|
+
// Identity — EXCEPT structural repairs the A4 pairing lint demands:
|
|
438
|
+
// (1) wrapped-block normalization: older JSONLHistoryStore versions
|
|
439
|
+
// wrote {type:'tool_use', toolUse:{id,name,input,metadata}} instead
|
|
440
|
+
// of the flat Anthropic wire shape the canon spec declares;
|
|
441
|
+
// (2) duplicate tool_results dropped (legacy retry-loop artifact wrote
|
|
442
|
+
// the same results dozens of times — providers reject duplicate
|
|
443
|
+
// responses; count is reported, native keeps them verbatim);
|
|
444
|
+
// (3) orphan tool_results get a marked synthetic tool_use, same repair
|
|
445
|
+
// contract as the claude-code path.
|
|
446
|
+
if (Array.isArray(rec.message?.content)) {
|
|
447
|
+
rec.message.content = rec.message.content.map((b) => b?.type === 'tool_use' && b.toolUse
|
|
448
|
+
? { type: 'tool_use', id: b.toolUse.id, name: b.toolUse.name, input: b.toolUse.input, ...(b.toolUse.metadata ? { metadata: b.toolUse.metadata } : {}) }
|
|
449
|
+
: b);
|
|
450
|
+
const kept = [];
|
|
451
|
+
const orphanIds = [];
|
|
452
|
+
for (const b of rec.message.content) {
|
|
453
|
+
if (b?.type === 'tool_use' && b.id) {
|
|
454
|
+
cortexUses.add(b.id);
|
|
455
|
+
kept.push(b);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (b?.type === 'tool_result' && b.tool_use_id) {
|
|
459
|
+
if (cortexAnswered.has(b.tool_use_id)) {
|
|
460
|
+
dupResults++;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!cortexUses.has(b.tool_use_id))
|
|
464
|
+
orphanIds.push({ id: b.tool_use_id, name: b.tool_name });
|
|
465
|
+
cortexAnswered.add(b.tool_use_id);
|
|
466
|
+
}
|
|
467
|
+
kept.push(b);
|
|
468
|
+
}
|
|
469
|
+
if (!kept.length)
|
|
470
|
+
continue; // record was entirely duplicate results
|
|
471
|
+
rec.message.content = kept;
|
|
472
|
+
// Abandoned-call repair (mirror of orphan-result): a pending call
|
|
473
|
+
// followed by a non-result record never got its result recorded
|
|
474
|
+
// (crashed/killed turn — Claude Code writes "[interrupted]" results
|
|
475
|
+
// itself; older cortex server sessions did not). Synthesize an
|
|
476
|
+
// error-marked result so strict providers accept the history. Tail
|
|
477
|
+
// in-flight calls (EOF) stay untouched — next sync completes them.
|
|
478
|
+
const hasResult = kept.some((b) => b?.type === 'tool_result');
|
|
479
|
+
if (!hasResult && cortexOpen.length) {
|
|
480
|
+
orphanRepairs += cortexOpen.length;
|
|
481
|
+
out.write(JSON.stringify({
|
|
482
|
+
uuid: `synth-result-${cortexOpen[0].id}`,
|
|
483
|
+
timestamp: rec.timestamp ?? lastTs,
|
|
484
|
+
type: 'user',
|
|
485
|
+
synthetic: 'canon-abandoned-call-repair',
|
|
486
|
+
message: {
|
|
487
|
+
role: 'user',
|
|
488
|
+
content: cortexOpen.map((o) => ({
|
|
489
|
+
type: 'tool_result', tool_use_id: o.id, is_error: true,
|
|
490
|
+
content: '[canon repair: no result was recorded for this call — the harness turn was interrupted]',
|
|
491
|
+
})),
|
|
492
|
+
},
|
|
493
|
+
provenance: { harness, native: ctx.nativeRel, line: lineNo, ref: blob },
|
|
494
|
+
}) + '\n');
|
|
495
|
+
msgCount++;
|
|
496
|
+
for (const o of cortexOpen)
|
|
497
|
+
cortexAnswered.add(o.id);
|
|
498
|
+
cortexOpen = [];
|
|
499
|
+
}
|
|
500
|
+
for (const b of kept) {
|
|
501
|
+
if (b?.type === 'tool_use' && b.id)
|
|
502
|
+
cortexOpen.push({ id: b.id, name: b.name });
|
|
503
|
+
if (b?.type === 'tool_result' && b.tool_use_id)
|
|
504
|
+
cortexOpen = cortexOpen.filter((o) => o.id !== b.tool_use_id);
|
|
505
|
+
}
|
|
506
|
+
if (orphanIds.length) {
|
|
507
|
+
orphanRepairs += orphanIds.length;
|
|
508
|
+
for (const o of orphanIds)
|
|
509
|
+
cortexUses.add(o.id);
|
|
510
|
+
out.write(JSON.stringify({
|
|
511
|
+
uuid: `synth-use-${orphanIds[0].id}`,
|
|
512
|
+
timestamp: rec.timestamp ?? lastTs,
|
|
513
|
+
type: 'assistant',
|
|
514
|
+
synthetic: 'canon-orphan-result-repair',
|
|
515
|
+
message: { role: 'assistant', content: orphanIds.map((o) => ({ type: 'tool_use', id: o.id, name: o.name ?? 'unknown_tool', input: {} })) },
|
|
516
|
+
provenance: { harness, native: ctx.nativeRel, line: lineNo, ref: blob },
|
|
517
|
+
}) + '\n');
|
|
518
|
+
msgCount++;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// same chain-integrity guarantee on the identity path (second-generation
|
|
522
|
+
// files — pulled sessions re-synced as cortex natives — carry parents
|
|
523
|
+
// that were sidecarred in their first translation)
|
|
524
|
+
if (typeof rec.parentUuid === 'string' && !emittedUuids.has(rec.parentUuid)) {
|
|
525
|
+
rec.parentUuid = lastEmittedUuid;
|
|
526
|
+
}
|
|
527
|
+
if (rec.uuid) {
|
|
528
|
+
emittedUuids.add(rec.uuid);
|
|
529
|
+
lastEmittedUuid = rec.uuid;
|
|
530
|
+
}
|
|
531
|
+
rec.provenance = { harness, native: ctx.nativeRel, line: lineNo, ref: blob };
|
|
532
|
+
out.write(JSON.stringify(rec) + '\n');
|
|
533
|
+
msgCount++;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (!MESSAGE_TYPES.has(rec.type)) {
|
|
537
|
+
// sidecar events never break a fragment run (attachments etc. interleave)
|
|
538
|
+
if (rec.uuid)
|
|
539
|
+
eventParent.set(rec.uuid, rec.parentUuid ?? null);
|
|
540
|
+
events.push(line);
|
|
541
|
+
eventTypeCounts[rec.type ?? '?'] = (eventTypeCounts[rec.type ?? '?'] ?? 0) + 1;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (rec.type === 'assistant') {
|
|
545
|
+
flushRes();
|
|
546
|
+
if (pendAsst.length && asstGroupKey(pendAsst[0].rec) !== asstGroupKey(rec))
|
|
547
|
+
flushAsst();
|
|
548
|
+
pendAsst.push({ rec, line: lineNo, ref: blob });
|
|
549
|
+
}
|
|
550
|
+
else if (rec.type === 'user' && isToolResultOnly(rec)) {
|
|
551
|
+
flushAsst();
|
|
552
|
+
pendRes.push({ rec, line: lineNo, ref: blob });
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
flushAsst();
|
|
556
|
+
flushRes();
|
|
557
|
+
// A non-result message record interposes — any still-open call from the
|
|
558
|
+
// just-flushed assistant was abandoned mid-conversation. Repair before
|
|
559
|
+
// emitting this record so the tool_use/tool_result pair stays adjacent.
|
|
560
|
+
drainAbandoned(rec.timestamp ?? lastTs, lineNo, blob);
|
|
561
|
+
emitCanon(rec, lineNo, blob);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// EOF flush: tail in-flight calls are deliberately left in ccOpen (undrained)
|
|
565
|
+
// — verify's tail window allows them; the next sync completes the pair.
|
|
566
|
+
flushAsst();
|
|
567
|
+
flushRes();
|
|
568
|
+
await new Promise((res, rej) => out.end((e) => (e ? rej(e) : res())));
|
|
569
|
+
if (msgCount === 0 && events.length === 0 && fileErrors.length === 0) {
|
|
570
|
+
// nothing translatable (e.g. empty file) — visible, not silent
|
|
571
|
+
fs.unlinkSync(tmpAbs);
|
|
572
|
+
errors.push(`${ctx.nativeRel} — empty logical file (0 records)`);
|
|
573
|
+
manifest[lf.rel] = lf.sig;
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
finalizeOutput(destAbs, tmpAbs);
|
|
577
|
+
if (events.length)
|
|
578
|
+
fs.writeFileSync(eventsAbs, events.join('\n') + '\n');
|
|
579
|
+
else if (fs.existsSync(eventsAbs))
|
|
580
|
+
fs.unlinkSync(eventsAbs);
|
|
581
|
+
// projection: canon IS the nexus-cortex dialect — materialize by reference
|
|
582
|
+
writeIfChanged(path.join(STORE, 'projections', 'nexus-cortex', lf.rel + '.ref'), destRel + '\n');
|
|
583
|
+
errors.push(...fileErrors);
|
|
584
|
+
if (!fileErrors.length)
|
|
585
|
+
manifest[lf.rel] = lf.sig; // failed files retry next run
|
|
586
|
+
st.files++;
|
|
587
|
+
st.messages += msgCount;
|
|
588
|
+
st.events += events.length;
|
|
589
|
+
}
|
|
590
|
+
// ── docs the store carries about its own translation ───────────────────────
|
|
591
|
+
const MAPPING_MD = `# /canon — the canonical line
|
|
592
|
+
|
|
593
|
+
One canonical Message per line (schema: nexus-cortex \`packages/core/src/session/MessageTypes.ts\`,
|
|
594
|
+
spec: \`docs/CANON.md\`). Derived from /native by \`canon-translate\`; re-derivable at any time.
|
|
595
|
+
|
|
596
|
+
## claude-code → canon
|
|
597
|
+
- \`user\` / \`assistant\` / \`system\` / \`file-history-snapshot\` → canon Message.
|
|
598
|
+
\`message\` bodies are VERBATIM (canonical ContentBlock is the Anthropic wire shape).
|
|
599
|
+
Added: \`timeline\` {sessionId, conversationId=sessionId, turnNumber}, top-level
|
|
600
|
+
\`model\` {id: message.model, provider: anthropic, apiPattern: messages}, top-level
|
|
601
|
+
\`usage\` (camelCase view of message.usage), \`provenance\` {harness, native, line, ref}.
|
|
602
|
+
All native fields retained — canon is a superset; nothing is lost at write time.
|
|
603
|
+
- turnNumber semantics: increments on each user PROMPT (user message with no
|
|
604
|
+
tool_result block); assistant/tool/system records share the current turn.
|
|
605
|
+
- FRAGMENT MERGING: Claude Code's writer emits one record per content block
|
|
606
|
+
(parallel tool calls = N assistant records sharing a requestId; results = N
|
|
607
|
+
user records). Canon reconstructs the TRUE message boundary: consecutive
|
|
608
|
+
same-requestId assistant fragments merge into one canonical assistant message
|
|
609
|
+
(content blocks concatenated; \`mergedFrom\` lists absorbed uuids; parentUuid
|
|
610
|
+
links into absorbed fragments are remapped), and consecutive tool-result-only
|
|
611
|
+
user records merge likewise. Native fragment granularity remains in /native.
|
|
612
|
+
- ORPHAN-RESULT REPAIR: a tool_result whose tool_use record was never written
|
|
613
|
+
(user-backgrounded tools interrupted mid-turn) gets a SYNTHETIC assistant
|
|
614
|
+
tool_use emitted before it, marked \`synthetic: canon-orphan-result-repair\` —
|
|
615
|
+
the canonical line is structurally valid for every consumer, and the repair
|
|
616
|
+
is explicit, never silent. Tail-side DANGLING tool_use (live session snapshotted
|
|
617
|
+
mid-call) is left as-is: the receiving harness's crash-repair handles it and
|
|
618
|
+
the next sync completes the pair append-only.
|
|
619
|
+
- Every other record type (mode, permission-mode, last-prompt, ai-title, attachment,
|
|
620
|
+
queue-operation, file-history-delta, started, result, unknown) → the
|
|
621
|
+
\`<session>.events.jsonl\` sidecar, verbatim. Carried, never dropped.
|
|
622
|
+
- \`file-history-snapshot\` lacks uuid/timestamp natively → uuid=\`fhs-<messageId>\`,
|
|
623
|
+
timestamp from snapshot.timestamp.
|
|
624
|
+
|
|
625
|
+
## nexus-cortex → canon
|
|
626
|
+
Identity + \`provenance\` stamp (the harness's native format IS canon).
|
|
627
|
+
|
|
628
|
+
## Provenance
|
|
629
|
+
\`provenance.ref\` = git BLOB SHA (12 hex) of the native file (or .part chunk)
|
|
630
|
+
containing the source line — content-stable, so re-translating unchanged natives
|
|
631
|
+
yields byte-identical canon. Each record is Merkle-anchored to its verbatim
|
|
632
|
+
native source through git's object model.
|
|
633
|
+
|
|
634
|
+
## Not yet translated
|
|
635
|
+
grok-build and gemini-cli natives are stored but have no adapter yet — see
|
|
636
|
+
TRANSLATED.md. Adapters live in the nexus-cortex library and arrive with Phase C.
|
|
637
|
+
`;
|
|
638
|
+
const PROJECTIONS_MD = `# /projections — canon fanned back out into each harness dialect
|
|
639
|
+
|
|
640
|
+
## nexus-cortex/
|
|
641
|
+
Canon IS the nexus-cortex dialect, so these projections are materialized BY
|
|
642
|
+
REFERENCE: each \`<path>.ref\` file contains the store-relative path of the canon
|
|
643
|
+
file to load. Resolution: read the ref, open that path (a \`.jsonl\`, or its
|
|
644
|
+
\`.part-NNNN\` chunks concatenated in order). To resume a session in the
|
|
645
|
+
nexus-cortex TUI, copy/cat the resolved file into \`.cortex/sessions/\`.
|
|
646
|
+
|
|
647
|
+
Byte-duplicating identity projections would double the repo for zero information;
|
|
648
|
+
refs keep the "repo speaks every dialect" contract explicit and cheap.
|
|
649
|
+
|
|
650
|
+
## Other dialects
|
|
651
|
+
Divergent projections (claude-code, gemini, grok renderings of canon) are produced
|
|
652
|
+
by the library's gateway adapters and arrive with Phase C (\`cortex canon translate\`).
|
|
653
|
+
Until then their absence is stated here rather than implied.
|
|
654
|
+
`;
|
|
655
|
+
const CANON_REPO = o.repoUrl ?? process.env.CANON_REPO ?? 'https://github.com/Spitfire-Products/nexus-canon-store';
|
|
656
|
+
if (!fs.existsSync(path.join(STORE, '.git'))) {
|
|
657
|
+
// Working clone is disposable (quota lesson 2026-07-27: keep it OFF the
|
|
658
|
+
// workspace quota — pass --store /tmp/canon-store); remote is the truth.
|
|
659
|
+
console.log(`[canon-translate] no store at ${STORE} — cloning ${CANON_REPO}`);
|
|
660
|
+
execFileSync('git', ['clone', '-q', CANON_REPO, STORE], {
|
|
661
|
+
encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
const git = (a) => execFileSync('git', a, { cwd: STORE, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
|
|
665
|
+
blobs = blobMap();
|
|
666
|
+
const claudeFiles = discover(path.join(STORE, 'native', 'claude-code'), 'claude-code');
|
|
667
|
+
const cortexFiles = discover(path.join(STORE, 'native', 'nexus-cortex'), 'nexus-cortex');
|
|
668
|
+
for (const lf of claudeFiles)
|
|
669
|
+
await translateFile(lf, 'claude-code');
|
|
670
|
+
for (const lf of cortexFiles)
|
|
671
|
+
await translateFile(lf, 'nexus-cortex');
|
|
672
|
+
const translated = Object.values(stats).reduce((n, s) => n + s.files, 0);
|
|
673
|
+
const repairNote = dupResults + orphanRepairs > 0 ? `, ${orphanRepairs} orphan-repair(s), ${dupResults} dup result(s) dropped` : '';
|
|
674
|
+
const summary = `${translated} translated, ${unchanged} unchanged, ${errors.length} error(s)${repairNote}`;
|
|
675
|
+
if (DRY) {
|
|
676
|
+
console.log(`[canon-translate DRY] would translate ${translated} logical file(s), ${unchanged} unchanged`);
|
|
677
|
+
return { translated, unchanged, errors, summary, pushed: false };
|
|
678
|
+
}
|
|
679
|
+
if (translated || errors.length) {
|
|
680
|
+
const statLines = Object.entries(stats)
|
|
681
|
+
.map(([h, s]) => `| ${h} | ${s.files} | ${s.messages} | ${s.events} |`)
|
|
682
|
+
.join('\n');
|
|
683
|
+
const eventLines = Object.entries(eventTypeCounts).sort()
|
|
684
|
+
.map(([t, n]) => `- \`${t}\`: ${n}`).join('\n');
|
|
685
|
+
writeIfChanged(path.join(STORE, 'canon', 'TRANSLATED.md'), `# Translation census (regenerated by canon-translate; counts are per-run deltas)\n\n` +
|
|
686
|
+
`| harness | files (this run) | messages | events |\n|---|---|---|---|\n${statLines || '| — | 0 | 0 | 0 |'}\n\n` +
|
|
687
|
+
`Sidecar event records this run:\n${eventLines || '- none'}\n\n` +
|
|
688
|
+
`## Native-only (no adapter yet — visible, not silent)\n- grok-build\n- gemini-cli\n` +
|
|
689
|
+
`- nexus-cortex \`*.json\` metadata files (session metadata stays native)\n`);
|
|
690
|
+
writeIfChanged(path.join(STORE, 'canon', 'MAPPING.md'), MAPPING_MD);
|
|
691
|
+
writeIfChanged(path.join(STORE, 'projections', 'nexus-cortex', 'PROJECTIONS.md'), PROJECTIONS_MD);
|
|
692
|
+
}
|
|
693
|
+
const errPath = path.join(STORE, 'canon', 'TRANSLATE_ERRORS.md');
|
|
694
|
+
if (errors.length) {
|
|
695
|
+
fs.writeFileSync(errPath, `# Translate errors (current as of last run — failed files retry next run)\n\n` +
|
|
696
|
+
errors.map((e) => `- ${e}\n`).join(''));
|
|
697
|
+
}
|
|
698
|
+
else if (fs.existsSync(errPath))
|
|
699
|
+
fs.unlinkSync(errPath);
|
|
700
|
+
fs.mkdirSync(path.dirname(MANIFEST_PATH), { recursive: true });
|
|
701
|
+
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest));
|
|
702
|
+
let pushed = false;
|
|
703
|
+
git(['add', '-A']);
|
|
704
|
+
if (git(['status', '--porcelain']).trim()) {
|
|
705
|
+
git(['commit', '-q', '-m', `canon-translate: ${summary}`]);
|
|
706
|
+
git(['push', '-q', 'origin', 'main']);
|
|
707
|
+
console.log(`[canon-translate] pushed: ${summary}`);
|
|
708
|
+
pushed = true;
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
console.log(`[canon-translate] no changes (${summary})`);
|
|
712
|
+
}
|
|
713
|
+
if (errors.length) {
|
|
714
|
+
for (const e of errors.slice(0, 10))
|
|
715
|
+
console.error(' error:', e);
|
|
716
|
+
}
|
|
717
|
+
return { translated, unchanged, errors, summary, pushed };
|
|
718
|
+
}
|
|
719
|
+
//# sourceMappingURL=canonTranslate.js.map
|