create-agentic-workspace 0.17.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agentic-workspace",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "The pre-session bootstrap wizard for an Agentic Foundry workspace: declares (never grants) the permission floor, absorbs foundry-bootstrap.sh's out-of-session identity wiring, and scaffolds a seven-file schema-valid workspace. Zero dependencies, no lifecycle scripts, no telemetry.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "marketplace_name": "agentic-foundry",
35
35
  "marketplace_repo": "lukasrepublic/agentic-foundry",
36
36
  "plugin_name": "foundry",
37
- "plugin_version": "1.17.0",
37
+ "plugin_version": "1.17.1",
38
38
  "pins_researched": "2026-08-02"
39
39
  }
40
40
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "plugin_root_glob": "~/.claude/plugins/cache/*/foundry/*",
4
- "generated_for_plugin_version": "1.17.0",
4
+ "generated_for_plugin_version": "1.17.1",
5
5
  "entries": [
6
6
  {
7
7
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
@@ -14,6 +14,7 @@
14
14
  // never written), atomic writes, and a plan/apply split so `--dry-run` prints the same row.
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
+ import { confinedJoin } from './util.mjs';
17
18
 
18
19
  export const NORMATIVE_CLOSE = '<!-- /normative -->';
19
20
  export const AMENDMENTS_HEADING = '## Amendments';
@@ -22,7 +23,10 @@ export const AMENDMENTS_HEADING = '## Amendments';
22
23
  * "...\n\n" gets the heading directly. Header columns are `foundry-amend.py`'s own row shape. */
23
24
  export const AMENDMENTS_BLOCK = `${AMENDMENTS_HEADING}\n\n| date | what changed | why reality required it | auth_seq |\n|---|---|---|---|\n`;
24
25
 
25
- const SPEC_BASENAME_RE = /^feat-.*\.md$/;
26
+ // ER #223 (v1.17.1): EVERY `*.md` under specs/, not only `feat-*.md` — foundry-amend.py has no
27
+ // filename rule (it classifies by the normative region), and an adopter's delivery atoms are named
28
+ // `spec-*.md`. A README or template without a normative region lands in `skipped`, never written.
29
+ const SPEC_BASENAME_RE = /\.md$/;
26
30
  const CODE_FENCE_RE = /```[\s\S]*?```/g;
27
31
 
28
32
  /** The verdict `foundry-amend.py`'s amendments_section_ok gives: `present` when a `## Amendments`
@@ -42,15 +46,19 @@ export function classifySpec(text) {
42
46
  return 'absent';
43
47
  }
44
48
 
45
- /** Every regular `feat-*.md` under `<root>/specs`, depth-first, with symlinked FILES reported
49
+ /** Every regular `*.md` under `<root>/specs` (any basename — ER #223), depth-first, with symlinked FILES reported
46
50
  * separately (never followed, never written — AC-AMB-2) and symlinked DIRECTORIES not descended
47
51
  * (the same confinement instinct as the scaffold's confinedJoin: nothing outside the workspace
48
52
  * root is ever touched). Absent `specs/` yields an empty walk, not an error. */
49
53
  export function walkSpecs(physicalRoot) {
50
- const specsRoot = path.join(physicalRoot, 'specs');
51
54
  const files = [];
52
55
  const symlinks = [];
53
- if (!fs.existsSync(specsRoot)) return { files, symlinks };
56
+ // v1.17.1 security review Risk 1: the ROOT is confined like every entry under it — a `specs`
57
+ // that is itself a symlink (or resolves outside the workspace) is never descended, never written.
58
+ const specsRoot = confinedJoin(physicalRoot, 'specs');
59
+ if (!specsRoot) return { files, symlinks };
60
+ const rootStat = fs.lstatSync(specsRoot, { throwIfNoEntry: false });
61
+ if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) return { files, symlinks };
54
62
  const stack = [specsRoot];
55
63
  while (stack.length > 0) {
56
64
  const dir = stack.pop();
@@ -120,8 +128,19 @@ export function applyAmendmentsBackfill(plan) {
120
128
  }
121
129
  if (classifySpec(text) !== 'absent') continue;
122
130
  const tmp = `${abs}.amendments-backfill.tmp`;
123
- fs.writeFileSync(tmp, text + appendBytesFor(text), 'utf-8');
124
- fs.renameSync(tmp, abs);
131
+ // v1.17.1 security review Risk 2: `wx` refuses to write through a planted sibling — a symlink
132
+ // or a leftover file at the temp path means this spec is skipped, never written elsewhere.
133
+ try {
134
+ fs.writeFileSync(tmp, text + appendBytesFor(text), { encoding: 'utf-8', flag: 'wx' });
135
+ } catch {
136
+ continue;
137
+ }
138
+ try {
139
+ fs.renameSync(tmp, abs);
140
+ } catch {
141
+ fs.rmSync(tmp, { force: true });
142
+ continue;
143
+ }
125
144
  written += 1;
126
145
  }
127
146
  plan.applied = true;
package/src/update.mjs CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from './floorReconcile.mjs';
17
17
  import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
18
18
  import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
19
- import { buildUpgradeReport, writeUpgradeReport, NEXT_LINE } from './upgradeReport.mjs';
19
+ import { buildUpgradeReport, writeUpgradeReport, installedVersionBefore, NEXT_LINE } from './upgradeReport.mjs';
20
20
  import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows, statuslineChanged } from './statuslineWiring.mjs';
21
21
  import {
22
22
  ALLOWED_CLAUDE_SUBCOMMANDS, resolveClaudeOnPath, runClaude,
@@ -124,6 +124,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
124
124
 
125
125
  const registry = readInstalledPluginsRegistry(configDir);
126
126
  const isInstalled = isInstalledInScopeFactory(registry, pluginKey, cwd);
127
+ // ER #222: the report's `from` is what THIS workspace had installed, read before any mutation.
128
+ const installedBefore = installedVersionBefore(registry, pluginKey, cwd);
127
129
 
128
130
  const migrations = [];
129
131
  const indeterminateInstalledness = [];
@@ -336,7 +338,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
336
338
  // completed run (overwritten — a report, not a managed file; `.foundry/*` is gitignored), and
337
339
  // named in the LAST line so the operator's next step is never a guess.
338
340
  const report = buildUpgradeReport({
339
- beforeEntry, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
341
+ installedBefore, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
340
342
  });
341
343
  const reportPath = writeUpgradeReport(physicalRoot, report);
342
344
  print('');
@@ -11,6 +11,7 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { confinedJoin } from './util.mjs';
14
+ import { scopeRecordsFor } from './pluginRefresh.mjs';
14
15
 
15
16
  export const REPORT_REL = '.foundry/upgrade-report.json';
16
17
  export const REPORT_SCHEMA_VERSION = 1;
@@ -24,19 +25,22 @@ export function versionOrNull(v) {
24
25
  return typeof v === 'string' && v.length <= 64 && VERSION_RE.test(v) ? v : null;
25
26
  }
26
27
 
27
- /** Pure: assemble the report object. `beforeEntry`/`afterEntry` are the marketplace manifest's
28
- * plugin entries around the refresh (either may be null); `filePlan` is the managed-file plan;
28
+ /** Pure: assemble the report object. `installedBefore` is this workspace's installed version from
29
+ * `installedVersionBefore` (or null); `afterEntry` is the marketplace manifest's plugin entry after
30
+ * the refresh (or null); `filePlan` is the managed-file plan;
29
31
  * `amendmentsPlan` is the applied backfill plan (or null); `phases` is what renderSummary got. */
30
32
  export function buildUpgradeReport({
31
- beforeEntry, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
33
+ installedBefore = null, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
32
34
  }) {
33
35
  const seedRow = (filePlan || []).find((f) => f.seed);
34
36
  return {
35
37
  schema_version: REPORT_SCHEMA_VERSION,
36
38
  ran_at: now.toISOString(),
37
- // null when no manifest was readable before the refresh (a first install) — the skill then
38
- // lists only the current version's CHANGELOG section (AC-PUS-1, Out of scope).
39
- from_plugin_version: beforeEntry ? versionOrNull(beforeEntry.version) : null,
39
+ // ER #222 (v1.17.1): THIS workspace's installed version from installed_plugins.json, captured
40
+ // before any mutation — never the marketplace clone's advertised version, which is per machine
41
+ // and already moved by the time Phase 1 reads it. null (no record, unreadable registry, a
42
+ // first install) makes the skill list only the current version's CHANGELOG section.
43
+ from_plugin_version: versionOrNull(installedBefore),
40
44
  to_plugin_version: (afterEntry && versionOrNull(afterEntry.version)) || versionOrNull(toPluginVersion),
41
45
  phases: (phases || []).map((p) => ({ name: p.name, verdict: p.verdict, ...(p.reason ? { reason: p.reason } : {}) })),
42
46
  amendments: amendmentsPlan
@@ -47,6 +51,18 @@ export function buildUpgradeReport({
47
51
  };
48
52
  }
49
53
 
54
+ /** The version THIS workspace had installed before the run (ER #222): the project-scope record
55
+ * whose `projectPath` is `cwd`, else the user-scope record (no `projectPath`), else null. Reads the
56
+ * registry object `readInstalledPluginsRegistry` returns; `ok: false` is null, never a guess. */
57
+ export function installedVersionBefore(registry, pluginKey, cwd) {
58
+ if (!registry || !registry.ok) return null;
59
+ const records = scopeRecordsFor(registry.doc, pluginKey).filter((r) => r && typeof r === 'object');
60
+ const project = records.find((r) => r.projectPath && path.resolve(String(r.projectPath)) === path.resolve(cwd));
61
+ const user = records.find((r) => !r.projectPath);
62
+ const hit = project || user;
63
+ return hit ? versionOrNull(hit.version) : null;
64
+ }
65
+
50
66
  /** Write the report under the workspace root, creating `.foundry/` if needed. Overwrites.
51
67
  * Confined the way every other Phase-4 writer is (PR #218 security review, Risk 4): the target is
52
68
  * joined through `confinedJoin`, a `.foundry` that is a symlink or a leaf that is not a regular