@ucsandman/legcli 0.8.0 → 0.9.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +21 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +71 -0
  8. package/docs/README.md +2 -0
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +21 -0
  11. package/docs/board-guide.md +13 -0
  12. package/docs/cli-contracts.md +22 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +22 -1
  15. package/docs/faq.md +19 -0
  16. package/docs/getting-started.md +272 -251
  17. package/docs/harness.md +319 -0
  18. package/fixtures/verified.json +1 -1
  19. package/package.json +7 -3
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/check-branding.mjs +118 -0
  22. package/scripts/check-claims.mjs +1 -1
  23. package/scripts/license-sign.mjs +1 -1
  24. package/scripts/limits-table.mjs +1 -1
  25. package/scripts/live-limits.mjs +1 -1
  26. package/scripts/npm-publish-gate.mjs +114 -0
  27. package/scripts/probe.mjs +4 -3
  28. package/scripts/seed-fake-cards.mjs +4 -3
  29. package/scripts/seed-floor-board.mjs +5 -4
  30. package/scripts/seed-wes-board.mjs +5 -4
  31. package/scripts/stripe-setup.mjs +1 -1
  32. package/scripts/sync-harness-engine.mjs +159 -0
  33. package/scripts/sync-leg-agents.mjs +127 -0
  34. package/src/accounts.mjs +1 -2
  35. package/src/adapters/codex.mjs +1 -1
  36. package/src/attach.mjs +75 -19
  37. package/src/auth.mjs +2 -2
  38. package/src/board/board.js +3 -3
  39. package/src/board/sessions.js +77 -3
  40. package/src/bundle.mjs +54 -8
  41. package/src/chain.mjs +1 -1
  42. package/src/contract.mjs +4 -3
  43. package/src/fsx.mjs +5 -2
  44. package/src/handoff.mjs +6 -6
  45. package/src/harness/cli.mjs +281 -0
  46. package/src/harness/fingerprint.mjs +68 -0
  47. package/src/harness/index.mjs +407 -0
  48. package/src/harness/registry.mjs +124 -0
  49. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  50. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  51. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  52. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  53. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  54. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  55. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  56. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  57. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  69. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  70. package/src/hook.mjs +49 -49
  71. package/src/land.mjs +7 -35
  72. package/src/launcher.mjs +38 -26
  73. package/src/ledger.mjs +6 -6
  74. package/src/license.mjs +10 -9
  75. package/src/live-capture.mjs +1 -1
  76. package/src/mergequeue.mjs +5 -5
  77. package/src/orchestrator.mjs +28 -4
  78. package/src/preferences.mjs +37 -3
  79. package/src/redact.mjs +1 -1
  80. package/src/resume.mjs +17 -15
  81. package/src/runner.mjs +2 -2
  82. package/src/scheduler.mjs +1 -1
  83. package/src/server.mjs +38 -10
  84. package/src/session-detail.mjs +15 -1
  85. package/src/sessions.mjs +6 -3
  86. package/src/share.mjs +2 -2
  87. package/src/stations/agent.mjs +1 -1
  88. package/src/sync/dashclaw.mjs +4 -4
  89. package/src/synthesis.mjs +165 -0
  90. package/src/taps/agy.mjs +2 -2
  91. package/src/taps/claude-usage.mjs +1 -1
  92. package/src/taps/claude.mjs +170 -170
  93. package/src/taps/codex.mjs +286 -286
  94. package/src/taps/grok.mjs +2 -2
  95. package/src/trust.mjs +205 -36
  96. package/src/usage.mjs +5 -1
  97. package/src/worktree.mjs +5 -4
  98. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  99. package/fixtures/live/agy/err.log +0 -0
  100. package/fixtures/live/agy/out.log +0 -1
  101. package/fixtures/live/agy/supervisor.log +0 -2
  102. package/fixtures/live/claude/err.log +0 -0
  103. package/fixtures/live/claude/out.log +0 -1
  104. package/fixtures/live/claude/supervisor.log +0 -2
  105. package/fixtures/live/codex/err.log +0 -1
  106. package/fixtures/live/codex/out.log +0 -8
  107. package/fixtures/live/codex/supervisor.log +0 -2
  108. package/fixtures/live/grok/err.log +0 -32
  109. package/fixtures/live/grok/out.log +0 -7
  110. package/fixtures/live/grok/supervisor.log +0 -2
@@ -0,0 +1,375 @@
1
+ /**
2
+ * engine/harness/common.cjs — helpers shared by every source and target adapter.
3
+ *
4
+ * Nothing here knows a client. Paths, guarded writes, directory links, managed
5
+ * regions inside user-owned files, frontmatter, TOML string escaping, and the
6
+ * secret detector that keeps token values out of generated config.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ const ROOT = path.resolve(__dirname, '..', '..');
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Embedding configuration
19
+ //
20
+ // The engine runs as this repo's own CLI and as a library inside another
21
+ // product. Everything a host may need to own is here, and the defaults are
22
+ // exactly what the repo did before this object existed:
23
+ // brand.mark the ownership claim written into every generated file (and
24
+ // the string makeWriter looks for to recognise a file as ours)
25
+ // brand.region the name inside managed-region markers
26
+ // brand.id a safe identifier for a key a host owns inside a user file
27
+ // secretPatterns regexes replacing core/safety/guards.json (null = read it)
28
+ // shimPath the hook shim targets point at (null = engine/hooks/shim.cjs)
29
+ // `configure()` merges; `GENERATED_MARK` below is a live getter over it.
30
+ // ---------------------------------------------------------------------------
31
+ const config = {
32
+ brand: { id: 'agnostic-ai', mark: 'GENERATED by agnostic-ai', region: 'agnostic-ai' },
33
+ secretPatterns: null,
34
+ shimPath: null,
35
+ // Directories besides the user's home that a rules `@import` may resolve into
36
+ // (null = this repo). A host that ships no importable rules passes [].
37
+ importRoots: null,
38
+ };
39
+
40
+ let patternCache = null;
41
+
42
+ function configure(opts = {}) {
43
+ if (opts.brand) config.brand = Object.assign({}, config.brand, opts.brand);
44
+ if (opts.secretPatterns !== undefined) { config.secretPatterns = opts.secretPatterns; patternCache = null; }
45
+ if (opts.shimPath !== undefined) config.shimPath = opts.shimPath;
46
+ if (opts.importRoots !== undefined) config.importRoots = opts.importRoots;
47
+ return config;
48
+ }
49
+
50
+ function importRoots() {
51
+ return Array.isArray(config.importRoots) ? config.importRoots : [ROOT];
52
+ }
53
+
54
+ function expandPath(p, home = os.homedir()) {
55
+ if (!p) return '';
56
+ let resolved = String(p).replace(/%([^%]+)%/g, (_, n) => process.env[n] || '');
57
+ if (resolved === '~') return home;
58
+ if (resolved.startsWith('~/') || resolved.startsWith('~\\')) return path.join(home, resolved.slice(2));
59
+ if (!path.isAbsolute(resolved)) return path.join(ROOT, resolved);
60
+ return resolved;
61
+ }
62
+
63
+ // Path comparison folds case where the filesystem does (Windows, macOS) and
64
+ // keeps it where two spellings are two files (Linux).
65
+ const foldCase = (s) => (process.platform === 'linux' ? s : s.toLowerCase());
66
+
67
+ /** Render an absolute path back to `~/...` for display and for generated headers. */
68
+ function tildePath(p, home = os.homedir()) {
69
+ const norm = (s) => foldCase(path.resolve(s).replace(/\\/g, '/'));
70
+ const abs = path.resolve(p).replace(/\\/g, '/');
71
+ return norm(p).startsWith(norm(home) + '/') ? '~' + abs.slice(path.resolve(home).length) : abs;
72
+ }
73
+
74
+ const readText = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; } };
75
+ const readJSON = (p) => { const raw = readText(p); if (raw == null) return null; try { return JSON.parse(raw); } catch (_) { return undefined; } };
76
+ const sha = (text) => crypto.createHash('sha256').update(String(text), 'utf8').digest('hex');
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Secret detection (patterns from core/safety/guards.json, fail closed)
80
+ // ---------------------------------------------------------------------------
81
+ const FALLBACK_SENSITIVE = [
82
+ '(?i)sk-[0-9a-zA-Z]{20,}', '(?i)ghp_[0-9a-zA-Z]{36}', '(?i)xox[baprs]-[0-9a-zA-Z-]{10,}',
83
+ '(?i)AIza[0-9A-Za-z-_]{35}', '(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----', '(?i)bearer\\s+[a-zA-Z0-9_\\-\\.]{20,}',
84
+ ];
85
+
86
+ const toRegExp = (p) => (p instanceof RegExp ? p : new RegExp(String(p).replace(/^\(\?i\)/, ''), String(p).startsWith('(?i)') ? 'i' : ''));
87
+
88
+ // Compiled once per configuration: looksSecret() runs on every config value a
89
+ // capture sees, and re-reading guards.json for each one showed up in a host's
90
+ // hand-off timing.
91
+ function sensitivePatterns() {
92
+ if (patternCache) return patternCache;
93
+ if (Array.isArray(config.secretPatterns) && config.secretPatterns.length) {
94
+ patternCache = config.secretPatterns.map(toRegExp);
95
+ return patternCache;
96
+ }
97
+ const guards = readJSON(path.join(ROOT, 'core', 'safety', 'guards.json'));
98
+ const list = guards && guards.guards && guards.guards.secretScan && guards.guards.secretScan.sensitivePatterns;
99
+ patternCache = (list && list.length ? list : FALLBACK_SENSITIVE).map(toRegExp);
100
+ return patternCache;
101
+ }
102
+
103
+ /**
104
+ * True when a config VALUE looks like a credential: matches a known token
105
+ * shape, or its key names a secret and the value is long and high-entropy.
106
+ */
107
+ const SECRET_KEY = /(key|token|secret|password|passwd|pwd|credential|auth|dsn)/i;
108
+ const charClasses = (v) => [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(v)).length;
109
+ // A long, space-free, non-path value that is all hex or mixes three classes
110
+ // is a credential shape whatever its key is called.
111
+ function highEntropy(v) {
112
+ if (v.length < 32 || /\s/.test(v) || /[\\/]/.test(v)) return false;
113
+ if (/^[0-9a-f]+$/i.test(v)) return true;
114
+ return charClasses(v) >= 3;
115
+ }
116
+ // A URL carries a credential in its userinfo password or in a query value
117
+ // that is itself secret-shaped.
118
+ function urlSecret(v, keyed) {
119
+ let u;
120
+ try { u = new URL(v); } catch (_) { return false; }
121
+ if (u.password) return true;
122
+ for (const [k, val] of u.searchParams) if (SECRET_KEY.test(k) && val.length >= 16) return true;
123
+ // under a secret-named key, a long mixed path segment is the credential (a
124
+ // webhook URL, a signed link); under any other key a URL is an address
125
+ if (keyed) for (const seg of u.pathname.split('/')) if (seg.length >= 16 && /\d/.test(seg) && /[a-z]/i.test(seg)) return true;
126
+ return false;
127
+ }
128
+
129
+ function looksSecret(key, value) {
130
+ if (typeof value !== 'string' || !value) return false;
131
+ if (/^\$\{[A-Za-z0-9_]+\}$/.test(value) || /^\$[A-Za-z0-9_]+$/.test(value)) return false; // already a reference
132
+ if (sensitivePatterns().some((re) => re.test(value))) return true;
133
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return urlSecret(value, SECRET_KEY.test(String(key || '')));
134
+ if (highEntropy(value)) return true;
135
+ if (!SECRET_KEY.test(String(key || ''))) return false;
136
+ if (value.length < 16) return false;
137
+ if (/^(true|false|\d+)$/i.test(value)) return false;
138
+ if (/\s/.test(value)) return false;
139
+ // under a secret-named key, one class is enough once the value is long
140
+ return charClasses(value) >= 2 || value.length >= 32;
141
+ }
142
+
143
+ /** An MCP server URL that carries a credential in its userinfo, its query or a long token-like path segment. */
144
+ const urlCarriesCredential = (v) => typeof v === 'string' && urlSecret(v, true);
145
+
146
+ /** Every span of `text` that matches a known token shape, for scanning free text. */
147
+ function findSecrets(text) {
148
+ const out = [];
149
+ const s = String(text == null ? '' : text);
150
+ for (const re of sensitivePatterns()) {
151
+ const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
152
+ let m;
153
+ while ((m = g.exec(s)) !== null) { out.push(m[0]); if (m[0].length === 0) g.lastIndex++; }
154
+ }
155
+ // two patterns matching one span (a host's list beside this one) is one secret
156
+ return [...new Set(out)];
157
+ }
158
+
159
+ /** The same text with every token shape replaced; used before a capture is saved. */
160
+ function redactSecrets(text) {
161
+ let s = String(text == null ? '' : text);
162
+ for (const re of sensitivePatterns()) s = s.replace(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'), '[REDACTED]');
163
+ return s;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Guarded writes (same semantics as engine/sync/sync.cjs::writeGuarded)
168
+ // ---------------------------------------------------------------------------
169
+ function backupFile(backupsDir, tag, targetPath) {
170
+ fs.mkdirSync(backupsDir, { recursive: true });
171
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
172
+ const dest = path.join(backupsDir, `${tag}-${path.basename(targetPath)}-${stamp}.bak`);
173
+ fs.copyFileSync(targetPath, dest);
174
+ return dest;
175
+ }
176
+
177
+ /**
178
+ * Build the `write(path, content, opts)` function handed to adapters.
179
+ * state.files[path] = sha of what we last wrote
180
+ * state.drift[path] = sha of a hand edit we already backed up (backup once)
181
+ * A file whose first line still carries GENERATED_MARK (or the given `header`)
182
+ * is ours to overwrite whatever touched it: the header IS the ownership claim.
183
+ */
184
+ function makeWriter({ state, backupsDir, tag, check, force, log }) {
185
+ state.files = state.files || {};
186
+ state.drift = state.drift || {};
187
+ return function write(targetPath, content, opts = {}) {
188
+ const exists = fs.existsSync(targetPath);
189
+ const existing = exists ? fs.readFileSync(targetPath, 'utf8') : null;
190
+ if (existing === content) {
191
+ state.files[targetPath] = sha(content);
192
+ return { action: 'unchanged', backup: null };
193
+ }
194
+ const recorded = Object.prototype.hasOwnProperty.call(state.files, targetPath);
195
+ // The ownership claim is the GENERATED mark inside the preamble (a title
196
+ // line usually comes first). A title alone is not a claim: a user who keeps
197
+ // the title and deletes the mark has taken the file over.
198
+ const headLines = existing === null ? [] : existing.split(/\r?\n/, 6).slice(0, 5);
199
+ const stillOurs = Boolean(existing !== null && headLines.some((l) => l.includes(config.brand.mark)));
200
+ const managedRegion = Boolean(opts.region); // caller only replaces a marked region; the rest is the user's
201
+ const drifted = Boolean(exists && recorded && state.files[targetPath] !== sha(existing) && !stillOurs && !managedRegion);
202
+ if (check) return { action: drifted ? 'skipped-hand-edited' : 'would-write', backup: null };
203
+ let backup = null;
204
+ if (drifted) {
205
+ if (state.drift[targetPath] !== sha(existing)) {
206
+ backup = backupFile(backupsDir, tag, targetPath);
207
+ state.drift[targetPath] = sha(existing);
208
+ }
209
+ if (!force) {
210
+ if (log) log(` ! skipped (hand-edited): ${targetPath}${backup ? ` (backup ${backup})` : ''}`);
211
+ return { action: 'skipped-hand-edited', backup };
212
+ }
213
+ } else if (exists) {
214
+ backup = backupFile(backupsDir, tag, targetPath);
215
+ }
216
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
217
+ fs.writeFileSync(targetPath, content, 'utf8');
218
+ state.files[targetPath] = sha(content);
219
+ delete state.drift[targetPath];
220
+ return { action: 'written', backup };
221
+ };
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Managed regions inside files the user also owns
226
+ // ---------------------------------------------------------------------------
227
+ const regionMarkers = (component, comment = '#') => ({
228
+ start: `${comment} >>> ${config.brand.region} ${component} start (generated, do not edit)`,
229
+ end: `${comment} <<< ${config.brand.region} ${component} end`,
230
+ });
231
+
232
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
233
+
234
+ /** Replace (or append) the marked region; everything outside is preserved byte for byte. */
235
+ function replaceRegion(text, markers, body) {
236
+ const re = new RegExp(`\\n*${escapeRe(markers.start)}[\\s\\S]*?${escapeRe(markers.end)}\\n?`, 'g');
237
+ const region = body.trim() ? `${markers.start}\n${body.trimEnd()}\n${markers.end}\n` : '';
238
+ const base = (text || '').replace(re, '\n');
239
+ if (!region) return base.replace(/\n{3,}/g, '\n\n');
240
+ return base.replace(/\n{3,}/g, '\n\n').replace(/\s*$/, base.trim() ? '\n\n' : '') + region;
241
+ }
242
+
243
+ function readRegion(text, markers) {
244
+ const re = new RegExp(`${escapeRe(markers.start)}\\n([\\s\\S]*?)${escapeRe(markers.end)}`);
245
+ const m = (text || '').match(re);
246
+ return m ? m[1] : null;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Directory links (junction on Windows, symlink elsewhere). Never replaces a real dir.
251
+ // ---------------------------------------------------------------------------
252
+ const plainPath = (p) => foldCase(path.resolve(String(p).replace(/^\\\\\?\\/, '')).replace(/\\/g, '/'));
253
+
254
+ function readLinkTarget(p) {
255
+ try { return fs.readlinkSync(p); } catch (_) { return null; }
256
+ }
257
+
258
+ function link(src, dest, { check } = {}) {
259
+ const current = readLinkTarget(dest);
260
+ if (current && plainPath(current) === plainPath(src)) return { action: 'unchanged' };
261
+ // A real directory is never replaced, in check mode or otherwise; report the
262
+ // same verdict both ways so --check after a clean apply stays clean.
263
+ if (!current && fs.existsSync(dest)) return { action: 'skipped-real-directory' };
264
+ if (check) return { action: 'would-link' };
265
+ if (current) fs.rmSync(dest, { recursive: true, force: true });
266
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
267
+ try {
268
+ fs.symlinkSync(src, dest, 'junction');
269
+ } catch (err) {
270
+ if (process.platform !== 'win32') throw err;
271
+ // cmd.exe re-parses its command line whatever spawnSync was given, so a
272
+ // path carrying one of its metacharacters is refused rather than quoted
273
+ // and hoped for; the adapter reports the skill as not linked.
274
+ if (/[&|^<>()%!"]/.test(dest) || /[&|^<>()%!"]/.test(src)) throw new Error(`refusing mklink fallback: a path carries a cmd metacharacter (${err.message})`);
275
+ const r = spawnSync('cmd', ['/c', 'mklink', '/J', dest, src], { stdio: 'pipe', windowsHide: true, encoding: 'utf8' });
276
+ if (r.status !== 0) throw new Error(`mklink /J failed: ${(r.stderr || r.stdout || '').trim() || err.message}`);
277
+ }
278
+ return { action: 'linked' };
279
+ }
280
+
281
+ function unlinkIfLink(dest) {
282
+ if (readLinkTarget(dest) === null) return false;
283
+ fs.rmSync(dest, { recursive: true, force: true });
284
+ return true;
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Markdown frontmatter + TOML strings
289
+ // ---------------------------------------------------------------------------
290
+ function parseFrontmatter(md) {
291
+ const m = String(md).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
292
+ if (!m) return { meta: {}, body: String(md) };
293
+ const meta = {};
294
+ let key = null;
295
+ for (const line of m[1].split(/\r?\n/)) {
296
+ const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
297
+ if (kv) { key = kv[1]; meta[key] = kv[2].trim().replace(/^["'](.*)["']$/, '$1'); }
298
+ else if (key && /^\s+\S/.test(line)) meta[key] = `${meta[key]} ${line.trim()}`.trim(); // folded continuation
299
+ }
300
+ return { meta, body: m[2] };
301
+ }
302
+
303
+ /**
304
+ * Quote a frontmatter value when YAML would otherwise read it as something
305
+ * else: `[a b]` is a flow sequence, `{}` a map, `yes`/`no`/`true`/`1.5` typed
306
+ * scalars, a leading `*`, `&`, `!`, `%`, `@`, `` ` ``, `|`, `>`, `'`, `"`, `#`
307
+ * or a `: ` / ` #` inside changes the parse. Bit 2026-09-06: an argument-hint
308
+ * of `[text to translate]` was emitted bare and became a one-item list.
309
+ */
310
+ function yamlScalar(v) {
311
+ const s = String(v);
312
+ // Typed scalars (true, 12, null) are left bare on purpose: `readonly: true`
313
+ // and `alwaysApply: true` must stay booleans for the client that reads them.
314
+ const risky = /^[[\]{}*&!%@`|>'"#,?-]|: | #|^\s|\s$/;
315
+ return risky.test(s) ? JSON.stringify(s) : s;
316
+ }
317
+
318
+ function renderFrontmatter(meta, body) {
319
+ const lines = ['---'];
320
+ for (const [k, v] of Object.entries(meta)) if (v !== undefined && v !== null && v !== '') lines.push(`${k}: ${yamlScalar(v)}`);
321
+ lines.push('---');
322
+ return `${lines.join('\n')}\n\n${String(body).trim()}\n`;
323
+ }
324
+
325
+ const tomlStr = (s) => JSON.stringify(String(s)); // TOML basic strings share JSON escaping
326
+ function tomlMultiline(s) {
327
+ return '"""\n' + String(s).replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"') + '\n"""';
328
+ }
329
+
330
+ /**
331
+ * Remove whole `## <title>` sections from a markdown document. Used by every
332
+ * target that drops source-client-only sections (core/port.json
333
+ * rules.dropSectionsForTargets); engine/sync/sync.cjs shares it.
334
+ */
335
+ function stripSections(markdown, sectionTitles) {
336
+ let result = String(markdown);
337
+ for (const title of sectionTitles || []) {
338
+ // `$(?![\s\S])` is end-of-input; a bare `$` under /m would stop at the first newline.
339
+ const regex = new RegExp(`(^##\\s+${escapeRe(String(title))}\\b[\\s\\S]*?)(?=^##\\s|$(?![\\s\\S]))`, 'm');
340
+ result = result.replace(regex, '');
341
+ }
342
+ return result.replace(/\n{3,}/g, '\n\n').trim();
343
+ }
344
+
345
+ /** The hook shim every non-Codex target points its ported hooks at. */
346
+ function shimPath() {
347
+ return config.shimPath || path.join(ROOT, 'engine', 'hooks', 'shim.cjs');
348
+ }
349
+
350
+ /** List the skill directories (entries holding a SKILL.md) under a dir. Follows links. */
351
+ function listSkills(dir) {
352
+ const out = [];
353
+ let entries;
354
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return out; }
355
+ for (const e of entries) {
356
+ if (e.name.startsWith('.')) continue;
357
+ const full = path.join(dir, e.name);
358
+ let isDir = e.isDirectory();
359
+ if (e.isSymbolicLink()) { try { isDir = fs.statSync(full).isDirectory(); } catch (_) { isDir = false; } }
360
+ if (isDir && fs.existsSync(path.join(full, 'SKILL.md'))) out.push({ name: e.name, path: full, link: readLinkTarget(full) });
361
+ }
362
+ return out.sort((a, b) => a.name.localeCompare(b.name));
363
+ }
364
+
365
+ module.exports = {
366
+ ROOT, config, configure, shimPath, importRoots, expandPath, tildePath, readText, readJSON, sha,
367
+ looksSecret, sensitivePatterns, findSecrets, redactSecrets, urlCarriesCredential,
368
+ makeWriter, backupFile,
369
+ regionMarkers, replaceRegion, readRegion, escapeRe, stripSections,
370
+ link, unlinkIfLink, readLinkTarget, plainPath, listSkills,
371
+ parseFrontmatter, renderFrontmatter, yamlScalar, tomlStr, tomlMultiline,
372
+ };
373
+ // Live: every adapter reads `common.GENERATED_MARK` at call time, so a host that
374
+ // calls configure() before its first capture/apply brands every generated file.
375
+ Object.defineProperty(module.exports, 'GENERATED_MARK', { enumerable: true, get: () => config.brand.mark });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * engine/harness/index.cjs — the port engine as a library.
3
+ *
4
+ * This is the one file a host product requires. Everything a host may need to
5
+ * own is an option, never a repo path:
6
+ *
7
+ * const engine = require('agnostic-ai/engine/harness/index.cjs');
8
+ * engine.configure({ brand: { id, mark, region }, secretPatterns, shimPath });
9
+ * const registry = engine.loadRegistry(home, { targets: myTargets });
10
+ * const { bundle, warnings } = engine.capture({ from: 'claude', home, outDir, registry, port });
11
+ * const report = engine.apply({ bundle, home, registry, port, storageDir, to: ['codex'] });
12
+ * engine.status({ bundle, home, registry, port, storageDir });
13
+ *
14
+ * Nothing here reads `core/port.json`, `core/templates/targets.json` or
15
+ * `core/safety/guards.json` when the host passes `port`, `registry` and
16
+ * `secretPatterns`. The engine's own CLI (cli.cjs) is a thin caller of the
17
+ * same functions with the repo's files as defaults.
18
+ *
19
+ * Node's ESM loader imports this file directly (`import engine from
20
+ * '.../index.cjs'`), which is how a zero-dependency ESM host embeds it
21
+ * without a build step.
22
+ */
23
+
24
+ const common = require('./common.cjs');
25
+ const bundle = require('./bundle.cjs');
26
+ const capture = require('./capture.cjs');
27
+ const apply = require('./apply.cjs');
28
+ const status = require('./status.cjs');
29
+ const toml = require('./toml.cjs');
30
+
31
+ module.exports = {
32
+ // configuration
33
+ configure: common.configure,
34
+ config: common.config,
35
+ // the pipeline
36
+ capture: capture.capture,
37
+ apply: apply.apply,
38
+ status: status.status,
39
+ // registry and policy
40
+ loadRegistry: capture.loadRegistry,
41
+ loadPort: capture.loadPort,
42
+ detectSource: capture.detectSource,
43
+ selectTargets: apply.selectTargets,
44
+ // reporting helpers
45
+ formatTable: apply.formatTable,
46
+ formatDropped: status.formatDropped,
47
+ renderHtml: status.renderHtml,
48
+ readState: apply.readState,
49
+ // the modules themselves, for a host that needs the details
50
+ common,
51
+ bundle,
52
+ toml,
53
+ COMPONENTS: bundle.COMPONENTS,
54
+ GLYPH: apply.GLYPH,
55
+ };