@codedology/code-forge 0.15.3 → 0.16.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 (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +9 -1
  4. package/docs/reference/config.md +11 -1
  5. package/native/README.md +10 -0
  6. package/native/bin/darwin-arm64/code-forge-native +0 -0
  7. package/native/bin/darwin-x64/code-forge-native +0 -0
  8. package/native/bin/linux-arm64/code-forge-native +0 -0
  9. package/native/bin/linux-x64/code-forge-native +0 -0
  10. package/package.json +7 -2
  11. package/schema/code-forge.schema.json +26 -1
  12. package/schema/facts.schema.json +1 -1
  13. package/skill/SKILL.md +1 -1
  14. package/skill/references/adapters/solo.md +1 -1
  15. package/skill/references/autopilot.md +6 -1
  16. package/skill/references/code.md +37 -0
  17. package/skill/references/decisions.md +1 -1
  18. package/skill/references/plan.md +18 -5
  19. package/skill/references/review.md +47 -9
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/facts-sheet.md +9 -3
  22. package/src/cli/block.mjs +284 -11
  23. package/src/cli/doctor.mjs +32 -1
  24. package/src/cli/help.mjs +1 -0
  25. package/src/cli/native-status.mjs +13 -0
  26. package/src/cli/review-file.mjs +126 -14
  27. package/src/cli/spawn.mjs +26 -22
  28. package/src/cli/status.mjs +80 -14
  29. package/src/decide/escalation.mjs +5 -5
  30. package/src/doctor/index.mjs +11 -0
  31. package/src/doctor/worker-probe.mjs +1 -1
  32. package/src/gates/dependents.mjs +200 -0
  33. package/src/gates/expand.mjs +83 -26
  34. package/src/gates/run.mjs +76 -14
  35. package/src/ledger/tail.mjs +89 -0
  36. package/src/review/agent-waiver.mjs +95 -8
  37. package/src/review/definitions.mjs +170 -0
  38. package/src/review/fixloop.mjs +4 -4
  39. package/src/review/gate-check.mjs +33 -13
  40. package/src/review/model-stats.mjs +125 -0
  41. package/src/review/packet.mjs +111 -22
  42. package/src/review/reopen.mjs +38 -13
  43. package/src/review/route.mjs +58 -4
  44. package/src/session/facts.mjs +252 -70
  45. package/src/session/plan-check.mjs +4 -1
  46. package/src/session/spawn.mjs +70 -19
  47. package/src/state/block-end.mjs +3 -1
  48. package/src/state/block.mjs +56 -21
  49. package/src/state/run.mjs +12 -5
  50. package/src/util/native.mjs +133 -0
  51. package/src/worker/engine.mjs +11 -26
  52. package/src/worker/loop.mjs +150 -20
  53. package/src/worker/queue.mjs +3 -2
  54. package/src/worker/review-state.mjs +15 -3
  55. package/src/worker/watch.mjs +97 -83
  56. package/src/worker/worker-warning.mjs +35 -0
@@ -7,109 +7,123 @@ import { snapshotFor } from '../state/config-snapshot.mjs';
7
7
  import { readRun } from '../state/run.mjs';
8
8
  import { ownsAt } from '../review/gate-check.mjs';
9
9
  import { exec } from '../util/exec.mjs';
10
+ import { withLock } from '../util/file-locks.mjs';
10
11
  import { blockTickets, enqueueHash, isDone, queueDir, supersedeTicket } from './queue.mjs';
11
12
  import { assertRowPath, DELETED_HASH, gitChildEnv, hashBytes } from './ticket.mjs';
12
13
 
13
14
  const SHA = /^[0-9a-f]{40,64}$/;
14
15
  const BLOCK = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
16
+ const QUIET_MS = 15000;
17
+ const MAX_BURST_MS = 120000;
15
18
 
16
19
  /**
17
20
  * One scheduler tick, also exposed directly for deterministic race tests.
18
21
  * The boot config gates ALL watch work; absent/wait/overlap never reads the run or spawns git.
19
22
  * @param {{repoRoot: string, runId: string, cfg: Record<string, any>, readRun?: typeof readRun,
20
23
  * writeRow: (row: Record<string, any>) => Promise<unknown>, exec?: typeof exec,
21
- * running?: ReadonlySet<string>, hooks?: {afterHead?: (head: string) => Promise<unknown>, beforeQueue?: () => Promise<unknown>}}} opts
24
+ * running?: ReadonlySet<string>, now?: () => number,
25
+ * hooks?: {afterHead?: (head: string) => Promise<unknown>, beforeQueue?: () => Promise<unknown>}}} opts
22
26
  */
23
- export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readRun, writeRow, exec: execute = exec, running = new Set(), hooks = {} }) {
27
+ export async function watchTick({ repoRoot, runId, cfg, readRun: loadRun = readRun, writeRow, exec: execute = exec, running = new Set(), now = Date.now, hooks = {} }) {
24
28
  if (reviewFileLoop(cfg) !== 'watch') return;
25
29
  const record = await loadRun(runId);
26
30
  if (record.status !== 'active' || reviewFileLoop(snapshotFor(record, '')?.config ?? cfg) !== 'watch') return;
27
31
  const env = gitChildEnv();
28
32
  for (const [block, entry] of Object.entries(record.blocks ?? {})) {
29
33
  if (entry.status !== 'open' || !BLOCK.test(block)) continue;
30
- const stateFile = path.join(queueDir(repoRoot), 'watch', `${block}.json`);
31
- let tree = null;
32
- let root = repoRoot;
33
- let state = {};
34
- try { state = JSON.parse(readFileSync(stateFile, 'utf8')) ?? {}; } catch { /* restart at base */ }
35
- const base = entry.base_sha;
36
- /** @param {Record<string, any>} next */
37
- const save = (next) => {
38
- mkdirSync(path.dirname(stateFile), { recursive: true });
39
- const tmp = `${stateFile}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
34
+ await withLock(`watch:${realpathSync(repoRoot)}\0${block}`, async () => {
35
+ const stateFile = path.join(queueDir(repoRoot), 'watch', `${block}.json`);
36
+ let tree = null;
37
+ let root = repoRoot;
38
+ let state = {};
39
+ try { state = JSON.parse(readFileSync(stateFile, 'utf8')) ?? {}; } catch { /* restart at base */ }
40
+ const base = entry.base_sha;
41
+ /** @param {Record<string, any>} next */
42
+ const save = (next) => {
43
+ mkdirSync(path.dirname(stateFile), { recursive: true });
44
+ const tmp = `${stateFile}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
45
+ try {
46
+ writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
47
+ renameSync(tmp, stateFile);
48
+ state = next;
49
+ } finally { rmSync(tmp, { force: true }); }
50
+ };
51
+ /** @param {string} reason */
52
+ const skip = async (reason) => {
53
+ const skipped = Array.isArray(state.skipped) ? state.skipped : [];
54
+ if (!skipped.includes(reason)) {
55
+ await writeRow({ event: 'watch.skipped', block, reason });
56
+ save({ ...state, skipped: [...skipped, reason] });
57
+ }
58
+ };
40
59
  try {
41
- writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600 });
42
- renameSync(tmp, stateFile);
43
- state = next;
44
- } finally { rmSync(tmp, { force: true }); }
45
- };
46
- /** @param {string} reason */
47
- const skip = async (reason) => {
48
- const skipped = Array.isArray(state.skipped) ? state.skipped : [];
49
- if (!skipped.includes(reason)) {
50
- await writeRow({ event: 'watch.skipped', block, reason });
51
- save({ ...state, skipped: [...skipped, reason] });
52
- }
53
- };
54
- try {
55
- if (entry.worktree) {
56
- root = realpathSync(entry.worktree.path);
57
- tree = root;
58
- }
59
- if (state.run !== runId || state.base !== base || state.tree !== tree) state = { run: runId, base, tree, skipped: [] };
60
- /** @param {string[]} argv */
61
- const git = (argv) => execute(['git', ...argv], { cwd: root, env, timeoutMs: 10000 });
62
- const headResult = await git(['rev-parse', '--verify', 'HEAD^{commit}']);
63
- const head = headResult.stdout.trim();
64
- if (headResult.result !== 'ok' || !SHA.test(head)) { await skip('unreadable_head'); continue; }
65
- const earlier = blockTickets({ repoRoot, run: runId, block, tree });
66
- await hooks.afterHead?.(head);
67
- if (typeof base !== 'string' || !SHA.test(base) || (await git(['merge-base', '--is-ancestor', base, head])).result !== 'ok') {
68
- await skip('not_descendant'); continue;
69
- }
70
- const last = typeof state.sha === 'string' && SHA.test(state.sha) ? state.sha : base;
71
- if (last !== base && (await git(['merge-base', '--is-ancestor', last, head])).result !== 'ok') {
72
- await skip('rewound');
73
- const { sha: _sha, ...restart } = state;
74
- save(restart);
75
- continue;
76
- }
77
- // A healthy tick ends the skip streak: no new commit here, or a queued commit below. An
78
- // unreadable commit keeps the streak, so it writes one watch.skipped row, not one per tick.
79
- if (last === head) { if (state.skipped?.length) save({ ...state, skipped: [] }); continue; }
80
- const diff = await git(['diff', '--name-only', '-z', '--no-renames', last, head, '--']);
81
- if (diff.result !== 'ok') { await skip('unreadable_head'); continue; }
82
- const owns = ownsAt(tree ? '' : path.relative(repoRoot, record.workspace).split(path.sep).join('/'));
83
- const files = diff.stdout.split('\0').filter(Boolean);
84
- const owned = files.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
85
- if (owned.length !== files.length) await skip('unowned_path');
86
- // Stage every hash before publishing anything: an unreadable object queues nothing.
87
- const hashes = [];
88
- let unreadable = false;
89
- for (const raw of owned.filter((file) => reviewExcludeRule(snapshotFor(record, '')?.config ?? cfg, file) === null)) {
90
- const file = assertRowPath(raw);
91
- const listed = await git(['ls-tree', '-z', head, '--', file]);
92
- if (listed.result !== 'ok') { unreadable = true; break; }
93
- if (!listed.stdout) { hashes.push({ file, content_hash: DELETED_HASH }); continue; }
94
- if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
95
- const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
96
- if (shown.result !== 'ok') { unreadable = true; break; }
97
- hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
98
- }
99
- if (unreadable) { await skip('unreadable_head'); continue; }
100
- await hooks.beforeQueue?.();
101
- for (const { file, content_hash } of hashes) {
102
- const held = earlier.some((old) => old.file === file);
103
- if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
104
- for (const old of earlier) {
105
- if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
60
+ if (entry.worktree) {
61
+ root = realpathSync(entry.worktree.path);
62
+ tree = root;
63
+ }
64
+ if (state.run !== runId || state.base !== base || state.tree !== tree) state = { run: runId, base, tree, skipped: [] };
65
+ /** @param {string[]} argv */
66
+ const git = (argv) => execute(['git', ...argv], { cwd: root, env, timeoutMs: 10000 });
67
+ const headResult = await git(['rev-parse', '--verify', 'HEAD^{commit}']);
68
+ const head = headResult.stdout.trim();
69
+ if (headResult.result !== 'ok' || !SHA.test(head)) { await skip('unreadable_head'); return; }
70
+ const earlier = blockTickets({ repoRoot, run: runId, block, tree });
71
+ await hooks.afterHead?.(head);
72
+ if (typeof base !== 'string' || !SHA.test(base) || (await git(['merge-base', '--is-ancestor', base, head])).result !== 'ok') {
73
+ await skip('not_descendant'); return;
74
+ }
75
+ const last = typeof state.sha === 'string' && SHA.test(state.sha) ? state.sha : base;
76
+ const previous = typeof state.pending?.sha === 'string' && SHA.test(state.pending.sha) ? state.pending.sha : last;
77
+ if (previous !== base && (await git(['merge-base', '--is-ancestor', previous, head])).result !== 'ok') {
78
+ await skip('rewound');
79
+ const { sha: _sha, pending: _pending, burst_started_at: _burst, ...restart } = state;
80
+ save(restart);
81
+ return;
82
+ }
83
+ // A healthy tick ends the skip streak: no new commit here, or a queued commit below. An
84
+ // unreadable commit keeps the streak, so it writes one watch.skipped row, not one per tick.
85
+ if (last === head) { if (state.skipped?.length) save({ ...state, skipped: [] }); return; }
86
+ const time = now();
87
+ if (state.pending?.sha !== head) {
88
+ // A new HEAD restarts the quiet wait, but keeps the burst's original deadline.
89
+ save({ ...state, pending: { sha: head, seen_at: time }, burst_started_at: state.burst_started_at ?? time });
90
+ }
91
+ if (time - state.pending.seen_at < QUIET_MS && time - state.burst_started_at < MAX_BURST_MS) return;
92
+ const diff = await git(['diff', '--name-only', '-z', '--no-renames', last, head, '--']);
93
+ if (diff.result !== 'ok') { await skip('unreadable_head'); return; }
94
+ const owns = ownsAt(tree ? '' : path.relative(repoRoot, record.workspace).split(path.sep).join('/'));
95
+ const files = diff.stdout.split('\0').filter(Boolean);
96
+ const owned = files.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
97
+ if (owned.length !== files.length) await skip('unowned_path');
98
+ // Stage every hash before publishing anything: an unreadable object queues nothing.
99
+ const hashes = [];
100
+ let unreadable = false;
101
+ for (const raw of owned.filter((file) => reviewExcludeRule(snapshotFor(record, '')?.config ?? cfg, file) === null)) {
102
+ const file = assertRowPath(raw);
103
+ const listed = await git(['ls-tree', '-z', head, '--', file]);
104
+ if (listed.result !== 'ok') { unreadable = true; break; }
105
+ if (!listed.stdout) { hashes.push({ file, content_hash: DELETED_HASH }); continue; }
106
+ if (!/^100(?:644|755) blob /.test(listed.stdout)) { unreadable = true; break; }
107
+ const shown = await execute(['git', 'show', `${head}:${file}`], { cwd: root, env, timeoutMs: 10000, stdoutEncoding: 'base64' });
108
+ if (shown.result !== 'ok') { unreadable = true; break; }
109
+ hashes.push({ file, content_hash: hashBytes(Buffer.from(shown.stdout, 'base64')) });
110
+ }
111
+ if (unreadable) { await skip('unreadable_head'); return; }
112
+ await hooks.beforeQueue?.();
113
+ for (const { file, content_hash } of hashes) {
114
+ const held = earlier.some((old) => old.file === file);
115
+ if (!held) enqueueHash({ repoRoot, run: runId, block, file, content_hash, tree });
116
+ for (const old of earlier) {
117
+ if (old.file === file && old.content_hash !== content_hash && !running.has(old.ticket) && !isDone(repoRoot, old.ticket)) supersedeTicket(repoRoot, old.ticket);
118
+ }
119
+ if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
106
120
  }
107
- if (held) await writeRow({ event: 'watch.held', block, file, content_hash });
121
+ const { pending: _pending, burst_started_at: _burst, ...queued } = state;
122
+ save({ ...queued, run: runId, base, tree, sha: head, skipped: [] });
123
+ } catch {
124
+ // A bad path, missing tree or failed git read is a skip, never a block failure.
125
+ await skip('unreadable_head');
108
126
  }
109
- save({ ...state, run: runId, base, tree, sha: head, skipped: [] });
110
- } catch {
111
- // A bad path, missing tree or failed git read is a skip, never a block failure.
112
- await skip('unreadable_head');
113
- }
127
+ });
114
128
  }
115
129
  }
@@ -0,0 +1,35 @@
1
+ import { workerRegistry } from './queue.mjs';
2
+ import { repoRootOf } from './ticket.mjs';
3
+ import { checkWorkerPin, normalizeStartTime, processStartTime } from '../state/run.mjs';
4
+
5
+ /** @typedef {{pid: number, start_time: string, run: string}} WarningWorker */
6
+ /** @typedef {{live: boolean, worker: WarningWorker | null}} RegistryEntry */
7
+
8
+ /**
9
+ * Read-only advice about live workers serving a run without a matching process identity pin.
10
+ * Failure to discover the repository, read its registry or probe a pin never breaks status.
11
+ * @param {Record<string, any>} record
12
+ * @param {{repoRoot?: string, registry?: (root: string) => RegistryEntry[] | Promise<RegistryEntry[]>, probe?: import('../state/run.mjs').StartTimeProbe}} [opts]
13
+ * @returns {Promise<string[]>}
14
+ */
15
+ export async function unpinnedWorkerWarnings(record, { repoRoot, registry = workerRegistry, probe = processStartTime } = {}) {
16
+ try {
17
+ if (record.status === 'ended') return [];
18
+ const root = repoRoot ?? await repoRootOf(record.workspace);
19
+ const pins = record.workers ?? (record.worker ? [record.worker] : []);
20
+ const unpinned = (await registry(root)).filter(({ live, worker }) => live && worker?.run === record.run_id
21
+ && !pins.some((pin) => pin.pid === worker.pid
22
+ && normalizeStartTime(pin.started_at) === normalizeStartTime(worker.start_time)));
23
+ if (unpinned.length === 0) return [];
24
+
25
+ let suffix = '';
26
+ if (unpinned.length === 1 && pins.length > 0) {
27
+ const check = await checkWorkerPin(record, { probe });
28
+ if (!check.ok) suffix = ` (pinned pid ${pins[0].pid} is ${check.event === 'worker.replaced' ? 'replaced' : 'down'})`;
29
+ }
30
+ return unpinned.map(({ worker }) => `warning: live worker ${worker.pid} is not pinned: run start --reattach --run ${record.run_id}`
31
+ + (unpinned.length > 1 ? ` --worker-pid ${worker.pid}` : suffix));
32
+ } catch {
33
+ return [];
34
+ }
35
+ }