@codedology/code-forge 0.6.0 → 0.6.2
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 +37 -0
- package/README.md +35 -11
- package/docs/README.md +4 -4
- package/docs/reference/blocks.json +32 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +1 -1
- package/skill/SKILL.md +2 -1
- package/skill/references/autopilot.md +1 -1
- package/skill/references/decisions.md +1 -1
- package/skill/references/ledger.md +3 -1
- package/skill/references/plan.md +4 -4
- package/skill/references/rules-core.md +4 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/plan.md +4 -3
- package/src/autopilot/limits.mjs +1 -0
- package/src/cli/author.mjs +7 -4
- package/src/cli/facts.mjs +7 -5
- package/src/cli/jev.mjs +10 -5
- package/src/cli/plan.mjs +18 -6
- package/src/cli/run.mjs +3 -2
- package/src/cli/s2.mjs +5 -3
- package/src/cli/spawn.mjs +5 -3
- package/src/config/load.mjs +100 -3
- package/src/session/facts.mjs +291 -73
- package/src/session/plan-check.mjs +75 -6
package/src/cli/s2.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { readFile } from 'node:fs/promises';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
import { loadProjectConfig, slugFor } from '../config/load.mjs';
|
|
11
|
+
import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
|
|
12
12
|
import { runS2 } from '../session/s2.mjs';
|
|
13
13
|
import { runRootFor, SessionError } from '../session/spawn.mjs';
|
|
14
14
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
@@ -51,7 +51,9 @@ export async function runS2Verb(args, deps = {}) {
|
|
|
51
51
|
writeSafe(stderr, USAGE);
|
|
52
52
|
return 2;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// B50: the project root's config and ledger slug, also from a subfolder
|
|
55
|
+
const root = projectRootFor(process.cwd());
|
|
56
|
+
const loaded = await loadProjectConfig(root);
|
|
55
57
|
if (!loaded.ok) {
|
|
56
58
|
writeSafe(stderr, `s2: ${loaded.message}\n`);
|
|
57
59
|
return 2;
|
|
@@ -66,7 +68,7 @@ export async function runS2Verb(args, deps = {}) {
|
|
|
66
68
|
run: typeof flags.run === 'string' ? flags.run : undefined,
|
|
67
69
|
block: typeof flags.block === 'string' ? flags.block : undefined,
|
|
68
70
|
runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
|
|
69
|
-
slug: slugFor(cfg,
|
|
71
|
+
slug: slugFor(cfg, root),
|
|
70
72
|
timeoutMs: timeout === undefined ? undefined : timeout * 1000,
|
|
71
73
|
},
|
|
72
74
|
{ ...deps, stderr },
|
package/src/cli/spawn.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { readFile } from 'node:fs/promises';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
-
import { loadProjectConfig, slugFor } from '../config/load.mjs';
|
|
13
|
+
import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
|
|
14
14
|
import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
|
|
15
15
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
16
16
|
import { StateError } from '../state/paths.mjs';
|
|
@@ -60,7 +60,9 @@ export async function runSpawn(args, deps = {}) {
|
|
|
60
60
|
writeSafe(stderr, USAGE);
|
|
61
61
|
return 2;
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
// B50: the project root's config and ledger slug, also from a subfolder
|
|
64
|
+
const root = projectRootFor(process.cwd());
|
|
65
|
+
const loaded = await loadProjectConfig(root);
|
|
64
66
|
if (!loaded.ok) {
|
|
65
67
|
writeSafe(stderr, `spawn: ${loaded.message}\n`);
|
|
66
68
|
return 2;
|
|
@@ -81,7 +83,7 @@ export async function runSpawn(args, deps = {}) {
|
|
|
81
83
|
runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
|
|
82
84
|
run: typeof flags.run === 'string' ? flags.run : undefined,
|
|
83
85
|
block: typeof flags.block === 'string' ? flags.block : undefined,
|
|
84
|
-
slug: slugFor(cfg,
|
|
86
|
+
slug: slugFor(cfg, root),
|
|
85
87
|
},
|
|
86
88
|
{ ...deps, stderr },
|
|
87
89
|
);
|
package/src/config/load.mjs
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* (`err.pos`) and the source string — never from anything the `yaml` package formats for display.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
18
19
|
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import os from 'node:os';
|
|
19
21
|
import path from 'node:path';
|
|
20
22
|
import { parse as parseYAML } from 'yaml';
|
|
21
23
|
import { migrateConfig } from './migrate.mjs';
|
|
@@ -131,20 +133,115 @@ export async function loadConfigFile(filePath) {
|
|
|
131
133
|
* @returns {Promise<LoadResult>}
|
|
132
134
|
*/
|
|
133
135
|
export async function loadProjectConfig(cwd = process.cwd()) {
|
|
134
|
-
|
|
136
|
+
// B50: always the PROJECT ROOT's config ({@link projectRootFor}; idempotent when `cwd` is the
|
|
137
|
+
// root). A symlinked `.code-forge.yml` (dotfiles) loads like a file; a directory there is a `read-error`.
|
|
138
|
+
return loadConfigFile(path.join(projectRootFor(cwd), DEFAULT_CONFIG_FILENAME));
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
/**
|
|
138
|
-
*
|
|
142
|
+
* The nearest ancestor of `start` (itself included) that holds `marker`, or null.
|
|
143
|
+
* @param {string} start @param {string} marker
|
|
144
|
+
* @returns {string | null}
|
|
145
|
+
*/
|
|
146
|
+
function nearestWith(start, marker) {
|
|
147
|
+
for (let dir = start; ; dir = path.dirname(dir)) {
|
|
148
|
+
if (existsSync(path.join(dir, marker))) return dir;
|
|
149
|
+
if (path.dirname(dir) === dir) return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {string} p @returns {boolean} true when `p` is (or links to) a regular file — a symlinked config (dotfiles) counts, a directory never. */
|
|
154
|
+
function isRegularFile(p) {
|
|
155
|
+
try {
|
|
156
|
+
return statSync(p).isFile();
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {string} p @returns {string} `p` realpath'd, or resolved as given when it cannot be. */
|
|
163
|
+
function realOrResolved(p) {
|
|
164
|
+
try {
|
|
165
|
+
return realpathSync(p);
|
|
166
|
+
} catch {
|
|
167
|
+
return path.resolve(p);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The project root a verb run from `start` belongs to (B50). The git top level is found first
|
|
173
|
+
* (the nearest ancestor holding a `.git` directory or worktree file — a filesystem walk, no child
|
|
174
|
+
* `git`); the root is then the nearest folder from `start` up to AND INCLUDING that top level whose
|
|
175
|
+
* `.code-forge.yml` is a regular file (a symlink to one counts; a directory never), else the top
|
|
176
|
+
* level itself — a config above the repository never counts. Outside git, the same search runs
|
|
177
|
+
* from `start` up to the home directory (never past it) when `start` is under it, and looks at
|
|
178
|
+
* `start` alone when it is not (`/tmp/x`, `/opt/ci`: a stray config higher up never counts); found
|
|
179
|
+
* nothing ⇒ the root is `start`. Every walk runs on realpaths; the root comes back in the caller's
|
|
180
|
+
* spelling when an ancestor of the start as given resolves to it ({@link inCallerSpelling}). A verb
|
|
181
|
+
* run from a subfolder thus finds the project's config and ledger slug. Synchronous (no child process).
|
|
182
|
+
* @param {string} [start] - defaults to `process.cwd()`.
|
|
183
|
+
* @returns {string} absolute.
|
|
184
|
+
*/
|
|
185
|
+
export function projectRootFor(start = process.cwd()) {
|
|
186
|
+
return inCallerSpelling(start, resolvedRootFor(realOrResolved(start)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The root on realpaths: the git walk, the config walk and the HOME check all run on the resolved
|
|
191
|
+
* start, so a symlink into a repository finds its `.git`.
|
|
192
|
+
* @param {string} from - the realpath of the start.
|
|
193
|
+
* @returns {string} a realpath.
|
|
194
|
+
*/
|
|
195
|
+
function resolvedRootFor(from) {
|
|
196
|
+
const home = realOrResolved(os.homedir());
|
|
197
|
+
// a `.git` at HOME itself (a dotfiles repository) never makes HOME the project: outside git
|
|
198
|
+
const gitRoot = nearestWith(from, '.git');
|
|
199
|
+
if (gitRoot !== null && gitRoot !== home) return nearestConfigUpTo(from, gitRoot) ?? gitRoot;
|
|
200
|
+
const underHome = from === home || from.startsWith(`${home}${path.sep}`);
|
|
201
|
+
if (!underHome) return from; // the start alone: its own config (if any) or not, the root is the start
|
|
202
|
+
return nearestConfigUpTo(from, home) ?? from;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The root in the caller's spelling when it can be: the ancestor of `path.resolve(start)` (itself
|
|
207
|
+
* included) whose realpath IS the resolved root (`~/work/app` → `/data/app-v2` keeps `app`, and its
|
|
208
|
+
* slug); none ⇒ the realpath itself.
|
|
209
|
+
* @param {string} start @param {string} realRoot
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
function inCallerSpelling(start, realRoot) {
|
|
213
|
+
for (let dir = path.resolve(start); ; dir = path.dirname(dir)) {
|
|
214
|
+
if (realOrResolved(dir) === realRoot) return dir;
|
|
215
|
+
if (path.dirname(dir) === dir) return realRoot;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The nearest folder from `from` up to and including `stop` whose `.code-forge.yml` is a regular file.
|
|
221
|
+
* @param {string} from @param {string} stop - an ancestor of `from` (or `from` itself).
|
|
222
|
+
* @returns {string | null}
|
|
223
|
+
*/
|
|
224
|
+
function nearestConfigUpTo(from, stop) {
|
|
225
|
+
for (let dir = from; ; dir = path.dirname(dir)) {
|
|
226
|
+
if (isRegularFile(path.join(dir, DEFAULT_CONFIG_FILENAME))) return dir;
|
|
227
|
+
if (dir === stop || path.dirname(dir) === dir) return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `project.slug` from config, else the PROJECT ROOT's directory name ({@link projectRootFor} of
|
|
233
|
+
* `workspace`, B50: a subfolder never names the ledger) as a ledger slug. The configured
|
|
139
234
|
* value is NOT trusted here: `startRun` refuses any slug outside `/^[a-z0-9][a-z0-9-]*$/`
|
|
140
235
|
* (`bad-project`) before anything is written, so `../x` never reaches the ledger path. Run ids
|
|
141
236
|
* are checked the same way by the state layer (`assertRunId`, `bad-run-id`).
|
|
237
|
+
* `cfg` MUST be the config loaded from `projectRootFor(workspace)` (never the cwd's), or a
|
|
238
|
+
* configured `project.slug` is lost from a subfolder and the ledger splits.
|
|
142
239
|
* @param {Record<string, any>} cfg @param {string} workspace
|
|
143
240
|
* @returns {string}
|
|
144
241
|
*/
|
|
145
242
|
export function slugFor(cfg, workspace) {
|
|
146
243
|
const configured = cfg?.project?.slug;
|
|
147
244
|
if (typeof configured === 'string' && configured.length > 0) return configured;
|
|
148
|
-
const derived = path.basename(workspace).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
245
|
+
const derived = path.basename(projectRootFor(workspace)).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
149
246
|
return derived || 'project';
|
|
150
247
|
}
|