atris 3.71.2 → 3.72.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 (52) hide show
  1. package/atris/skills/campaigns/SKILL.md +1 -1
  2. package/bin/atris.js +8 -2
  3. package/commands/autoland.js +102 -2
  4. package/commands/campaign.js +263 -11
  5. package/commands/clean.js +60 -4
  6. package/commands/gm.js +1273 -7
  7. package/commands/improve.js +3 -2
  8. package/commands/member.js +28 -3
  9. package/commands/mission.js +82 -27
  10. package/commands/pulse.js +89 -5
  11. package/commands/sync.js +2 -2
  12. package/commands/task.js +49 -13
  13. package/commands/worktree.js +24 -2
  14. package/commands/zen.js +183 -0
  15. package/lib/auto-accept-certified.js +9 -4
  16. package/lib/campaign-cadence.js +53 -14
  17. package/lib/campaign-core.js +98 -17
  18. package/lib/campaign-keep.js +91 -0
  19. package/lib/campaign-lanes.js +11 -6
  20. package/lib/campaign-loop.js +173 -32
  21. package/lib/campaign-reading.js +14 -1
  22. package/lib/campaign-rsi.js +697 -0
  23. package/lib/campaign-run-lock.js +9 -1
  24. package/lib/campaign-schedule.js +124 -0
  25. package/lib/campaign-schema.js +3 -1
  26. package/lib/campaign-store.js +34 -2
  27. package/lib/campaign-trace.js +77 -29
  28. package/lib/engine-resource.js +5 -1
  29. package/lib/gm-bet.js +2219 -0
  30. package/lib/gm-phone-page.js +1102 -0
  31. package/lib/gm-phone.js +682 -0
  32. package/lib/gm-season.js +1162 -0
  33. package/lib/gm-target.js +292 -0
  34. package/lib/job-code-verify.js +169 -0
  35. package/lib/known-commands.js +1 -1
  36. package/lib/member-autoland.js +105 -14
  37. package/lib/member-progress.js +2 -2
  38. package/lib/member-report.js +11 -1
  39. package/lib/member-self-next-job.js +6 -1
  40. package/lib/member-up.js +17 -8
  41. package/lib/merged-review-close.js +206 -0
  42. package/lib/mission-code-provenance.js +118 -0
  43. package/lib/mission-protected-lane.js +52 -4
  44. package/lib/pulse.js +2 -1
  45. package/lib/qr.js +338 -0
  46. package/lib/rsi-record.js +88 -0
  47. package/lib/task-receipt.js +24 -6
  48. package/lib/yes-lock.js +530 -0
  49. package/lib/zen-apply.js +511 -0
  50. package/lib/zen-workspace.js +121 -0
  51. package/lib/zen.js +395 -0
  52. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { devNull } = require('os');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const BASELINE_SCHEMA = 'atris.mission_code_baseline.v1';
8
+ const PROVENANCE_SCHEMA = 'atris.mission_code_provenance.v1';
9
+ const BOOKKEEPING = ['.atris/', 'atris/runs/', 'atris/status/'];
10
+
11
+ function fail(reason) {
12
+ throw new Error(reason);
13
+ }
14
+
15
+ function git(root, args, reason = 'git_command_failed') {
16
+ // Disabling replacements alone still permits info/grafts and inherited graft
17
+ // files to rewrite ancestry. Evidence needs the actual commit relationships.
18
+ const env = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_GRAFT_FILE: devNull };
19
+ // Evidence belongs to this on-disk repository, even when the caller was
20
+ // launched by a Git hook with another index or repository in its environment.
21
+ for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_SHALLOW_FILE']) delete env[key];
22
+ const result = spawnSync('git', ['-c', 'core.fsmonitor=false', ...args], {
23
+ cwd: root, env, timeout: 15000, maxBuffer: Infinity,
24
+ });
25
+ if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) fail(reason);
26
+ const text = result.stdout.toString('utf8');
27
+ // A lossy filename decode cannot stand in for a complete path list.
28
+ if (!Buffer.from(text, 'utf8').equals(result.stdout)) fail('git_output_unreadable');
29
+ return text;
30
+ }
31
+
32
+ function repositoryRoot(root) {
33
+ const physical = fs.realpathSync(root);
34
+ const top = git(physical, ['rev-parse', '--show-toplevel'], 'not_git_repository').replace(/\n$/, '');
35
+ if (fs.realpathSync(top) !== physical) fail('root_not_git_top_level');
36
+ return physical;
37
+ }
38
+
39
+ function nulEntries(text) {
40
+ if (!text) return [];
41
+ if (!text.endsWith('\0')) fail('git_output_incomplete');
42
+ return text.slice(0, -1).split('\0');
43
+ }
44
+
45
+ function knownCommit(root, commit) {
46
+ if (typeof commit !== 'string' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) fail('commit_unavailable');
47
+ if (git(root, ['cat-file', '-t', commit], 'commit_unavailable').trim() !== 'commit') fail('commit_unavailable');
48
+ return commit;
49
+ }
50
+
51
+ function cleanHead(root) {
52
+ const head = knownCommit(root, git(root, ['rev-parse', '--verify', 'HEAD'], 'head_unavailable').trim());
53
+ // Disabling rename detection exposes both paths, including a product file
54
+ // moved into a bookkeeping directory. Every untracked file is inspected.
55
+ const entries = nulEntries(git(root, [
56
+ 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames', '--ignore-submodules=none',
57
+ ], 'git_status_unavailable'));
58
+ for (const entry of entries) {
59
+ if (entry.length < 4 || entry[2] !== ' ') fail('git_status_unreadable');
60
+ if (!BOOKKEEPING.some((prefix) => entry.slice(3).startsWith(prefix))) fail('product_dirty');
61
+ }
62
+ if (git(root, ['rev-parse', '--verify', 'HEAD'], 'head_unavailable').trim() !== head) fail('head_changed_during_capture');
63
+ return head;
64
+ }
65
+
66
+ function committedFiles(root, base, head) {
67
+ knownCommit(root, base);
68
+ knownCommit(root, head);
69
+ git(root, ['merge-base', '--is-ancestor', base, head], 'base_not_ancestor');
70
+ // No sample, path filter, or rename folding: deleted and renamed source
71
+ // paths matter as much as additions, including committed bookkeeping.
72
+ return nulEntries(git(root, [
73
+ 'diff', '--name-only', '--no-renames', '--no-ext-diff', '--no-textconv', '-z', base, head, '--',
74
+ ], 'git_diff_unavailable'));
75
+ }
76
+
77
+ function unavailable(schema, error) {
78
+ return { schema, status: 'unavailable', reason: error?.message || 'git_evidence_unavailable' };
79
+ }
80
+
81
+ function captureCodeBaseline(root) {
82
+ try {
83
+ const repo_root = repositoryRoot(root);
84
+ return { schema: BASELINE_SCHEMA, status: 'complete', repo_root, base_commit: cleanHead(repo_root) };
85
+ } catch (error) {
86
+ return unavailable(BASELINE_SCHEMA, error);
87
+ }
88
+ }
89
+
90
+ function collectCodeProvenance(root, baseline) {
91
+ try {
92
+ if (baseline?.schema !== BASELINE_SCHEMA || baseline.status !== 'complete') fail('baseline_unavailable');
93
+ const repo_root = repositoryRoot(root);
94
+ if (baseline.repo_root !== repo_root) fail('baseline_repository_mismatch');
95
+ const head_commit = cleanHead(repo_root);
96
+ const base_commit = baseline.base_commit;
97
+ const files = committedFiles(repo_root, base_commit, head_commit);
98
+ return { schema: PROVENANCE_SCHEMA, status: 'complete', repo_root, base_commit, head_commit, files };
99
+ } catch (error) {
100
+ return unavailable(PROVENANCE_SCHEMA, error);
101
+ }
102
+ }
103
+
104
+ function verifyCodeProvenance(root, provenance) {
105
+ try {
106
+ if (provenance?.schema !== PROVENANCE_SCHEMA || provenance.status !== 'complete' || !Array.isArray(provenance.files)) fail('provenance_unavailable');
107
+ const repo_root = repositoryRoot(root);
108
+ if (provenance.repo_root !== repo_root) fail('provenance_repository_mismatch');
109
+ // Historical evidence stays valid when the current checkout moves on.
110
+ const actual = committedFiles(repo_root, provenance.base_commit, provenance.head_commit);
111
+ if (actual.length !== provenance.files.length || actual.some((file, index) => file !== provenance.files[index])) fail('provenance_files_mismatch');
112
+ return { ok: true };
113
+ } catch (error) {
114
+ return { ok: false, reason: error?.message || 'git_evidence_unavailable' };
115
+ }
116
+ }
117
+
118
+ module.exports = { captureCodeBaseline, collectCodeProvenance, verifyCodeProvenance };
@@ -235,11 +235,53 @@ function gitSubcommand(args) {
235
235
  return '';
236
236
  }
237
237
 
238
+ // Tools that can publish, post, send, or merge. With a mission id, each one
239
+ // found on PATH gets a wrapper that checks the mission's your-yes lock first
240
+ // (lib/yes-lock.js); with no lock file the wrapper just runs the tool.
241
+ const YES_LOCK_TOOLS = ['npm', 'yarn', 'pnpm', 'npx', 'pnpx', 'bunx', 'gh', 'atris'];
242
+
243
+ // The lines every wrapper shares: ask the lock, refuse with its message, run
244
+ // the real tool, and close the steps a yes allowed once that run succeeds.
245
+ function yesLockLines(toolExpr, binExpr) {
246
+ return [
247
+ `const yesLock = require(${JSON.stringify(path.join(__dirname, 'yes-lock.js'))});`,
248
+ `const verdict = config.missionId ? yesLock.check({ stateDir: config.stateDir, missionId: config.missionId, seal: config.seal, tool: ${toolExpr}, args, gitBin: config.gitBin || null, cwd: process.cwd(), env: process.env }) : null;`,
249
+ `if (verdict && verdict.hold) { process.stderr.write(verdict.message + '\\n'); process.exit(yesLock.HELD_EXIT); }`,
250
+ `const runReal = () => { const child = spawnSync(${binExpr}, args, { cwd: process.cwd(), stdio: 'inherit' }); const ok = !child.error && child.status === 0; if (verdict && verdict.consume) yesLock.consume(config.stateDir, config.missionId, config.seal, verdict.consume, { ok, claims: verdict.claims }); if (child.error) { process.stderr.write(child.error.message + '\\n'); process.exit(1); } process.exit(child.status == null ? 1 : child.status); };`,
251
+ ];
252
+ }
253
+
254
+ function resolveTool(name, root, pathValue) {
255
+ const found = spawnSync('sh', ['-c', `command -v ${name}`], { cwd: root, encoding: 'utf8', timeout: 5000, env: { ...process.env, PATH: pathValue } });
256
+ const bin = String(found.stdout || '').trim();
257
+ return found.status === 0 && bin && path.isAbsolute(bin) ? bin : null;
258
+ }
259
+
260
+ function yesLockWrapper({ tool, realBin, missionId, seal, stateDir }) {
261
+ const config = { tool, realBin, missionId, seal, stateDir };
262
+ return [
263
+ '#!/usr/bin/env node',
264
+ "'use strict';",
265
+ `const { spawnSync } = require('child_process');`,
266
+ `const config = ${JSON.stringify(config)};`,
267
+ `const args = process.argv.slice(2);`,
268
+ ...yesLockLines('config.tool', 'config.realBin'),
269
+ 'runReal();',
270
+ '',
271
+ ].join('\n');
272
+ }
273
+
238
274
  function prepareMissionGitGuard({
239
275
  root = process.cwd(),
240
276
  tags = [],
241
277
  pathValue = process.env.PATH || '',
278
+ missionId = null,
279
+ // the sealed owner steps from the mission record, and where approvals live
280
+ yesLockSeal = [],
281
+ stateDir = null,
242
282
  } = {}) {
283
+ const seal = Array.isArray(yesLockSeal) ? yesLockSeal : [];
284
+ const locked = Boolean(missionId && seal.length && stateDir);
243
285
  const resolved = spawnSync('sh', ['-c', 'command -v git'], {
244
286
  cwd: root,
245
287
  encoding: 'utf8',
@@ -253,7 +295,7 @@ function prepareMissionGitGuard({
253
295
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-mission-git-'));
254
296
  const wrapper = path.join(dir, 'git');
255
297
  const modulePath = __filename;
256
- const config = { root: path.resolve(root), tags: normalizedTags(tags), gitBin };
298
+ const config = { root: path.resolve(root), tags: normalizedTags(tags), gitBin, missionId: locked ? missionId : null, seal, stateDir };
257
299
  const source = [
258
300
  '#!/usr/bin/env node',
259
301
  "'use strict';",
@@ -263,6 +305,7 @@ function prepareMissionGitGuard({
263
305
  gitSubcommand.toString(),
264
306
  defaultGit.toString(),
265
307
  `const args = process.argv.slice(2);`,
308
+ ...yesLockLines("'git'", 'config.gitBin'),
266
309
  `if (gitSubcommand(args) === 'commit') {`,
267
310
  ` const result = guard.inspectMissionProtectedDiff({ root: config.root, tags: config.tags, includeUnstaged: false, git: defaultGit(config.gitBin) });`,
268
311
  ` if (!result.allowed) {`,
@@ -270,12 +313,17 @@ function prepareMissionGitGuard({
270
313
  ` process.exit(78);`,
271
314
  ` }`,
272
315
  `}`,
273
- `const child = spawnSync(config.gitBin, args, { cwd: process.cwd(), stdio: 'inherit' });`,
274
- `if (child.error) { process.stderr.write(child.error.message + '\\n'); process.exit(1); }`,
275
- `process.exit(child.status == null ? 1 : child.status);`,
316
+ 'runReal();',
276
317
  '',
277
318
  ].join('\n');
278
319
  fs.writeFileSync(wrapper, source, { encoding: 'utf8', mode: 0o755 });
320
+ if (locked) {
321
+ for (const tool of YES_LOCK_TOOLS) {
322
+ const realBin = resolveTool(tool, root, pathValue);
323
+ if (!realBin) continue;
324
+ fs.writeFileSync(path.join(dir, tool), yesLockWrapper({ tool, realBin, missionId, seal, stateDir }), { encoding: 'utf8', mode: 0o755 });
325
+ }
326
+ }
279
327
 
280
328
  return {
281
329
  env: { ...process.env, PATH: `${dir}${path.delimiter}${pathValue}` },
package/lib/pulse.js CHANGED
@@ -603,6 +603,7 @@ function buildTickScript(opts = {}) {
603
603
  pathDirs = [],
604
604
  launchLabel = '',
605
605
  launchPlistPath = '',
606
+ tickFlags = [],
606
607
  } = opts;
607
608
  if (!root) throw new Error('buildTickScript: root is required');
608
609
  if (!stateHome) throw new Error('buildTickScript: stateHome is required');
@@ -690,7 +691,7 @@ ${runnerBinExport}
690
691
  ${runnerCommandTemplateExport}
691
692
  export ATRIS_SKIP_UPDATE_CHECK=1
692
693
 
693
- "$ATRIS" pulse tick --json${safeVerify == null ? '' : ` --verify '${safeVerify}'`} >> "$log" 2>&1
694
+ "$ATRIS" pulse tick --json${tickFlags.filter((f) => /^--[a-z-]+$/.test(f)).map((f) => ` ${f}`).join('')}${safeVerify == null ? '' : ` --verify '${safeVerify}'`} >> "$log" 2>&1
694
695
  tick_status=$?
695
696
 
696
697
  # runDaily no-ops via last_run_date after the first daily run.
package/lib/qr.js ADDED
@@ -0,0 +1,338 @@
1
+ 'use strict';
2
+
3
+ // A small QR code encoder: byte mode, error correction level M, versions 1
4
+ // to 10 (up to 213 bytes, plenty for a link). Follows the QR spec (ISO 18004)
5
+ // the same way most small encoders do: pick the smallest version that fits,
6
+ // add Reed-Solomon error correction per block, interleave, place the bits,
7
+ // then try all eight masks and keep the one with the lowest penalty.
8
+ //
9
+ // encodeQr(text) -> { version, size, mask, modules } where modules[y][x] is
10
+ // true for a dark square. qrLines(text) renders it for a terminal with half
11
+ // block characters, two rows of squares per line, quiet zone included.
12
+
13
+ const MAX_VERSION = 10;
14
+ // Level M tables, index = version (index 0 unused).
15
+ const ECC_PER_BLOCK = [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26];
16
+ const ECC_BLOCKS = [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5];
17
+ const FORMAT_BITS_M = 0;
18
+
19
+ function rawDataModules(ver) {
20
+ let result = (16 * ver + 128) * ver + 64;
21
+ if (ver >= 2) {
22
+ const numAlign = Math.floor(ver / 7) + 2;
23
+ result -= (25 * numAlign - 10) * numAlign - 55;
24
+ if (ver >= 7) result -= 36;
25
+ }
26
+ return result;
27
+ }
28
+
29
+ function dataCodewords(ver) {
30
+ return Math.floor(rawDataModules(ver) / 8) - ECC_PER_BLOCK[ver] * ECC_BLOCKS[ver];
31
+ }
32
+
33
+ // Multiply in GF(2^8) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1.
34
+ function gfMul(x, y) {
35
+ let z = 0;
36
+ for (let i = 7; i >= 0; i -= 1) {
37
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
38
+ z ^= ((y >>> i) & 1) * x;
39
+ }
40
+ return z & 0xff;
41
+ }
42
+
43
+ function rsDivisor(degree) {
44
+ const result = new Array(degree).fill(0);
45
+ result[degree - 1] = 1;
46
+ let root = 1;
47
+ for (let i = 0; i < degree; i += 1) {
48
+ for (let j = 0; j < result.length; j += 1) {
49
+ result[j] = gfMul(result[j], root);
50
+ if (j + 1 < result.length) result[j] ^= result[j + 1];
51
+ }
52
+ root = gfMul(root, 0x02);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function rsRemainder(data, divisor) {
58
+ const result = new Array(divisor.length).fill(0);
59
+ for (const b of data) {
60
+ const factor = b ^ result.shift();
61
+ result.push(0);
62
+ divisor.forEach((coef, i) => { result[i] ^= gfMul(coef, factor); });
63
+ }
64
+ return result;
65
+ }
66
+
67
+ function getBit(x, i) {
68
+ return ((x >>> i) & 1) !== 0;
69
+ }
70
+
71
+ // Data bits for byte mode, padded to the version's data capacity.
72
+ function dataBytes(bytes, ver) {
73
+ const bits = [];
74
+ const push = (value, len) => {
75
+ for (let i = len - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1);
76
+ };
77
+ push(0b0100, 4);
78
+ push(bytes.length, ver <= 9 ? 8 : 16);
79
+ for (const b of bytes) push(b, 8);
80
+ const capacity = dataCodewords(ver) * 8;
81
+ push(0, Math.min(4, capacity - bits.length));
82
+ push(0, (8 - (bits.length % 8)) % 8);
83
+ for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) push(pad, 8);
84
+ const out = [];
85
+ for (let i = 0; i < bits.length; i += 8) {
86
+ let byte = 0;
87
+ for (let j = 0; j < 8; j += 1) byte = (byte << 1) | bits[i + j];
88
+ out.push(byte);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // Split into blocks, add error correction to each, and interleave.
94
+ function withEcc(data, ver) {
95
+ const numBlocks = ECC_BLOCKS[ver];
96
+ const eccLen = ECC_PER_BLOCK[ver];
97
+ const raw = Math.floor(rawDataModules(ver) / 8);
98
+ const numShort = numBlocks - (raw % numBlocks);
99
+ const shortLen = Math.floor(raw / numBlocks);
100
+ const divisor = rsDivisor(eccLen);
101
+ const blocks = [];
102
+ for (let i = 0, k = 0; i < numBlocks; i += 1) {
103
+ const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
104
+ k += dat.length;
105
+ const ecc = rsRemainder(dat, divisor);
106
+ if (i < numShort) dat.push(0);
107
+ blocks.push(dat.concat(ecc));
108
+ }
109
+ const result = [];
110
+ for (let i = 0; i < blocks[0].length; i += 1) {
111
+ blocks.forEach((block, j) => {
112
+ if (i !== shortLen - eccLen || j >= numShort) result.push(block[i]);
113
+ });
114
+ }
115
+ return result;
116
+ }
117
+
118
+ function alignmentPositions(ver) {
119
+ if (ver === 1) return [];
120
+ const size = ver * 4 + 17;
121
+ const numAlign = Math.floor(ver / 7) + 2;
122
+ const step = Math.ceil((ver * 4 + 4) / (numAlign * 2 - 2)) * 2;
123
+ const result = [6];
124
+ for (let pos = size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos);
125
+ return result;
126
+ }
127
+
128
+ function makeGrid(ver) {
129
+ const size = ver * 4 + 17;
130
+ const modules = Array.from({ length: size }, () => new Array(size).fill(false));
131
+ const isFunction = Array.from({ length: size }, () => new Array(size).fill(false));
132
+ const set = (x, y, dark) => {
133
+ modules[y][x] = dark;
134
+ isFunction[y][x] = true;
135
+ };
136
+ return { ver, size, modules, isFunction, set };
137
+ }
138
+
139
+ function drawFormatBits(grid, mask) {
140
+ const { size, set } = grid;
141
+ const data = (FORMAT_BITS_M << 3) | mask;
142
+ let rem = data;
143
+ for (let i = 0; i < 10; i += 1) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
144
+ const bits = ((data << 10) | rem) ^ 0x5412;
145
+ for (let i = 0; i <= 5; i += 1) set(8, i, getBit(bits, i));
146
+ set(8, 7, getBit(bits, 6));
147
+ set(8, 8, getBit(bits, 7));
148
+ set(7, 8, getBit(bits, 8));
149
+ for (let i = 9; i < 15; i += 1) set(14 - i, 8, getBit(bits, i));
150
+ for (let i = 0; i < 8; i += 1) set(size - 1 - i, 8, getBit(bits, i));
151
+ for (let i = 8; i < 15; i += 1) set(8, size - 15 + i, getBit(bits, i));
152
+ set(8, size - 8, true);
153
+ }
154
+
155
+ function drawVersion(grid) {
156
+ const { ver, size, set } = grid;
157
+ if (ver < 7) return;
158
+ let rem = ver;
159
+ for (let i = 0; i < 12; i += 1) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
160
+ const bits = (ver << 12) | rem;
161
+ for (let i = 0; i < 18; i += 1) {
162
+ const bit = getBit(bits, i);
163
+ const a = size - 11 + (i % 3);
164
+ const b = Math.floor(i / 3);
165
+ set(a, b, bit);
166
+ set(b, a, bit);
167
+ }
168
+ }
169
+
170
+ function drawFunctionPatterns(grid) {
171
+ const { size, set } = grid;
172
+ for (let i = 0; i < size; i += 1) {
173
+ set(6, i, i % 2 === 0);
174
+ set(i, 6, i % 2 === 0);
175
+ }
176
+ const finder = (x, y) => {
177
+ for (let dy = -4; dy <= 4; dy += 1) {
178
+ for (let dx = -4; dx <= 4; dx += 1) {
179
+ const dist = Math.max(Math.abs(dx), Math.abs(dy));
180
+ const xx = x + dx;
181
+ const yy = y + dy;
182
+ if (xx >= 0 && xx < size && yy >= 0 && yy < size) set(xx, yy, dist !== 2 && dist !== 4);
183
+ }
184
+ }
185
+ };
186
+ finder(3, 3);
187
+ finder(size - 4, 3);
188
+ finder(3, size - 4);
189
+ const pos = alignmentPositions(grid.ver);
190
+ const n = pos.length;
191
+ for (let i = 0; i < n; i += 1) {
192
+ for (let j = 0; j < n; j += 1) {
193
+ if ((i === 0 && j === 0) || (i === 0 && j === n - 1) || (i === n - 1 && j === 0)) continue;
194
+ for (let dy = -2; dy <= 2; dy += 1) {
195
+ for (let dx = -2; dx <= 2; dx += 1) set(pos[i] + dx, pos[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
196
+ }
197
+ }
198
+ }
199
+ drawFormatBits(grid, 0);
200
+ drawVersion(grid);
201
+ }
202
+
203
+ function drawCodewords(grid, data) {
204
+ const { size, modules, isFunction } = grid;
205
+ let i = 0;
206
+ for (let right = size - 1; right >= 1; right -= 2) {
207
+ if (right === 6) right = 5;
208
+ for (let vert = 0; vert < size; vert += 1) {
209
+ for (let j = 0; j < 2; j += 1) {
210
+ const x = right - j;
211
+ const upward = ((right + 1) & 2) === 0;
212
+ const y = upward ? size - 1 - vert : vert;
213
+ if (!isFunction[y][x] && i < data.length * 8) {
214
+ modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
215
+ i += 1;
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ const MASKS = [
223
+ (x, y) => (x + y) % 2 === 0,
224
+ (x, y) => y % 2 === 0,
225
+ x => x % 3 === 0,
226
+ (x, y) => (x + y) % 3 === 0,
227
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
228
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
229
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
230
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
231
+ ];
232
+
233
+ function applyMask(grid, mask) {
234
+ const { size, modules, isFunction } = grid;
235
+ const test = MASKS[mask];
236
+ for (let y = 0; y < size; y += 1) {
237
+ for (let x = 0; x < size; x += 1) {
238
+ if (!isFunction[y][x] && test(x, y)) modules[y][x] = !modules[y][x];
239
+ }
240
+ }
241
+ }
242
+
243
+ // The spec's four penalty rules: runs of 5+ in a line, 2x2 blocks, finder
244
+ // look-alikes (1:1:3:1:1 with 4 light on a side), and dark/light balance.
245
+ function penalty(modules) {
246
+ const size = modules.length;
247
+ let score = 0;
248
+ const line = get => {
249
+ let run = 1;
250
+ for (let i = 1; i < size; i += 1) {
251
+ if (get(i) === get(i - 1)) {
252
+ run += 1;
253
+ if (run === 5) score += 3;
254
+ else if (run > 5) score += 1;
255
+ } else {
256
+ run = 1;
257
+ }
258
+ }
259
+ for (let i = 0; i + 10 < size + 4; i += 1) {
260
+ const at = k => (i + k >= 0 && i + k < size ? get(i + k) : false);
261
+ const core = at(0) && !at(1) && at(2) && at(3) && at(4) && !at(5) && at(6);
262
+ if (!core) continue;
263
+ const lightBefore = [1, 2, 3, 4].every(k => !at(-k));
264
+ const lightAfter = [7, 8, 9, 10].every(k => !at(k));
265
+ if (lightBefore || lightAfter) score += 40;
266
+ }
267
+ };
268
+ for (let y = 0; y < size; y += 1) line(x => modules[y][x]);
269
+ for (let x = 0; x < size; x += 1) line(y => modules[y][x]);
270
+ for (let y = 0; y < size - 1; y += 1) {
271
+ for (let x = 0; x < size - 1; x += 1) {
272
+ const c = modules[y][x];
273
+ if (c === modules[y][x + 1] && c === modules[y + 1][x] && c === modules[y + 1][x + 1]) score += 3;
274
+ }
275
+ }
276
+ let dark = 0;
277
+ for (const row of modules) for (const m of row) if (m) dark += 1;
278
+ const total = size * size;
279
+ const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
280
+ score += Math.max(0, k) * 10;
281
+ return score;
282
+ }
283
+
284
+ function encodeQr(text, { mask: forcedMask = null } = {}) {
285
+ const bytes = [...Buffer.from(String(text), 'utf8')];
286
+ let ver = 1;
287
+ for (; ver <= MAX_VERSION; ver += 1) {
288
+ const needed = 4 + (ver <= 9 ? 8 : 16) + bytes.length * 8;
289
+ if (needed <= dataCodewords(ver) * 8) break;
290
+ }
291
+ if (ver > MAX_VERSION) throw new Error(`text too long for a QR code here (${bytes.length} bytes, max 213)`);
292
+ const grid = makeGrid(ver);
293
+ drawFunctionPatterns(grid);
294
+ drawCodewords(grid, withEcc(dataBytes(bytes, ver), ver));
295
+ let mask = forcedMask;
296
+ if (mask == null) {
297
+ let best = Infinity;
298
+ for (let m = 0; m < 8; m += 1) {
299
+ applyMask(grid, m);
300
+ drawFormatBits(grid, m);
301
+ const p = penalty(grid.modules);
302
+ if (p < best) {
303
+ best = p;
304
+ mask = m;
305
+ }
306
+ applyMask(grid, m);
307
+ }
308
+ }
309
+ applyMask(grid, mask);
310
+ drawFormatBits(grid, mask);
311
+ return { version: ver, size: grid.size, mask, modules: grid.modules };
312
+ }
313
+
314
+ // Terminal art: two rows of squares per line with half block characters.
315
+ // With color on (the default) each line forces black on bright white, so the
316
+ // code scans the same on a dark or a light terminal. With color off, light
317
+ // squares print as blocks, which reads right on a dark terminal.
318
+ const FULL = '█';
319
+ const UPPER = '▀';
320
+ const LOWER = '▄';
321
+
322
+ function qrLines(text, { quiet = 2, color = true } = {}) {
323
+ const { modules, size } = encodeQr(text);
324
+ const at = (x, y) => (x >= 0 && y >= 0 && x < size && y < size ? modules[y][x] : false);
325
+ const lines = [];
326
+ for (let y = -quiet; y < size + quiet; y += 2) {
327
+ let line = '';
328
+ for (let x = -quiet; x < size + quiet; x += 1) {
329
+ const top = color ? at(x, y) : !at(x, y);
330
+ const bottom = color ? at(x, y + 1) : !at(x, y + 1);
331
+ line += top && bottom ? FULL : top ? UPPER : bottom ? LOWER : ' ';
332
+ }
333
+ lines.push(color ? `\x1b[30;107m${line}\x1b[0m` : line);
334
+ }
335
+ return lines;
336
+ }
337
+
338
+ module.exports = { encodeQr, qrLines, rsDivisor, rsRemainder, gfMul, dataCodewords, alignmentPositions };
package/lib/rsi-record.js CHANGED
@@ -320,6 +320,93 @@ function finishAttempt(root, attempt, outcome = {}, { log = () => {} } = {}) {
320
320
  }
321
321
  }
322
322
 
323
+ // Observed history did not run under today's policy. Use the schema directly:
324
+ // open/finish would invent current action and completion times for old work.
325
+ const OBSERVED_APPEND_PY = `import json, sys
326
+ from dataclasses import asdict
327
+ from datetime import datetime
328
+ sys.path.insert(0, sys.argv[1])
329
+ from backend.scripts.rsi.schema import Action, Context, Node, Outcome, append_node, tree_split
330
+ from backend.scripts.rsi.record import get_node
331
+ data = json.loads(sys.argv[2])
332
+ def instant(value): return datetime.fromisoformat(value.replace('Z', '+00:00'))
333
+ if data.get('nodeId'):
334
+ node = get_node(data['nodeId'])
335
+ if (node is None or node.lane != data['lane'] or
336
+ node.context.ground != data['ground'] or node.action.ground != data['ground'] or
337
+ node.action.engine != data['engine'] or node.action.policy_id != 'p-0000' or
338
+ node.context.base_commit != data['baseCommit'] or node.outcome.status == 'running' or
339
+ instant(node.opened_at) != instant(data['openedAt']) or
340
+ instant(node.created_at) != instant(data['finishedAt'])):
341
+ raise ValueError('observed attempt identity, policy, or provenance does not match')
342
+ outcome = asdict(node.outcome)
343
+ outcome.update(data['outcome'])
344
+ node.outcome = Outcome(**outcome)
345
+ node.recorded_at = None
346
+ else:
347
+ node = Node(
348
+ tree_id=data['tree'], lane=data['lane'],
349
+ context=Context(base_commit=data['baseCommit'], ground=data['ground']),
350
+ action=Action(policy_id='p-0000', kind='open', ground=data['ground'], engine=data['engine'], model=None, cap_s=0),
351
+ outcome=Outcome(**data['outcome']), split=tree_split(data['tree']),
352
+ created_at=data['finishedAt'], opened_at=data['openedAt'])
353
+ append_node(node)
354
+ print(node.id)
355
+ `;
356
+
357
+ function observedTime(value) {
358
+ if (typeof value !== 'string') return NaN;
359
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/);
360
+ if (!match) return NaN;
361
+ const year = Number(match[1]);
362
+ const month = Number(match[2]);
363
+ const day = Number(match[3]);
364
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
365
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
366
+ if (month < 1 || month > 12 || day < 1 || day > days[month - 1]) return NaN;
367
+ return Date.parse(value);
368
+ }
369
+
370
+ /** Append measured completed work, preserving its historical provenance.
371
+ * Missing metadata or an unavailable backend schema defers recording. This
372
+ * never chooses a policy and never changes the active-attempt environment.
373
+ */
374
+ function recordObservedAttempt(root, options = {}, { log = () => {} } = {}) {
375
+ const report = (message) => { try { log(message); } catch {} };
376
+ try {
377
+ const { lane = 'campaign', ground, engine, baseCommit, openedAt, finishedAt, outcome, nodeId } = options;
378
+ const engines = new Set(['codex', 'devin', 'cursor', 'agy', 'claude']);
379
+ const opened = observedTime(openedAt);
380
+ const finished = observedTime(finishedAt);
381
+ if (!root || !recorderPath(root) || typeof lane !== 'string' || !lane.trim() ||
382
+ typeof ground !== 'string' || !ground.trim() || !engines.has(engine) ||
383
+ typeof baseCommit !== 'string' || !/^[a-fA-F0-9]{7,64}$/.test(baseCommit) ||
384
+ !Number.isFinite(opened) || !Number.isFinite(finished) || opened > finished || finished > Date.now() ||
385
+ !outcome || typeof outcome !== 'object' || Array.isArray(outcome) ||
386
+ !['shipped', 'failed', 'nothing', 'waiting'].includes(outcome.status) ||
387
+ !['pass', 'fail', 'skipped'].includes(outcome.verify) ||
388
+ !Array.isArray(outcome.files) || !outcome.files.length ||
389
+ !Array.from(outcome.files).every((file) => typeof file === 'string' && file.trim()) ||
390
+ (nodeId !== undefined && (typeof nodeId !== 'string' || !nodeId.trim()))) return null;
391
+ const tree = nodeId ? null : startTree(root, lane);
392
+ if (!nodeId && !tree) {
393
+ report('rsi: observed attempt skipped (could not select a tree)');
394
+ return null;
395
+ }
396
+ const result = spawnSync(pythonBin(root), ['-c', OBSERVED_APPEND_PY, root,
397
+ JSON.stringify({ lane, ground, engine, baseCommit, openedAt, finishedAt, outcome, nodeId, tree })], {
398
+ cwd: root, encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, env: process.env,
399
+ });
400
+ const id = result.status === 0 ? String(result.stdout || '').trim().split('\n').pop() : '';
401
+ if (id) return id;
402
+ report(`rsi: observed attempt skipped${result.stderr ? ` (${String(result.stderr).trim().split('\n').pop()})` : ''}`);
403
+ return null;
404
+ } catch (error) {
405
+ report(`rsi: observed attempt skipped (${error && error.message ? error.message : error})`);
406
+ return null;
407
+ }
408
+ }
409
+
323
410
  module.exports = {
324
411
  IMPROVE_LANE,
325
412
  GUARD_ENV,
@@ -332,4 +419,5 @@ module.exports = {
332
419
  engineFromModel,
333
420
  beginAttempt,
334
421
  finishAttempt,
422
+ recordObservedAttempt,
335
423
  };