create-agentic-workspace 0.0.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/argv.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // argv.mjs — the argv parser. Its accepted long-flag set is derived from the question table AND
2
+ // FROM NO SECOND LIST (AC-BCL-2). --help is rendered one line per table record.
3
+ import { RefusalError } from './util.mjs';
4
+
5
+ /** Parse argv against `table` (defaults to QUESTION_TABLE at call sites). Returns
6
+ * { values: {id: value}, provided: Set<id> }. Throws RefusalError on an unknown flag, a missing
7
+ * value, or an out-of-`choices` value — writing nothing is the caller's responsibility (this
8
+ * function performs no I/O). */
9
+ export function parseArgv(argv, table) {
10
+ const byFlag = new Map(table.map((r) => [r.flag, r]));
11
+ const values = {};
12
+ const provided = new Set();
13
+
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const tok = argv[i];
16
+ if (!tok.startsWith('--')) {
17
+ throw new RefusalError(`unexpected positional argument: ${tok}`);
18
+ }
19
+ let name = tok.slice(2);
20
+ let inline = null;
21
+ const eq = name.indexOf('=');
22
+ if (eq !== -1) {
23
+ inline = name.slice(eq + 1);
24
+ name = name.slice(0, eq);
25
+ }
26
+ const rec = byFlag.get(name);
27
+ if (!rec) {
28
+ throw new RefusalError(`unknown flag: --${name}`, name);
29
+ }
30
+ if (rec.type === 'boolean') {
31
+ values[rec.id] = inline === null ? true : inline === 'true';
32
+ } else {
33
+ let v = inline;
34
+ if (v === null) {
35
+ const next = argv[i + 1];
36
+ if (next === undefined || (next.startsWith('--') && next.length > 2)) {
37
+ throw new RefusalError(`--${rec.flag} needs a value`, rec.flag);
38
+ }
39
+ v = next;
40
+ i++;
41
+ }
42
+ if (rec.choices && !rec.choices.includes(v)) {
43
+ throw new RefusalError(
44
+ `--${rec.flag} must be one of: ${rec.choices.join(', ')} (got ${JSON.stringify(v)})`,
45
+ rec.flag,
46
+ );
47
+ }
48
+ values[rec.id] = v;
49
+ }
50
+ provided.add(rec.id);
51
+ }
52
+ return { values, provided };
53
+ }
54
+
55
+ /** Render one `--help` line per table record — the third leg of the flag/prompt/help bijection. */
56
+ export function renderHelp(table, { programName = 'create-agentic-workspace' } = {}) {
57
+ const lines = [`Usage: ${programName} [options]`, ''];
58
+ for (const rec of table) {
59
+ const flagCol = rec.type === 'boolean' ? `--${rec.flag}` : `--${rec.flag} <value>`;
60
+ const req = rec.required ? ' (required)' : rec.default !== undefined && rec.default !== '' ? ` (default: ${rec.default})` : '';
61
+ const choices = rec.choices ? ` [choices: ${rec.choices.join('|')}]` : '';
62
+ lines.push(` ${flagCol.padEnd(24)} ${rec.prompt}${choices}${req}`);
63
+ }
64
+ lines.push('');
65
+ lines.push('This CLI collects and transmits nothing: no telemetry.');
66
+ return lines.join('\n');
67
+ }
@@ -0,0 +1,155 @@
1
+ // identity.mjs — the out-of-session identity half (AC-BCL-7), proved differentially equal to
2
+ // `scripts/foundry-bootstrap.sh`. Every git-config write goes through `git config` in an argv
3
+ // position — never composed/templated text — so git's own writer, quoting and locking apply, and
4
+ // the CLI's output is byte-identical to the shell script's for the same inputs.
5
+ import { execFileSync, execFile as execFileCb } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { RefusalError, physicalResolve } from './util.mjs';
11
+
12
+ const execFileAsync = promisify(execFileCb);
13
+
14
+ const SLUG_RE = /^[A-Za-z0-9._-]+$/;
15
+
16
+ /** Validate a --gh-account value against the closed charset, refusing '.'/'..' (AC-BCL-7). */
17
+ export function validateSlug(raw) {
18
+ if (raw === '.' || raw === '..' || !SLUG_RE.test(raw)) {
19
+ throw new RefusalError(
20
+ `invalid --gh-account slug (need ^[A-Za-z0-9._-]+$, not '.'/'..'): ${raw}`,
21
+ 'gh-account',
22
+ );
23
+ }
24
+ return raw;
25
+ }
26
+
27
+ const STRIPPED_ENV_VARS = ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_HOST'];
28
+
29
+ /** At most ONE `gh api user` invocation, timeout-bounded, jailed under its own GH_CONFIG_DIR, with
30
+ * the five higher-precedence token/host variables stripped from the child environment. Compares
31
+ * the returned `.login` against the declared slug (ASCII case-insensitive) and discards the probe
32
+ * WHOLE on any mismatch, parse failure, timeout, or absent `gh` — never a partial adopt. No probe
33
+ * output (in whole or in part) is ever written to a file, logged, or printed by this function. */
34
+ export async function probeGhIdentity(slug, { homeDir, timeoutMs = 5000, env = process.env } = {}) {
35
+ // A STRING LITERAL, never an env-supplied name (PR #61 security review Risk 1). This previously
36
+ // read `env.FOUNDRY_TEST_GH_BIN || 'gh'`, which made the spec's "the child-process set is closed
37
+ // to {git, gh}, so `claude` is never spawned" literally false: anything able to set that variable
38
+ // — a direnv .envrc in a freshly cloned repo, an exported var, CI — chose the binary this line
39
+ // spawns, `claude` included. It was also DEAD: the only reference in the tree was its own
40
+ // definition, since the test suites inject their `gh` stub via PATH. Removed rather than guarded;
41
+ // an unused escape hatch is all cost.
42
+ const gh = 'gh';
43
+ const jail = path.join(homeDir, '.config', `gh-${slug}`);
44
+ fs.mkdirSync(jail, { recursive: true });
45
+ const childEnv = { ...env, GH_CONFIG_DIR: jail };
46
+ for (const v of STRIPPED_ENV_VARS) delete childEnv[v];
47
+
48
+ let stdout;
49
+ try {
50
+ const result = await execFileAsync(gh, ['api', 'user'], { env: childEnv, timeout: timeoutMs });
51
+ stdout = result.stdout;
52
+ } catch {
53
+ return null; // absent gh, non-zero exit, or timeout — degrade, no further network call
54
+ }
55
+ let data;
56
+ try {
57
+ data = JSON.parse(stdout);
58
+ } catch {
59
+ return null;
60
+ }
61
+ const login = typeof data.login === 'string' ? data.login : '';
62
+ if (login.toLowerCase() !== slug.toLowerCase()) return null; // whole discard, never partial
63
+ const name = typeof data.name === 'string' ? data.name : '';
64
+ const email = typeof data.email === 'string' ? data.email : '';
65
+ if (!name || !email) return null;
66
+ return { name, email };
67
+ }
68
+
69
+ function parseGitAuthor(raw) {
70
+ const m = /^(.*) <([^<>]+)>$/.exec(raw);
71
+ if (!m) {
72
+ throw new RefusalError(`malformed --git-author (expected 'Name <email>'): ${raw}`, 'git-author');
73
+ }
74
+ return { name: m[1], email: m[2] };
75
+ }
76
+
77
+ /** Resolve the declared "Name <email>" (AC-BCL-7 order): (1) --git-author, (2) the bounded gh
78
+ * probe, (3) an interactive TTY prompt, (4) fail closed. */
79
+ export async function resolveIdentity(slug, { gitAuthor, homeDir, isTTY, promptFn } = {}) {
80
+ if (gitAuthor) {
81
+ return parseGitAuthor(gitAuthor);
82
+ }
83
+ const probed = await probeGhIdentity(slug, { homeDir });
84
+ if (probed) return probed;
85
+ if (isTTY && promptFn) {
86
+ const name = (await promptFn(`commit-identity name for account ${slug}: `)).trim();
87
+ const email = (await promptFn(`commit-identity email for account ${slug}: `)).trim();
88
+ if (name && email) return { name, email };
89
+ }
90
+ throw new RefusalError(
91
+ `could not resolve a commit identity for '${slug}' (give --git-author 'Name <email>', authenticate gh, or run interactively)`,
92
+ 'git-author',
93
+ );
94
+ }
95
+
96
+ function validateIdentityValue(kind, val) {
97
+ if (!val) throw new RefusalError(`commit-identity ${kind} is empty (refusing to write a fabricated identity)`);
98
+ if (val.includes('\n')) throw new RefusalError(`commit-identity ${kind} is not single-line (refusing to write)`);
99
+ if (kind === 'email' && !val.includes('@')) {
100
+ throw new RefusalError(`commit-identity email lacks '@': ${val} (refusing to write)`);
101
+ }
102
+ }
103
+
104
+ /** Physically-resolved target root with a trailing separator (the <canon> Terminology term). Works
105
+ * even when the target does not yet exist (preview time), by resolving the longest existing
106
+ * ancestor and appending the missing segments (see util.physicalResolve). */
107
+ export function canonicalTargetWithSlash(targetRoot) {
108
+ const real = physicalResolve(targetRoot);
109
+ return real.endsWith(path.sep) ? real : real + path.sep;
110
+ }
111
+
112
+ /** Describe (never execute) the three machine-scope writes wireIdentity would make — used by the
113
+ * preview/dry-run path (AC-BCL-3), which SHALL spawn zero child processes. */
114
+ export function plannedMachineScopeWrites({ slug, targetRoot, homeDir }) {
115
+ const canon = canonicalTargetWithSlash(targetRoot);
116
+ const matchPrefix = os.platform() === 'darwin' ? 'gitdir/i:' : 'gitdir:';
117
+ const inc = path.join(homeDir, '.config', 'git', `identity-${slug}`);
118
+ return [
119
+ { scope: 'global git config (machine-wide)', description: `includeIf.${matchPrefix}${canon}.git.path -> ${inc}` },
120
+ { scope: 'global git config (machine-wide)', description: `includeIf.${matchPrefix}${canon}.git/.path -> ${inc}` },
121
+ { scope: 'per-account include file (machine-wide)', description: inc },
122
+ ];
123
+ }
124
+
125
+ /** Wire the three machine-scope artifacts + the repo-local useConfigOnly + the .claude/gh-identity
126
+ * marker, EXACTLY as `scripts/foundry-bootstrap.sh`'s seed_commit_identity/seed_gh_identity do:
127
+ * every git-config write goes through `git config` in an argv position (AC-BCL-7). Returns the
128
+ * paths written, for the caller's machine-scope-write accounting (AC-BCL-9). */
129
+ export function wireIdentity({ slug, name, email, targetRoot, homeDir }) {
130
+ validateIdentityValue('name', name);
131
+ validateIdentityValue('email', email);
132
+
133
+ const inc = path.join(homeDir, '.config', 'git', `identity-${slug}`);
134
+ fs.mkdirSync(path.dirname(inc), { recursive: true });
135
+ execFileSync('git', ['config', '--file', inc, 'user.name', name]);
136
+ execFileSync('git', ['config', '--file', inc, 'user.email', email]);
137
+
138
+ const canon = canonicalTargetWithSlash(targetRoot);
139
+ const matchPrefix = os.platform() === 'darwin' ? 'gitdir/i:' : 'gitdir:';
140
+ const narrow1 = `${matchPrefix}${canon}.git`;
141
+ const narrow2 = `${matchPrefix}${canon}.git/`;
142
+ const gitConfigEnv = { ...process.env, HOME: homeDir };
143
+ execFileSync('git', ['config', '--global', `includeIf.${narrow1}.path`, inc], { env: gitConfigEnv });
144
+ execFileSync('git', ['config', '--global', `includeIf.${narrow2}.path`, inc], { env: gitConfigEnv });
145
+
146
+ const dotGitConfig = path.join(targetRoot, '.git', 'config');
147
+ execFileSync('git', ['config', '--file', dotGitConfig, 'user.useConfigOnly', 'true']);
148
+
149
+ const claudeDir = path.join(targetRoot, '.claude');
150
+ fs.mkdirSync(claudeDir, { recursive: true });
151
+ const marker = path.join(claudeDir, 'gh-identity');
152
+ fs.writeFileSync(marker, `${slug}\n`);
153
+
154
+ return { includeFile: inc, narrow1, narrow2, dotGitConfig, marker };
155
+ }
@@ -0,0 +1,179 @@
1
+ // permissionFloor.mjs — loads the bundled map, builds the written settings.json object (a
2
+ // verbatim declaration, never a grant — AC-BCL-4), renders the capability preview, and classifies
3
+ // drift between the map and a target's effective settings using the AC-DPF-8 vocabulary
4
+ // (AC-BCL-8). `covers()` agrees with tests/test_permission_floor_map.py::_subsumes on the shared
5
+ // 8-row table by construction (same prefix-subsumption rule).
6
+ import fs from 'node:fs';
7
+
8
+ export function loadMap(mapPath) {
9
+ const text = fs.readFileSync(mapPath, 'utf-8');
10
+ return JSON.parse(text);
11
+ }
12
+
13
+ const RULE_RE = /^([A-Za-z0-9_-]+)\((.*)\)$/s;
14
+
15
+ export function ruleBody(rule) {
16
+ const m = RULE_RE.exec(rule);
17
+ if (!m) return null;
18
+ return m[2];
19
+ }
20
+
21
+ export function ruleTool(rule) {
22
+ const m = RULE_RE.exec(rule);
23
+ return m ? m[1] : null;
24
+ }
25
+
26
+ /** covers(A, B): true if the broad rule A's reach (a `:*`-terminated prefix) subsumes rule B's
27
+ * body. Mirrors tests/test_permission_floor_map.py::_subsumes exactly (same prefix rule). */
28
+ export function covers(ruleA, ruleB) {
29
+ const bodyA = ruleBody(ruleA);
30
+ if (bodyA === null || !bodyA.endsWith(':*')) return false;
31
+ const sA = bodyA.slice(0, -2);
32
+ const bodyB = ruleBody(ruleB);
33
+ if (bodyB === null) return false;
34
+ const sB = bodyB.endsWith(':*') ? bodyB.slice(0, -2) : bodyB;
35
+ return sB.startsWith(sA);
36
+ }
37
+
38
+ const BLANKET_BODIES = new Set(['*', 'python3 *', 'python3:*']);
39
+
40
+ /** A blanket effective allow rule: one whose reach swallows the whole map (AC-DPF-3(a)'s named
41
+ * spellings this atom's own controls (g)(h)(i) exercise: `Bash(*)`, `Bash(python3 *)`,
42
+ * `Bash(python3:*)`). */
43
+ export function isBlanketAllow(rule) {
44
+ const body = ruleBody(rule);
45
+ if (body === null) return false;
46
+ const stripped = body.endsWith(':*') ? body.slice(0, -2) : body;
47
+ return BLANKET_BODIES.has(body) || stripped === '' || stripped === '*';
48
+ }
49
+
50
+ const SCRIPTS_BASENAME_RE = /scripts\/([A-Za-z0-9_.-]+)/;
51
+
52
+ /** A "ceremony" map entry (AC-DPF-8 rank 2): tier `ask` and its rule body names a scripts/
53
+ * basename — derived structurally, no second source of truth. */
54
+ export function isCeremonyEntry(entry) {
55
+ return entry.tier === 'ask' && SCRIPTS_BASENAME_RE.test(ruleBody(entry.rule) || '');
56
+ }
57
+
58
+ export const DRIFT_CLASSES = Object.freeze([
59
+ 'blanket-allow',
60
+ 'ask-shadowed-ceremony',
61
+ 'ask-shadowed',
62
+ 'deny-missing',
63
+ 'settings-unreadable',
64
+ 'stale-plugin-path',
65
+ 'allow-absent',
66
+ 'unclassified',
67
+ ]);
68
+
69
+ /** Build the settings.json object the CLI writes, verbatim from the bundled map plus the
70
+ * marketplace/plugin pins (AC-BCL-4). */
71
+ export function buildSettings(map, pins) {
72
+ const byTier = { allow: [], ask: [], deny: [] };
73
+ for (const e of map.entries) {
74
+ byTier[e.tier].push(e.rule);
75
+ }
76
+ // THE PINNED LITERAL (AC-BCL-4(b), contract v1.2 — PR #61 security review Block 1). `ref` is
77
+ // load-bearing, not decoration: without it the adopter's FIRST marketplace resolution floats to
78
+ // whatever the default branch points at, and because the floor's allow rules are
79
+ // version-wildcarded (`cache/*/foundry/*/scripts/...`), the operator's single trust acceptance
80
+ // becomes a standing grant over whatever code that resolution delivered. That is the same
81
+ // floating-pin defect `autoUpdate: false` guards the LATER fetches against, left open on the
82
+ // first one — and strictly weaker than the manual path docs/QUICKSTART.md documents
83
+ // (the documented `marketplace add …#<tag>` install line). Single-sourced from
84
+ // the `foundry` pin block, so it cannot drift from the marketplace manifest.
85
+ const marketplaceEntry = {
86
+ source: { source: 'github', repo: pins.marketplace_repo, ref: `v${pins.plugin_version}` },
87
+ autoUpdate: false,
88
+ };
89
+ return {
90
+ permissions: { allow: byTier.allow, ask: byTier.ask, deny: byTier.deny },
91
+ extraKnownMarketplaces: { [pins.marketplace_name]: marketplaceEntry },
92
+ enabledPlugins: { [`${pins.plugin_name}@${pins.marketplace_name}`]: true },
93
+ };
94
+ }
95
+
96
+ /** One capability-preview line per bundled-map entry, grouped by tier, carrying rule/tier/
97
+ * rationale (AC-BCL-3). */
98
+ export function renderCapabilityLines(map) {
99
+ const lines = [];
100
+ for (const tier of ['allow', 'ask', 'deny']) {
101
+ const entries = map.entries.filter((e) => e.tier === tier);
102
+ if (entries.length === 0) continue;
103
+ lines.push(` [${tier}] (${entries.length} rules)`);
104
+ for (const e of entries) {
105
+ lines.push(` - ${e.rule} — ${e.rationale}`);
106
+ }
107
+ }
108
+ return lines;
109
+ }
110
+
111
+ /** Classify drift between the bundled map and a target's effective permission configuration.
112
+ * `effective` is { allow: [{rule, origin, tierKey}], ask: [...], deny: [...] } drawn from
113
+ * settings.json unioned with settings.local.json (origin-tracked, AC-BCL-8). `readable` names
114
+ * whether each origin file that exists parsed successfully. `pluginRootExpansion` is the list of
115
+ * directories `plugin_root_glob` expanded to on disk (empty => stale-plugin-path). Returns an
116
+ * array of finding objects `{class, ...}` using exactly the AC-DPF-8 vocabulary — no other class
117
+ * name is ever emitted. */
118
+ export function classifyDrift(map, effective, { pluginRootExpansion = [], unreadableOrigins = [] } = {}) {
119
+ const findings = [];
120
+
121
+ for (const origin of unreadableOrigins) {
122
+ findings.push({ class: 'settings-unreadable', origin });
123
+ }
124
+
125
+ const shadowedByBlanket = new Set();
126
+ for (const a of effective.allow) {
127
+ if (isBlanketAllow(a.rule)) {
128
+ const swallowed = map.entries.filter((e) => e.tier !== 'allow');
129
+ findings.push({
130
+ class: 'blanket-allow',
131
+ rule: a.rule,
132
+ origin: a.origin,
133
+ swallows: swallowed.map((e) => e.rule),
134
+ });
135
+ for (const e of swallowed) shadowedByBlanket.add(e.rule);
136
+ }
137
+ }
138
+
139
+ for (const entry of map.entries.filter((e) => e.tier === 'ask')) {
140
+ if (shadowedByBlanket.has(entry.rule)) continue;
141
+ const coveringAllow = effective.allow.filter((a) => covers(a.rule, entry.rule));
142
+ if (coveringAllow.length > 0) {
143
+ findings.push({
144
+ class: isCeremonyEntry(entry) ? 'ask-shadowed-ceremony' : 'ask-shadowed',
145
+ rule: entry.rule,
146
+ coveredBy: coveringAllow.map((a) => ({ rule: a.rule, origin: a.origin, tierKey: a.tierKey })),
147
+ });
148
+ }
149
+ }
150
+
151
+ for (const entry of map.entries.filter((e) => e.tier === 'deny')) {
152
+ const coveringDeny = effective.deny.filter((d) => covers(d.rule, entry.rule) || d.rule === entry.rule);
153
+ if (coveringDeny.length === 0) {
154
+ findings.push({ class: 'deny-missing', rule: entry.rule });
155
+ }
156
+ }
157
+
158
+ if (pluginRootExpansion.length === 0) {
159
+ findings.push({ class: 'stale-plugin-path', glob: map.plugin_root_glob });
160
+ }
161
+
162
+ for (const entry of map.entries.filter((e) => e.tier === 'allow')) {
163
+ const coveringAllow = effective.allow.filter((a) => covers(a.rule, entry.rule) || a.rule === entry.rule);
164
+ if (coveringAllow.length === 0) {
165
+ findings.push({ class: 'allow-absent', rule: entry.rule });
166
+ }
167
+ }
168
+
169
+ for (const tierKey of ['allow', 'ask', 'deny']) {
170
+ for (const eff of effective[tierKey]) {
171
+ if (ruleBody(eff.rule) === null) {
172
+ const prefix = (eff.rule.split('(')[0] || '?').replace(/[^A-Za-z0-9_-]/g, '') || '?';
173
+ findings.push({ class: 'unclassified', toolPrefix: prefix.slice(0, 32), origin: eff.origin, tierKey });
174
+ }
175
+ }
176
+ }
177
+
178
+ return findings;
179
+ }
@@ -0,0 +1,55 @@
1
+ // preview.mjs — the full preview (AC-BCL-3): every managed path + action, every machine-scope
2
+ // write, and every capability the CLI will declare, before the first byte lands.
3
+ import { renderCapabilityLines } from './permissionFloor.mjs';
4
+
5
+ export function renderPreview({ plan, machineScopeWrites, map }) {
6
+ const lines = [];
7
+ lines.push('The following paths will be written or reconciled under the target root:');
8
+ for (const f of plan) {
9
+ lines.push(` [${f.action}] ${f.relPath}`);
10
+ }
11
+ lines.push('');
12
+ if (machineScopeWrites.length > 0) {
13
+ lines.push('The following machine-scope writes will be made (outside the target root):');
14
+ for (const w of machineScopeWrites) {
15
+ lines.push(` [${w.scope}] ${w.description}`);
16
+ }
17
+ } else {
18
+ lines.push('No machine-scope writes (no --gh-account given).');
19
+ }
20
+ lines.push('');
21
+ lines.push('The following capabilities will be declared (never granted — the workspace trust dialog decides):');
22
+ lines.push(...renderCapabilityLines(map));
23
+ return lines.join('\n');
24
+ }
25
+
26
+ // `isGitRepo` is computed by the CALLER and passed in, so this module stays pure (it renders text;
27
+ // it does not probe the filesystem).
28
+ //
29
+ // WHY THE GIT STEP IS PRINTED AND NOT PERFORMED. A workspace that is not a git repository is a
30
+ // dead end: the factory's whole discipline is branch-per-atom → PR → merge floor, and the setup
31
+ // path tells the reader to apply branch protection a few steps later — which needs a repo AND a
32
+ // remote. This CLI ALREADY writes a `.gitignore`, which means nothing without one.
33
+ //
34
+ // It is still printed rather than run. This CLI's contract is that it writes exactly the files it
35
+ // previewed and nothing else; `git init` would be an un-previewed side effect, and the choice of
36
+ // remote is genuinely the reader's. `create-vite` prints its next steps the same way, for the same
37
+ // reason. Telling keeps the promise; doing would quietly break it.
38
+ export const TRUST_HANDOFF_TEXT = (dir, { isGitRepo = true } = {}) => `
39
+ The workspace has been written. The permission floor above is a declaration, not a grant: the
40
+ platform's trust dialog is the consent ceremony, and the \`allow\` rules take effect only after
41
+ you accept it — the dialog lists them.
42
+
43
+ cd ${dir}${isGitRepo ? '' : `
44
+ git init && git add -A && git commit -m 'workspace seed'
45
+ # ^ this directory is NOT a git repository yet. The factory works through
46
+ # branch-per-atom → PR → the merge floor, so it needs a repo and a remote.
47
+ # Create the remote too (e.g. \`gh repo create <you>/<project>-handbook --private --source=. --push\`)
48
+ # before you reach the branch-protection step.`}
49
+ claude
50
+ > (accept the trust dialog when prompted)
51
+ > /foundry:init
52
+
53
+ Once the session is open, run \`/foundry:doctor\` — it compares this written floor against the
54
+ installed plugin's own copy of the map, the one independent check that did not travel through npm.
55
+ `;
@@ -0,0 +1,77 @@
1
+ // questions.mjs — THE single question table (AC-BCL-2). Every accepted long flag, every --help
2
+ // line and every interactive prompt are derived from this table and from no second list.
3
+ //
4
+ // Record shape: { id, flag, type: 'string'|'boolean', prompt, default? , required?, choices?,
5
+ // interactive } — `default` XOR `required: true` (never both, never neither).
6
+
7
+ export const QUESTION_TABLE = [
8
+ {
9
+ id: 'dir',
10
+ flag: 'dir',
11
+ type: 'string',
12
+ prompt: 'Workspace directory to create',
13
+ required: true,
14
+ interactive: true,
15
+ },
16
+ {
17
+ id: 'existing',
18
+ flag: 'existing',
19
+ type: 'boolean',
20
+ prompt: 'Target directory already exists and is non-empty (scaffold into it; never clobbers)',
21
+ default: false,
22
+ interactive: true,
23
+ },
24
+ {
25
+ id: 'stageMode',
26
+ flag: 'stage-mode',
27
+ type: 'string',
28
+ prompt: 'Stage mode',
29
+ default: 'lean',
30
+ choices: ['lean', 'scale'],
31
+ interactive: true,
32
+ },
33
+ {
34
+ id: 'ghAccount',
35
+ flag: 'gh-account',
36
+ type: 'string',
37
+ prompt: 'GitHub account slug for commit-identity isolation (blank to skip)',
38
+ default: '',
39
+ interactive: true,
40
+ },
41
+ {
42
+ id: 'gitAuthor',
43
+ flag: 'git-author',
44
+ type: 'string',
45
+ prompt: 'Git author "Name <email>" (blank: try gh, then prompt, then fail closed — only used with --gh-account)',
46
+ default: '',
47
+ interactive: true,
48
+ },
49
+ {
50
+ id: 'yes',
51
+ flag: 'yes',
52
+ type: 'boolean',
53
+ prompt: 'Accept every default and the write-phase confirmation without prompting',
54
+ default: false,
55
+ interactive: false,
56
+ },
57
+ {
58
+ id: 'dryRun',
59
+ flag: 'dry-run',
60
+ type: 'boolean',
61
+ prompt: 'Print the preview only; make no write and no side effect',
62
+ default: false,
63
+ interactive: false,
64
+ },
65
+ {
66
+ id: 'help',
67
+ flag: 'help',
68
+ type: 'boolean',
69
+ prompt: 'Show this help and exit',
70
+ default: false,
71
+ interactive: false,
72
+ },
73
+ ];
74
+
75
+ export function tableFlags(table = QUESTION_TABLE) {
76
+ return table.map((r) => r.flag);
77
+ }
@@ -0,0 +1,47 @@
1
+ // reconcile.mjs — the ONE write path: terraform-init reconcile shape, shared by a greenfield
2
+ // create, an --existing scaffold-into, and an idempotent re-run (AC-BCL-8/AC-BCL-9). A managed
3
+ // path that is absent is created; one that exists and matches is left untouched (no write
4
+ // syscall at all — bytes/inode/mtime survive); one that exists and differs is reported `drifted`
5
+ // and NEVER written, on any path, in any mode (never-clobber is unconditional).
6
+ import { ensureDirFor, fileBytesEqual } from './util.mjs';
7
+ import fs from 'node:fs';
8
+
9
+ /** Compute the action for each managed file WITHOUT writing anything. */
10
+ export function planManagedFiles(managedFiles) {
11
+ return managedFiles.map((f) => {
12
+ const { present, equal, notRegular } = fileBytesEqual(f.absPath, f.bytes);
13
+ let action;
14
+ if (!present) action = 'create';
15
+ else if (notRegular) action = 'drifted';
16
+ else action = equal ? 'unchanged' : 'drifted';
17
+ return { ...f, action };
18
+ });
19
+ }
20
+
21
+ /** Apply a plan: writes ONLY the `create` rows. `unchanged`/`drifted` rows are never touched —
22
+ * this is what makes never-clobber unconditional and re-runs byte/inode/mtime-stable. */
23
+ export function applyPlan(plan) {
24
+ for (const f of plan) {
25
+ if (f.action !== 'create') continue;
26
+ ensureDirFor(f.absPath);
27
+ // O_EXCL ('wx'), not a plain write (PR #61 security review Block 2, defence in depth). The
28
+ // plan already refuses to emit `create` for any path that exists — including a dangling
29
+ // symlink, since fileBytesEqual lstats rather than existsSync's. 'wx' makes the WRITE itself
30
+ // refuse a target that came into existence between plan and apply, so the never-clobber
31
+ // guarantee does not rest on the plan/apply window being atomic. A plain write would follow a
32
+ // symlink planted in that window; this one fails with EEXIST instead.
33
+ const fd = fs.openSync(f.absPath, 'wx');
34
+ try {
35
+ fs.writeFileSync(fd, f.bytes);
36
+ } finally {
37
+ fs.closeSync(fd);
38
+ }
39
+ }
40
+ return plan;
41
+ }
42
+
43
+ /** The total exit-code matrix (AC-BCL-8): 0 unless >=1 row is `drifted`, in which case 2. Refusals
44
+ * are a distinct path (exit 1) handled by the caller before any plan is computed. */
45
+ export function exitCodeForPlan(plan) {
46
+ return plan.some((f) => f.action === 'drifted') ? 2 : 0;
47
+ }