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/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
+ }
@@ -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,5 @@
1
+ # .foundry/
2
+
3
+ Runtime state for the Agentic Foundry plugin. Most of this directory is git-ignored by default
4
+ (see the managed block in the workspace's `.gitignore`); only a small, deliberately re-included
5
+ set is tracked — this file among them.
@@ -0,0 +1,9 @@
1
+ {
2
+ "schema_version": 1,
3
+ "repos": {
4
+ "workspace": {
5
+ "path": ".",
6
+ "role": "workspace"
7
+ }
8
+ }
9
+ }
@@ -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.
@@ -0,0 +1,5 @@
1
+ # specs/lifecycle/
2
+
3
+ Cross-cutting lifecycle records (release notes, decision logs, and similar non-atomic
4
+ governance artifacts) live here, separate from the atomic per-capability specs under
5
+ `specs/features/`.
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);