create-agent-rig 0.3.2 → 0.4.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.
- package/CHANGELOG.md +95 -10
- package/README.md +38 -0
- package/package.json +1 -1
- package/packages/cli/dist/commands/create.js +35 -7
- package/packages/cli/dist/commands/init.js +41 -3
- package/packages/cli/dist/commands/upgrade.js +300 -0
- package/packages/cli/dist/index.js +100 -13
- package/packages/cli/dist/lib/copy-tree.js +9 -1
- package/packages/cli/dist/lib/history.js +49 -0
- package/packages/cli/dist/lib/install-set.js +46 -0
- package/packages/cli/dist/lib/manifest.js +99 -0
- package/packages/cli/dist/lib/prompts.js +20 -0
- package/packages/cli/dist/lib/safe-path.js +41 -0
- package/packages/cli/dist/lib/substitute.js +32 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +17 -2
- package/templates/agent-os/universal/PLAN.md +14 -3
- package/templates/hash-history.json +263 -0
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
2
|
import { parseArgs } from 'node:util';
|
|
6
3
|
import { CreateError, createProject } from './commands/create.js';
|
|
7
4
|
import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
|
|
5
|
+
import { UpgradeError, applyUpgrade, planUpgrade } from './commands/upgrade.js';
|
|
8
6
|
import { makePalette } from './lib/colors.js';
|
|
9
|
-
import { promptTarget } from './lib/prompts.js';
|
|
7
|
+
import { promptConfirm, promptTarget } from './lib/prompts.js';
|
|
10
8
|
import { collectGovernance, renderSummary } from './lib/summary.js';
|
|
11
9
|
import { DEFAULT_TARGET, TARGET_NAMES } from './lib/targets.js';
|
|
10
|
+
import { packageVersion } from './lib/version.js';
|
|
12
11
|
const USAGE = `Usage: create-agent-rig <dir> [options]
|
|
13
12
|
|
|
14
13
|
Scaffolds a new project into <dir>: agent operating system (.claude/, CLAUDE.md)
|
|
@@ -25,14 +24,11 @@ Options
|
|
|
25
24
|
|
|
26
25
|
Also: create-agent-rig init [--dry-run] [--force]
|
|
27
26
|
Install the process layer (rules, gates, stop rules — no architecture
|
|
28
|
-
assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
34
|
-
return pkg.version;
|
|
35
|
-
}
|
|
27
|
+
assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md.
|
|
28
|
+
|
|
29
|
+
Also: create-agent-rig upgrade [--dry-run] [--yes]
|
|
30
|
+
Bring the rig in the CURRENT repo up to this version. Replaces the files it
|
|
31
|
+
installed and you did not touch; everything else is reported, never merged.`;
|
|
36
32
|
async function runInit(rawArgs) {
|
|
37
33
|
let values;
|
|
38
34
|
try {
|
|
@@ -77,10 +73,99 @@ async function runInit(rawArgs) {
|
|
|
77
73
|
}
|
|
78
74
|
return 0;
|
|
79
75
|
}
|
|
76
|
+
const MARK = {
|
|
77
|
+
update: '~',
|
|
78
|
+
new: '+',
|
|
79
|
+
conflict: '!',
|
|
80
|
+
deleted: '-',
|
|
81
|
+
wiring: '!',
|
|
82
|
+
unchanged: '·',
|
|
83
|
+
};
|
|
84
|
+
function renderUpgradePlan(repoDir, plan) {
|
|
85
|
+
const of = (verdict) => plan.actions.filter((a) => a.verdict === verdict);
|
|
86
|
+
const lines = [
|
|
87
|
+
`agent-rig upgrade — ${plan.kind} rig in ${repoDir}`,
|
|
88
|
+
plan.bootstrapped
|
|
89
|
+
? ` no manifest here (a pre-0.4.0 rig) — matching files against released versions`
|
|
90
|
+
: ` installed by ${plan.fromVersion}`,
|
|
91
|
+
` upgrading to ${plan.toVersion}`,
|
|
92
|
+
'',
|
|
93
|
+
];
|
|
94
|
+
for (const verdict of ['update', 'new', 'deleted', 'conflict', 'wiring']) {
|
|
95
|
+
for (const action of of(verdict)) {
|
|
96
|
+
lines.push(` ${MARK[verdict]} ${action.rel}` + (action.reason ? ` — ${action.reason}` : ''));
|
|
97
|
+
// A conflict is only useful if the new version can be diffed by hand.
|
|
98
|
+
if (verdict === 'conflict' && action.templatePath) {
|
|
99
|
+
lines.push(` new version: ${action.templatePath}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const unchanged = of('unchanged').length;
|
|
104
|
+
lines.push('', ` ${of('update').length} to replace, ${of('new').length} new, ` +
|
|
105
|
+
`${of('conflict').length} yours (kept), ${unchanged} already current`);
|
|
106
|
+
return `${lines.join('\n')}\n`;
|
|
107
|
+
}
|
|
108
|
+
async function runUpgrade(rawArgs) {
|
|
109
|
+
let values;
|
|
110
|
+
try {
|
|
111
|
+
({ values } = parseArgs({
|
|
112
|
+
args: rawArgs,
|
|
113
|
+
options: { 'dry-run': { type: 'boolean' }, yes: { type: 'boolean' } },
|
|
114
|
+
allowPositionals: false,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
process.stderr.write(`${error.message}\n\n${USAGE}\n`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
const cwd = process.cwd();
|
|
122
|
+
const plan = await planUpgrade(cwd);
|
|
123
|
+
process.stdout.write(renderUpgradePlan(cwd, plan));
|
|
124
|
+
// The one thing this command will not do for you — printed with the plan,
|
|
125
|
+
// because the dry run is where a reader decides whether there is work here,
|
|
126
|
+
// and a report that mentions entries it never shows is not a plan.
|
|
127
|
+
if (plan.wiring !== null) {
|
|
128
|
+
process.stdout.write(`\n! .claude/settings.json is never replaced — it is where your own hooks live.\n` +
|
|
129
|
+
` This version wires them like this; merge in what is missing:\n\n` +
|
|
130
|
+
plan.wiring.replace(/^/gm, ' ') +
|
|
131
|
+
'\n');
|
|
132
|
+
}
|
|
133
|
+
if (values['dry-run'] === true) {
|
|
134
|
+
process.stdout.write('\nDry run — nothing written.\n');
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
// The plan above is the review step, so it has to be answered before
|
|
138
|
+
// anything is written. On a terminal that is a question; off one it is the
|
|
139
|
+
// same refusal `create` makes without --target — never guess for a run that
|
|
140
|
+
// cannot be asked, least of all when the answer rewrites its repository.
|
|
141
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
142
|
+
if (values.yes !== true) {
|
|
143
|
+
if (!isInteractive) {
|
|
144
|
+
process.stderr.write('Refusing to rewrite files in a non-interactive run. ' +
|
|
145
|
+
'Re-run with --yes once the plan above is what you want (or --dry-run to keep looking).\n');
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
const confirmed = await promptConfirm('\nApply this plan?', {
|
|
149
|
+
input: process.stdin,
|
|
150
|
+
output: process.stderr,
|
|
151
|
+
isInteractive,
|
|
152
|
+
});
|
|
153
|
+
if (!confirmed) {
|
|
154
|
+
process.stdout.write('Nothing written.\n');
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const result = await applyUpgrade(cwd, plan);
|
|
159
|
+
process.stdout.write(`\nWrote ${result.written.length} files.\n`);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
80
162
|
async function main() {
|
|
81
163
|
if (process.argv[2] === 'init') {
|
|
82
164
|
return runInit(process.argv.slice(3));
|
|
83
165
|
}
|
|
166
|
+
if (process.argv[2] === 'upgrade') {
|
|
167
|
+
return runUpgrade(process.argv.slice(3));
|
|
168
|
+
}
|
|
84
169
|
let positionals;
|
|
85
170
|
let values;
|
|
86
171
|
try {
|
|
@@ -144,7 +229,9 @@ main()
|
|
|
144
229
|
process.exitCode = code;
|
|
145
230
|
})
|
|
146
231
|
.catch((error) => {
|
|
147
|
-
if (error instanceof CreateError ||
|
|
232
|
+
if (error instanceof CreateError ||
|
|
233
|
+
error instanceof InitError ||
|
|
234
|
+
error instanceof UpgradeError) {
|
|
148
235
|
process.stderr.write(`${error.message}\n`);
|
|
149
236
|
}
|
|
150
237
|
else {
|
|
@@ -34,6 +34,14 @@ export async function copyTree(srcDir, destDir, options = {}) {
|
|
|
34
34
|
* composition for collisions before anything is copied.
|
|
35
35
|
*/
|
|
36
36
|
export async function listTree(srcDir, options = {}) {
|
|
37
|
+
return (await listTreeEntries(srcDir, options)).map((entry) => entry.rel);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* {@link listTree} with the source path kept alongside each destination — what
|
|
41
|
+
* an upgrade needs to read the new version of a file, and to name where it
|
|
42
|
+
* lives when it must not write it.
|
|
43
|
+
*/
|
|
44
|
+
export async function listTreeEntries(srcDir, options = {}) {
|
|
37
45
|
const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
|
|
38
46
|
const files = [];
|
|
39
47
|
const walk = async (dir, relDir) => {
|
|
@@ -47,7 +55,7 @@ export async function listTree(srcDir, options = {}) {
|
|
|
47
55
|
await walk(path.join(dir, entry.name), relPath);
|
|
48
56
|
}
|
|
49
57
|
else if (entry.isFile()) {
|
|
50
|
-
files.push(relPath);
|
|
58
|
+
files.push({ rel: relPath, source: path.join(dir, entry.name) });
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { templatesRoot } from '../templates.js';
|
|
4
|
+
export const EMPTY_HISTORY = { versions: [], files: {} };
|
|
5
|
+
/**
|
|
6
|
+
* Whether every release this table knows about carried this path.
|
|
7
|
+
*
|
|
8
|
+
* It is the one question that lets a manifest-less rig keep a **deletion**: if
|
|
9
|
+
* the file shipped in every version the rig could possibly be, then it is gone
|
|
10
|
+
* because somebody removed it — not because the rig predates it. A path added
|
|
11
|
+
* later is genuinely new to an older rig and must still be delivered.
|
|
12
|
+
*/
|
|
13
|
+
export function presentInEveryRelease(history, rel) {
|
|
14
|
+
const oldest = history.versions[0];
|
|
15
|
+
const entry = history.files[rel];
|
|
16
|
+
return oldest !== undefined && entry !== undefined && entry.since === oldest;
|
|
17
|
+
}
|
|
18
|
+
export function historyPath() {
|
|
19
|
+
return path.join(templatesRoot(), 'hash-history.json');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fails open to an empty table: a missing or corrupt history means "nothing is
|
|
23
|
+
* recognised", which downgrades files to conflicts. That is the safe
|
|
24
|
+
* direction — the unsafe one is claiming a file is untouched when it is not.
|
|
25
|
+
*/
|
|
26
|
+
export async function loadHashHistory() {
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(await readFile(historyPath(), 'utf8'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return EMPTY_HISTORY;
|
|
33
|
+
}
|
|
34
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
35
|
+
return EMPTY_HISTORY;
|
|
36
|
+
const { versions, files } = parsed;
|
|
37
|
+
if (!Array.isArray(versions) || typeof files !== 'object' || files === null)
|
|
38
|
+
return EMPTY_HISTORY;
|
|
39
|
+
const clean = {};
|
|
40
|
+
for (const [rel, entry] of Object.entries(files)) {
|
|
41
|
+
const { since, hashes } = (entry ?? {});
|
|
42
|
+
if (typeof since !== 'string')
|
|
43
|
+
continue;
|
|
44
|
+
if (!Array.isArray(hashes) || hashes.some((h) => typeof h !== 'string'))
|
|
45
|
+
continue;
|
|
46
|
+
clean[rel] = { since, hashes };
|
|
47
|
+
}
|
|
48
|
+
return { versions: versions.filter((v) => typeof v === 'string'), files: clean };
|
|
49
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { listTreeEntries } from './copy-tree.js';
|
|
3
|
+
import { substituteContent, substituteFileName } from './substitute.js';
|
|
4
|
+
import { agentOsStackDir, agentOsUniversalDir } from '../templates.js';
|
|
5
|
+
/**
|
|
6
|
+
* The agent-os layers of a target, in composition order. One definition, read
|
|
7
|
+
* by `create` when it copies them and by `upgrade` when it refreshes them —
|
|
8
|
+
* two lists would drift, and the one nobody looks at would be the wrong one.
|
|
9
|
+
*/
|
|
10
|
+
export function agentOsLayerDirs(stacks) {
|
|
11
|
+
return [
|
|
12
|
+
{ name: 'agent-os/universal', dir: agentOsUniversalDir() },
|
|
13
|
+
...stacks.map((stack) => ({ name: `agent-os/stack/${stack}`, dir: agentOsStackDir(stack) })),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Exactly the agent-os bytes `create` writes, for a given target and project.
|
|
18
|
+
*
|
|
19
|
+
* The skeleton is deliberately absent: after generation the code is the user's
|
|
20
|
+
* project, not the rig's — `upgrade` refreshes the process layer and nothing
|
|
21
|
+
* else. Layers claim disjoint paths (`composition.ts` refuses otherwise), so
|
|
22
|
+
* this is a plain concatenation.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 Two assumptions, both load-bearing and both true today: the layer is all
|
|
25
|
+
* **text** (this reads every file as UTF-8; a template test pins it for the
|
|
26
|
+
* process half) and none of it is **executable** (`upgrade` writes with a bare
|
|
27
|
+
* `writeFile` and does not carry the mode across the way `copyTree` does —
|
|
28
|
+
* nothing in `templates/agent-os` currently has a mode to carry). Adding the
|
|
29
|
+
* first binary asset or executable script here means teaching both.
|
|
30
|
+
*/
|
|
31
|
+
export async function agentOsInstallSet(stacks, ctx) {
|
|
32
|
+
const files = [];
|
|
33
|
+
for (const layer of agentOsLayerDirs(stacks)) {
|
|
34
|
+
const entries = await listTreeEntries(layer.dir, {
|
|
35
|
+
transformName: (name) => substituteFileName(name, ctx),
|
|
36
|
+
});
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
files.push({
|
|
39
|
+
rel: entry.rel,
|
|
40
|
+
source: entry.source,
|
|
41
|
+
content: substituteContent(await readFile(entry.source, 'utf8'), ctx),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isSafeSegment } from './safe-path.js';
|
|
5
|
+
/**
|
|
6
|
+
* The install manifest: what this rig installed, at which version, and the
|
|
7
|
+
* hash each file had when it was written.
|
|
8
|
+
*
|
|
9
|
+
* It exists to answer the one hard question an upgrade has — *did the user
|
|
10
|
+
* edit this file?* — with evidence instead of a guess. It is **evidence, not a
|
|
11
|
+
* command**: a file the manifest names but the disk no longer has is reported,
|
|
12
|
+
* never silently restored.
|
|
13
|
+
*
|
|
14
|
+
* It is meant to be committed. Without it in the repository, an upgrade run on
|
|
15
|
+
* CI or on a colleague's machine is blind and falls back to the hash history.
|
|
16
|
+
*/
|
|
17
|
+
export const MANIFEST_REL = '.claude/.rig-manifest.json';
|
|
18
|
+
export function sha256(data) {
|
|
19
|
+
return createHash('sha256').update(data).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
function isStringRecord(value) {
|
|
22
|
+
return (typeof value === 'object' &&
|
|
23
|
+
value !== null &&
|
|
24
|
+
!Array.isArray(value) &&
|
|
25
|
+
Object.values(value).every((v) => typeof v === 'string'));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A manifest, or `null` when there is nothing trustworthy to read.
|
|
29
|
+
*
|
|
30
|
+
* The distinction matters: `null` means "no evidence", which sends the upgrade
|
|
31
|
+
* to the hash history. A half-parsed manifest treated as an empty one would
|
|
32
|
+
* claim every file on disk belongs to the user, and upgrade nothing at all.
|
|
33
|
+
*/
|
|
34
|
+
export function parseManifest(raw) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
43
|
+
return null;
|
|
44
|
+
const m = parsed;
|
|
45
|
+
if (typeof m.version !== 'string')
|
|
46
|
+
return null;
|
|
47
|
+
if (m.kind !== 'create' && m.kind !== 'init')
|
|
48
|
+
return null;
|
|
49
|
+
const project = m.project;
|
|
50
|
+
if (typeof project !== 'object' ||
|
|
51
|
+
project === null ||
|
|
52
|
+
typeof project.name !== 'string' ||
|
|
53
|
+
typeof project.scope !== 'string' ||
|
|
54
|
+
typeof project.region !== 'string') {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// Values, not just types. These are substituted into file names and joined
|
|
58
|
+
// into paths, and this file is committed — it reaches a maintainer's disk
|
|
59
|
+
// through a pull request. A name of `../..` would send every write out of
|
|
60
|
+
// the repository, so an unsafe value invalidates the whole manifest rather
|
|
61
|
+
// than being quietly corrected into something plausible.
|
|
62
|
+
if (!isSafeSegment(project.name) || !isSafeSegment(project.scope))
|
|
63
|
+
return null;
|
|
64
|
+
if (project.region !== '' && !isSafeSegment(project.region))
|
|
65
|
+
return null;
|
|
66
|
+
if (!Array.isArray(m.stacks) || m.stacks.some((s) => typeof s !== 'string'))
|
|
67
|
+
return null;
|
|
68
|
+
if (m.stacks.some((s) => !isSafeSegment(s)))
|
|
69
|
+
return null;
|
|
70
|
+
if (!isStringRecord(m.files))
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
version: m.version,
|
|
74
|
+
kind: m.kind,
|
|
75
|
+
project: { name: project.name, scope: project.scope, region: project.region },
|
|
76
|
+
stacks: [...m.stacks],
|
|
77
|
+
files: { ...m.files },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Stable bytes: sorted paths, so a re-run produces no diff of its own. */
|
|
81
|
+
export function serializeManifest(manifest) {
|
|
82
|
+
const files = {};
|
|
83
|
+
for (const rel of Object.keys(manifest.files).sort())
|
|
84
|
+
files[rel] = manifest.files[rel];
|
|
85
|
+
return `${JSON.stringify({ ...manifest, files }, null, 2)}\n`;
|
|
86
|
+
}
|
|
87
|
+
export async function readManifest(repoDir) {
|
|
88
|
+
try {
|
|
89
|
+
return parseManifest(await readFile(path.join(repoDir, ...MANIFEST_REL.split('/')), 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function writeManifest(repoDir, manifest) {
|
|
96
|
+
const dest = path.join(repoDir, ...MANIFEST_REL.split('/'));
|
|
97
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
98
|
+
await writeFile(dest, serializeManifest(manifest));
|
|
99
|
+
}
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
+
/**
|
|
3
|
+
* A yes/no gate before something irreversible. **The default is no**, and a
|
|
4
|
+
* non-interactive caller gets `false` without being asked — the same rule the
|
|
5
|
+
* rest of this CLI follows: never guess for a run that cannot answer.
|
|
6
|
+
*
|
|
7
|
+
* Unlike the target prompt, an unrecognised answer is *not* forgiving: the
|
|
8
|
+
* question is asked before rewriting files in somebody's repository, and "I
|
|
9
|
+
* did not understand you" must not resolve to "go ahead".
|
|
10
|
+
*/
|
|
11
|
+
export function promptConfirm(question, streams) {
|
|
12
|
+
if (!streams.isInteractive)
|
|
13
|
+
return Promise.resolve(false);
|
|
14
|
+
const rl = createInterface({ input: streams.input, output: streams.output });
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
17
|
+
rl.close();
|
|
18
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
2
22
|
/**
|
|
3
23
|
* Pick a target interactively: by number, by name, or Enter for the default.
|
|
4
24
|
* Anything unrecognised falls back to the default — generation should never
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Path safety for values that came from **outside the CLI** — the install
|
|
4
|
+
* manifest is committed to a repository, so it arrives in pull requests like
|
|
5
|
+
* any other file, and its values are substituted into paths.
|
|
6
|
+
*
|
|
7
|
+
* One module owns both halves so they cannot disagree: what may become a path
|
|
8
|
+
* segment, and where a resolved path is allowed to land.
|
|
9
|
+
*/
|
|
10
|
+
/** A value that can be substituted into a path without steering it. */
|
|
11
|
+
export function isSafeSegment(value) {
|
|
12
|
+
return (value !== '' &&
|
|
13
|
+
value !== '.' &&
|
|
14
|
+
value !== '..' &&
|
|
15
|
+
!value.includes('/') &&
|
|
16
|
+
!value.includes('\\') &&
|
|
17
|
+
!value.includes('\0'));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `rel` resolved under `root`, or `null` when it would land anywhere else —
|
|
21
|
+
* including an absolute path, an empty path, and the classic sibling
|
|
22
|
+
* (`/tmp/rig` must not contain `/tmp/rig-evil`).
|
|
23
|
+
*
|
|
24
|
+
* This is the containment behind every write an upgrade makes. It is deliberate
|
|
25
|
+
* belt-and-braces: the values that build `rel` are validated where they are
|
|
26
|
+
* parsed, and this refuses the write anyway.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveInside(root, rel) {
|
|
29
|
+
if (rel === '' || path.isAbsolute(rel))
|
|
30
|
+
return null;
|
|
31
|
+
const segments = rel.split('/');
|
|
32
|
+
// Refused, not repaired: joining an absolute or `..`-bearing path onto the
|
|
33
|
+
// root would silently turn hostile input into a plausible-looking write.
|
|
34
|
+
if (segments.some((segment) => !isSafeSegment(segment)))
|
|
35
|
+
return null;
|
|
36
|
+
const base = path.resolve(root);
|
|
37
|
+
const dest = path.resolve(base, ...segments);
|
|
38
|
+
if (dest === base)
|
|
39
|
+
return null;
|
|
40
|
+
return dest.startsWith(base + path.sep) ? dest : null;
|
|
41
|
+
}
|
|
@@ -5,6 +5,38 @@ export function substituteContent(content, ctx) {
|
|
|
5
5
|
.replaceAll('__REGION__', ctx.region)
|
|
6
6
|
.replaceAll('@app/', `@${ctx.projectScope}/`);
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* The inverse of {@link substituteContent}, used **only to recognise** an
|
|
10
|
+
* installed file as a released version of its template: the released bytes
|
|
11
|
+
* carry tokens, the installed bytes carry the project's own name, and without
|
|
12
|
+
* this every token-carrying file (the kill switch, the loop skill, `CLAUDE.md`)
|
|
13
|
+
* would be a permanent conflict on every rig.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 Limits, and both of them fail in the safe direction — an unrecognised file
|
|
16
|
+
* is reported and left alone, never overwritten:
|
|
17
|
+
*
|
|
18
|
+
* - `__PROJECT_SCOPE__` is not reversed. It substitutes to the same string as
|
|
19
|
+
* `__PROJECT_NAME__`, so the two are indistinguishable after the fact; the
|
|
20
|
+
* agent-os layer (the only layer an upgrade touches) uses neither the scope
|
|
21
|
+
* token nor `@app/` in prose, which a template test pins.
|
|
22
|
+
* - A template that contains the project's name as a *literal* reverses into a
|
|
23
|
+
* token that was never there. That costs nothing on its own — recognition
|
|
24
|
+
* offers the untouched bytes as a candidate too, and those still match. It
|
|
25
|
+
* bites only on a file carrying **both** a token and the literal, which then
|
|
26
|
+
* reads as a conflict for that one project.
|
|
27
|
+
*/
|
|
28
|
+
export function detokenizeContent(content, ctx) {
|
|
29
|
+
let out = content;
|
|
30
|
+
// scope first: reversing the name first would rewrite `@name/` into
|
|
31
|
+
// `@__PROJECT_NAME__/` and the scope form could never match afterwards
|
|
32
|
+
if (ctx.projectScope !== '')
|
|
33
|
+
out = out.replaceAll(`@${ctx.projectScope}/`, '@app/');
|
|
34
|
+
if (ctx.region !== '')
|
|
35
|
+
out = out.replaceAll(ctx.region, '__REGION__');
|
|
36
|
+
if (ctx.projectName !== '')
|
|
37
|
+
out = out.replaceAll(ctx.projectName, '__PROJECT_NAME__');
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
8
40
|
/**
|
|
9
41
|
* Files that must exist in the generated project under a dotted name, but are
|
|
10
42
|
* stored un-dotted in the template because `npm publish` strips the dotted
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { templatesRoot } from '../templates.js';
|
|
4
|
+
/**
|
|
5
|
+
* The version of the rig doing the installing — stamped into every manifest
|
|
6
|
+
* and printed by `--version`.
|
|
7
|
+
*
|
|
8
|
+
* Resolved through {@link templatesRoot} so there is one walk from this file
|
|
9
|
+
* to the package root, valid in the repo, the tarball and a git install alike.
|
|
10
|
+
*/
|
|
11
|
+
export async function packageVersion() {
|
|
12
|
+
const pkgPath = path.join(templatesRoot(), '..', 'package.json');
|
|
13
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
14
|
+
return pkg.version;
|
|
15
|
+
}
|
|
@@ -303,8 +303,23 @@ three poisons the only channel by which this project learns.
|
|
|
303
303
|
the very next query.
|
|
304
304
|
- **Closing:** close it with the merged PR linked, immediately after the
|
|
305
305
|
post-merge verdict — not in a cleanup pass.
|
|
306
|
-
|
|
307
|
-
|
|
306
|
+
- **Write-back:** with the close, record what it **unblocked** — the items that
|
|
307
|
+
were waiting on this one, by name. It is the journal's `unblocked` field, and
|
|
308
|
+
it is **required, not a step for when it applies**: an absent line and an
|
|
309
|
+
unpaid debt are the same observation from outside, so the empty case has to
|
|
310
|
+
be written to mean anything. Which empty case matters — "nothing was waiting"
|
|
311
|
+
is an answer, "this queue has no dependency links" is the absence of one
|
|
312
|
+
(§0), and a queue that cannot be asked must never be reported as asked.
|
|
313
|
+
|
|
314
|
+
🔴 It is a **report, not an edit to those items.** Blocked state is
|
|
315
|
+
re-resolved from the blocker itself on every selection (§2), so nothing is
|
|
316
|
+
stuck waiting to be corrected — and a label fixed by hand is evidence
|
|
317
|
+
destroyed, which §2 forbids by name. What the write-back buys is the thing no
|
|
318
|
+
query can answer: whether anyone **looked**. Where the close changed a fact
|
|
319
|
+
rather than a state — an Operator-queue item it settles — the paragraph
|
|
320
|
+
closing this section applies instead, and that edit lands in the same PR.
|
|
321
|
+
|
|
322
|
+
Between the opening and the close the item keeps absorbing what happens **as it happens** —
|
|
308
323
|
a decision, a deviation, a defect found in passing, a tier discovered mid-work. A
|
|
309
324
|
run that dies mid-task leaves its whole trail on the item; a run that batches its
|
|
310
325
|
comments to the end leaves nothing.
|
|
@@ -31,7 +31,8 @@ must not take its history with it.
|
|
|
31
31
|
The fields exist so an entry can be visibly **incomplete**. A journal with no
|
|
32
32
|
stated shape decays into a diary that reads fine and proves nothing.
|
|
33
33
|
|
|
34
|
-
<!-- Template — copy the block, drop the fields that do not apply
|
|
34
|
+
<!-- Template — copy the block, drop the fields that do not apply (`unblocked` is
|
|
35
|
+
the exception: it is stated even when the answer is "nothing"):
|
|
35
36
|
|
|
36
37
|
### <one-line summary of the session>
|
|
37
38
|
|
|
@@ -41,8 +42,18 @@ stated shape decays into a diary that reads fine and proves nothing.
|
|
|
41
42
|
- **reviewed** — changes that went through a reviewer gate, and what it returned
|
|
42
43
|
- **stopped at** — which stop condition ended the session (or "checkpoint,
|
|
43
44
|
still running")
|
|
44
|
-
- **
|
|
45
|
-
|
|
45
|
+
- **unblocked** — what the session's closes released. **The field that is never
|
|
46
|
+
dropped** — a missing line and an unpaid debt read identically from outside,
|
|
47
|
+
and this is the only record of whether anyone looked. It has **three**
|
|
48
|
+
answers and they do not substitute for each other: the items that were
|
|
49
|
+
waiting, by name; "nothing was waiting", where the queue carries dependency
|
|
50
|
+
links and none pointed here; and "this queue has no dependency links", where
|
|
51
|
+
it cannot answer at all — a flat-list queue is **absent**, not satisfied, and
|
|
52
|
+
writing "nothing was waiting" there claims a look that no query could perform
|
|
53
|
+
- **queue hygiene** — queue state the session found unreliable and **reported**:
|
|
54
|
+
a stale marker, a dependency already satisfied, an item that describes work
|
|
55
|
+
already done. Reported, never corrected in passing — quietly fixing the
|
|
56
|
+
metadata destroys the evidence that the metadata is unreliable
|
|
46
57
|
- **cost** — the counts the session actually observed: reviewer subagents run,
|
|
47
58
|
CI runs consumed (re-runs included — the cheapest signal that a task fought
|
|
48
59
|
its tests), deploys triggered
|