create-agentic-workspace 0.0.0 → 0.2.1
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/README.md +46 -0
- package/bin/create-agentic-workspace.mjs +33 -0
- package/package.json +36 -4
- package/permission-floor.json +411 -0
- package/src/answers.mjs +68 -0
- package/src/argv.mjs +67 -0
- package/src/identity.mjs +155 -0
- package/src/permissionFloor.mjs +179 -0
- package/src/preview.mjs +38 -0
- package/src/questions.mjs +77 -0
- package/src/reconcile.mjs +47 -0
- package/src/run.mjs +208 -0
- package/src/scaffold.mjs +71 -0
- package/src/util.mjs +78 -0
- package/templates/CLAUDE.md.tmpl +16 -0
- package/templates/foundry-README.md +5 -0
- package/templates/foundry-project.json.tmpl +9 -0
- package/templates/gitignore.tmpl +18 -0
- package/templates/specs-features-README.md +7 -0
- package/templates/specs-lifecycle-README.md +5 -0
- package/bin.mjs +0 -7
package/src/run.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// run.mjs — the orchestrator. Wires argv -> answers -> preview -> confirm -> reconcile -> identity
|
|
2
|
+
// -> drift report -> trust hand-off. No `import` of a network module anywhere in this closure
|
|
3
|
+
// (AC-BCL-9); the only reachable egress is identity.mjs's bounded `gh api user` probe.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createInterface } from 'node:readline/promises';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { QUESTION_TABLE } from './questions.mjs';
|
|
9
|
+
import { parseArgv, renderHelp } from './argv.mjs';
|
|
10
|
+
import { resolveAnswers, isYesMode } from './answers.mjs';
|
|
11
|
+
import { RefusalError, physicalResolve, isNonEmptyDir } from './util.mjs';
|
|
12
|
+
import { loadMap, buildSettings, classifyDrift } from './permissionFloor.mjs';
|
|
13
|
+
import { buildManagedFiles, DECLARED_PATH_SET } from './scaffold.mjs';
|
|
14
|
+
import { planManagedFiles, applyPlan, exitCodeForPlan } from './reconcile.mjs';
|
|
15
|
+
import { renderPreview, TRUST_HANDOFF_TEXT } from './preview.mjs';
|
|
16
|
+
import { validateSlug, resolveIdentity, wireIdentity, plannedMachineScopeWrites } from './identity.mjs';
|
|
17
|
+
|
|
18
|
+
export { DECLARED_PATH_SET };
|
|
19
|
+
|
|
20
|
+
function loadPins(pkgDir) {
|
|
21
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
|
|
22
|
+
return pkg.foundry;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureGitRepo(physicalRoot) {
|
|
26
|
+
if (!fs.existsSync(path.join(physicalRoot, '.git'))) {
|
|
27
|
+
fs.mkdirSync(physicalRoot, { recursive: true });
|
|
28
|
+
execFileSync('git', ['init', '--quiet', physicalRoot]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read the target's effective permission rules (settings.json unioned with settings.local.json,
|
|
33
|
+
* origin-tracked) for the drift report. Never throws on absence; reports unreadable JSON. */
|
|
34
|
+
function readEffectiveRules(physicalRoot) {
|
|
35
|
+
const effective = { allow: [], ask: [], deny: [] };
|
|
36
|
+
const unreadable = [];
|
|
37
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
38
|
+
const p = path.join(physicalRoot, '.claude', name);
|
|
39
|
+
if (!fs.existsSync(p)) continue;
|
|
40
|
+
try {
|
|
41
|
+
const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
42
|
+
const perms = data.permissions || {};
|
|
43
|
+
for (const tierKey of ['allow', 'ask', 'deny']) {
|
|
44
|
+
for (const rule of perms[tierKey] || []) {
|
|
45
|
+
effective[tierKey].push({ rule, origin: name, tierKey });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
unreadable.push(name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { effective, unreadable };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function expandPluginRootGlob(glob, homeDir) {
|
|
56
|
+
// Non-recursive expansion of ~/.claude/plugins/cache/*/foundry/* against homeDir.
|
|
57
|
+
const rel = glob.replace(/^~\//, '');
|
|
58
|
+
const parts = rel.split('/');
|
|
59
|
+
let dirs = [homeDir];
|
|
60
|
+
for (const part of parts) {
|
|
61
|
+
const next = [];
|
|
62
|
+
for (const d of dirs) {
|
|
63
|
+
if (part === '*') {
|
|
64
|
+
// statSync via throwIfNoEntry:false, never a bare statSync (PR #61 security review Risk 4).
|
|
65
|
+
// This walk runs AFTER applyPlan, over the operator's own home directory, purely to derive
|
|
66
|
+
// the ADVISORY `stale-plugin-path` finding. A broken symlink inside the plugin cache — not
|
|
67
|
+
// the CLI's business and not something it can prevent — made the bare statSync throw into
|
|
68
|
+
// run.mjs's catch-all and turned a SUCCESSFUL scaffold into exit 1 plus a stack trace. An
|
|
69
|
+
// advisory probe must never be able to fail the run that already did its work.
|
|
70
|
+
const dStat = fs.statSync(d, { throwIfNoEntry: false });
|
|
71
|
+
if (dStat && dStat.isDirectory()) {
|
|
72
|
+
for (const child of fs.readdirSync(d)) {
|
|
73
|
+
const full = path.join(d, child);
|
|
74
|
+
const childStat = fs.statSync(full, { throwIfNoEntry: false });
|
|
75
|
+
if (childStat && childStat.isDirectory()) next.push(full);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
const full = path.join(d, part);
|
|
80
|
+
if (fs.existsSync(full)) next.push(full);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
dirs = next;
|
|
84
|
+
}
|
|
85
|
+
return dirs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Run the CLI end to end. Returns { exitCode, output }. Never throws — every failure path is
|
|
89
|
+
* caught and turned into a refusal-shaped exit 1 (or, for a bug, exit 1 with the error message). */
|
|
90
|
+
export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir }) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
const print = (s) => lines.push(s);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = parseArgv(argv, QUESTION_TABLE);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e instanceof RefusalError) {
|
|
100
|
+
print(`refused: ${e.message}`);
|
|
101
|
+
return { exitCode: 1, output: lines.join('\n') };
|
|
102
|
+
}
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (parsed.values.help === true) {
|
|
107
|
+
print(renderHelp(QUESTION_TABLE));
|
|
108
|
+
return { exitCode: 0, output: lines.join('\n') };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const yesMode = isYesMode(parsed.values, isTTY);
|
|
112
|
+
const answers = await resolveAnswers(QUESTION_TABLE, parsed, { yesMode, input, output });
|
|
113
|
+
|
|
114
|
+
const targetRoot = path.resolve(cwd, answers.dir);
|
|
115
|
+
const nonEmpty = isNonEmptyDir(targetRoot);
|
|
116
|
+
if (nonEmpty && !answers.existing) {
|
|
117
|
+
throw new RefusalError(
|
|
118
|
+
`${targetRoot} exists and is non-empty; use --existing to scaffold into it`,
|
|
119
|
+
'existing',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (nonEmpty && answers.existing && !yesMode) {
|
|
123
|
+
const rl = createInterface({ input, output });
|
|
124
|
+
const typed = (await rl.question(`Type the directory's basename to confirm ('${path.basename(targetRoot)}'): `)).trim();
|
|
125
|
+
rl.close();
|
|
126
|
+
if (typed !== path.basename(targetRoot)) {
|
|
127
|
+
throw new RefusalError('basename confirmation did not match; refusing', 'existing');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let slug = '';
|
|
132
|
+
if (answers.ghAccount) {
|
|
133
|
+
slug = validateSlug(answers.ghAccount);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const physicalRoot = physicalResolve(targetRoot);
|
|
137
|
+
const map = loadMap(path.join(pkgDir, 'permission-floor.json'));
|
|
138
|
+
const pins = loadPins(pkgDir);
|
|
139
|
+
const settingsObj = buildSettings(map, pins);
|
|
140
|
+
const settingsBytes = Buffer.from(`${JSON.stringify(settingsObj, null, 2)}\n`, 'utf-8');
|
|
141
|
+
|
|
142
|
+
const managedFiles = buildManagedFiles({
|
|
143
|
+
templatesDir: path.join(pkgDir, 'templates'),
|
|
144
|
+
physicalRoot,
|
|
145
|
+
projectName: path.basename(targetRoot),
|
|
146
|
+
stageMode: answers.stageMode,
|
|
147
|
+
settingsBytes,
|
|
148
|
+
});
|
|
149
|
+
const plan = planManagedFiles(managedFiles);
|
|
150
|
+
const machineScopeWrites = slug ? plannedMachineScopeWrites({ slug, targetRoot, homeDir }) : [];
|
|
151
|
+
|
|
152
|
+
print(renderPreview({ plan, machineScopeWrites, map }));
|
|
153
|
+
|
|
154
|
+
if (answers.dryRun) {
|
|
155
|
+
print('(dry-run: no write, no side effect, zero child processes spawned)');
|
|
156
|
+
return { exitCode: 0, output: lines.join('\n') };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!yesMode) {
|
|
160
|
+
const rl = createInterface({ input, output });
|
|
161
|
+
const confirm = (await rl.question('Write the workspace as previewed above? [y/N]: ')).trim().toLowerCase();
|
|
162
|
+
rl.close();
|
|
163
|
+
if (confirm !== 'y' && confirm !== 'yes') {
|
|
164
|
+
throw new RefusalError('write-phase confirmation declined; writing nothing');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
applyPlan(plan);
|
|
169
|
+
|
|
170
|
+
if (slug) {
|
|
171
|
+
ensureGitRepo(physicalRoot);
|
|
172
|
+
const identity = await resolveIdentity(slug, {
|
|
173
|
+
gitAuthor: answers.gitAuthor,
|
|
174
|
+
homeDir,
|
|
175
|
+
isTTY,
|
|
176
|
+
promptFn: isTTY
|
|
177
|
+
? async (q) => {
|
|
178
|
+
const rl = createInterface({ input, output });
|
|
179
|
+
const a = await rl.question(q);
|
|
180
|
+
rl.close();
|
|
181
|
+
return a;
|
|
182
|
+
}
|
|
183
|
+
: null,
|
|
184
|
+
});
|
|
185
|
+
wireIdentity({ slug, name: identity.name, email: identity.email, targetRoot: physicalRoot, homeDir });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { effective, unreadable } = readEffectiveRules(physicalRoot);
|
|
189
|
+
const pluginRootExpansion = expandPluginRootGlob(map.plugin_root_glob, homeDir);
|
|
190
|
+
const findings = classifyDrift(map, effective, { pluginRootExpansion, unreadableOrigins: unreadable });
|
|
191
|
+
if (findings.length > 0) {
|
|
192
|
+
print('');
|
|
193
|
+
print(`Permission-floor report (advisory, ${findings.length} finding(s)):`);
|
|
194
|
+
for (const f of findings) print(` [${f.class}] ${JSON.stringify(f)}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
print(TRUST_HANDOFF_TEXT(targetRoot));
|
|
198
|
+
|
|
199
|
+
return { exitCode: exitCodeForPlan(plan), output: lines.join('\n') };
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (e instanceof RefusalError) {
|
|
202
|
+
print(`refused: ${e.message}`);
|
|
203
|
+
return { exitCode: 1, output: lines.join('\n') };
|
|
204
|
+
}
|
|
205
|
+
print(`error: ${e.stack || e.message}`);
|
|
206
|
+
return { exitCode: 1, output: lines.join('\n') };
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// scaffold.mjs — the closed, schema-valid seven-file scaffold (AC-BCL-6) + settings.json,
|
|
2
|
+
// materialized through the confinement join (AC-BCL-9). Template entries are declared once, here.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { confinedJoin, RefusalError } from './util.mjs';
|
|
6
|
+
|
|
7
|
+
/** The closed template-materialization manifest (AC-BCL-6's six template-derived paths;
|
|
8
|
+
* .claude/settings.json is the seventh, generated separately — see buildManagedFiles). */
|
|
9
|
+
export const TEMPLATE_ENTRIES = Object.freeze([
|
|
10
|
+
{ template: 'CLAUDE.md.tmpl', target: 'CLAUDE.md' },
|
|
11
|
+
{ template: 'gitignore.tmpl', target: '.gitignore' },
|
|
12
|
+
{ template: 'foundry-project.json.tmpl', target: '.claude/foundry-project.json' },
|
|
13
|
+
{ template: 'specs-features-README.md', target: 'specs/features/README.md' },
|
|
14
|
+
{ template: 'specs-lifecycle-README.md', target: 'specs/lifecycle/README.md' },
|
|
15
|
+
{ template: 'foundry-README.md', target: '.foundry/README.md' },
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** Render a template's raw text with the CLAUDE.md substitutions (the only templated file). */
|
|
19
|
+
function renderTemplate(target, raw, { projectName, stageMode }) {
|
|
20
|
+
if (target === 'CLAUDE.md') {
|
|
21
|
+
return raw.replaceAll('{{PROJECT_NAME}}', projectName).replaceAll('{{STAGE_MODE}}', stageMode);
|
|
22
|
+
}
|
|
23
|
+
return raw;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build the full managed-file set: the six templated paths + the generated settings.json,
|
|
27
|
+
* joined onto the physically-resolved target root, refusing (RefusalError) any entry whose
|
|
28
|
+
* resolution escapes it (AC-BCL-9). `entries` defaults to TEMPLATE_ENTRIES; tests may inject a
|
|
29
|
+
* mutated list to exercise the traversal refusal without touching the shipped manifest. */
|
|
30
|
+
export function buildManagedFiles({
|
|
31
|
+
templatesDir,
|
|
32
|
+
physicalRoot,
|
|
33
|
+
projectName,
|
|
34
|
+
stageMode,
|
|
35
|
+
settingsBytes,
|
|
36
|
+
entries = TEMPLATE_ENTRIES,
|
|
37
|
+
}) {
|
|
38
|
+
const files = [];
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const joined = confinedJoin(physicalRoot, entry.target);
|
|
41
|
+
if (joined === null) {
|
|
42
|
+
throw new RefusalError(
|
|
43
|
+
`refusing to materialize ${entry.template}: path escapes the target root (${entry.target})`,
|
|
44
|
+
entry.target,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const raw = fs.readFileSync(path.join(templatesDir, entry.template), 'utf-8');
|
|
48
|
+
const rendered = renderTemplate(entry.target, raw, { projectName, stageMode });
|
|
49
|
+
files.push({ relPath: entry.target, absPath: joined, bytes: Buffer.from(rendered, 'utf-8') });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const settingsJoined = confinedJoin(physicalRoot, '.claude/settings.json');
|
|
53
|
+
if (settingsJoined === null) {
|
|
54
|
+
throw new RefusalError(
|
|
55
|
+
'refusing to write .claude/settings.json: path escapes the target root',
|
|
56
|
+
'.claude/settings.json',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
files.push({ relPath: '.claude/settings.json', absPath: settingsJoined, bytes: settingsBytes });
|
|
60
|
+
return files;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const DECLARED_PATH_SET = Object.freeze([
|
|
64
|
+
'CLAUDE.md',
|
|
65
|
+
'.gitignore',
|
|
66
|
+
'.claude/settings.json',
|
|
67
|
+
'.claude/foundry-project.json',
|
|
68
|
+
'specs/features/README.md',
|
|
69
|
+
'specs/lifecycle/README.md',
|
|
70
|
+
'.foundry/README.md',
|
|
71
|
+
]);
|
package/src/util.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// util.mjs — small shared helpers. No third-party deps; node: builtins and relative imports only.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** A refusal: the CLI writes nothing and names the offending flag/path/entry. */
|
|
7
|
+
export class RefusalError extends Error {
|
|
8
|
+
constructor(message, flag) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'RefusalError';
|
|
11
|
+
this.flag = flag;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sha256(bytes) {
|
|
16
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Physically resolve a path (symlinks followed), even if it does not yet exist — resolves the
|
|
20
|
+
* longest existing ancestor and joins the remaining (non-existent) segments onto it. */
|
|
21
|
+
export function physicalResolve(targetPath) {
|
|
22
|
+
const abs = path.resolve(targetPath);
|
|
23
|
+
const parts = abs.split(path.sep);
|
|
24
|
+
let base = path.sep;
|
|
25
|
+
let i = 1;
|
|
26
|
+
// find the longest existing ancestor
|
|
27
|
+
let existing = abs;
|
|
28
|
+
const missing = [];
|
|
29
|
+
while (existing !== path.sep && !fs.existsSync(existing)) {
|
|
30
|
+
missing.unshift(path.basename(existing));
|
|
31
|
+
existing = path.dirname(existing);
|
|
32
|
+
}
|
|
33
|
+
const realBase = fs.existsSync(existing) ? fs.realpathSync(existing) : existing;
|
|
34
|
+
return missing.length ? path.join(realBase, ...missing) : realBase;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Join a template-relative path onto a physically-resolved target root, refusing (returns null)
|
|
38
|
+
* any resolution that escapes the root — an absolute template path, a `..` segment, or a
|
|
39
|
+
* symlinked ancestor that resolves outside. Never throws; callers decide how to report. */
|
|
40
|
+
export function confinedJoin(physicalRoot, relPath) {
|
|
41
|
+
if (path.isAbsolute(relPath)) return null;
|
|
42
|
+
const segments = relPath.split(/[\\/]+/);
|
|
43
|
+
if (segments.some((s) => s === '..')) return null;
|
|
44
|
+
const joined = path.join(physicalRoot, relPath);
|
|
45
|
+
const resolved = physicalResolve(joined);
|
|
46
|
+
const rootWithSep = physicalRoot.endsWith(path.sep) ? physicalRoot : physicalRoot + path.sep;
|
|
47
|
+
if (resolved !== physicalRoot && !resolved.startsWith(rootWithSep)) return null;
|
|
48
|
+
return joined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ensureDirFor(filePath) {
|
|
52
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function fileBytesEqual(existingPath, desiredBytes) {
|
|
56
|
+
// lstat, NOT existsSync (PR #61 security review Block 2). existsSync FOLLOWS symlinks, so a
|
|
57
|
+
// DANGLING symlink at a managed path reported `false` -> the row was classified `create` ->
|
|
58
|
+
// writeFileSync followed the link and landed the write at the link's target, OUTSIDE the
|
|
59
|
+
// physically-resolved target root (AC-BCL-9) and reachable at $HOME/.claude/settings.json
|
|
60
|
+
// (AC-BCL-4(d)) — a user-scope settings file is NOT gated by the workspace trust dialog, so on
|
|
61
|
+
// that one path the CLI stopped declaring and started granting. lstat does not follow the final
|
|
62
|
+
// component, so ANY existing entry — dangling symlink included — is seen and falls to the
|
|
63
|
+
// `notRegular` arm below, i.e. `drifted`: reported, never written. Symlinks whose targets EXIST
|
|
64
|
+
// were already caught (realpath resolves them out of root), as were symlinked ancestors; this
|
|
65
|
+
// closes the dangling-leaf case, the only one that escaped.
|
|
66
|
+
const stat = fs.lstatSync(existingPath, { throwIfNoEntry: false });
|
|
67
|
+
if (!stat) return { present: false, equal: false };
|
|
68
|
+
if (!stat.isFile()) return { present: true, equal: false, notRegular: true };
|
|
69
|
+
const actual = fs.readFileSync(existingPath);
|
|
70
|
+
return { present: true, equal: Buffer.compare(actual, Buffer.from(desiredBytes)) === 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isNonEmptyDir(dirPath) {
|
|
74
|
+
if (!fs.existsSync(dirPath)) return false;
|
|
75
|
+
const stat = fs.lstatSync(dirPath);
|
|
76
|
+
if (!stat.isDirectory()) return true; // exists as a non-directory: treat as "occupied"
|
|
77
|
+
return fs.readdirSync(dirPath).length > 0;
|
|
78
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} — an Agentic Foundry workspace
|
|
2
|
+
|
|
3
|
+
This workspace was scaffolded by `npx create-agentic-workspace`. The Agentic Foundry plugin's
|
|
4
|
+
verbs are `/foundry:*`; run `/foundry:init` inside a trusted Claude Code session to finish the
|
|
5
|
+
logical wiring (operator registry, project config), then `/foundry:doctor` to confirm
|
|
6
|
+
`DOCTOR-GREEN`.
|
|
7
|
+
|
|
8
|
+
## Stage mode
|
|
9
|
+
|
|
10
|
+
Stage mode: {{STAGE_MODE}}
|
|
11
|
+
|
|
12
|
+
`lean` favors the direct loop with lighter ceremony; `scale` enforces the full gates.
|
|
13
|
+
|
|
14
|
+
## Conventions
|
|
15
|
+
|
|
16
|
+
Specs are atomic: `specs/features/<domain>/<capability>/feat-<capability>.md`, each with stable AC-IDs, a delimited `<!-- normative -->` region, and a sibling `acceptance-contract.yaml`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Managed by create-agentic-workspace. The block below is byte-identical to the shipped
|
|
2
|
+
# scripts/foundry-runtime.gitignore applier's own content (AC-BCL-5) so the two can never diverge.
|
|
3
|
+
# FOUNDRY-RUNTIME-GITIGNORE-BEGIN
|
|
4
|
+
# .foundry/ runtime partitions are ignored by default; the designed-tracked set is re-included.
|
|
5
|
+
/.foundry/*
|
|
6
|
+
!/.foundry/README.md
|
|
7
|
+
!/.foundry/build-provenance.yaml
|
|
8
|
+
!/.foundry/stack-profile.lock
|
|
9
|
+
# FOUNDRY-RUNTIME-GITIGNORE-END
|
|
10
|
+
|
|
11
|
+
# Local, UNGATED permission overrides — never commit them (PR #61 security review Risk 5).
|
|
12
|
+
# The floor model this workspace is scaffolded around distinguishes the committed
|
|
13
|
+
# .claude/settings.json, whose `allow` rules take effect only after the operator accepts the trust
|
|
14
|
+
# dialog that lists them, from .claude/settings.local.json, which is read with NO trust ceremony
|
|
15
|
+
# (it is where the harness's "always allow" persist option writes). A committed settings.local.json
|
|
16
|
+
# would therefore propagate ungated local grants to every clone, with no dialog and no review —
|
|
17
|
+
# which is precisely the shadowing that /foundry:doctor's permission-floor probe exists to report.
|
|
18
|
+
/.claude/settings.local.json
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# specs/features/
|
|
2
|
+
|
|
3
|
+
Atomic feature specs live here, one directory per capability:
|
|
4
|
+
`specs/features/<domain>/<capability>/feat-<capability>.md`, each with stable AC-IDs, a
|
|
5
|
+
delimited `<!-- normative -->` region, and a sibling `acceptance-contract.yaml`.
|
|
6
|
+
|
|
7
|
+
Run `/foundry:intake "…"` inside a trusted Claude Code session to draft the first one.
|
package/bin.mjs
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
console.error(
|
|
3
|
-
"create-agentic-workspace@0.0.0 is a placeholder published only to bootstrap\n" +
|
|
4
|
-
"npm trusted publishing. It does nothing. Install the latest version instead:\n" +
|
|
5
|
-
" npx create-agentic-workspace@latest\n"
|
|
6
|
-
);
|
|
7
|
-
process.exit(1);
|