@codedology/code-forge 0.1.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 (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `code-forge review-file` (plan §4.1, §4.8, V4; block B11) — the coder's side of async review.
3
+ *
4
+ * review-file <path> --block <id> [--run <id>] enqueue; prints {ticket, status: queued|done, file}
5
+ * review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
6
+ * {ticket, status: pending} when --max elapses
7
+ *
8
+ * The same content enqueued again after an `unavailable` result is a fresh attempt (`retry: true`),
9
+ * not the old result.
10
+ * A done result that is not approved and carries open findings adds `fix_list` (what the coder
11
+ * fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
12
+ * §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) — the coder
13
+ * stops and reports; only the orchestrator and the human act on it.
14
+ *
15
+ * Everything is a file read/write inside the workspace (a sandboxed coder has no network). The
16
+ * path is given relative to the current directory and stored relative to the REPOSITORY ROOT
17
+ * (V4); an absolute path or a `..` segment is refused with `bad-path`. With no live worker the
18
+ * answer is `worker_down` — never approval — and nothing is enqueued; the coder must then print
19
+ * `===BLOCK <id> FAILED: worker down===`. Output is one JSON line on stdout.
20
+ * Exit codes: 0 queued/done/pending, 1 bad-path or another refusal, 2 usage, 3 worker_down.
21
+ */
22
+
23
+ import { readFileSync, rmSync } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { enqueue, isDone, liveWorker, queueDir } from '../worker/queue.mjs';
26
+ import { assertBlockId, assertTicketId, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
27
+ import { parseFlags } from '../state/cli-args.mjs';
28
+ import { writeSafe } from '../util/redact.mjs';
29
+
30
+ const USAGE = 'usage: code-forge review-file <path> --block <id> [--run <id>] | review-file --wait <ticket> [--max <seconds>s]\n';
31
+
32
+ export const DEFAULT_WAIT_MS = 90_000;
33
+ const POLL_MS = 200;
34
+
35
+ /**
36
+ * `90s`, `90`, `1500ms` → milliseconds.
37
+ * @param {unknown} raw @returns {number}
38
+ */
39
+ export function parseMax(raw) {
40
+ const m = typeof raw === 'string' ? /^(\d{1,7})(ms|s)?$/.exec(raw) : null;
41
+ if (!m) throw new WorkerError('usage', '--max must look like 90s or 1500ms');
42
+ const n = Number(m[1]);
43
+ return m[2] === 'ms' ? n : n * 1000;
44
+ }
45
+
46
+ /**
47
+ * @param {string} repoRoot @param {string} ticket
48
+ * @returns {Record<string, any> | null} the worker's result named by the done marker.
49
+ */
50
+ function doneResult(repoRoot, ticket) {
51
+ try {
52
+ const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
53
+ const rel = typeof marker.result === 'string' ? marker.result : '';
54
+ if (!rel.startsWith(`.code-forge${path.sep}reviews${path.sep}`) || rel.split(path.sep).includes('..')) return null;
55
+ return JSON.parse(readFileSync(path.join(repoRoot, rel), 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The coder-facing summary of a done result: the fix list and the stop reason, when any.
63
+ * @param {Record<string, any> | null} result
64
+ * @returns {Record<string, any>}
65
+ */
66
+ export function fixList(result) {
67
+ if (!result || result.approved === true) return {};
68
+ /** @type {Record<string, any>} */
69
+ const out = {};
70
+ if (Array.isArray(result.findings) && result.findings.length > 0) {
71
+ out.fix_list = result.findings.map((/** @type {Record<string, any>} */ f) => ({
72
+ id: f?.id ?? null,
73
+ severity: f?.severity ?? null,
74
+ lines: `${f?.line_start ?? '?'}-${f?.line_end ?? '?'}`,
75
+ claim: f?.claim ?? '',
76
+ fix: f?.fix ?? '',
77
+ }));
78
+ }
79
+ if (typeof result.stopped === 'string') out.stop = result.stopped;
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * @param {string[]} args
85
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number}} [deps]
86
+ * @returns {Promise<number>}
87
+ */
88
+ export async function runReviewFile(args, deps = {}) {
89
+ const stdout = deps.stdout ?? process.stdout;
90
+ const stderr = deps.stderr ?? process.stderr;
91
+ const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
92
+ const cwd = deps.cwd ?? process.cwd();
93
+ try {
94
+ const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max'] });
95
+ const repoRoot = await repoRootOf(cwd);
96
+
97
+ if (typeof flags.wait === 'string') {
98
+ if (positionals.length > 0 || flags.block !== undefined) throw new WorkerError('usage', '--wait takes only a ticket and --max');
99
+ const ticket = assertTicketId(flags.wait);
100
+ const maxMs = flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max);
101
+ const deadline = Date.now() + maxMs;
102
+ for (;;) {
103
+ if (isDone(repoRoot, ticket)) {
104
+ const result = doneResult(repoRoot, ticket);
105
+ say({ ticket, status: 'done', result, ...fixList(result) });
106
+ return 0;
107
+ }
108
+ if (!liveWorker(repoRoot)) {
109
+ say({ ticket, status: 'worker_down' });
110
+ return 3;
111
+ }
112
+ if (Date.now() >= deadline) {
113
+ say({ ticket, status: 'pending' });
114
+ return 0;
115
+ }
116
+ await new Promise((resolve) => setTimeout(resolve, deps.pollMs ?? POLL_MS));
117
+ }
118
+ }
119
+
120
+ if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
121
+ const block = assertBlockId(flags.block);
122
+ const file = normalizeRequestPath(positionals[0], cwd, repoRoot);
123
+ const worker = liveWorker(repoRoot);
124
+ if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
125
+ say({ status: 'worker_down', file });
126
+ return 3;
127
+ }
128
+ const queued = enqueue({ repoRoot, run: worker.run, block, file });
129
+ if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
130
+ // `unavailable` is never approval AND never final: drop the done marker so the worker runs
131
+ // the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
132
+ rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
133
+ say({ ...enqueue({ repoRoot, run: worker.run, block, file }), retry: true });
134
+ return 0;
135
+ }
136
+ say(queued);
137
+ return 0;
138
+ } catch (err) {
139
+ const code = err instanceof WorkerError || err?.name === 'StateError' ? err.code : 'error';
140
+ if (code === 'usage') {
141
+ writeSafe(stderr, `review-file: ${err.message}\n${USAGE}`);
142
+ return 2;
143
+ }
144
+ say({ status: 'refused', reason: code, message: err?.message ?? String(err) });
145
+ return 1;
146
+ }
147
+ }
148
+
149
+ /** @param {string[]} args @returns {Promise<number>} */
150
+ export default async function run(args) {
151
+ return runReviewFile(args);
152
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `code-forge run start|status|end` (plan §4.9). The orchestrator runs these; a coder never does
3
+ * (`run start` is on the forbidden list, ★ `code-forge-worker-from-coder`).
4
+ *
5
+ * run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]
6
+ * run start --reattach --run <id> [--worker-pid <pid>]
7
+ * run status --run <id>
8
+ * run end --run <id>
9
+ *
10
+ * B11: from the CLI (the default export), `run start` without `--worker-pid` sweeps dead run
11
+ * roots, launches `code-forge worker --run <id>` detached, waits until it announces itself in the
12
+ * queue and pins it; `run start --reattach` without `--worker-pid` re-pins the live worker the
13
+ * queue announces (`repinWorker`). Called as `runRun(args, deps)` without `deps.startWorker` (the
14
+ * state tests), no worker is launched and none is pinned unless `--worker-pid` is given.
15
+ */
16
+
17
+ import path from 'node:path';
18
+ import { loadProjectConfig } from '../config/load.mjs';
19
+ import { appendRow } from '../ledger/write.mjs';
20
+ import { intFlag, parseFlags } from '../state/cli-args.mjs';
21
+ import { StateError } from '../state/paths.mjs';
22
+ import { endRun, processStartTime, readRun, reattachWorker, startRun, stopPinnedWorker } from '../state/run.mjs';
23
+ import { writeSafe } from '../util/redact.mjs';
24
+ import { sweepRoots, tmpBase } from '../util/tmp.mjs';
25
+ import { launchWorker, repinWorker } from '../worker/loop.mjs';
26
+ import { repoRootOf } from '../worker/ticket.mjs';
27
+
28
+ /**
29
+ * `project.slug` from config, else the workspace directory name as a ledger slug. The configured
30
+ * value is NOT trusted here: `startRun` refuses any slug outside `/^[a-z0-9][a-z0-9-]*$/`
31
+ * (`bad-project`) before anything is written, so `../x` never reaches the ledger path. Run ids
32
+ * are checked the same way by the state layer (`assertRunId`, `bad-run-id`).
33
+ * @param {Record<string, any>} cfg @param {string} workspace
34
+ * @returns {string}
35
+ */
36
+ export function slugFor(cfg, workspace) {
37
+ const configured = cfg?.project?.slug;
38
+ if (typeof configured === 'string' && configured.length > 0) return configured;
39
+ const derived = path.basename(workspace).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
40
+ return derived || 'project';
41
+ }
42
+
43
+ /** @param {string} slug */
44
+ const ledgerWriter = (slug) => (/** @type {Record<string, any>} */ row) => appendRow(row, { slug });
45
+
46
+ /** @typedef {(pin: {pid: number, started_at: string}, opts: {probe?: import('../state/run.mjs').StartTimeProbe}) => Promise<{stopped: boolean, reason?: string}>} StopWorker */
47
+
48
+ /**
49
+ * A worker that `run start` launched but could not pin must not outlive the command as an orphan:
50
+ * stop it through the pinned-worker stopper (pid > 1, start time re-checked right before the
51
+ * signal, so a recycled pid is never signalled) and name the pid on stderr. `startedAt` is the
52
+ * start time read right after the launch; null means the process was already gone.
53
+ * @param {{pid: number, startedAt: string | null}} launched
54
+ * @param {{stopWorker: StopWorker, probe?: import('../state/run.mjs').StartTimeProbe, err: (s: string) => void}} deps
55
+ */
56
+ async function stopLaunched({ pid, startedAt }, { stopWorker, probe, err }) {
57
+ /** @type {{stopped: boolean, reason?: string}} */
58
+ let outcome;
59
+ try {
60
+ outcome = startedAt === null ? { stopped: false, reason: 'not running' } : await stopWorker({ pid, started_at: startedAt }, { ...(probe ? { probe } : {}) });
61
+ } catch (thrown) {
62
+ outcome = { stopped: false, reason: thrown?.message ?? String(thrown) };
63
+ }
64
+ err(`run start: launched worker pid ${pid} was not pinned; ${outcome.stopped ? 'stopped it' : `not stopped (${outcome.reason ?? 'unknown'})`}\n`);
65
+ }
66
+
67
+ /**
68
+ * @param {string[]} args
69
+ * @param {{
70
+ * stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown},
71
+ * probe?: import('../state/run.mjs').StartTimeProbe,
72
+ * stopWorker?: StopWorker,
73
+ * startWorker?: (opts: {runId: string, workspace: string}) => Promise<{pid: number, repoRoot: string}>,
74
+ * }} [deps] - `startWorker` (the CLI passes B11's `launchWorker`) launches the worker on a plain
75
+ * `run start`; `stopWorker` defaults to the state layer's `stopPinnedWorker` (pid > 1 and a
76
+ * re-verified start time before any signal) — `run end` uses it, and so does a `run start` whose
77
+ * launched worker could not be pinned.
78
+ * @returns {Promise<number>}
79
+ */
80
+ export async function runRun(args, deps = {}) {
81
+ const { stdout = process.stdout, stderr = process.stderr, probe, stopWorker, startWorker } = deps;
82
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
83
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
84
+ const [sub, ...rest] = args;
85
+ if (!['start', 'status', 'end'].includes(sub)) {
86
+ err(USAGE);
87
+ return 2;
88
+ }
89
+ try {
90
+ const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid'], booleans: ['reattach'] });
91
+ const runId = typeof flags.run === 'string' ? flags.run : undefined;
92
+ const workerPid = intFlag(flags['worker-pid'], 'worker-pid');
93
+
94
+ if (sub === 'start' && flags.reattach) {
95
+ if (!runId) throw new StateError('usage', 'run start --reattach needs --run');
96
+ const { project, workspace } = await readRun(runId);
97
+ if (workerPid !== undefined) {
98
+ await reattachWorker({ runId, workerPid, writeRow: ledgerWriter(project), probe });
99
+ out(`run ${runId}: worker re-pinned to pid ${workerPid}\n`);
100
+ return 0;
101
+ }
102
+ const repinned = await repinWorker({ runId, repoRoot: await repoRootOf(workspace), writeRow: ledgerWriter(project), ...(probe ? { probe } : {}) });
103
+ out(`run ${runId}: worker re-pinned to pid ${repinned.worker.pid}\n`);
104
+ return 0;
105
+ }
106
+ if (sub === 'start') {
107
+ const workspace = path.resolve(typeof flags.cwd === 'string' ? flags.cwd : process.cwd());
108
+ const loaded = await loadProjectConfig(workspace);
109
+ if (!loaded.ok) {
110
+ err(`run start: ${loaded.message}\n`);
111
+ return 2;
112
+ }
113
+ const project = slugFor(loaded.config, workspace);
114
+ const engine = typeof flags.engine === 'string' ? flags.engine : undefined;
115
+ const tmpRoot = typeof loaded.config?.tmp?.root === 'string' ? loaded.config.tmp.root : undefined;
116
+ await sweepRoots({ root: tmpBase(tmpRoot) }); // dead runs' temp roots and orphans (§9.6, V12)
117
+ let record = await startRun({ workspace, project, config: loaded.config, engine, runId, workerPid, writeRow: ledgerWriter(project), probe });
118
+ if (!record.worker && startWorker) {
119
+ const launched = await startWorker({ runId: record.run_id, workspace });
120
+ const startedAt = await (probe ?? processStartTime)(launched.pid); // what the stop below re-checks against
121
+ try {
122
+ // pin ONLY the pid we launched: an older announced worker must not be pinned over it
123
+ record = await repinWorker({ runId: record.run_id, repoRoot: launched.repoRoot, writeRow: ledgerWriter(project), expectPid: launched.pid, ...(probe ? { probe } : {}) });
124
+ } catch (thrown) {
125
+ await stopLaunched({ pid: launched.pid, startedAt }, { stopWorker: stopWorker ?? stopPinnedWorker, probe, err });
126
+ throw thrown;
127
+ }
128
+ }
129
+ out(`run ${record.run_id} started · engine ${record.engine} · worker ${record.worker ? `pid ${record.worker.pid}` : 'not pinned'}\n`);
130
+ if (!record.worker) err('run start: no worker pinned — start the worker, then `run start --reattach --run <id> --worker-pid <pid>`\n');
131
+ return 0;
132
+ }
133
+ if (sub === 'status' && runId) {
134
+ const record = await readRun(runId);
135
+ const { run_id, status, engine, blocks, orphans } = record;
136
+ out(`${JSON.stringify({ run_id, status, engine, worker_pinned: Boolean(record.worker), blocks, orphans }, null, 2)}\n`);
137
+ return 0;
138
+ }
139
+ if (sub === 'end' && runId) {
140
+ const { project } = await readRun(runId);
141
+ const record = await endRun({ runId, writeRow: ledgerWriter(project), stopWorker, probe });
142
+ out(`run ${record.run_id} ended\n`);
143
+ return 0;
144
+ }
145
+ } catch (thrown) {
146
+ err(`run ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
147
+ return thrown instanceof StateError && thrown.code === 'usage' ? 2 : 1;
148
+ }
149
+ err(USAGE);
150
+ return 2;
151
+ }
152
+
153
+ const USAGE =
154
+ 'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | end --run <id>\n';
155
+
156
+ /** @param {string[]} args @returns {Promise<number>} */
157
+ export default async function run(args) {
158
+ return runRun(args, { startWorker: launchWorker });
159
+ }
package/src/cli/s2.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `code-forge s2 --packet <file.json> [--run <id>] [--block <id>] [--timeout <s>]` (plan §3.4;
3
+ * block B9a). The packet file is `{question, context?, options?, s1?}`; the verb prints the S2
4
+ * answer `{decision, confidence, reason, overrule, ask_human, human_question}` as one JSON line.
5
+ * Exit codes: 0 answered, 1 failed or invalid answer, 2 usage, 3 L3 and every fallback
6
+ * unavailable (S2 ⇒ stop and ask the human, §5.6).
7
+ */
8
+
9
+ import { readFile } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { loadProjectConfig } from '../config/load.mjs';
12
+ import { runS2 } from '../session/s2.mjs';
13
+ import { runRootFor, SessionError } from '../session/spawn.mjs';
14
+ import { intFlag, parseFlags } from '../state/cli-args.mjs';
15
+ import { StateError } from '../state/paths.mjs';
16
+ import { writeSafe } from '../util/redact.mjs';
17
+ import { slugFor } from './run.mjs';
18
+ import { exitCodeFor } from './spawn.mjs';
19
+
20
+ /**
21
+ * @param {string} file @param {string} flag
22
+ * @returns {Promise<any>}
23
+ * @throws {SessionError} `usage` naming the flag only — never the file's content.
24
+ */
25
+ async function readJSONFlag(file, flag) {
26
+ let text;
27
+ try {
28
+ text = await readFile(path.resolve(file), 'utf8');
29
+ } catch {
30
+ throw new SessionError('usage', `--${flag} cannot be read`);
31
+ }
32
+ try {
33
+ return JSON.parse(text);
34
+ } catch {
35
+ throw new SessionError('usage', `--${flag} is not a valid JSON file`);
36
+ }
37
+ }
38
+
39
+ const USAGE = 'usage: code-forge s2 --packet <file.json> [--run <id>] [--block <id>] [--timeout <seconds>]\n';
40
+
41
+ /**
42
+ * @param {string[]} args
43
+ * @param {import('../session/spawn.mjs').SessionDeps & {stdout?: {write: (s: string) => unknown}}} [deps]
44
+ * @returns {Promise<number>}
45
+ */
46
+ export async function runS2Verb(args, deps = {}) {
47
+ const stdout = deps.stdout ?? process.stdout;
48
+ const stderr = deps.stderr ?? process.stderr;
49
+ try {
50
+ const { flags, positionals } = parseFlags(args, { values: ['packet', 'run', 'block', 'timeout'] });
51
+ if (positionals.length > 0 || typeof flags.packet !== 'string') {
52
+ writeSafe(stderr, USAGE);
53
+ return 2;
54
+ }
55
+ const loaded = await loadProjectConfig(process.cwd());
56
+ if (!loaded.ok) {
57
+ writeSafe(stderr, `s2: ${loaded.message}\n`);
58
+ return 2;
59
+ }
60
+ const cfg = loaded.config;
61
+ const timeout = intFlag(flags.timeout, 'timeout');
62
+ const packet = await readJSONFlag(flags.packet, 'packet');
63
+ const result = await runS2(
64
+ {
65
+ cfg,
66
+ packet,
67
+ run: typeof flags.run === 'string' ? flags.run : undefined,
68
+ block: typeof flags.block === 'string' ? flags.block : undefined,
69
+ runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
70
+ slug: slugFor(cfg, process.cwd()),
71
+ timeoutMs: timeout === undefined ? undefined : timeout * 1000,
72
+ },
73
+ { ...deps, stderr },
74
+ );
75
+ if (result.status === 'ok') {
76
+ writeSafe(stdout, `${JSON.stringify(result.answer)}\n`);
77
+ } else {
78
+ writeSafe(stderr, `s2: ${result.status}${result.reason ? ` (${result.reason})` : ''}\n`);
79
+ }
80
+ return exitCodeFor(result);
81
+ } catch (err) {
82
+ if (err instanceof StateError || err instanceof SessionError) {
83
+ writeSafe(stderr, `s2: ${/** @type {Error} */ (err).message}\n`);
84
+ return 2;
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+
90
+ /** @param {string[]} args */
91
+ export default async function s2Verb(args) {
92
+ return runS2Verb(args);
93
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `code-forge spawn --level L<n> --role <role> --brief <file> [--schema <file>] [--cwd <dir>]
3
+ * [--run <id>] [--block <id>] [--timeout <s>] [--background]` (plan §5.2; block B9a).
4
+ *
5
+ * Runs one isolated session through `src/session/spawn.mjs` and prints its result as one JSON line
6
+ * on stdout; the `level=… provider=… model=… effort=… fallback_step=…` line goes to stderr.
7
+ * Exit codes: 0 ok / started, 1 failed (timeout, bad output, refused), 2 usage, 3 unavailable
8
+ * (every step of the ladder reported unavailability).
9
+ */
10
+
11
+ import { readFile } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { loadProjectConfig } from '../config/load.mjs';
14
+ import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
15
+ import { intFlag, parseFlags } from '../state/cli-args.mjs';
16
+ import { StateError } from '../state/paths.mjs';
17
+ import { writeSafe } from '../util/redact.mjs';
18
+ import { slugFor } from './run.mjs';
19
+
20
+ /**
21
+ * @param {string} file @param {string} flag
22
+ * @returns {Promise<any>}
23
+ * @throws {SessionError} `usage` naming the flag only — never the file's content.
24
+ */
25
+ async function readJSONFlag(file, flag) {
26
+ let text;
27
+ try {
28
+ text = await readFile(path.resolve(file), 'utf8');
29
+ } catch {
30
+ throw new SessionError('usage', `--${flag} cannot be read`);
31
+ }
32
+ try {
33
+ return JSON.parse(text);
34
+ } catch {
35
+ throw new SessionError('usage', `--${flag} is not a valid JSON file`);
36
+ }
37
+ }
38
+
39
+ const USAGE = `usage: code-forge spawn --level L0|L1|L2|L3 --role ${ROLES.join('|')} --brief <file> [--schema <file>] [--cwd <dir>] [--run <id>] [--block <id>] [--timeout <seconds>] [--background]\n`;
40
+
41
+ /**
42
+ * @param {{status: string}} result
43
+ * @returns {number}
44
+ */
45
+ export function exitCodeFor(result) {
46
+ if (result.status === 'ok' || result.status === 'started') return 0;
47
+ return result.status === 'unavailable' ? 3 : 1;
48
+ }
49
+
50
+ /**
51
+ * @param {string[]} args
52
+ * @param {import('../session/spawn.mjs').SessionDeps & {stdout?: {write: (s: string) => unknown}}} [deps]
53
+ * @returns {Promise<number>}
54
+ */
55
+ export async function runSpawn(args, deps = {}) {
56
+ const stdout = deps.stdout ?? process.stdout;
57
+ const stderr = deps.stderr ?? process.stderr;
58
+ try {
59
+ const { flags, positionals } = parseFlags(args, { values: ['level', 'role', 'brief', 'schema', 'cwd', 'run', 'block', 'timeout'], booleans: ['background'] });
60
+ if (positionals.length > 0 || typeof flags.level !== 'string' || typeof flags.role !== 'string' || typeof flags.brief !== 'string') {
61
+ writeSafe(stderr, USAGE);
62
+ return 2;
63
+ }
64
+ const loaded = await loadProjectConfig(process.cwd());
65
+ if (!loaded.ok) {
66
+ writeSafe(stderr, `spawn: ${loaded.message}\n`);
67
+ return 2;
68
+ }
69
+ const cfg = loaded.config;
70
+ const timeout = intFlag(flags.timeout, 'timeout');
71
+ const schema = typeof flags.schema === 'string' ? await readJSONFlag(flags.schema, 'schema') : undefined;
72
+ const result = await spawnSession(
73
+ {
74
+ cfg,
75
+ level: /** @type {any} */ (flags.level),
76
+ role: /** @type {any} */ (flags.role),
77
+ promptPath: path.resolve(flags.brief),
78
+ cwd: typeof flags.cwd === 'string' ? path.resolve(flags.cwd) : undefined,
79
+ schema,
80
+ timeoutMs: timeout === undefined ? undefined : timeout * 1000,
81
+ background: flags.background === true,
82
+ runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
83
+ run: typeof flags.run === 'string' ? flags.run : undefined,
84
+ block: typeof flags.block === 'string' ? flags.block : undefined,
85
+ slug: slugFor(cfg, process.cwd()),
86
+ },
87
+ { ...deps, stderr },
88
+ );
89
+ const { text: _text, ...shown } = /** @type {Record<string, any>} */ (result);
90
+ writeSafe(stdout, `${JSON.stringify(shown)}\n`);
91
+ return exitCodeFor(result);
92
+ } catch (err) {
93
+ if (err instanceof StateError || err instanceof SessionError) {
94
+ writeSafe(stderr, `spawn: ${/** @type {Error} */ (err).message}\n`);
95
+ return err instanceof SessionError && err.code === 'forbidden' ? 1 : 2;
96
+ }
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ /** @param {string[]} args */
102
+ export default async function spawnVerb(args) {
103
+ return runSpawn(args);
104
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `code-forge upgrade [--source <path>]` — re-points every recorded install at a skill source
3
+ * (plan §10.3 acceptance: "`upgrade` re-points all 3 recorded links"; research/installer-patterns.md
4
+ * point 8, "a self `upgrade` command"). A symlink record is re-created to point at the new
5
+ * source; a copy record is re-copied from it. With no `--source`, defaults to this running
6
+ * package's own `skill/` directory (the normal case: `npm update` moved the package, `upgrade`
7
+ * re-syncs every harness to it) — `--source` exists for pointing at an explicit path instead
8
+ * (tests, or a power user working from a git checkout of the skill).
9
+ */
10
+
11
+ import { stat } from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { writeSafe } from '../util/redact.mjs';
16
+ import { installsPath, upgradeAll } from '../install/link.mjs';
17
+
18
+ const USAGE = 'usage: code-forge upgrade [--source <path>]\n';
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ /** This package's own skill directory: `<package root>/skill`, i.e. two levels above `src/cli/`. */
22
+ export const DEFAULT_SKILL_SOURCE = path.join(__dirname, '..', '..', 'skill');
23
+
24
+ /**
25
+ * Strict parse: `[--source <path>]` only.
26
+ * @param {string[]} args
27
+ * @returns {{source?: string} | null}
28
+ */
29
+ export function parseUpgradeArgs(args) {
30
+ if (args.length === 0) {
31
+ return {};
32
+ }
33
+ const [flag, value, ...extra] = args;
34
+ if (flag !== '--source' || typeof value !== 'string' || value.length === 0 || extra.length > 0) {
35
+ return null;
36
+ }
37
+ return { source: value };
38
+ }
39
+
40
+ /**
41
+ * @param {string[]} args
42
+ * @param {object} deps
43
+ * @param {{write: (s: string) => unknown}} [deps.stdout]
44
+ * @param {{write: (s: string) => unknown}} [deps.stderr]
45
+ * @param {string} [deps.home]
46
+ * @param {string} [deps.defaultSource]
47
+ * @param {string} [deps.cwd] - a relative `--source` is resolved against this, never against the
48
+ * link's own directory (which `symlink()` would otherwise silently use).
49
+ * @returns {Promise<number>}
50
+ */
51
+ export async function runUpgrade(
52
+ args,
53
+ {
54
+ stdout = process.stdout,
55
+ stderr = process.stderr,
56
+ home = os.homedir(),
57
+ defaultSource = DEFAULT_SKILL_SOURCE,
58
+ cwd = process.cwd(),
59
+ } = {},
60
+ ) {
61
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
62
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
63
+
64
+ const parsed = parseUpgradeArgs(args);
65
+ if (!parsed) {
66
+ err(USAGE);
67
+ return 2;
68
+ }
69
+
70
+ // Resolved BEFORE use: a relative --source (e.g. "./skill") must mean "relative to where the
71
+ // user ran this command", never get stored/symlinked as a relative string (which `readlink`
72
+ // would then resolve against the LINK's own directory, not the user's cwd).
73
+ const resolvedSource = path.resolve(cwd, parsed.source ?? defaultSource);
74
+
75
+ let sourceStat;
76
+ try {
77
+ sourceStat = await stat(resolvedSource);
78
+ } catch {
79
+ err(`upgrade: source "${resolvedSource}" does not exist\n`);
80
+ return 1;
81
+ }
82
+ if (!sourceStat.isDirectory()) {
83
+ err(`upgrade: source "${resolvedSource}" is not a directory\n`);
84
+ return 1;
85
+ }
86
+
87
+ const updated = await upgradeAll({ installsFile: installsPath(home), source: resolvedSource });
88
+ for (const record of updated) {
89
+ out(`upgraded ${record.harness} (${record.scope}) -> ${record.source}\n`);
90
+ }
91
+ if (updated.length === 0) {
92
+ out('nothing to upgrade\n');
93
+ }
94
+ return 0;
95
+ }
96
+
97
+ /**
98
+ * @param {string[]} args
99
+ * @returns {Promise<number>}
100
+ */
101
+ export default async function upgrade(args) {
102
+ return runUpgrade(args, {});
103
+ }