axstack 0.31.0 → 0.33.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/docs/host-operations.md +72 -24
- package/docs/workflows.md +194 -35
- package/package.json +1 -1
- package/skills/axstack/references/automations.md +9 -12
- package/skills/axstack/references/autopilot.md +19 -4
- package/skills/axstack/references/pr-shape.md +6 -5
- package/skills/axstack/references/run-record.md +8 -16
- package/skills/axstack/references/t3-runtime.md +17 -20
- package/skills/axstack/scripts/dispatch-plan.js +56 -27
- package/skills/axstack/scripts/pr-shape.js +134 -0
- package/skills/axstack/scripts/readiness-run.js +161 -0
- package/skills/axstack/scripts/readiness.js +420 -0
- package/skills/axstack/scripts/run-init.js +105 -0
- package/skills/axstack-audit/SKILL.md +1 -1
- package/skills/axstack-implement/SKILL.md +20 -9
- package/skills/axstack-improve/SKILL.md +17 -0
- package/skills/axstack-relay/SKILL.md +196 -42
- package/skills/axstack-relay/hermes/hermes-skill.md +52 -11
- package/skills/axstack-review/SKILL.md +12 -3
- package/skills/axstack-watch/SKILL.md +137 -47
- package/skills/axstack-watch/references/watch-runtime.md +13 -4
- package/skills/axstack-relay/hermes/axstack-reply.sh +0 -49
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { readFileSync, mkdirSync, writeFileSync, lstatSync, realpathSync, renameSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { executeDeclared } from './readiness-run.js';
|
|
4
|
+
|
|
5
|
+
const scriptVersion = '1.1.0';
|
|
6
|
+
const criteriaVersion = 1;
|
|
7
|
+
|
|
8
|
+
function gitOutput(repo, args, stdin = 'ignore') {
|
|
9
|
+
const argv = ['git', '--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', repo, ...args];
|
|
10
|
+
const result = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', stdin });
|
|
11
|
+
if (result.exitCode !== 0) throw new Error(result.stderr.toString().trim());
|
|
12
|
+
return result.stdout;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function git(repo, ...args) {
|
|
16
|
+
return gitOutput(repo, args).toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readTextBlobs(repo, ids) {
|
|
20
|
+
const unique = [...new Set(ids)];
|
|
21
|
+
const text = new Map();
|
|
22
|
+
if (!unique.length) return text;
|
|
23
|
+
const data = gitOutput(repo, ['cat-file', '--batch'], new TextEncoder().encode(`${unique.join('\n')}\n`));
|
|
24
|
+
let offset = 0;
|
|
25
|
+
for (const oid of unique) {
|
|
26
|
+
const end = data.indexOf(10, offset);
|
|
27
|
+
const header = end < 0 ? null : /^([0-9a-f]+) blob (\d+)$/.exec(data.subarray(offset, end).toString());
|
|
28
|
+
const size = Number(header?.[2]);
|
|
29
|
+
const start = end + 1;
|
|
30
|
+
if (header?.[1] !== oid || !Number.isSafeInteger(size) || size < 0 || data[start + size] !== 10) throw new Error('incomplete git blob response');
|
|
31
|
+
const blob = data.subarray(start, start + size);
|
|
32
|
+
offset = start + size + 1;
|
|
33
|
+
if (blob.includes(0)) continue;
|
|
34
|
+
try { text.set(oid, new TextDecoder('utf-8', { fatal: true }).decode(blob)); }
|
|
35
|
+
catch { /* Binary content is not configuration or prose. */ }
|
|
36
|
+
}
|
|
37
|
+
if (offset !== data.length) throw new Error('unexpected git blob response');
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeOrigin(origin, repo) {
|
|
42
|
+
if (!origin) return '';
|
|
43
|
+
const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(origin);
|
|
44
|
+
let address = origin;
|
|
45
|
+
if (!origin.includes('://')) {
|
|
46
|
+
address = scp ? `ssh://${scp[1]}/${scp[2]}` : `file://${absolute(origin.startsWith('/') ? origin : `${repo}/${origin}`).split('/').map(encodeURIComponent).join('/')}`;
|
|
47
|
+
}
|
|
48
|
+
const url = new URL(address);
|
|
49
|
+
let path = url.pathname.replace(/\/+$/, '').replace(/\.git$/, '');
|
|
50
|
+
if (url.hostname.toLowerCase() === 'github.com') path = path.toLowerCase();
|
|
51
|
+
const host = url.hostname.toLowerCase() === 'github.com' ? 'github.com' : url.host.toLowerCase();
|
|
52
|
+
return `${url.protocol === 'file:' ? 'file' : 'https'}://${host}${path}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function snapshot(repo, revision) {
|
|
56
|
+
const entries = new Map(git(repo, 'ls-tree', '-rz', '--full-tree', revision).split('\0').filter(Boolean).map((entry) => {
|
|
57
|
+
const tab = entry.indexOf('\t');
|
|
58
|
+
const [mode, type, oid] = entry.slice(0, tab).split(' ');
|
|
59
|
+
return [entry.slice(tab + 1), { mode, type, oid }];
|
|
60
|
+
}));
|
|
61
|
+
const ids = [...entries].filter(([path, entry]) => entry.type === 'blob' && (
|
|
62
|
+
/\.md$/i.test(path) || ['package.json', 'tsconfig.json', 'yarn.lock'].includes(path) || /^\.github\/workflows\/[^/]+\.ya?ml$/.test(path)
|
|
63
|
+
)).map(([, entry]) => entry.oid);
|
|
64
|
+
let text;
|
|
65
|
+
return {
|
|
66
|
+
revision,
|
|
67
|
+
paths: [...entries.keys()],
|
|
68
|
+
has: (path) => entries.get(path)?.type === 'blob',
|
|
69
|
+
mode: (path) => entries.get(path)?.mode,
|
|
70
|
+
read(path) {
|
|
71
|
+
if (entries.get(path)?.type !== 'blob') return '';
|
|
72
|
+
text ??= readTextBlobs(repo, ids);
|
|
73
|
+
return text.get(entries.get(path).oid) ?? '';
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// tsconfig permits comments and trailing commas; preserve strings while removing them.
|
|
79
|
+
function jsonc(text) {
|
|
80
|
+
const stripped = text.replace(/"(?:\\.|[^"\\])*"|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, (token) => token.startsWith('"') ? token : '');
|
|
81
|
+
return JSON.parse(stripped.replace(/"(?:\\.|[^"\\])*"|,\s*(?=[}\]])/g, (token) => token.startsWith('"') ? token : ''));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const definitions = [
|
|
85
|
+
['docs.agents', 'Docs'], ['docs.claude', 'Docs'],
|
|
86
|
+
['style.linter', 'Style'], ['style.formatter', 'Style'], ['style.strict', 'Style'], ['style.pre-commit', 'Style'],
|
|
87
|
+
['env.lockfile', 'Build/Env'], ['env.toolchain', 'Build/Env'], ['env.build', 'Build/Env', 'executed'],
|
|
88
|
+
['testing.files', 'Testing'], ['testing.test', 'Testing', 'executed'], ['testing.lint-typecheck', 'Testing', 'executed'], ['testing.verify-skill', 'Testing'],
|
|
89
|
+
['security.pr-tests', 'Governance/Security'], ['security.status-checks', 'Governance/Security'], ['security.updates', 'Governance/Security'], ['security.secrets', 'Governance/Security'],
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
function packageManager(files) {
|
|
93
|
+
const locks = [
|
|
94
|
+
['bun', ['bun.lock', 'bun.lockb']], ['npm', ['package-lock.json', 'npm-shrinkwrap.json']],
|
|
95
|
+
['pnpm', ['pnpm-lock.yaml']], ['yarn', ['yarn.lock']],
|
|
96
|
+
].filter(([, names]) => names.some(files.has));
|
|
97
|
+
if (!locks.length) return { reason: 'no lockfile' };
|
|
98
|
+
if (locks.length > 1) return { reason: 'ambiguous lockfile' };
|
|
99
|
+
const [name, paths] = locks[0];
|
|
100
|
+
if (name === 'yarn' && !/^__metadata:/m.test(files.read('yarn.lock'))) return { reason: 'unsupported package manager' };
|
|
101
|
+
return { name, paths: paths.filter(files.has) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function declaredCommands(files, pkg, manager, set, invalidPackage) {
|
|
105
|
+
const prose = files.paths.filter((path) => /\.md$/i.test(path)).map((path) => files.read(path)).join('\n');
|
|
106
|
+
for (const [id, names] of [['env.build', ['build']], ['testing.test', ['test']], ['testing.lint-typecheck', ['lint', 'typecheck']]]) {
|
|
107
|
+
if (invalidPackage) {
|
|
108
|
+
set(id, 'unknown', [`git show ${files.revision}:package.json`], 'invalid package.json');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const present = names.filter((name) => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim());
|
|
112
|
+
const evidence = present.map((name) => `git show ${files.revision}:package.json: scripts.${name}`);
|
|
113
|
+
if (!present.length) {
|
|
114
|
+
const mentioned = names.some((name) => new RegExp(`\\b(?:bun|npm|pnpm|yarn)\\s+(?:run\\s+)?${name}\\b`).test(prose));
|
|
115
|
+
const reason = `missing ${names.join(' and ')} script${names.length > 1 ? 's' : ''}${mentioned ? '; prose-declared; not run' : ''}`;
|
|
116
|
+
set(id, id === 'env.build' ? 'n/a' : 'fail', ['committed package.json script declarations'], reason);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (manager.reason) set(id, 'unknown', evidence.concat(['committed lockfile selection']), manager.reason);
|
|
120
|
+
else set(id, 'unknown', evidence.concat(manager.paths, present.map((name) => `${manager.name} run ${name} (not executed)`)), 'not run');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function assess(files) {
|
|
125
|
+
const revision = files.revision;
|
|
126
|
+
const has = (pattern) => files.paths.filter((path) => files.has(path) && pattern.test(path));
|
|
127
|
+
const js = files.has('package.json') || has(/\.[cm]?[jt]sx?$/).length > 0;
|
|
128
|
+
let pkg = {};
|
|
129
|
+
let invalidPackage = false;
|
|
130
|
+
try {
|
|
131
|
+
pkg = JSON.parse(files.read('package.json') || '{}');
|
|
132
|
+
if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) throw new Error('invalid package.json');
|
|
133
|
+
} catch { pkg = {}; invalidPackage = true; }
|
|
134
|
+
const criteria = definitions.map(([id, pillar, kind = 'configured']) => ({ id, pillar, kind, status: 'unknown', evidence: [`git ls-tree -r ${revision}`], reason: js ? 'not run' : 'unsupported stack' }));
|
|
135
|
+
const set = (id, status, evidence, reason = '') => Object.assign(criteria.find((item) => item.id === id), { status, evidence, reason });
|
|
136
|
+
const configured = (id, matches) => set(id, matches.length ? 'pass' : 'fail', matches.length ? matches.map((path) => `git show ${revision}:${path}`) : [`git ls-tree -r ${revision}: no matching configuration`], matches.length ? '' : 'missing configuration');
|
|
137
|
+
if (!js) return criteria;
|
|
138
|
+
configured('docs.agents', files.has('AGENTS.md') ? ['AGENTS.md'] : []);
|
|
139
|
+
const claude = files.read('CLAUDE.md');
|
|
140
|
+
const linked = files.mode('CLAUDE.md') === '120000' ? /^(\.\/)?AGENTS\.md$/.test(claude.trim()) : /@AGENTS\.md\b/.test(claude);
|
|
141
|
+
configured('docs.claude', linked ? ['CLAUDE.md'] : []);
|
|
142
|
+
configured('style.linter', has(/(^|\/)(eslint\.config\.[cm]?[jt]s|\.eslintrc(?:\.[^/]+)?|biome\.jsonc?|oxlint\.json)$/).concat(pkg.eslintConfig ? ['package.json'] : []));
|
|
143
|
+
configured('style.formatter', has(/(^|\/)(\.prettierrc(?:\.[^/]+)?|prettier\.config\.[cm]?[jt]s|biome\.jsonc?|\.dprint\.json|dprint\.json)$/).concat(pkg.prettier ? ['package.json'] : []));
|
|
144
|
+
if (!files.has('tsconfig.json') && !has(/\.[cm]?tsx?$/).length && !pkg.dependencies?.typescript && !pkg.devDependencies?.typescript) {
|
|
145
|
+
set('style.strict', 'n/a', [`git ls-tree -r ${revision}: no TypeScript`], 'no TypeScript');
|
|
146
|
+
} else {
|
|
147
|
+
try {
|
|
148
|
+
set('style.strict', jsonc(files.read('tsconfig.json') || '{}').compilerOptions?.strict === true ? 'pass' : 'fail', [`git show ${revision}:tsconfig.json`], '');
|
|
149
|
+
} catch { set('style.strict', 'unknown', [`git show ${revision}:tsconfig.json`], 'invalid tsconfig'); }
|
|
150
|
+
}
|
|
151
|
+
configured('style.pre-commit', has(/(^|\/)(\.pre-commit-config\.ya?ml|lefthook\.ya?ml|\.lefthook\.ya?ml|\.husky\/pre-commit)$/).concat(pkg.husky?.hooks?.['pre-commit'] ? ['package.json'] : []));
|
|
152
|
+
configured('env.lockfile', has(/^(bun\.lockb?|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock)$/));
|
|
153
|
+
const manager = packageManager(files);
|
|
154
|
+
declaredCommands(files, pkg, manager, set, invalidPackage);
|
|
155
|
+
configured('env.toolchain', has(/^(\.tool-versions|mise\.toml|\.mise\.toml|\.nvmrc|\.devcontainer\.json|\.devcontainer\/(devcontainer\.json|[^/]+\/devcontainer\.json))$/).concat(pkg.engines && Object.keys(pkg.engines).length ? ['package.json'] : []));
|
|
156
|
+
configured('testing.files', has(/(^|\/)((tests?|__tests__)\/.*\.[cm]?[jt]sx?|[^/]+\.(test|spec)\.[cm]?[jt]sx?)$/));
|
|
157
|
+
configured('testing.verify-skill', has(/^\.agents\/skills\/verify-[^/]+\/SKILL\.md$/));
|
|
158
|
+
const workflows = has(/^\.github\/workflows\/[^/]+\.ya?ml$/);
|
|
159
|
+
let prTests = false;
|
|
160
|
+
let prCandidate = false;
|
|
161
|
+
let invalid = false;
|
|
162
|
+
const codeql = [];
|
|
163
|
+
for (const path of workflows) {
|
|
164
|
+
try {
|
|
165
|
+
const workflow = Bun.YAML.parse(files.read(path));
|
|
166
|
+
const trigger = workflow?.on;
|
|
167
|
+
const pr = trigger === 'pull_request' || (Array.isArray(trigger) ? trigger.includes('pull_request') : trigger && Object.hasOwn(trigger, 'pull_request'));
|
|
168
|
+
const steps = Object.values(workflow?.jobs ?? {}).flatMap((job) => job?.steps ?? []);
|
|
169
|
+
const runs = steps.map((step) => step?.run).filter((run) => typeof run === 'string');
|
|
170
|
+
if (steps.some((step) => typeof step?.uses === 'string' && /^github\/codeql-action\//.test(step.uses))) codeql.push(path);
|
|
171
|
+
const invokes = (run, name) => new RegExp(`(?:^|[\\n;&|])\\s*${name}\\s+${name === 'bun' ? 'run\\s+' : '(?:run\\s+)?'}test(?:\\s|[;&|]|$)`).test(run);
|
|
172
|
+
if (pr && runs.some((run) => ['bun', 'npm', 'pnpm', 'yarn'].some((name) => invokes(run, name)))) prCandidate = true;
|
|
173
|
+
if (pr && manager.name && runs.some((run) => invokes(run, manager.name))) prTests = true;
|
|
174
|
+
} catch { invalid = true; }
|
|
175
|
+
}
|
|
176
|
+
const workflowReason = prTests ? '' : invalid ? 'invalid workflow YAML' : prCandidate && manager.reason ? manager.reason : 'missing pull_request test step';
|
|
177
|
+
set('security.pr-tests', prTests ? 'pass' : invalid || (prCandidate && manager.reason) ? 'unknown' : 'fail', workflows.length ? workflows.map((path) => `git show ${revision}:${path}`) : [`git ls-tree -r ${revision}: no workflows`], workflowReason);
|
|
178
|
+
configured('security.updates', has(/(^|\/)(\.github\/(dependabot\.ya?ml|codeql\/[^/]+\.ya?ml)|(?:\.?)renovaterc(?:\.json5?)?|renovate\.json5?)$/).concat(codeql));
|
|
179
|
+
const secrets = has(/(^|\/)(\.env[^/]*|id_(rsa|dsa|ecdsa|ed25519)|[^/]+\.(pem|key|p12|pfx))$/).filter((path) => !/(^|\/)\.env\.(example|sample|template)$/.test(path));
|
|
180
|
+
set('security.secrets', secrets.length ? 'fail' : 'pass', secrets.length ? [...new Set(secrets)].map((path) => `git show ${revision}:${path}`) : [`git ls-tree -r ${revision}: no tracked env or private keys`], secrets.length ? 'tracked env or private keys' : '');
|
|
181
|
+
return criteria;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function summarizePillars(criteria) {
|
|
185
|
+
const result = {};
|
|
186
|
+
for (const item of criteria) {
|
|
187
|
+
const pillar = result[item.pillar] ??= { passes: 0, total: 0, unknown: 0, na: 0 };
|
|
188
|
+
if (item.status === 'n/a') pillar.na++;
|
|
189
|
+
else pillar.total++;
|
|
190
|
+
if (item.status === 'pass') pillar.passes++;
|
|
191
|
+
if (item.status === 'unknown') pillar.unknown++;
|
|
192
|
+
}
|
|
193
|
+
for (const pillar of Object.values(result)) pillar.fraction = `${pillar.passes}/${pillar.total}`;
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function forgeStatus(origin) {
|
|
198
|
+
const match = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)$/.exec(origin);
|
|
199
|
+
const evidence = [];
|
|
200
|
+
const result = (status, reason) => ({ status, reason, evidence: evidence.length ? evidence : ['origin remote'] });
|
|
201
|
+
if (!match) return result('unknown', 'no GitHub origin');
|
|
202
|
+
function api(endpoint) {
|
|
203
|
+
const argv = [process.env.GH || 'gh', 'api', '--hostname', 'github.com', '--method', 'GET', '--paginate', '--slurp', endpoint];
|
|
204
|
+
evidence.push(`gh api --method GET --paginate --slurp ${endpoint}`);
|
|
205
|
+
const response = Bun.spawnSync(argv, { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
|
206
|
+
if (response.exitCode !== 0) throw new Error(response.stderr.toString().trim());
|
|
207
|
+
const pages = JSON.parse(response.stdout.toString());
|
|
208
|
+
if (!Array.isArray(pages) || !pages.length) throw new Error('incomplete gh response');
|
|
209
|
+
return pages;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const metadata = api(`repos/${match[1]}`);
|
|
213
|
+
if (metadata.length !== 1 || typeof metadata[0]?.default_branch !== 'string' || !metadata[0].default_branch) throw new Error('missing default branch');
|
|
214
|
+
const branch = encodeURIComponent(metadata[0].default_branch);
|
|
215
|
+
let protectionGap = false;
|
|
216
|
+
try {
|
|
217
|
+
const protection = api(`repos/${match[1]}/branches/${branch}/protection`);
|
|
218
|
+
if (protection.length !== 1 || !protection[0] || Array.isArray(protection[0])) throw new Error('incomplete protection response');
|
|
219
|
+
const required = protection[0].required_status_checks;
|
|
220
|
+
const checks = [...(required?.contexts ?? []), ...(required?.checks ?? []).map((check) => check.context)];
|
|
221
|
+
if (checks.some((name) => typeof name === 'string' && name.length)) {
|
|
222
|
+
evidence.push(`default branch ${metadata[0].default_branch}: required checks ${checks.join(', ')}`);
|
|
223
|
+
return result('pass', '');
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (!/\bHTTP (403|404)\b/.test(error.message)) throw error;
|
|
227
|
+
protectionGap = !/Branch not protected.*(?:404)|(?:404).*Branch not protected/i.test(error.message);
|
|
228
|
+
evidence.push(error.message);
|
|
229
|
+
}
|
|
230
|
+
const pages = api(`repos/${match[1]}/rules/branches/${branch}?per_page=100`);
|
|
231
|
+
if (pages.some((page) => !Array.isArray(page))) throw new Error('incomplete branch rules response');
|
|
232
|
+
const required = pages.flat().filter((rule) => rule?.type === 'required_status_checks').flatMap((rule) => rule.parameters?.required_status_checks ?? []);
|
|
233
|
+
if (required.some((check) => typeof check?.context === 'string' && check.context.length)) {
|
|
234
|
+
evidence.push(`default branch ${metadata[0].default_branch}: active rules require ${required.map((check) => check.context).join(', ')}`);
|
|
235
|
+
return result('pass', '');
|
|
236
|
+
}
|
|
237
|
+
return result(protectionGap ? 'unknown' : 'fail', protectionGap ? 'gh settings unavailable' : 'needs admin');
|
|
238
|
+
} catch (error) {
|
|
239
|
+
evidence.push(error.message);
|
|
240
|
+
return result('unknown', 'gh settings unavailable');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function dirty(repo) {
|
|
245
|
+
// Status normally invokes clean/process filters. Observe raw source bytes instead.
|
|
246
|
+
const keys = git(repo, 'config', '--null', '--list').split('\0').map((entry) => entry.split('\n')[0]).filter((key) => /^filter\..*\.(clean|process)$/.test(key));
|
|
247
|
+
const overrides = keys.flatMap((key) => ['-c', `${key}=`, '-c', `${key.replace(/\.(clean|process)$/, '.required')}=false`]);
|
|
248
|
+
return git(repo, ...overrides, 'status', '--porcelain', '--untracked-files=all').length > 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function report(args, paths) {
|
|
252
|
+
const repo = args['--repo'];
|
|
253
|
+
let revision;
|
|
254
|
+
try { revision = git(repo, 'rev-parse', '--verify', '--end-of-options', `${args['--rev'] ?? 'HEAD'}^{commit}`).trim(); }
|
|
255
|
+
catch (error) { throw new Error(`${error.message}; pass --rev <existing commit or ref>`); }
|
|
256
|
+
let rawOrigin = '';
|
|
257
|
+
try { rawOrigin = git(repo, 'remote', 'get-url', 'origin').trim(); } catch { /* No origin. */ }
|
|
258
|
+
const origin = normalizeOrigin(rawOrigin, repo);
|
|
259
|
+
const header = {
|
|
260
|
+
inputs: { repo: realpathSync(repo), rev: args['--rev'] ?? 'HEAD', baseline: args['--baseline'] ?? null, run: Boolean(args['--run']) },
|
|
261
|
+
repoIdentity: { roots: git(repo, 'rev-list', '--max-parents=0', revision).trim().split('\n').sort(), origin },
|
|
262
|
+
revision, dirty: dirty(repo),
|
|
263
|
+
observedAt: new Date().toISOString(), ghObservedAt: new Date().toISOString(), scriptVersion, criteriaVersion,
|
|
264
|
+
};
|
|
265
|
+
const files = snapshot(repo, revision);
|
|
266
|
+
const criteria = assess(files);
|
|
267
|
+
let execution;
|
|
268
|
+
if (args['--run']) {
|
|
269
|
+
let pkg = {};
|
|
270
|
+
try { pkg = JSON.parse(files.read('package.json') || '{}'); } catch { /* Static criteria already record invalid JSON. */ }
|
|
271
|
+
execution = await executeDeclared(repo, revision, paths, criteria, packageManager(files), pkg, {
|
|
272
|
+
timeoutMs: Number(args['--timeout-ms'] ?? 120000), logBytes: Number(args['--log-bytes'] ?? 1048576),
|
|
273
|
+
});
|
|
274
|
+
header.network = execution.network;
|
|
275
|
+
header.descendants = execution.descendants;
|
|
276
|
+
header.scratch = execution.scratch;
|
|
277
|
+
}
|
|
278
|
+
header.ghObservedAt = new Date().toISOString();
|
|
279
|
+
const settings = criteria.find((item) => item.id === 'security.status-checks');
|
|
280
|
+
if (settings.reason !== 'unsupported stack') Object.assign(settings, forgeStatus(origin));
|
|
281
|
+
return { header, criteria, pillars: summarizePillars(criteria), ...(execution && { commands: execution.commands, executionError: execution.error }) };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function absolute(path) {
|
|
285
|
+
const parts = [];
|
|
286
|
+
for (const part of (path.startsWith('/') ? path : `${process.cwd()}/${path}`).split('/')) {
|
|
287
|
+
if (part === '..') parts.pop();
|
|
288
|
+
else if (part && part !== '.') parts.push(part);
|
|
289
|
+
}
|
|
290
|
+
return `/${parts.join('/')}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function rejectSymlinks(path) {
|
|
294
|
+
let current = path.startsWith('/') ? '' : process.cwd();
|
|
295
|
+
for (const part of path.split('/').filter(Boolean)) {
|
|
296
|
+
current += `/${part}`;
|
|
297
|
+
try {
|
|
298
|
+
if (lstatSync(current).isSymbolicLink()) throw new Error(`--out symlink rejected: ${current}; pass --out <path without symlinks>`);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error.code !== 'ENOENT') throw error;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function outputPaths(args) {
|
|
306
|
+
let common;
|
|
307
|
+
try { common = realpathSync(git(args['--repo'], 'rev-parse', '--path-format=absolute', '--git-common-dir').trim()); }
|
|
308
|
+
catch (error) { throw new Error(`${error.message}; pass --repo <existing Git checkout>`); }
|
|
309
|
+
const out = absolute(args['--out']);
|
|
310
|
+
if (!args['--out'].endsWith('.json')) throw new Error('--out must end in .json; pass --out <git-common-dir>/axstack/readiness/<name>.json');
|
|
311
|
+
if (!out.startsWith(`${common}/axstack/readiness/`)) throw new Error('--out must be under <git-common-dir>/axstack/readiness/; pass --out <git-common-dir>/axstack/readiness/<name>.json');
|
|
312
|
+
rejectSymlinks(args['--out']);
|
|
313
|
+
const evidence = out.slice(0, -5);
|
|
314
|
+
rejectSymlinks(evidence);
|
|
315
|
+
return { out, evidence };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function writeReport(paths, report) {
|
|
319
|
+
// Stage inside the allowed evidence folder; replacing the inode also avoids hardlink writes.
|
|
320
|
+
mkdirSync(paths.evidence, { recursive: true, mode: 0o700 });
|
|
321
|
+
const staged = `${paths.evidence}/.${crypto.randomUUID()}.report.json`;
|
|
322
|
+
writeFileSync(staged, `${JSON.stringify(report, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
323
|
+
try { renameSync(staged, paths.out); }
|
|
324
|
+
catch (error) {
|
|
325
|
+
unlinkSync(staged);
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function compareBaseline(report, path, io) {
|
|
331
|
+
if (!path) return 0;
|
|
332
|
+
try {
|
|
333
|
+
const before = JSON.parse(readFileSync(path, 'utf8'));
|
|
334
|
+
if (before.header?.criteriaVersion !== report.header.criteriaVersion) throw new Error('criteria version mismatch');
|
|
335
|
+
const identity = before.header?.repoIdentity;
|
|
336
|
+
if (!identity || identity.origin !== report.header.repoIdentity.origin || JSON.stringify(identity.roots) !== JSON.stringify(report.header.repoIdentity.roots)) throw new Error('foreign repository');
|
|
337
|
+
if (!Array.isArray(before.criteria) || before.criteria.some((item) => !item || typeof item.id !== 'string' || !['pass', 'fail', 'unknown', 'n/a'].includes(item.status))) throw new Error('invalid baseline criteria');
|
|
338
|
+
const previous = new Map(before.criteria.map((item) => [item.id, item.status]));
|
|
339
|
+
if (previous.size !== before.criteria.length) throw new Error('duplicate baseline criterion');
|
|
340
|
+
const current = new Map(report.criteria.map((item) => [item.id, item.status]));
|
|
341
|
+
const changes = [...new Set([...previous.keys(), ...current.keys()])].sort().filter((id) => previous.get(id) !== current.get(id)).map((id) => ({ id, before: previous.get(id) ?? null, after: current.get(id) ?? null }));
|
|
342
|
+
const regressions = changes.filter((item) => item.before === 'pass' && item.after === 'fail').map((item) => item.id);
|
|
343
|
+
report.comparison = { changes, regressions };
|
|
344
|
+
if (regressions.length) io.stderr(`Readiness regressions: ${regressions.join(', ')}\n`);
|
|
345
|
+
return regressions.length ? 10 : 0;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
report.comparisonUnavailable = `comparison unavailable: ${error.message}`;
|
|
348
|
+
io.stderr(`${report.comparisonUnavailable}\n`);
|
|
349
|
+
return 2;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const help = `Usage: bun readiness.js --repo <path> [--rev <ref>] [--baseline <report.json>] [--run] --out <path>
|
|
354
|
+
Assessment of committed JavaScript/TypeScript repository files.
|
|
355
|
+
Flags: --repo, --rev (default HEAD), --baseline, --run, --out, --help,
|
|
356
|
+
--timeout-ms (positive integer; default 120000), --log-bytes (positive integer; default 1048576).
|
|
357
|
+
Prints revision, inputs, counts, full JSON path and omitted criterion/command/log counts.
|
|
358
|
+
Writes only --out and its evidence folder under <git-common-dir>/axstack/readiness/.
|
|
359
|
+
--out must end in .json; symlinks are rejected. Its evidence folder omits that suffix.
|
|
360
|
+
--run also creates an owned 0700 scratch clone outside HOME in TMPDIR (fallback /tmp),
|
|
361
|
+
installs dependencies there and runs declared build/test/lint/typecheck scripts.
|
|
362
|
+
Bun and npm also run implicit pre/post scripts for those declared names.
|
|
363
|
+
pnpm disables declared-name pre/post hooks with --config.enable-pre-post-scripts=false;
|
|
364
|
+
Yarn Berry has no implicit declared-name hooks. Install lifecycle scripts still run.
|
|
365
|
+
Removes scratch afterwards; capped logs and argv/exit/duration JSON receipts are
|
|
366
|
+
written in the evidence folder next to --out. Every command gets closed stdin,
|
|
367
|
+
the PATH/LANG/CI/HOME/TMPDIR allowlist, its own timeout and process-group cleanup.
|
|
368
|
+
Only the frozen install permits network when unshare -rn plus loopback-up probe
|
|
369
|
+
succeeds; otherwise the header says network: not isolated. Limits apply to all steps.
|
|
370
|
+
Each step uses a probed PID namespace with --kill-child when available; otherwise
|
|
371
|
+
the header says descendants: not contained and escaped processes may survive.
|
|
372
|
+
200 ms drain grace after exit or timeout bounds pipe waits; incomplete logs are recorded.
|
|
373
|
+
SIGINT and SIGTERM terminate the active group and clean scratch before returning.
|
|
374
|
+
SIGKILL cannot trigger cleanup; scratch and active processes may remain.
|
|
375
|
+
Repository code can write outside scratch; --run is not a sandbox.
|
|
376
|
+
Without --run no repository commands execute. GH selects the read-only gh executable.
|
|
377
|
+
Dirty observation disables Git filters and filesystem-monitor hooks.
|
|
378
|
+
Example: bun readiness.js --repo /repo --run --out /repo/.git/axstack/readiness/check.json
|
|
379
|
+
Exit codes: 0 report (including failed criteria), 1 error, 2 incomplete, 10 regression.`;
|
|
380
|
+
|
|
381
|
+
export async function main(argv, io = { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text) }) {
|
|
382
|
+
try {
|
|
383
|
+
if (argv.includes('--help')) {
|
|
384
|
+
io.stdout(`${help}\n`);
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
const args = {};
|
|
388
|
+
for (let i = 0; i < argv.length; i++) {
|
|
389
|
+
const flag = argv[i];
|
|
390
|
+
if (flag === '--run' && !args[flag]) { args[flag] = true; continue; }
|
|
391
|
+
if (!['--repo', '--rev', '--baseline', '--out', '--timeout-ms', '--log-bytes'].includes(flag) || !argv[i + 1] || args[flag]) {
|
|
392
|
+
throw new Error(`invalid argument: ${flag}; use --help for flags and an example`);
|
|
393
|
+
}
|
|
394
|
+
args[flag] = argv[++i];
|
|
395
|
+
}
|
|
396
|
+
if (!args['--repo'] || !args['--out']) throw new Error('missing inputs; pass --repo <path> and --out <git-common-dir>/axstack/readiness/<name>.json');
|
|
397
|
+
for (const flag of ['--timeout-ms', '--log-bytes']) {
|
|
398
|
+
if (args[flag] && (!/^\d+$/.test(args[flag]) || !Number.isSafeInteger(Number(args[flag])) || Number(args[flag]) < 1 || Number(args[flag]) > 2147483647)) throw new Error(`invalid ${flag}; pass a positive integer up to 2147483647`);
|
|
399
|
+
}
|
|
400
|
+
const paths = outputPaths(args);
|
|
401
|
+
const result = await report(args, paths);
|
|
402
|
+
const code = compareBaseline(result, args['--baseline'], io);
|
|
403
|
+
writeReport(paths, result);
|
|
404
|
+
const counts = Object.fromEntries(['pass', 'fail', 'unknown', 'n/a'].map((status) => [status, result.criteria.filter((item) => item.status === status).length]));
|
|
405
|
+
io.stdout(`${JSON.stringify({
|
|
406
|
+
revision: result.header.revision, inputs: result.header.inputs, network: result.header.network, descendants: result.header.descendants, executionError: result.executionError,
|
|
407
|
+
reportPath: paths.out, counts, pillars: result.pillars, omittedCriteria: result.criteria.length, omittedCommands: result.commands?.length ?? 0,
|
|
408
|
+
omittedLogBytes: (result.commands ?? []).reduce((total, step) => total + step.omittedBytes, 0),
|
|
409
|
+
incompleteLogs: (result.commands ?? []).filter((step) => step.logIncomplete).length,
|
|
410
|
+
})}\n`);
|
|
411
|
+
return code || (result.executionError ? 2 : 0);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
io.stderr(`Readiness error: ${error.message}\n`);
|
|
414
|
+
return 1;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (import.meta.main) {
|
|
419
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
420
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const help = `Usage: bun scripts/run-init.js --slug <slug> [--date YYYYMMDD]
|
|
4
|
+
Example: bun scripts/run-init.js --slug cli-lessons --date 20261008
|
|
5
|
+
|
|
6
|
+
--slug Required run slug: lowercase letters, digits and hyphens only
|
|
7
|
+
--date Valid calendar date in YYYYMMDD; defaults to today's UTC date
|
|
8
|
+
--help Show flags, side effects and exit codes without writing
|
|
9
|
+
|
|
10
|
+
Writes: creates <git-common-dir>/axstack/runs/<date>-<slug>[-n]/ and any
|
|
11
|
+
missing parent directories, then progress.md and empty evidence/ inside it.
|
|
12
|
+
Reads the Compact template in the adjacent references/run-record.md.
|
|
13
|
+
Creates an owned 0700 scratch directory named axstack-<run-id>-<random>
|
|
14
|
+
under the system temp directory (TMPDIR, otherwise /tmp), never under $HOME.
|
|
15
|
+
Leaves these paths for the caller to manage; never selects an existing run.
|
|
16
|
+
If scratch creation fails, the new empty run directory remains for caller cleanup.
|
|
17
|
+
Prints paths and revision/inputs as JSON, with per-term status and evidence.
|
|
18
|
+
Exit codes: 0 ok, 1 input, Git or filesystem error; see --help to correct inputs.
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
function optionsFrom(argv) {
|
|
22
|
+
const options = {};
|
|
23
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
24
|
+
const flag = argv[i];
|
|
25
|
+
if (!['--slug', '--date'].includes(flag)) throw new Error(`unexpected argument ${flag}; use --help`);
|
|
26
|
+
const value = argv[i + 1];
|
|
27
|
+
if (!value || value.startsWith('--')) throw new Error(`missing value; pass ${flag} <value>`);
|
|
28
|
+
if (flag in options) throw new Error(`duplicate ${flag}; pass it once`);
|
|
29
|
+
options[flag] = value;
|
|
30
|
+
}
|
|
31
|
+
if (!options['--slug'] || !/^[a-z0-9-]+$/.test(options['--slug'])) {
|
|
32
|
+
throw new Error('pass --slug <slug> using only lowercase letters, digits and hyphens');
|
|
33
|
+
}
|
|
34
|
+
const date = options['--date'] ?? new Date().toISOString().slice(0, 10).replaceAll('-', '');
|
|
35
|
+
const iso = `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`;
|
|
36
|
+
const parsed = new Date(`${iso}T00:00:00Z`);
|
|
37
|
+
if (!/^\d{8}$/.test(date) || Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== iso) {
|
|
38
|
+
throw new Error('pass --date YYYYMMDD with a valid calendar date');
|
|
39
|
+
}
|
|
40
|
+
return { slug: options['--slug'], date };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function git(...args) {
|
|
44
|
+
const result = Bun.spawnSync(['git', '--no-optional-locks', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
|
45
|
+
return result.exitCode === 0 ? result.stdout.toString().trim() : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function main(argv, io = { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text) }) {
|
|
49
|
+
try {
|
|
50
|
+
if (argv.includes('--help')) {
|
|
51
|
+
io.stdout(help);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
const { slug, date } = optionsFrom(argv);
|
|
55
|
+
const gitCommonDir = git('rev-parse', '--path-format=absolute', '--git-common-dir');
|
|
56
|
+
if (!gitCommonDir) throw new Error('not in a Git repository; run this helper from a Git checkout');
|
|
57
|
+
const head = git('rev-parse', '--verify', 'HEAD');
|
|
58
|
+
const source = realpathSync(`${import.meta.dir}/../references/run-record.md`);
|
|
59
|
+
const template = readFileSync(source, 'utf8').match(/^## Compact template\r?\n[\s\S]*?^```text\r?\n([\s\S]*?)^```/m)?.[1];
|
|
60
|
+
if (!template) throw new Error('compact template missing; restore references/run-record.md');
|
|
61
|
+
if (!process.env.HOME) throw new Error('HOME is unset; set HOME to your home directory before running run-init');
|
|
62
|
+
const temp = realpathSync(process.env.TMPDIR || '/tmp');
|
|
63
|
+
const home = realpathSync(process.env.HOME);
|
|
64
|
+
if (temp === home || temp.startsWith(`${home === '/' ? '' : home}/`)) {
|
|
65
|
+
throw new Error('set TMPDIR to a system temp directory outside $HOME');
|
|
66
|
+
}
|
|
67
|
+
const runs = `${gitCommonDir}/axstack/runs`;
|
|
68
|
+
mkdirSync(runs, { recursive: true });
|
|
69
|
+
let runId, runDir;
|
|
70
|
+
for (let suffix = 0; ; suffix += 1) {
|
|
71
|
+
runId = `${date}-${slug}${suffix ? `-${suffix}` : ''}`;
|
|
72
|
+
runDir = `${runs}/${runId}`;
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(runDir);
|
|
75
|
+
break;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code !== 'EEXIST') throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const recordPath = `${runDir}/progress.md`;
|
|
81
|
+
const evidenceDir = `${runDir}/evidence`;
|
|
82
|
+
const scratchDir = mkdtempSync(`${temp}/axstack-${runId}-`);
|
|
83
|
+
chmodSync(scratchDir, 0o700);
|
|
84
|
+
const scratch = statSync(scratchDir);
|
|
85
|
+
if (scratch.uid !== process.getuid() || (scratch.mode & 0o777) !== 0o700) {
|
|
86
|
+
throw new Error('scratch ownership or permissions invalid; inspect the new scratch directory');
|
|
87
|
+
}
|
|
88
|
+
mkdirSync(evidenceDir);
|
|
89
|
+
writeFileSync(recordPath, template.replace(/^Run: .*$/m, `Run: ${runId}`), { flag: 'wx' });
|
|
90
|
+
io.stdout(`${JSON.stringify({ runId, runDir, recordPath, evidenceDir, scratchDir,
|
|
91
|
+
inputs: { head, gitCommonDir, template: source, slug, date },
|
|
92
|
+
terms: [
|
|
93
|
+
{ id: 'revision', status: head ? 'pass' : 'unknown', evidence: head ?? 'HEAD unavailable (unborn or unreadable)' },
|
|
94
|
+
{ id: 'run', status: 'pass', evidence: { runDir, recordPath, evidenceDir, template: source } },
|
|
95
|
+
{ id: 'scratch', status: 'pass', evidence: { scratchDir, uid: scratch.uid, mode: '0700' } },
|
|
96
|
+
],
|
|
97
|
+
})}\n`);
|
|
98
|
+
return 0;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
io.stderr(`run-init error: ${error.message}\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (import.meta.main) process.exitCode = main(process.argv.slice(2));
|
|
@@ -154,7 +154,7 @@ Each proposal names:
|
|
|
154
154
|
4. a regression scenario first, followed by an unchanged holdout evaluation;
|
|
155
155
|
5. a cost and quality comparison when those values were measured; and
|
|
156
156
|
6. the authorized delivery path: the auditor suggests, the driver arranges an
|
|
157
|
-
author and independent review, a reviewed PR is proposed, and the owning watch thread applies watch §5, including
|
|
157
|
+
author and independent review, a reviewed PR is proposed, and the owning watch thread applies watch §5, including proven verification and escalation rules.
|
|
158
158
|
|
|
159
159
|
Keep evaluation data, candidate changes, and validation separate. This is an
|
|
160
160
|
original Axstack workflow with no outside dependency or extra framework to
|
|
@@ -264,15 +264,24 @@ After verified readback of every own PR publication, the driver arms or joins
|
|
|
264
264
|
its maintain-mode chat-run watch under [Autopilot](../axstack/references/autopilot.md).
|
|
265
265
|
That watch owns the bound T3 schedule wake; its runtime sets the cadence.
|
|
266
266
|
A turn with unsettled launched threads must end only under the bound-watch rule in the T3 runtime contract.
|
|
267
|
-
With settled threads, end a turn only when every required PR is `merge-ready` or `held`.
|
|
268
|
-
`axstack-relay`
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
With settled threads, end a turn only when every required PR is `merge-ready` or `held`.
|
|
268
|
+
Under the recorded Notification policy, `axstack-relay` follows the categories
|
|
269
|
+
below; serious risks relay immediately and genuine blocked operations needing
|
|
270
|
+
user intervention relay after bounded safe recovery.
|
|
271
271
|
Routine questions stay in the T3 driver thread. Progress, CI pending, and completion always stay
|
|
272
272
|
in the T3 driver thread.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
Relay eligibility is limited to user-decision holds, serious-risk holds, the
|
|
274
|
+
60-minute blocked ping, unresolved-rating notice and capped peer PR milestones
|
|
275
|
+
under the Notification policy.
|
|
276
|
+
Under the recorded Notification policy, `verification not proven` is a
|
|
277
|
+
user-decision hold. The 60-minute blocked ping is a notification-only category
|
|
278
|
+
under the Notification policy. The unresolved-rating notice is a notification-only
|
|
279
|
+
category under the Notification policy, deduplicated per comment and head.
|
|
280
|
+
The unresolved-rating notice grants no reply authority.
|
|
281
|
+
The unresolved-rating notice never clears the hold.
|
|
282
|
+
Never send routine merge-ready or merged relays
|
|
283
|
+
for own PRs. Notifications for peer PRs are unchanged, including at most two
|
|
284
|
+
merge-ready/merged milestones per run, deduplicated across implementation and release.
|
|
276
285
|
|
|
277
286
|
Merge-ready opens the merge boundary.
|
|
278
287
|
For own PRs, automatic merge is the default under the
|
|
@@ -282,12 +291,14 @@ standalone authorized maintenance), including small and adopted work.
|
|
|
282
291
|
Missing or idle ownership follows lifecycle reconciliation and explicit transfer first.
|
|
283
292
|
Apply `axstack-watch` §5's merge card and full predicate; an approval alone
|
|
284
293
|
never grants merge authority.
|
|
285
|
-
|
|
294
|
+
Personal own PRs, including `deploying` bases, follow automatic merge under
|
|
295
|
+
watch §5. Work promotion PRs and `deploying` or unknown bases are merged by the
|
|
296
|
+
user on the forge. Peer PRs are merged by the user on the forge.
|
|
286
297
|
User merges are bottom-up for a stack.
|
|
287
298
|
A manager, worker, reviewer, monitor, or nightly triage must never merge.
|
|
288
299
|
Observation-only and peer watches never merge.
|
|
289
300
|
Watch §5 owns provider provenance, approval carryover, eligible bases, every
|
|
290
|
-
planned stack member's publication,
|
|
301
|
+
planned stack member's publication, proven verification, escalation, and card-reply exceptions.
|
|
291
302
|
This policy grants no release, npm publish, or host install authority.
|
|
292
303
|
Re-read every predicate term under watch §5 before merging. Confirm merge
|
|
293
304
|
commits are allowed, `delete_branch_on_merge` is false, and the base has no
|
|
@@ -68,6 +68,23 @@ assertion. Only explicitly authorized, proven C/D batches route to
|
|
|
68
68
|
`axstack-implement` as explicitly structure-preserving work: same-check green
|
|
69
69
|
before/after, through its normal independent review. Discovery remains report-only.
|
|
70
70
|
|
|
71
|
+
### Readiness lens
|
|
72
|
+
|
|
73
|
+
For repository readiness, run from this skill directory:
|
|
74
|
+
```sh
|
|
75
|
+
bun ../axstack/scripts/readiness.js --repo <repo> --rev <rev> --out <git-common-dir>/axstack/readiness/<UTC>-<rev>.json --baseline <previous-report.json>
|
|
76
|
+
```
|
|
77
|
+
Both modes print revision, counts and report path, with evidence in the saved report.
|
|
78
|
+
|
|
79
|
+
Pass the previous report explicitly as `--baseline`; omit it for the first report.
|
|
80
|
+
Return ranked gaps. Route testing and dev-environment gaps to `axstack-verify`;
|
|
81
|
+
route others to `axstack-implement`, one PR per coherent repair.
|
|
82
|
+
Escalate `needs admin` gaps to the user. Never repair without authority.
|
|
83
|
+
|
|
84
|
+
Use `--run` only for repositories the user owns or authorizes; record this
|
|
85
|
+
authority in the run record. It is not a sandbox: repository code can read
|
|
86
|
+
on-disk credentials and write outside scratch. Flags live in `--help`.
|
|
87
|
+
|
|
71
88
|
## 2. Return decision evidence
|
|
72
89
|
|
|
73
90
|
Produce a small ranked candidate set. For each candidate include:
|