@lyupro/codex-bridge 0.6.8 → 0.6.9
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/CHANGELOG.md +109 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +11 -5
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +88 -75
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +91 -0
- package/cli/lifecycle-lock.mjs +335 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +2 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +49 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +26 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +2 -8
- package/src/home/hooks/reply-guard.mjs +2 -7
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +7 -4
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +7 -4
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/events.mjs +2 -1
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +14 -16
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +38 -34
- package/src/home/lib/runner/launcher.mjs +2 -2
- package/src/home/lib/runner/preflight.mjs +7 -0
- package/src/home/lib/runner/prompts.mjs +35 -2
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +29 -20
- package/src/home/lib/runner/worker.mjs +3 -3
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Separates executable source from comments and literals for the home-write audit.
|
|
3
|
+
* Plan_65 B1b moves lexing here so a malformed mask cannot hide a home writer.
|
|
4
|
+
*
|
|
5
|
+
* Regex literals are lexed as literals because the first audit did not: the `"` inside the pattern
|
|
6
|
+
* at src/home/lib/config-edit.mjs:20 opened a "string" that masked the rest of that module, and the
|
|
7
|
+
* one module writing the operator's config.json into the home passed the audit unseen (2026-09-25).
|
|
8
|
+
*/
|
|
9
|
+
export function lexSource(source) {
|
|
10
|
+
const code = source.split('');
|
|
11
|
+
const withoutComments = source.split('');
|
|
12
|
+
const blank = (target, start, end) => {
|
|
13
|
+
for (let index = start; index < end; index += 1) {
|
|
14
|
+
if (source[index] !== '\n' && source[index] !== '\r') target[index] = ' ';
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const regexKeywords = new Set([
|
|
18
|
+
'return', 'typeof', 'case', 'do', 'else', 'in', 'of', 'new', 'delete', 'void', 'throw', 'yield', 'await',
|
|
19
|
+
]);
|
|
20
|
+
const expressionStart = new Set('([{,=:!&|?{};+-*%<>~^');
|
|
21
|
+
|
|
22
|
+
const regexEnd = (start) => {
|
|
23
|
+
let inClass = false;
|
|
24
|
+
let escapedChar = false;
|
|
25
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
26
|
+
const char = source[index];
|
|
27
|
+
if (char === '\n' || char === '\r') return -1;
|
|
28
|
+
if (escapedChar) escapedChar = false;
|
|
29
|
+
else if (char === '\\') escapedChar = true;
|
|
30
|
+
else if (char === '[') inClass = true;
|
|
31
|
+
else if (char === ']') inClass = false;
|
|
32
|
+
else if (char === '/' && !inClass) {
|
|
33
|
+
while (/[A-Za-z]/.test(source[index + 1] ?? '')) index += 1;
|
|
34
|
+
return index + 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return -1;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const scanCode = (start, interpolation = false) => {
|
|
41
|
+
let index = start;
|
|
42
|
+
let braces = 0;
|
|
43
|
+
let canStartRegex = true;
|
|
44
|
+
const controlParens = [];
|
|
45
|
+
let previousWord = '';
|
|
46
|
+
while (index < source.length) {
|
|
47
|
+
const char = source[index];
|
|
48
|
+
const next = source[index + 1];
|
|
49
|
+
if (interpolation && char === '}') {
|
|
50
|
+
if (braces === 0) {
|
|
51
|
+
blank(code, index, index + 1);
|
|
52
|
+
return index + 1;
|
|
53
|
+
}
|
|
54
|
+
braces -= 1;
|
|
55
|
+
} else if (char === '{') {
|
|
56
|
+
braces += 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (char === '/' && (next === '/' || next === '*')) {
|
|
60
|
+
const commentStart = index;
|
|
61
|
+
if (next === '/') {
|
|
62
|
+
index += 2;
|
|
63
|
+
while (index < source.length && source[index] !== '\n' && source[index] !== '\r') index += 1;
|
|
64
|
+
} else {
|
|
65
|
+
index += 2;
|
|
66
|
+
while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) index += 1;
|
|
67
|
+
index = index < source.length ? index + 2 : source.length;
|
|
68
|
+
}
|
|
69
|
+
blank(withoutComments, commentStart, index);
|
|
70
|
+
blank(code, commentStart, index);
|
|
71
|
+
if (source.slice(commentStart, index).includes('\n') || source.slice(commentStart, index).includes('\r')) {
|
|
72
|
+
canStartRegex = true;
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (char === '"' || char === "'") {
|
|
78
|
+
const quote = char;
|
|
79
|
+
const stringStart = index++;
|
|
80
|
+
let escapedChar = false;
|
|
81
|
+
while (index < source.length) {
|
|
82
|
+
const current = source[index++];
|
|
83
|
+
if (escapedChar) escapedChar = false;
|
|
84
|
+
else if (current === '\\') escapedChar = true;
|
|
85
|
+
else if (current === quote) break;
|
|
86
|
+
}
|
|
87
|
+
blank(code, stringStart, index);
|
|
88
|
+
canStartRegex = false;
|
|
89
|
+
previousWord = '';
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (char === '`') {
|
|
94
|
+
const scanTemplate = (templateStart) => {
|
|
95
|
+
let cursor = templateStart + 1;
|
|
96
|
+
blank(code, templateStart, cursor);
|
|
97
|
+
let escapedChar = false;
|
|
98
|
+
while (cursor < source.length) {
|
|
99
|
+
const current = source[cursor];
|
|
100
|
+
if (escapedChar) {
|
|
101
|
+
escapedChar = false;
|
|
102
|
+
blank(code, cursor, cursor + 1);
|
|
103
|
+
cursor += 1;
|
|
104
|
+
} else if (current === '\\') {
|
|
105
|
+
escapedChar = true;
|
|
106
|
+
blank(code, cursor, cursor + 1);
|
|
107
|
+
cursor += 1;
|
|
108
|
+
} else if (current === '`') {
|
|
109
|
+
blank(code, cursor, cursor + 1);
|
|
110
|
+
return cursor + 1;
|
|
111
|
+
} else if (current === '$' && source[cursor + 1] === '{') {
|
|
112
|
+
blank(code, cursor, cursor + 2);
|
|
113
|
+
cursor = scanCode(cursor + 2, true);
|
|
114
|
+
escapedChar = false;
|
|
115
|
+
} else {
|
|
116
|
+
blank(code, cursor, cursor + 1);
|
|
117
|
+
cursor += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return cursor;
|
|
121
|
+
};
|
|
122
|
+
index = scanTemplate(index);
|
|
123
|
+
canStartRegex = false;
|
|
124
|
+
previousWord = '';
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (char === '/' && canStartRegex) {
|
|
129
|
+
const end = regexEnd(index);
|
|
130
|
+
if (end !== -1) {
|
|
131
|
+
blank(code, index, end);
|
|
132
|
+
index = end;
|
|
133
|
+
canStartRegex = false;
|
|
134
|
+
previousWord = '';
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (/\s/.test(char)) {
|
|
140
|
+
if (char === '\n' || char === '\r') canStartRegex = true;
|
|
141
|
+
index += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (/[A-Za-z_$]/.test(char)) {
|
|
145
|
+
const wordStart = index++;
|
|
146
|
+
while (/[\w$]/.test(source[index] ?? '')) index += 1;
|
|
147
|
+
previousWord = source.slice(wordStart, index);
|
|
148
|
+
canStartRegex = regexKeywords.has(previousWord);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (/[0-9]/.test(char)) {
|
|
152
|
+
index += 1;
|
|
153
|
+
while (/[\w.]/.test(source[index] ?? '')) index += 1;
|
|
154
|
+
canStartRegex = false;
|
|
155
|
+
previousWord = '';
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (char === '(') {
|
|
160
|
+
controlParens.push(/^(?:if|while|for|with|switch|catch)$/.test(previousWord));
|
|
161
|
+
canStartRegex = true;
|
|
162
|
+
} else if (char === ')') {
|
|
163
|
+
canStartRegex = controlParens.pop() === true;
|
|
164
|
+
} else if (char === '[' || char === '{') {
|
|
165
|
+
canStartRegex = true;
|
|
166
|
+
} else if (char === ']') {
|
|
167
|
+
canStartRegex = false;
|
|
168
|
+
} else if (char === '/' && next === '=') {
|
|
169
|
+
canStartRegex = true;
|
|
170
|
+
index += 1;
|
|
171
|
+
} else {
|
|
172
|
+
canStartRegex = expressionStart.has(char);
|
|
173
|
+
}
|
|
174
|
+
previousWord = '';
|
|
175
|
+
index += 1;
|
|
176
|
+
}
|
|
177
|
+
return index;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
scanCode(0);
|
|
181
|
+
return { code: code.join(''), withoutComments: withoutComments.join('') };
|
|
182
|
+
}
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* config, conventions and an obsolete-file fixture on 2026-08-11, where the stray config then
|
|
8
8
|
* stood in the way of the seeded-file migration the release was about.
|
|
9
9
|
*
|
|
10
|
+
* `CODEX_RUNS_ROOT` joined before a failure rather than after one (Plan_65 B12): the runs root
|
|
11
|
+
* falls back to the operator's `~/.claude/codex-runs`, so a runner test that did not name it
|
|
12
|
+
* filed its runs among real ones, and the purge liveness checks would read real live runs as the
|
|
13
|
+
* fixture's — a verdict that depends on what the operator happens to be running.
|
|
14
|
+
*
|
|
10
15
|
* It sits in its own module because the isolation test imports it, and importing the runner would
|
|
11
16
|
* start a second suite inside the first.
|
|
12
17
|
*/
|
|
13
|
-
export const ISOLATED_ROOTS = Object.freeze(['CODEX_HOME', 'CODEX_BRIDGE_HOME']);
|
|
18
|
+
export const ISOLATED_ROOTS = Object.freeze(['CODEX_HOME', 'CODEX_BRIDGE_HOME', 'CODEX_RUNS_ROOT']);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates open-work rules and reciprocal document references, exposing them through the CLI.
|
|
3
|
+
* Plan_72: 15 of 20 handoff records dropped unfinished items because open work had no single home.
|
|
4
|
+
* Plan_72 R3 B2: with the checklist index absent even strict validation was green, so no live-step marker was
|
|
5
|
+
* ever judged; the index is now required wherever the workroom exists.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { isInvokedDirectly } from '../../cli/invoked-directly.mjs';
|
|
11
|
+
import { readRegister } from './register.mjs';
|
|
12
|
+
import { checkLiveThresholds } from './live-thresholds.mjs';
|
|
13
|
+
|
|
14
|
+
const REGISTER = 'docs/plans/open-work.md';
|
|
15
|
+
const INDEX = 'docs/checklists/operator-checklists.md';
|
|
16
|
+
const WORKROOM = ['docs/plans', 'docs/checklists'];
|
|
17
|
+
const REQUIRED = ['состояние', 'владелец', 'источник', 'дом', 'следующий шаг'];
|
|
18
|
+
const KNOWN = new Set([...REQUIRED, 'блокер', 'доказательство']);
|
|
19
|
+
const STATES = new Set(['open', 'blocked', 'done', 'cancelled']);
|
|
20
|
+
const OWNERS = new Set(['агент', 'оператор']);
|
|
21
|
+
|
|
22
|
+
function markdownFiles(directory) {
|
|
23
|
+
const files = [];
|
|
24
|
+
if (!fs.existsSync(directory)) return files;
|
|
25
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true })
|
|
26
|
+
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const file = path.join(directory, entry.name);
|
|
29
|
+
if (entry.isDirectory()) files.push(...markdownFiles(file));
|
|
30
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) files.push(file);
|
|
31
|
+
}
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function homePaths(value) {
|
|
36
|
+
const paths = new Set();
|
|
37
|
+
const prose = value.replace(/\[[^\]]*\]\((<[^>]+>|(?:[^()\r\n]|\([^()\r\n]*\))+)\)/g, (link, destination) => {
|
|
38
|
+
let target = destination.trim();
|
|
39
|
+
target = target.startsWith('<') ? target.slice(1, -1) : target.replace(/\s+["'].*$/, '');
|
|
40
|
+
if (!/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(target)) {
|
|
41
|
+
target = target.split(/[?#]/)[0];
|
|
42
|
+
try { target = decodeURIComponent(target); } catch { /* Keep malformed targets visible. */ }
|
|
43
|
+
if (target.endsWith('.md')) paths.add(target);
|
|
44
|
+
}
|
|
45
|
+
// Link labels and remote destinations are text, not additional plain repository paths.
|
|
46
|
+
return ' '.repeat(link.length);
|
|
47
|
+
});
|
|
48
|
+
for (const match of prose.matchAll(/(?<![\w/.:\\-])docs\/[^\s`<>()[\],;]+?\.md(?=$|[\s`<>()[\],;#?.!:])/g)) {
|
|
49
|
+
paths.add(match[0]);
|
|
50
|
+
}
|
|
51
|
+
return paths;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateOpenWork(root, { strict = false } = {}) {
|
|
55
|
+
if (typeof root !== 'string' || !root) throw new TypeError('A repository root is required');
|
|
56
|
+
root = path.resolve(root);
|
|
57
|
+
const violations = [];
|
|
58
|
+
const notices = [];
|
|
59
|
+
const report = (file, line, problem) => violations.push(`${file}:${line}: ${problem}`);
|
|
60
|
+
const present = WORKROOM.map((directory) => {
|
|
61
|
+
const absolute = path.join(root, directory);
|
|
62
|
+
return fs.existsSync(absolute) && fs.statSync(absolute).isDirectory();
|
|
63
|
+
});
|
|
64
|
+
if (!strict && present.every((exists) => !exists)) {
|
|
65
|
+
return { violations, notices, message: 'Workroom is absent; nothing was checked.' };
|
|
66
|
+
}
|
|
67
|
+
if (strict) {
|
|
68
|
+
WORKROOM.forEach((directory, index) => {
|
|
69
|
+
if (!present[index]) report(directory, 1, 'workroom folder is missing (strict mode)');
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const registerFile = path.join(root, REGISTER);
|
|
73
|
+
const registerExists = fs.existsSync(registerFile);
|
|
74
|
+
if (!registerExists) {
|
|
75
|
+
report(REGISTER, 1, 'register is missing');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { items, queue } = registerExists ? readRegister(registerFile) : { items: [], queue: [] };
|
|
79
|
+
const indexFile = path.join(root, INDEX);
|
|
80
|
+
if (!fs.existsSync(indexFile)) {
|
|
81
|
+
report(INDEX, 1, 'checklist index is missing');
|
|
82
|
+
} else {
|
|
83
|
+
let version;
|
|
84
|
+
try { version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; }
|
|
85
|
+
catch { /* The threshold rule reports one package.json:1 violation for an unavailable version. */ }
|
|
86
|
+
const live = checkLiveThresholds({
|
|
87
|
+
indexText: fs.readFileSync(indexFile, 'utf8'), indexFile: INDEX,
|
|
88
|
+
version, versionFile: 'package.json', items,
|
|
89
|
+
});
|
|
90
|
+
for (const entry of live.violations) report(entry.file, entry.line, entry.text);
|
|
91
|
+
for (const entry of live.notices) notices.push(`${entry.file}:${entry.line}: ${entry.text}`);
|
|
92
|
+
}
|
|
93
|
+
if (!registerExists) return { violations, notices, message: null };
|
|
94
|
+
if (!items.length) report(REGISTER, 1, 'register must contain at least one item');
|
|
95
|
+
const byId = new Map();
|
|
96
|
+
const states = new Map();
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
if (byId.has(item.id)) report(REGISTER, item.line, `duplicate item id ${item.id}`);
|
|
99
|
+
else byId.set(item.id, item);
|
|
100
|
+
const fields = new Map();
|
|
101
|
+
for (const field of item.fields) {
|
|
102
|
+
if (!KNOWN.has(field.key)) report(REGISTER, field.line, `${item.id}: unknown field ${field.key}`);
|
|
103
|
+
if (fields.has(field.key)) report(REGISTER, field.line, `${item.id}: repeated field ${field.key}`);
|
|
104
|
+
else fields.set(field.key, field);
|
|
105
|
+
}
|
|
106
|
+
for (const key of REQUIRED) {
|
|
107
|
+
if (!fields.get(key)?.value.trim()) {
|
|
108
|
+
report(REGISTER, fields.get(key)?.line ?? item.line, `${item.id}: required field ${key} is missing or empty`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const state = fields.get('состояние')?.value.trim();
|
|
112
|
+
if (fields.has('состояние') && !STATES.has(state)) {
|
|
113
|
+
report(REGISTER, fields.get('состояние').line, `${item.id}: invalid состояние ${state}`);
|
|
114
|
+
}
|
|
115
|
+
if (!states.has(item.id)) states.set(item.id, state);
|
|
116
|
+
const owner = fields.get('владелец');
|
|
117
|
+
if (owner && !OWNERS.has(owner.value.trim())) {
|
|
118
|
+
report(REGISTER, owner.line, `${item.id}: invalid владелец ${owner.value.trim()}`);
|
|
119
|
+
}
|
|
120
|
+
const evidence = state === 'blocked' ? 'блокер' : ['done', 'cancelled'].includes(state) ? 'доказательство' : null;
|
|
121
|
+
if (evidence && !fields.get(evidence)?.value.trim()) {
|
|
122
|
+
report(REGISTER, fields.get(evidence)?.line ?? item.line, `${item.id}: ${state} requires ${evidence}`);
|
|
123
|
+
}
|
|
124
|
+
const home = fields.get('дом');
|
|
125
|
+
for (const source of home?.valueLines ?? []) {
|
|
126
|
+
for (const target of homePaths(source.value)) {
|
|
127
|
+
const absolute = target.startsWith('docs/')
|
|
128
|
+
? path.resolve(root, target) : path.resolve(root, 'docs/plans', target);
|
|
129
|
+
const relative = path.relative(root, absolute);
|
|
130
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
131
|
+
report(REGISTER, source.line, `${item.id}: home path is outside the repository: ${target}`);
|
|
132
|
+
} else if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
|
|
133
|
+
report(REGISTER, source.line, `${item.id}: home file does not exist: ${target}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const queued = new Set();
|
|
140
|
+
for (const entry of queue) {
|
|
141
|
+
if (queued.has(entry.id)) report(REGISTER, entry.line, `duplicate queue id ${entry.id}`);
|
|
142
|
+
queued.add(entry.id);
|
|
143
|
+
if (!byId.has(entry.id)) report(REGISTER, entry.line, `queue names unknown item ${entry.id}`);
|
|
144
|
+
else if (!['open', 'blocked'].includes(states.get(entry.id))) {
|
|
145
|
+
report(REGISTER, entry.line, `queue item ${entry.id} must be open or blocked`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// D1 section 5: checking only outgoing links would let a deleted row strand a plan's work.
|
|
150
|
+
for (const directory of WORKROOM) {
|
|
151
|
+
for (const file of markdownFiles(path.join(root, directory))) {
|
|
152
|
+
if (file === registerFile) continue;
|
|
153
|
+
const relative = path.relative(root, file).split(path.sep).join('/');
|
|
154
|
+
for (const [index, line] of fs.readFileSync(file, 'utf8').split(/\r?\n/).entries()) {
|
|
155
|
+
for (const match of line.matchAll(/OW-\d{3,}/g)) {
|
|
156
|
+
if (!byId.has(match[0])) report(relative, index + 1, `reference names unknown item ${match[0]}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { violations, notices, message: null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const scriptFile = fileURLToPath(import.meta.url);
|
|
165
|
+
// The one same-file judge (.claude/context/contracts.md): comparing spellings silenced every guard once.
|
|
166
|
+
if (isInvokedDirectly(process.argv[1], import.meta.url)) {
|
|
167
|
+
try {
|
|
168
|
+
const args = process.argv.slice(2);
|
|
169
|
+
if (args.some((arg) => arg !== '--strict')) throw new Error('Usage: node scripts/open-work/check.mjs [--strict]');
|
|
170
|
+
const root = path.resolve(path.dirname(scriptFile), '../..');
|
|
171
|
+
const result = validateOpenWork(root, { strict: args.includes('--strict') });
|
|
172
|
+
for (const violation of result.violations) console.log(violation);
|
|
173
|
+
for (const notice of result.notices) console.log(`note: ${notice}`);
|
|
174
|
+
if (result.message) console.log(result.message);
|
|
175
|
+
process.exitCode = result.violations.length ? 1 : 0;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error(`${REGISTER}:1: ${error.message}`);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks pending live-step release thresholds and their exact open-work holds without I/O.
|
|
3
|
+
* Plan_72 R3 B1: releases 0.6.3…0.6.8 passed unnoticed while live steps still waited for them;
|
|
4
|
+
* on 2026-10-01 fifteen checklist-index lines were overdue.
|
|
5
|
+
*/
|
|
6
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
7
|
+
const STEP = /^\d+[a-zа-яё]?$/;
|
|
8
|
+
const WORK = /^OW-\d{3,}$/;
|
|
9
|
+
|
|
10
|
+
function matches(value, pattern) {
|
|
11
|
+
return typeof value === 'string' && value.match(pattern)?.[0] === value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function compareVersions(left, right) {
|
|
15
|
+
const a = left.split('.').map(BigInt);
|
|
16
|
+
const b = right.split('.').map(BigInt);
|
|
17
|
+
for (let i = 0; i < 3; i++) {
|
|
18
|
+
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateGroup(group, seen, report) {
|
|
24
|
+
if (group === null || typeof group !== 'object' || Array.isArray(group)) {
|
|
25
|
+
report('live group must be an object');
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
let valid = true;
|
|
29
|
+
const fail = (text) => { valid = false; report(text); };
|
|
30
|
+
for (const key of Object.keys(group)) {
|
|
31
|
+
if (!['steps', 'threshold', 'work'].includes(key)) fail(`unknown live group key: ${key}`);
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(group.steps) || !group.steps.length) {
|
|
34
|
+
fail('live group steps must be a non-empty array');
|
|
35
|
+
} else {
|
|
36
|
+
const local = new Set();
|
|
37
|
+
for (const step of group.steps) {
|
|
38
|
+
if (!matches(step, STEP)) fail('live group step must match digits with at most one lowercase letter');
|
|
39
|
+
else if (local.has(step)) fail(`duplicate step ${step} in live group`);
|
|
40
|
+
else if (seen.has(step)) fail(`step ${step} appears in more than one live group`);
|
|
41
|
+
local.add(step);
|
|
42
|
+
seen.add(step);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (group.threshold !== 'none' && !matches(group.threshold, VERSION)) {
|
|
46
|
+
fail('live group threshold must be a three-integer version or none');
|
|
47
|
+
}
|
|
48
|
+
if (Object.hasOwn(group, 'work') && !matches(group.work, WORK)) {
|
|
49
|
+
fail('live group work must match OW- followed by at least three digits');
|
|
50
|
+
}
|
|
51
|
+
if (group.threshold === 'none' && !Object.hasOwn(group, 'work')) {
|
|
52
|
+
fail('live group with threshold none requires work');
|
|
53
|
+
}
|
|
54
|
+
return valid;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateHold(group, checklist, items, report) {
|
|
58
|
+
const item = items.find((candidate) => candidate.id === group.work);
|
|
59
|
+
if (!item) {
|
|
60
|
+
report(`hold ${group.work}: item does not exist`);
|
|
61
|
+
return { valid: false };
|
|
62
|
+
}
|
|
63
|
+
let valid = true;
|
|
64
|
+
const fail = (text) => { valid = false; report(`hold ${group.work}: ${text}`); };
|
|
65
|
+
const state = item.fields.find((field) => field.key === 'состояние')?.value.trim();
|
|
66
|
+
if (!['open', 'blocked'].includes(state)) fail('state must be open or blocked');
|
|
67
|
+
|
|
68
|
+
const expected = new Set(group.steps);
|
|
69
|
+
let matching = 0;
|
|
70
|
+
// Compare individual source lines: joining continuations would invent a hold for missing steps.
|
|
71
|
+
for (const home of item.fields.filter((field) => field.key === 'дом')) {
|
|
72
|
+
for (const source of home.valueLines) {
|
|
73
|
+
const binding = source.value.trim().match(/^(docs\/checklists\/\S+\.md) steps (.+)$/);
|
|
74
|
+
if (!binding || binding[1] !== checklist) continue;
|
|
75
|
+
const steps = binding[2].split(',').map((step) => step.trim());
|
|
76
|
+
const actual = new Set(steps);
|
|
77
|
+
if (actual.size !== steps.length) fail(`duplicate steps in binding for ${checklist}`);
|
|
78
|
+
if (actual.size !== expected.size || steps.some((step) => !expected.has(step))) {
|
|
79
|
+
fail(`binding for ${checklist} has steps ${steps.join(', ')}, expected ${group.steps.join(', ')}`);
|
|
80
|
+
} else if (actual.size === steps.length) matching++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (matching !== 1) fail(`home must contain exactly one binding for ${checklist} steps ${group.steps.join(', ')}`);
|
|
84
|
+
|
|
85
|
+
const reason = item.fields.find((field) => field.key === 'блокер')?.value.trim();
|
|
86
|
+
if (group.threshold === 'none' && !reason) fail('threshold none requires a non-empty blocker');
|
|
87
|
+
return { valid, reason };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function checkLiveThresholds({ indexText, indexFile, version, versionFile, items }) {
|
|
91
|
+
const violations = [];
|
|
92
|
+
const notices = [];
|
|
93
|
+
// Register continuation values can contain newlines, but diagnostics must remain one line.
|
|
94
|
+
const entry = (file, line, text) => ({ file, line, text: text.replace(/[\r\n\u2028\u2029]+/g, ' ') });
|
|
95
|
+
const validVersion = matches(version, VERSION);
|
|
96
|
+
if (!validVersion) violations.push(entry(versionFile, 1, 'package version must be a three-integer version'));
|
|
97
|
+
|
|
98
|
+
let active = false;
|
|
99
|
+
let found = false;
|
|
100
|
+
for (const [index, textLine] of indexText.split(/\r?\n/).entries()) {
|
|
101
|
+
if (/^## Актуальные\s*$/.test(textLine)) {
|
|
102
|
+
active = true;
|
|
103
|
+
found = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (textLine.startsWith('## ')) active = false;
|
|
107
|
+
if (!active || !textLine.startsWith('- [')) continue;
|
|
108
|
+
|
|
109
|
+
const line = index + 1;
|
|
110
|
+
const report = (text) => violations.push(entry(indexFile, line, text));
|
|
111
|
+
const link = textLine.match(/\[[^\]]*\]\(([^)\r\n]+)\)/);
|
|
112
|
+
if (!link) report('active checklist line must contain a markdown link');
|
|
113
|
+
const checklist = link ? `docs/checklists/${link[1]}` : null;
|
|
114
|
+
const marker = textLine.match(/ live=(\[.*\])\s*$/);
|
|
115
|
+
if ((textLine.match(/ live=/g) ?? []).length !== 1 || !marker) {
|
|
116
|
+
report('active checklist line must end with exactly one live= array marker');
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
let groups;
|
|
120
|
+
try { groups = JSON.parse(marker[1]); }
|
|
121
|
+
catch {
|
|
122
|
+
report('live marker contains invalid JSON');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(groups)) {
|
|
126
|
+
report('live marker must be an array');
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const group of groups) {
|
|
131
|
+
if (!validateGroup(group, seen, report) || !checklist) continue;
|
|
132
|
+
const hold = Object.hasOwn(group, 'work') ? validateHold(group, checklist, items, report) : null;
|
|
133
|
+
if (!validVersion) continue;
|
|
134
|
+
const steps = group.steps.join(', ');
|
|
135
|
+
if (group.threshold === 'none') {
|
|
136
|
+
if (hold.valid) notices.push(entry(indexFile, line,
|
|
137
|
+
`no threshold, held by ${group.work}: ${checklist} steps ${steps}: ${hold.reason}`));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const compared = compareVersions(group.threshold, version);
|
|
141
|
+
if (compared > 0) continue;
|
|
142
|
+
if (compared === 0) {
|
|
143
|
+
notices.push(entry(indexFile, line,
|
|
144
|
+
`due: ${checklist} steps ${steps} wait for ${group.threshold}, the package is ${version}`));
|
|
145
|
+
} else if (hold?.valid) {
|
|
146
|
+
notices.push(entry(indexFile, line,
|
|
147
|
+
`overdue, held by ${group.work}: ${checklist} steps ${steps} waited for ${group.threshold}`));
|
|
148
|
+
} else {
|
|
149
|
+
report(`overdue: ${checklist} steps ${steps} waited for ${group.threshold}, the package is ${version}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!found) violations.push(entry(indexFile, 1, 'checklist index is missing the ## Актуальные section'));
|
|
154
|
+
return { violations, notices };
|
|
155
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the open-work register into source-located items and queue entries without judging them.
|
|
3
|
+
* Plan_72: 15 of 20 handoff records dropped unfinished items because open work had no single home.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
|
|
7
|
+
export function parseRegister(text) {
|
|
8
|
+
const items = [];
|
|
9
|
+
const queue = [];
|
|
10
|
+
let item = null;
|
|
11
|
+
let field = null;
|
|
12
|
+
let inQueue = false;
|
|
13
|
+
|
|
14
|
+
for (const [index, textLine] of text.split(/\r?\n/).entries()) {
|
|
15
|
+
const line = index + 1;
|
|
16
|
+
const heading = textLine.match(/^### (OW-\d{3,}) — (.+)$/);
|
|
17
|
+
if (/^#{1,6}\s/.test(textLine)) {
|
|
18
|
+
item = null;
|
|
19
|
+
field = null;
|
|
20
|
+
if (/^#{1,2}\s/.test(textLine)) {
|
|
21
|
+
// The real D1 register labels its queue with a parenthetical explanation.
|
|
22
|
+
inQueue = /^## Очередь(?:\s+\(.*\))?\s*$/.test(textLine);
|
|
23
|
+
}
|
|
24
|
+
if (heading) {
|
|
25
|
+
item = { id: heading[1], title: heading[2], line, fields: [] };
|
|
26
|
+
items.push(item);
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (inQueue) {
|
|
32
|
+
const entry = textLine.match(/^\d+\. (OW-\d{3,})(?=\s|$)/);
|
|
33
|
+
if (entry) queue.push({ id: entry[1], line });
|
|
34
|
+
}
|
|
35
|
+
if (!item) continue;
|
|
36
|
+
|
|
37
|
+
const match = textLine.match(/^- ([^:]+):\s*(.*)$/);
|
|
38
|
+
if (match) {
|
|
39
|
+
field = { key: match[1], value: match[2], line, valueLines: [{ value: match[2], line }] };
|
|
40
|
+
item.fields.push(field);
|
|
41
|
+
} else if (field && /^ {2}/.test(textLine)) {
|
|
42
|
+
const value = textLine.slice(2);
|
|
43
|
+
field.value += `\n${value}`;
|
|
44
|
+
field.valueLines.push({ value, line });
|
|
45
|
+
} else {
|
|
46
|
+
field = null;
|
|
47
|
+
// Blank lines may separate fields; other prose ends the item's field block.
|
|
48
|
+
if (textLine.trim()) item = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { items, queue };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readRegister(file) {
|
|
55
|
+
return parseRegister(fs.readFileSync(file, 'utf8'));
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names additions to a directory's direct filesystem entries without deleting anything.
|
|
3
|
+
* On 2026-09-24 a suite run left Microsoft/Windows/PowerShell/ModuleAnalysisCache in the
|
|
4
|
+
* repository root; git cannot witness ignored entries, so this guard reads the filesystem.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
|
|
8
|
+
export function snapshotRoot(dir) {
|
|
9
|
+
return fs.readdirSync(dir).sort();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function addedEntries(before, after) {
|
|
13
|
+
const previous = new Set(before);
|
|
14
|
+
return after.filter((name) => !previous.has(name));
|
|
15
|
+
}
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
* straight into the operator's real `~/.lyupro/.codex-bridge/`, where the stray config then stood
|
|
13
13
|
* in the way of the very migration the release was about. Roots live in one list here so a third
|
|
14
14
|
* one cannot be added without this file being the place it is declared.
|
|
15
|
+
*
|
|
16
|
+
* Plan_62 D22 requires package-home writes to remain removable by artifact id. Plan_65 B1b runs
|
|
17
|
+
* the write audit here so the suite cannot forget to enforce the boundary before creating roots.
|
|
18
|
+
*
|
|
19
|
+
* On 2026-09-24 a PowerShell started with empty LOCALAPPDATA left
|
|
20
|
+
* Microsoft/Windows/PowerShell/ModuleAnalysisCache in the repository root during a suite run.
|
|
21
|
+
* Git cannot witness ignored entries, so filesystem snapshots name every new direct entry and
|
|
22
|
+
* fail the run without deleting it; another process may have created it while the suite ran.
|
|
15
23
|
*/
|
|
16
24
|
import fs from 'node:fs';
|
|
17
25
|
import os from 'node:os';
|
|
@@ -19,7 +27,10 @@ import path from 'node:path';
|
|
|
19
27
|
import { fileURLToPath } from 'node:url';
|
|
20
28
|
import { spawnSync } from 'node:child_process';
|
|
21
29
|
import { ISOLATED_ROOTS } from './isolated-roots.mjs';
|
|
30
|
+
import { auditWrites } from './home-write-audit.mjs';
|
|
31
|
+
import { HOME_WRITE_INVENTORY } from './home-write-inventory.mjs';
|
|
22
32
|
import { readmeText, suiteCountMismatch } from './suite-count.mjs';
|
|
33
|
+
import { snapshotRoot, addedEntries } from './root-guard/root-entries.mjs';
|
|
23
34
|
|
|
24
35
|
// One pattern only: on 2026-09-25 three files passed as three arguments ran just the first, and a
|
|
25
36
|
// verification reported green for two files it never touched. Refuse before any root is created.
|
|
@@ -28,6 +39,15 @@ if (process.argv.length > 3) {
|
|
|
28
39
|
process.exit(2);
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
43
|
+
const { violations } = auditWrites({ repositoryRoot, inventory: HOME_WRITE_INVENTORY });
|
|
44
|
+
if (violations.length) {
|
|
45
|
+
for (const { module, sink, problem } of violations) {
|
|
46
|
+
process.stderr.write(`run-tests: home-write audit: ${module} ${sink} — ${problem}\n`);
|
|
47
|
+
}
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
const created = ISOLATED_ROOTS.map((name) => [
|
|
32
52
|
name,
|
|
33
53
|
fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
|
|
@@ -40,7 +60,6 @@ const wholeSuite = !process.argv[2];
|
|
|
40
60
|
const tapFile = wholeSuite
|
|
41
61
|
? path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-summary-')), 'summary.tap')
|
|
42
62
|
: null;
|
|
43
|
-
const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
44
63
|
|
|
45
64
|
// The spec reporter is named explicitly because the second reporter would otherwise replace it,
|
|
46
65
|
// and the operator would lose the live output this command exists to show.
|
|
@@ -50,11 +69,17 @@ const reporters = tapFile
|
|
|
50
69
|
: [];
|
|
51
70
|
|
|
52
71
|
try {
|
|
72
|
+
const before = snapshotRoot(repositoryRoot);
|
|
53
73
|
const result = spawnSync(process.execPath, ['--test', ...reporters, pattern], {
|
|
54
74
|
stdio: 'inherit',
|
|
55
75
|
env: { ...process.env, ...Object.fromEntries(created), CODEX_BRIDGE_TEST_TMP: testTmpRoot },
|
|
56
76
|
});
|
|
77
|
+
const additions = addedEntries(before, snapshotRoot(repositoryRoot));
|
|
57
78
|
process.exitCode = result.status ?? 1;
|
|
79
|
+
for (const name of additions) {
|
|
80
|
+
process.stderr.write(`run-tests: the suite left ${name} in the repository root (or something else created it while the suite ran)\n`);
|
|
81
|
+
}
|
|
82
|
+
if (additions.length && process.exitCode === 0) process.exitCode = 1;
|
|
58
83
|
if (tapFile && process.exitCode === 0) {
|
|
59
84
|
let tap = null;
|
|
60
85
|
try {
|