naive-framework 0.2.0 → 0.4.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.
Files changed (78) hide show
  1. package/AGENTS.md +1 -1
  2. package/docs/decisions/decisions.json +15 -0
  3. package/evidence/skills/fidelity-ladder-audit/ladder-census.mjs +2 -1
  4. package/interface/adapters.mjs +7 -6
  5. package/interface/boundaries.mjs +5 -4
  6. package/interface/brand.mjs +6 -5
  7. package/interface/cli/mentura.mjs +4 -3
  8. package/interface/cli/naive.mjs +11 -6
  9. package/interface/context.mjs +7 -6
  10. package/interface/demo/board.mjs +9 -8
  11. package/interface/demo/decision-harness.mjs +4 -3
  12. package/interface/gate-hooks.mjs +2 -1
  13. package/interface/harness/evals.mjs +8 -7
  14. package/interface/harness/run.mjs +14 -13
  15. package/interface/instrumentation.mjs +4 -3
  16. package/interface/layer.json +4 -0
  17. package/interface/package-routes.mjs +4 -3
  18. package/interface/provenance.mjs +5 -4
  19. package/interface/reads.mjs +4 -3
  20. package/interface/release-card.mjs +2 -1
  21. package/interface/router.mjs +10 -9
  22. package/interface/skills/choosing-artifacts/SKILL.md +113 -0
  23. package/interface/vitals.mjs +3 -2
  24. package/knowledge/connectors/catalog.mjs +3 -2
  25. package/knowledge/connectors/filesystem.mjs +10 -5
  26. package/knowledge/connectors/index.mjs +3 -2
  27. package/operations/marketing/check-public.mjs +7 -6
  28. package/operations/marketing/site/plan.mjs +4 -3
  29. package/operations/marketing/site/presence.mjs +4 -3
  30. package/package.json +1 -1
  31. package/platform/integrations/rho.mjs +2 -1
  32. package/purpose/frameworks/idea-map/route.mjs +5 -4
  33. package/purpose/frameworks/product-anatomy/anatomy.mjs +2 -1
  34. package/schema/access.mjs +2 -1
  35. package/schema/add-department.mjs +3 -2
  36. package/schema/assess-problem.mjs +3 -2
  37. package/schema/assets.mjs +6 -5
  38. package/schema/brand.mjs +11 -10
  39. package/schema/caching.mjs +5 -4
  40. package/schema/check-config.mjs +4 -3
  41. package/schema/check-data-model.mjs +2 -1
  42. package/schema/check-data-packages.mjs +6 -5
  43. package/schema/check-execution.mjs +4 -3
  44. package/schema/check-people.mjs +22 -8
  45. package/schema/check-private.mjs +2 -1
  46. package/schema/check-ready.mjs +6 -5
  47. package/schema/check-sources.mjs +2 -1
  48. package/schema/check-where-used.mjs +4 -3
  49. package/schema/decision-analytics.mjs +2 -1
  50. package/schema/decisions.mjs +14 -11
  51. package/schema/foundation.mjs +8 -7
  52. package/schema/founder.schema.json +2 -2
  53. package/schema/frames.mjs +4 -3
  54. package/schema/freshness.mjs +13 -12
  55. package/schema/gate-drafts.mjs +8 -7
  56. package/schema/init-venture.mjs +48 -20
  57. package/schema/migrate-venture.mjs +8 -7
  58. package/schema/migrations/kit-to-package.mjs +4 -3
  59. package/schema/migrations/package-layout.mjs +9 -5
  60. package/schema/model-steps.mjs +8 -7
  61. package/schema/naive-config.mjs +4 -3
  62. package/schema/need-now.mjs +5 -4
  63. package/schema/packages.mjs +11 -10
  64. package/schema/payouts.mjs +3 -2
  65. package/schema/people.schema.json +2 -3
  66. package/schema/port-venture.mjs +206 -132
  67. package/schema/release-read.mjs +5 -4
  68. package/schema/releases.mjs +21 -20
  69. package/schema/run-handoff.mjs +5 -4
  70. package/schema/setup.mjs +5 -4
  71. package/schema/stale.mjs +6 -5
  72. package/schema/structure.mjs +5 -4
  73. package/schema/time-to-ready.mjs +7 -6
  74. package/schema/triggers.mjs +7 -6
  75. package/schema/upgrades.mjs +31 -30
  76. package/schema/venture-paths.mjs +97 -0
  77. package/schema/venture.schema.json +25 -17
  78. package/schema/workspace.schema.json +5 -1
@@ -1,161 +1,235 @@
1
1
  #!/usr/bin/env node
2
- // naive port: brings an existing project into a new venture by keeping its files as they are (EE-076).
3
- // Each file is placed, unchanged, in the package directory its name or folder points to; a code
4
- // repository or anything that should stay where it is becomes a pointer in knowledge/sources.json;
5
- // what fits nowhere is left behind, by name, so nothing disappears silently. Migrate (rewriting a
6
- // project into the Naive convention) is the other way in, and is not this command.
2
+ // naive port: moves an existing folder into a venture, as it is (EE-076, revised by Torian on
3
+ // 2026-10-29: port moves, it no longer copies files into packages). The folder is moved whole, with a
4
+ // rename, so not one file in it is opened or changed; the venture names it in sources.json as a source
5
+ // of truth (a read-only filesystem source) and logs the move in provenance.jsonl with the path it came
6
+ // from. Migrate (rewriting a project into the Naive convention) is the other way in.
7
7
  //
8
- // node schema/port-venture.mjs --from <project> --venture <new venture dir>
9
- // writes the plan to <venture>/.release/port-plan.json and prints it; changes nothing else
10
- // node schema/port-venture.mjs --venture <dir> --apply --confirm port
11
- // carries out the plan as it now reads; the founder may first set any item's action to
12
- // port, point or leave
8
+ // node schema/port-venture.mjs <folder> --venture <dir> [--to <path>] [--links <dir> ...]
9
+ // adds the move to the plan at <venture>/.release/port-plan.json and prints it; moves nothing.
10
+ // The folder goes to <venture>/codebase/<its name> unless --to says where (relative to the venture
11
+ // or absolute). Run once per folder: each run adds or replaces that folder's item.
12
+ // node schema/port-venture.mjs --venture <dir> --apply --confirm port [--even-if-busy]
13
+ // carries out every planned move the founder left as move; any item can be set to leave first
13
14
  //
14
- // The project is only ever read. A port never overwrites a file in the venture, refuses a plan
15
- // whose source files changed since it was written, and records every copy in provenance.jsonl
16
- // with the source path it was derived from.
15
+ // The plan says what a move would break: worktrees of a git repository (they point at its old path
16
+ // until `git worktree repair` runs from the new one), symbolic links that point into the folder, and
17
+ // processes working inside it, which refuse the apply unless --even-if-busy. A port never overwrites,
18
+ // never copies (a move across disks is refused, not copied), and refuses an item whose folder changed
19
+ // identity since the plan. Every run writes a trace to .release/port-trace.json, in the shape
20
+ // platform/integrations/langsmith.mjs uploads.
17
21
 
22
+ import { spawnSync } from "node:child_process";
18
23
  import { createHash } from "node:crypto";
19
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
20
- import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
21
- import { record } from "../interface/provenance.mjs";
22
- import { SECRET } from "./migrate-venture.mjs";
24
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
25
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
26
+ import { agentFromEnv, record } from "../interface/provenance.mjs";
27
+ import { CODEBASE, ventureFile } from "./venture-paths.mjs";
23
28
 
24
29
  export const PLAN = ".release/port-plan.json";
25
- const SKIP = new Set([".git", "node_modules", ".next", ".turbo", "dist", "build", ".venv", "__pycache__", ".DS_Store"]);
26
- const REPO_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", "Gemfile", "pom.xml"];
27
- const CODE = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".h", ".cpp", ".cs", ".php", ".sh", ".sql", ".css", ".scss", ".html", ".lock", ".yml", ".yaml", ".toml"]);
28
- const MAX_BYTES = 5 * 1024 * 1024;
30
+ export const TRACE = ".release/port-trace.json";
31
+ const MAP = "knowledge/sources.json";
32
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+|-$/g, "") || "folder";
33
+ const inside = (a, b) => a === b || a.startsWith(b + sep);
34
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
29
35
 
30
- // The first rule whose pattern matches wins: the file name is tried before its folders, so a file
31
- // says more about itself than the folder it sits in. Patterns match words in lowercase paths.
32
- const RULES = [
33
- [/\b(problem|pain[- ]?points?)\b/, "foundation", "states the problem"],
34
- [/\b(icp|personas?|ideal[- ]customer|customer[- ]profile|segments?)\b/, "foundation", "describes the customer"],
35
- [/\b(vision|mission|tagline|values|manifesto|one[- ]pager|strategy)\b/, "foundation", "states what the venture is for"],
36
- [/\b(interviews?|surveys?|user[- ]research|customer[- ]research|discovery|experiments?|hypothes[ie]s|validation)\b/, "validation", "is evidence about the problem or the solution"],
37
- [/\b(investors?|pitch|deck|fundrais\w*|safe|term[- ]sheet|cap[- ]table|data[- ]room)\b/, "investors", "is for or about investors"],
38
- [/\b(pricing|prices?|revenue|budget|forecast|runway|financ\w*|p&l|burn|unit[- ]economics|model)\b/, "finance", "is about money the venture plans or earns"],
39
- [/\b(brand\w*|marketing|copy|landing|seo|social|launch|blog|newsletter|campaigns?|content|go[- ]to[- ]market|gtm)\b/, "marketing", "is marketing or sales"],
40
- [/\b(roadmap|specs?|prd|requirements|features?|product|designs?|ux|ui|wireframes?|mockups?|backlog)\b/, "product", "describes the product"],
41
- [/\b(team|hiring|roles?|org[- ]chart|founders?|equity|vesting|people|onboarding|handbook|culture)\b/, "people", "is about the people"],
42
- [/\b(customers?|clients?|accounts|crm|leads|pipeline)\b/, "customers", "is about customers"],
43
- [/\b(decisions?|adrs?|decision[- ]log)\b/, "decisions", "records decisions"],
44
- [/\b(legal|contracts?|agreements?|incorporation|bylaws|operating[- ]agreement|nda|terms|privacy|trademark|ip)\b/, "_private/legal", "is a legal document, kept private so it never reaches a model"],
45
- ];
46
- const BANK = /\b(bank|transactions?|ledger|payroll|tax(es)?)\b/;
36
+ // Files and bytes under a folder, without following links, to show the size of what moves, and a
37
+ // manifest hash (each path with its size, never its contents) for the provenance entry.
38
+ function measure(root) {
39
+ let files = 0, bytes = 0;
40
+ const lines = [];
41
+ const stack = [root];
42
+ while (stack.length) {
43
+ const d = stack.pop();
44
+ let list = [];
45
+ try { list = readdirSync(d, { withFileTypes: true }); } catch { continue; }
46
+ for (const e of list) {
47
+ const p = join(d, e.name);
48
+ if (e.isDirectory()) stack.push(p);
49
+ else { files++; let n = 0; try { n = lstatSync(p).size; } catch {} bytes += n; lines.push(`${relative(root, p)} ${n}`); }
50
+ }
51
+ }
52
+ return { files, bytes, manifest: createHash("sha256").update(lines.sort().join("\n")).digest("hex") };
53
+ }
47
54
 
48
- const words = (s) => s.toLowerCase().replace(/[_.]+/g, " ");
49
- const sha = (f) => createHash("sha256").update(readFileSync(f)).digest("hex");
50
- const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project";
55
+ // Symbolic links under `roots` (to `depth` levels, skipping node_modules) whose target is inside `src`.
56
+ // A link inside `src` that points inside it with a relative path keeps working, so only absolute ones count.
57
+ export function linksInto(src, roots, depth = 4) {
58
+ const out = [];
59
+ const seen = new Set();
60
+ const visit = (d, n) => {
61
+ if (n > depth || seen.has(d)) return;
62
+ seen.add(d);
63
+ let list = [];
64
+ try { list = readdirSync(d, { withFileTypes: true }); } catch { return; }
65
+ for (const e of list) {
66
+ if (e.name === "node_modules" || e.name === ".git") continue;
67
+ const p = join(d, e.name);
68
+ if (e.isSymbolicLink()) {
69
+ const t = readlinkSync(p);
70
+ const target = resolve(dirname(p), t);
71
+ if (inside(target, src) && !(inside(p, src) && !isAbsolute(t))) out.push({ link: p, target: t });
72
+ } else if (e.isDirectory()) visit(p, n + 1);
73
+ }
74
+ };
75
+ for (const r of roots) visit(resolve(r), 0);
76
+ return out;
77
+ }
51
78
 
52
- /** Where one file goes, or why it stays behind. `rel` is relative to the project root. */
53
- export function place(rel, bytes) {
54
- const name = basename(rel, extname(rel));
55
- const ext = extname(rel).toLowerCase();
56
- if (bytes > MAX_BYTES) return { action: "leave", reason: `larger than ${MAX_BYTES / 1024 / 1024} MB; point to it instead` };
57
- if (CODE.has(ext)) return { action: "leave", reason: "code or configuration: it stays in its repository" };
58
- const folders = dirname(rel) === "." ? [] : dirname(rel).split(sep).reverse();
59
- for (const [where, confidence] of [[name, "high"], ...folders.map((f) => [f, "low"])]) {
60
- if (BANK.test(words(where))) return { action: "leave", reason: "bank and payroll data come from the bank connection, not from files" };
61
- const hit = RULES.find(([re]) => re.test(words(where)));
62
- if (hit) return { action: "port", to: `${hit[1]}/${basename(rel)}`, function: hit[1].split("/").at(-1), confidence, reason: `${confidence === "high" ? "its name" : `its folder ${where}`} ${hit[2]}` };
63
- }
64
- if (/^readme$/i.test(name)) return { action: "leave", reason: "the venture has its own AGENTS.md and profile.md; read this to write profile.md" };
65
- return { action: "leave", reason: "nothing in its name or folders matches a package; place it by hand or migrate it" };
79
+ // The git facts a move touches: whether the folder is a repository, its other worktrees, and how
80
+ // many changes are uncommitted (the move keeps them; it is only said).
81
+ function git(src) {
82
+ const g = join(src, ".git");
83
+ if (!existsSync(g)) return null;
84
+ if (statSync(g).isFile()) return { linked: true, worktrees: [], uncommitted: null };
85
+ const run = (...a) => spawnSync("git", ["-C", src, ...a], { encoding: "utf8" });
86
+ const wt = (run("worktree", "list", "--porcelain").stdout ?? "").split("\n").filter((l) => l.startsWith("worktree ")).map((l) => l.slice(9)).filter((p) => real(p) !== real(src));
87
+ const st = run("status", "--porcelain");
88
+ return { linked: false, worktrees: wt, uncommitted: st.status === 0 ? st.stdout.split("\n").filter(Boolean).length : null };
66
89
  }
67
90
 
68
- function walk(root, rel = "", out = { files: [], repos: [], secrets: [] }) {
69
- for (const e of readdirSync(join(root, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
70
- if (SKIP.has(e.name) || e.isSymbolicLink()) continue;
71
- const r = rel ? join(rel, e.name) : e.name;
72
- if (e.isDirectory()) {
73
- if (REPO_MARKERS.some((m) => existsSync(join(root, r, m)))) out.repos.push(r);
74
- else walk(root, r, out);
75
- } else if (e.isFile() && SECRET.test(r)) out.secrets.push(r);
76
- else if (e.isFile() && !e.name.startsWith(".")) out.files.push(r);
91
+ // Processes whose working directory is inside the folder (macOS and Linux lsof).
92
+ export function busy(src) {
93
+ const r = spawnSync("lsof", ["-d", "cwd", "-Fpcn"], { encoding: "utf8" });
94
+ if (r.error || !r.stdout) return [];
95
+ const out = [];
96
+ let p = null, c = null;
97
+ for (const l of r.stdout.split("\n")) {
98
+ if (l[0] === "p") p = l.slice(1);
99
+ else if (l[0] === "c") c = l.slice(1);
100
+ else if (l[0] === "n" && inside(l.slice(1), real(src)) && p !== String(process.pid)) out.push({ pid: Number(p), command: c, cwd: l.slice(1) });
77
101
  }
78
102
  return out;
79
103
  }
80
104
 
81
- /** The plan for porting `from` into the venture at `dir`. Reads both, writes neither. */
82
- export function plan(from, dir) {
83
- const src = resolve(from);
84
- if (!existsSync(src) || !statSync(src).isDirectory()) throw new Error(`${from} is not a directory`);
105
+ const readPlan = (dir) => existsSync(ventureFile(dir, PLAN)) ? JSON.parse(readFileSync(ventureFile(dir, PLAN), "utf8")) : null;
106
+ const readMap = (dir) => existsSync(ventureFile(dir, MAP)) ? JSON.parse(readFileSync(ventureFile(dir, MAP), "utf8")) : { sources: [], records: {} };
107
+
108
+ /** The planned move of `folder` into the venture at `dir`. Reads both, writes neither. */
109
+ export function planOne(folder, dir, { to, links = [] } = {}) {
110
+ if (!existsSync(folder) || !lstatSync(folder).isDirectory()) throw new Error(`${folder} is not a directory`);
85
111
  if (!existsSync(join(dir, "venture.json"))) throw new Error(`${dir} is not a venture: create one first with naive init`);
86
- if (resolve(dir).startsWith(src + sep) || resolve(dir) === src) throw new Error("the venture cannot sit inside the project it ports from");
87
- const { files, repos, secrets } = walk(src);
88
- const items = secrets.map((f) => ({ from: f, action: "leave", reason: "looks like a secret, so it was never opened" }));
89
- // The project itself is a repository when it carries a marker at its root: point to it, and still
90
- // port its documents.
91
- for (const r of REPO_MARKERS.some((m) => existsSync(join(src, m))) ? ["", ...repos] : repos)
92
- items.push({ from: r || ".", action: "point", source: slug(r ? basename(r) : basename(src)), reason: "a code repository stays where it is; the venture points to it" });
93
- const taken = new Set();
94
- for (const f of files) {
95
- const p = place(f, statSync(join(src, f)).size);
96
- if (p.action === "port") {
97
- // Two files with one name in one package: the second keeps its folder name in front.
98
- if (taken.has(p.to)) p.to = `${dirname(p.to)}/${slug(basename(dirname(f)))}-${basename(f)}`;
99
- if (taken.has(p.to) || existsSync(join(dir, p.to))) Object.assign(p, { action: "leave", reason: `${p.to} already exists in the venture; a port never overwrites` });
100
- else taken.add(p.to);
101
- }
102
- items.push({ from: f, sha256: sha(join(src, f)), ...p });
103
- }
104
- const count = (a) => items.filter((i) => i.action === a).length;
105
- return { from: src, venture: JSON.parse(readFileSync(join(dir, "venture.json"), "utf8")).id, at: new Date().toISOString(),
106
- summary: { port: count("port"), point: count("point"), leave: count("leave") }, items };
112
+ const src = real(folder);
113
+ const v = real(dir);
114
+ if (inside(v, src)) throw new Error("the venture cannot sit inside the folder it ports");
115
+ const dest = resolve(v, to ?? join(CODEBASE, basename(src)));
116
+ const s = statSync(src);
117
+ const item = { action: "move", from: src, to: dest, source: slug(basename(src)), identity: { dev: s.dev, ino: s.ino }, ...measure(src) };
118
+ const warnings = [];
119
+ const leave = (why) => Object.assign(item, { action: "leave", reason: why });
120
+ if (existsSync(dest)) leave(`${dest} already exists; a port never overwrites`);
121
+ else if (inside(dest, src)) leave("the destination is inside the folder itself");
122
+ else if (!inside(dest, v)) warnings.push("the destination is outside the venture folder; sources.json will name it by absolute path");
123
+ // A rename only works on one disk: the nearest existing parent of the destination must share it.
124
+ let up = dirname(dest);
125
+ while (!existsSync(up)) up = dirname(up);
126
+ if (item.action === "move" && statSync(up).dev !== s.dev) leave("the destination is on another disk; a port moves, it never copies");
127
+ if (item.action === "move" && readMap(dir).sources.some((x) => x.id === item.source)) leave(`sources.json already has a source ${item.source}`);
128
+ const g = git(src);
129
+ item.git = g;
130
+ if (g?.linked) warnings.push("this folder is a linked git worktree; its main repository will point at the old path until `git worktree repair` runs there");
131
+ if (g?.worktrees.length) warnings.push(`${g.worktrees.length} worktree${g.worktrees.length === 1 ? "" : "s"} of this repository point at its old path until \`git -C ${dest} worktree repair\` runs: ${g.worktrees.join(", ")}`);
132
+ if (g?.uncommitted) warnings.push(`${g.uncommitted} uncommitted change${g.uncommitted === 1 ? "" : "s"} move with it, unchanged`);
133
+ item.links = linksInto(src, [dirname(src), src, ...links]);
134
+ if (item.links.length) warnings.push(`${item.links.length} symbolic link${item.links.length === 1 ? "" : "s"} point into it and will break: ${item.links.map((x) => x.link).join(", ")}`);
135
+ item.busy = busy(src);
136
+ if (item.busy.length) warnings.push(`${item.busy.length} process${item.busy.length === 1 ? " is" : "es are"} working inside it (${[...new Set(item.busy.map((x) => x.command))].join(", ")}); apply refuses unless --even-if-busy`);
137
+ item.warnings = warnings;
138
+ return item;
107
139
  }
108
140
 
109
- /** Carries out a plan as the founder left it. Returns what was done. */
110
- export function apply(dir, p, agent = { kind: "person", via: "cli" }) {
111
- const src = p.from;
112
- for (const i of p.items.filter((x) => x.action === "port")) {
113
- if (!existsSync(join(src, i.from)) || sha(join(src, i.from)) !== i.sha256) throw new Error(`${i.from} changed since the plan was written; write the plan again`);
114
- if (existsSync(join(dir, i.to))) throw new Error(`${i.to} already exists in the venture; a port never overwrites`);
141
+ /** Adds (or replaces) one folder's item in the venture's plan and writes it. */
142
+ export function plan(folder, dir, opts = {}) {
143
+ const item = planOne(folder, dir, opts);
144
+ const p = readPlan(dir) ?? { venture: JSON.parse(readFileSync(join(dir, "venture.json"), "utf8")).id, items: [] };
145
+ const others = p.items.filter((i) => i.from !== item.from && i.action === "move" && !i.done);
146
+ if (others.some((i) => i.source === item.source)) Object.assign(item, { action: "leave", reason: `another planned move already uses the source id ${item.source}` });
147
+ if (others.some((i) => i.to === item.to)) Object.assign(item, { action: "leave", reason: `another planned move already goes to ${item.to}` });
148
+ p.items = [...p.items.filter((i) => i.from !== item.from || i.done), item];
149
+ p.at = new Date().toISOString();
150
+ mkdirSync(dirname(ventureFile(dir, PLAN)), { recursive: true });
151
+ writeFileSync(ventureFile(dir, PLAN), JSON.stringify(p, null, 2) + "\n");
152
+ return { plan: p, item };
153
+ }
154
+
155
+ /** Carries out the plan as the founder left it. Checks every item before moving any. */
156
+ export function apply(dir, p, { agent = agentFromEnv("person", "cli"), evenIfBusy = false } = {}) {
157
+ const moves = p.items.filter((i) => i.action === "move" && !i.done);
158
+ const ids = new Set(readMap(dir).sources.map((s) => s.id));
159
+ for (const i of moves) {
160
+ if (!existsSync(i.from)) throw new Error(`${i.from} is gone; write the plan again`);
161
+ const s = statSync(i.from);
162
+ if (s.dev !== i.identity.dev || s.ino !== i.identity.ino) throw new Error(`${i.from} is not the folder the plan was written for; write the plan again`);
163
+ if (existsSync(i.to)) throw new Error(`${i.to} already exists; a port never overwrites`);
164
+ if (ids.has(i.source)) throw new Error(`sources.json already has a source ${i.source}`);
165
+ const b = busy(i.from);
166
+ if (b.length && !evenIfBusy) throw new Error(`${b.length} process${b.length === 1 ? " is" : "es are"} working inside ${i.from} (${b.map((x) => `${x.command} ${x.pid}`).join(", ")}); stop them or add --even-if-busy`);
115
167
  }
116
- const done = { ported: [], pointed: [] };
117
- for (const i of p.items.filter((x) => x.action === "port")) {
118
- mkdirSync(dirname(join(dir, i.to)), { recursive: true });
119
- copyFileSync(join(src, i.from), join(dir, i.to));
120
- record(dir, { entity: i.to, activity: "port", agent, derived_from: [`file://${join(src, i.from)}`], note: `ported unchanged: ${i.reason}` });
121
- done.ported.push(i.to);
122
- }
123
- const points = p.items.filter((x) => x.action === "point");
124
- if (points.length) {
125
- const f = join(dir, "knowledge/sources.json");
126
- const map = existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : { sources: [], records: {} };
127
- for (const i of points) {
128
- if (map.sources.some((s) => s.id === i.source)) continue;
129
- map.sources.push({ id: i.source, connector: "filesystem", writes: "none", root: join(src, i.from === "." ? "" : i.from), note: `ported as a pointer: ${i.reason}` });
130
- done.pointed.push(i.source);
131
- }
132
- mkdirSync(dirname(f), { recursive: true });
133
- writeFileSync(f, JSON.stringify(map, null, 2) + "\n");
134
- if (done.pointed.length) record(dir, { entity: "knowledge/sources.json", activity: "port", agent, note: `pointers to ${done.pointed.join(", ")}` });
168
+ const done = [];
169
+ for (const i of moves) {
170
+ mkdirSync(dirname(i.to), { recursive: true });
171
+ renameSync(i.from, i.to);
172
+ const map = readMap(dir);
173
+ const root = inside(i.to, real(dir)) ? relative(real(dir), i.to) : i.to;
174
+ map.sources.push({ id: i.source, connector: "filesystem", writes: "none", root, note: `source of truth, moved here unchanged from ${i.from} by naive port` });
175
+ mkdirSync(dirname(ventureFile(dir, MAP)), { recursive: true });
176
+ writeFileSync(ventureFile(dir, MAP), JSON.stringify(map, null, 2) + "\n");
177
+ record(dir, { entity: root, activity: "port", agent, derived_from: [`file://${i.from}`], sha256: measure(i.to).manifest, note: `moved unchanged (${i.files} files); named in sources.json as source ${i.source}` });
178
+ i.done = new Date().toISOString();
179
+ done.push(i);
180
+ writeFileSync(ventureFile(dir, PLAN), JSON.stringify(p, null, 2) + "\n");
135
181
  }
136
182
  return done;
137
183
  }
138
184
 
185
+ // One run as a trace for platform/integrations/langsmith.mjs: the run, and a child per item.
186
+ function trace(dir, name, inputs, start, items, error) {
187
+ const end = new Date().toISOString();
188
+ const t = { name, run_type: "chain", start, end, inputs, outputs: { items: items.map(({ from, to, action, done }) => ({ from, to, action, done })) }, ...(error ? { error } : {}),
189
+ tags: ["naive", "port"], children: items.map((i) => ({ name: `${i.action} ${basename(i.from)}`, run_type: "tool", start, end, inputs: { from: i.from, to: i.to },
190
+ outputs: { action: i.action, reason: i.reason, files: i.files, bytes: i.bytes, warnings: i.warnings, done: i.done } })) };
191
+ try { mkdirSync(dirname(ventureFile(dir, TRACE)), { recursive: true }); writeFileSync(ventureFile(dir, TRACE), JSON.stringify(t, null, 2) + "\n"); } catch { return; }
192
+ // The harness's way (interface/harness/run.mjs): with a key set, the platform exporter ships the trace.
193
+ if (process.env.LANGSMITH_API_KEY) {
194
+ const p = spawnSync("node", [join(dirname(new URL(import.meta.url).pathname), "../platform/integrations/langsmith.mjs"), ventureFile(dir, TRACE), "--project", process.env.LANGSMITH_PROJECT ?? "naive-port"], { encoding: "utf8" });
195
+ console.log(p.status === 0 ? `LangSmith: ${p.stdout.trim()}` : `LangSmith upload failed: ${(p.stderr || p.stdout).trim()}`);
196
+ }
197
+ }
198
+
199
+ const mb = (b) => `${(b / 1024 / 1024).toFixed(1)} MB`;
200
+
139
201
  if (import.meta.url === `file://${process.argv[1]}`) {
140
202
  const args = process.argv.slice(2);
141
203
  const opt = (k) => { const i = args.indexOf(`--${k}`); return i < 0 ? undefined : args[i + 1]; };
204
+ const all = (k) => args.flatMap((a, i) => a === `--${k}` ? [args[i + 1]] : []);
205
+ const VALUED = new Set(["--venture", "--to", "--links", "--confirm", "--from"]);
206
+ const folder = opt("from") ?? args.find((a, i) => !a.startsWith("--") && !VALUED.has(args[i - 1]));
142
207
  const dir = opt("venture");
143
- if (!dir) { console.error("usage: node schema/port-venture.mjs --from <project> --venture <dir> | --venture <dir> --apply --confirm port"); process.exit(2); }
144
- try {
145
- if (args.includes("--apply")) {
146
- if (opt("confirm") !== "port") { console.error("type --confirm port to carry out the plan"); process.exit(1); }
147
- if (!existsSync(join(dir, PLAN))) { console.error(`no plan at ${join(dir, PLAN)}: run with --from first`); process.exit(1); }
148
- const d = apply(dir, JSON.parse(readFileSync(join(dir, PLAN), "utf8")));
149
- console.log(`ported ${d.ported.length} files unchanged; pointed to ${d.pointed.length} source${d.pointed.length === 1 ? "" : "s"}`);
150
- } else {
151
- const from = opt("from");
152
- if (!from) { console.error("--from <project> is needed to write a plan"); process.exit(2); }
153
- const p = plan(from, dir);
154
- mkdirSync(join(dir, ".release"), { recursive: true });
155
- writeFileSync(join(dir, PLAN), JSON.stringify(p, null, 2) + "\n");
156
- for (const i of p.items) console.log(`${i.action.padEnd(5)} ${i.from}${i.to ? ` -> ${i.to}` : i.source ? ` -> source ${i.source}` : ""} (${i.reason})`);
157
- console.log(`\n${p.summary.port} to port, ${p.summary.point} to point to, ${p.summary.leave} left behind. Nothing has changed yet.`);
158
- console.log(`Read ${relative(process.cwd(), join(dir, PLAN))}, set any item's action to port, point or leave, then run with --apply --confirm port.`);
159
- }
160
- } catch (e) { console.error(e.message); process.exit(1); }
208
+ if (!dir) { console.error("usage: node schema/port-venture.mjs <folder> --venture <dir> [--to <path>] | --venture <dir> --apply --confirm port"); process.exit(2); }
209
+ const start = new Date().toISOString();
210
+ if (args.includes("--apply")) {
211
+ if (opt("confirm") !== "port") { console.error("type --confirm port to carry out the plan"); process.exit(1); }
212
+ const p = readPlan(dir);
213
+ if (!p) { console.error(`no plan at ${ventureFile(dir, PLAN)}: run naive port <folder> first`); process.exit(1); }
214
+ try {
215
+ const d = apply(dir, p, { evenIfBusy: args.includes("--even-if-busy") });
216
+ trace(dir, "naive port apply", { venture: dir }, start, d);
217
+ for (const i of d) console.log(`moved ${i.from} -> ${i.to} (${i.files} files, unchanged; source ${i.source})`);
218
+ for (const i of d.filter((x) => x.git?.worktrees.length)) console.log(`\nrepair its worktrees: git -C ${i.to} worktree repair`);
219
+ console.log(`\nmoved ${d.length} folder${d.length === 1 ? "" : "s"}. Trace: ${relative(process.cwd(), ventureFile(dir, TRACE))}`);
220
+ } catch (e) { trace(dir, "naive port apply", { venture: dir }, start, p.items, e.message); console.error(e.message); process.exit(1); }
221
+ } else {
222
+ if (!folder) { console.error("name the folder to port: naive port <folder>"); process.exit(2); }
223
+ try {
224
+ const { plan: p, item } = plan(folder, dir, { to: opt("to"), links: all("links") });
225
+ trace(dir, "naive port plan", { folder, venture: dir, to: opt("to") }, start, [item]);
226
+ for (const i of p.items) {
227
+ console.log(`${(i.done ? "moved" : i.action).padEnd(5)} ${i.from} -> ${i.to} (${i.files} files, ${mb(i.bytes)}${i.reason ? `; ${i.reason}` : ""})`);
228
+ if (!i.done) for (const w of i.warnings ?? []) console.log(` ! ${w}`);
229
+ }
230
+ const n = p.items.filter((i) => i.action === "move" && !i.done).length;
231
+ console.log(`\n${n} folder${n === 1 ? "" : "s"} to move, unchanged. Nothing has moved yet.`);
232
+ console.log(`Read ${relative(process.cwd(), ventureFile(dir, PLAN))}, set any item's action to leave, then run with --apply --confirm port.`);
233
+ } catch (e) { trace(dir, "naive port plan", { folder, venture: dir }, start, [], e.message); console.error(e.message); process.exit(1); }
234
+ }
161
235
  }
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { existsSync, readFileSync } from "node:fs";
18
18
  import { join, normalize } from "node:path";
19
+ import { ventureFile } from "./venture-paths.mjs";
19
20
 
20
21
  export const RELEASE_DIR = ".release";
21
22
  export const MANIFEST = ".release/release.json";
@@ -24,7 +25,7 @@ export const RECORDS = ".release/records";
24
25
 
25
26
  /** The release manifest of a venture, or null when it has none. */
26
27
  export function readRelease(dir) {
27
- const f = join(dir, MANIFEST);
28
+ const f = ventureFile(dir, MANIFEST);
28
29
  return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
29
30
  }
30
31
 
@@ -32,7 +33,7 @@ export function readRelease(dir) {
32
33
  export function frozenText(dir, file) {
33
34
  const rel = normalize(file);
34
35
  if (rel.startsWith("..") || rel.startsWith("/")) return null;
35
- const f = join(dir, RECORDS, rel);
36
+ const f = join(ventureFile(dir, RECORDS), rel);
36
37
  return existsSync(f) ? readFileSync(f, "utf8") : null;
37
38
  }
38
39
 
@@ -94,10 +95,10 @@ export function answer(dir, method, path, { draw } = {}) {
94
95
  if (p.split("/").includes("..")) return json(400, { error: "no .. in a release path" });
95
96
  if (p === "/" || p === "") return json(200, index(m));
96
97
  if (p === "/release.json") return json(200, m);
97
- if (p === "/card.json") return existsSync(join(dir, CARD)) ? json(200, JSON.parse(readFileSync(join(dir, CARD), "utf8"))) : json(404, { error: "this release has no card" });
98
+ if (p === "/card.json") return existsSync(ventureFile(dir, CARD)) ? json(200, JSON.parse(readFileSync(ventureFile(dir, CARD), "utf8"))) : json(404, { error: "this release has no card" });
98
99
  if (p === "/card.svg") {
99
100
  if (!draw) return json(404, { error: "no card drawing here" });
100
- return { status: 200, type: "image/svg+xml; charset=utf-8", body: draw(JSON.parse(readFileSync(join(dir, CARD), "utf8"))) };
101
+ return { status: 200, type: "image/svg+xml; charset=utf-8", body: draw(JSON.parse(readFileSync(ventureFile(dir, CARD), "utf8"))) };
101
102
  }
102
103
  if (p.startsWith("/records/")) {
103
104
  const file = p.slice("/records/".length);
@@ -43,6 +43,7 @@ import { attributeSources, connect } from "../knowledge/connectors/index.mjs";
43
43
  import { FRAME, confirmedIn } from "./foundation.mjs";
44
44
  import { HOME, packages } from "./packages.mjs";
45
45
  import { CARD, MANIFEST, RECORDS, RELEASE_DIR, frozenText, readRelease } from "./release-read.mjs";
46
+ import { ventureFile } from "./venture-paths.mjs";
46
47
 
47
48
  const repo = new URL("..", import.meta.url).pathname;
48
49
  const load = (p) => JSON.parse(readFileSync(p, "utf8"));
@@ -68,7 +69,7 @@ export function key() {
68
69
 
69
70
  /** The local cache for a key: { entries, hits, misses, fresh, get(k, f), save() }. A different key starts it fresh. */
70
71
  export function openCache(dir, k = key()) {
71
- const f = join(dir, CACHE);
72
+ const f = ventureFile(dir, CACHE);
72
73
  const kh = sha(JSON.stringify(k)).slice(0, 16);
73
74
  let old = null;
74
75
  try { old = existsSync(f) ? load(f) : null; } catch { old = null; }
@@ -87,7 +88,7 @@ export function openCache(dir, k = key()) {
87
88
 
88
89
  /** Every file of a venture, relative: skips dot folders, nested ventures and _-folders other than _streams. */
89
90
  export function ventureFiles(dir, sub = "") {
90
- return readdirSync(join(dir, sub), { withFileTypes: true }).flatMap((e) => {
91
+ return readdirSync(ventureFile(dir, sub), { withFileTypes: true }).flatMap((e) => {
91
92
  const rel = sub ? `${sub}/${e.name}` : e.name;
92
93
  if (e.name.startsWith(".") || e.name === "node_modules") return [];
93
94
  if (e.isDirectory()) return existsSync(join(dir, rel, "venture.json")) || (e.name.startsWith("_") && e.name !== "_streams") ? [] : ventureFiles(dir, rel);
@@ -121,7 +122,7 @@ export function confirmation(dir, file, type, log) {
121
122
  const es = log.filter((e) => e.entity === file);
122
123
  if (!es.length) return { ok: false, why: "no provenance entry: written outside the router", fix: "baseline" };
123
124
  const last = es.at(-1);
124
- const now = sha(readFileSync(join(dir, file)));
125
+ const now = sha(readFileSync(ventureFile(dir, file)));
125
126
  if (last.sha256 && last.sha256 !== now) return { ok: false, why: "changed since its last provenance entry", fix: "rewrite" };
126
127
  const statements = FRAME.questions.flatMap((q) => q.statements).filter((s) => s.record === type && s.id);
127
128
  const ok = confirmedIn(dir, file, null) || (statements.length > 0 && statements.every((s) => confirmedIn(dir, file, s.id)));
@@ -195,7 +196,7 @@ export async function survey(dir, { cache } = {}) {
195
196
  const stream = file.match(/^(.*\/_streams\/[^/]+)\//);
196
197
  if (stream) { if (!streams.has(stream[1])) streams.set(stream[1], file); continue; }
197
198
  if (file.endsWith(".decision.json") && file.split("/").at(-2) === "decisions") {
198
- const d = load(join(dir, file));
199
+ const d = load(ventureFile(dir, file));
199
200
  const ro = routeOf(file, ms);
200
201
  if (d.status === "decided") frozen.push({ file, record: "decision-record", route: ro?.route ?? `decisions/${d.id}`, route_template: ro?.template ?? "decisions/[id]", ...(ro?.id ? { id: ro.id } : {}),
201
202
  as_of: d.provenance?.approved_at ?? null, confirmed_by: d.provenance?.approved_by ?? d.decided_by ?? "a person" });
@@ -209,7 +210,7 @@ export async function survey(dir, { cache } = {}) {
209
210
  if (!type) { leftOut.push({ file, why: "not a record: notes and readmes never ship" }); continue; }
210
211
  if (NOT_SHIPPED[type]) { leftOut.push({ file, why: NOT_SHIPPED[type] }); continue; }
211
212
  if (LIVE[type]) { holes.push({ kind: type === "money-account" ? "money" : "stream", name: ro.id ?? file, file, record: type, route: ro.route, route_template: ro.template, why: LIVE[type], live: `${CLI} ${ro.route} read --venture ${v.id}`, to_freeze: "stays live by design: confirm a dated summary record to freeze a number" }); continue; }
212
- const text = readFileSync(join(dir, file), "utf8");
213
+ const text = readFileSync(ventureFile(dir, file), "utf8");
213
214
  let parsed = null;
214
215
  if (file.endsWith(".json")) { try { parsed = JSON.parse(text); } catch { blockers.push({ file, route: ro.route, record: type, why: "not valid JSON", fix: `fix ${file}` }); continue; } }
215
216
  if (parsed?.private === true) { privateCount.records++; continue; }
@@ -251,9 +252,9 @@ export async function survey(dir, { cache } = {}) {
251
252
  const id = d.split("/").at(-1);
252
253
  holes.push({ kind: "stream", name: d, file: first, record: "data-stream", stream: id, route: d, route_template: null, why: "a stream is append-only activity; it is never frozen", live: `read ${d}/ in the venture`, to_freeze: "stays live by design" });
253
254
  }
254
- if (existsSync(join(dir, "_private"))) privateCount.folder = ventureFilesAll(join(dir, "_private")).length;
255
+ if (existsSync(join(ventureFile(dir, "_private")))) privateCount.folder = ventureFilesAll(join(ventureFile(dir, "_private"))).length;
255
256
  // Drafts (P-31): never shipped. One with no confirmed record behind it blocks its package.
256
- const drafts = existsSync(join(dir, "_drafts")) ? readdirSync(join(dir, "_drafts")).filter((f) => f.endsWith(".draft")) : [];
257
+ const drafts = existsSync(join(ventureFile(dir, "_drafts"))) ? readdirSync(join(ventureFile(dir, "_drafts"))).filter((f) => f.endsWith(".draft")) : [];
257
258
  for (const d of drafts) {
258
259
  const file = d.replace(/\.draft$/, "").replace(/__/g, "/");
259
260
  const ro = routeOf(file, ms);
@@ -293,7 +294,7 @@ async function frozenCommands(dir, v, frozen, plan, cache, log) {
293
294
  try {
294
295
  mkdirSync(shadow, { recursive: true });
295
296
  writeFileSync(join(shadow, "venture.json"), JSON.stringify(v, null, 2) + "\n");
296
- for (const f of frozen) { mkdirSync(dirname(join(shadow, f.file)), { recursive: true }); writeFileSync(join(shadow, f.file), f.text ?? readFileSync(join(dir, f.file), "utf8")); }
297
+ for (const f of frozen) { mkdirSync(dirname(join(shadow, f.file)), { recursive: true }); writeFileSync(join(shadow, f.file), f.text ?? readFileSync(ventureFile(dir, f.file), "utf8")); }
297
298
  // The log, so confirmations hold in the copy; the release's own entries are left out of the key.
298
299
  const kept = log.filter((e) => !(e.activity === "release" && e.entity === MANIFEST));
299
300
  writeFileSync(join(shadow, "provenance.jsonl"), log.map((e) => JSON.stringify(e)).join("\n") + (log.length ? "\n" : ""));
@@ -369,19 +370,19 @@ function checks(dir) {
369
370
  }
370
371
 
371
372
  function writeRelease(dir, m, frozen) {
372
- const stage = join(dir, RELEASE_DIR, `.stage-${process.pid}`);
373
+ const stage = join(ventureFile(dir, RELEASE_DIR), `.stage-${process.pid}`);
373
374
  rmSync(stage, { recursive: true, force: true });
374
375
  for (const f of frozen) {
375
376
  const dest = join(stage, "records", f.file);
376
377
  mkdirSync(dirname(dest), { recursive: true });
377
- writeFileSync(dest, f.text ?? readFileSync(join(dir, f.file), "utf8"));
378
+ writeFileSync(dest, f.text ?? readFileSync(ventureFile(dir, f.file), "utf8"));
378
379
  }
379
380
  mkdirSync(join(stage, "records"), { recursive: true });
380
- rmSync(join(dir, RECORDS), { recursive: true, force: true });
381
- renameSync(join(stage, "records"), join(dir, RECORDS));
381
+ rmSync(ventureFile(dir, RECORDS), { recursive: true, force: true });
382
+ renameSync(join(stage, "records"), ventureFile(dir, RECORDS));
382
383
  rmSync(stage, { recursive: true, force: true });
383
- writeFileSync(join(dir, MANIFEST), JSON.stringify(m, null, 2) + "\n");
384
- writeFileSync(join(dir, CARD), JSON.stringify(cardOf(dir, m), null, 2) + "\n");
384
+ writeFileSync(ventureFile(dir, MANIFEST), JSON.stringify(m, null, 2) + "\n");
385
+ writeFileSync(ventureFile(dir, CARD), JSON.stringify(cardOf(dir, m), null, 2) + "\n");
385
386
  }
386
387
 
387
388
  const strip = ({ text, ...rest }) => ({ ...rest, sha256: sha(text ?? "") });
@@ -435,7 +436,7 @@ export async function add(dir, route, { now = new Date().toISOString(), actor =
435
436
  if (!r) return { ok: false, error: `${route} is not a collection item route (such as product/<id>)` };
436
437
  const id = route.slice(r.route.length - 4);
437
438
  if (m.records.some((x) => x.route === route)) return { ok: false, error: `${route} is already in release ${m.release}; a full build updates it` };
438
- const file = r.files.map((g) => g.replace("*", id)).find((f) => existsSync(join(dir, f)));
439
+ const file = r.files.map((g) => g.replace("*", id)).find((f) => existsSync(ventureFile(dir, f)));
439
440
  if (!file) return { ok: false, error: `no ${r.record} ${id} in the venture` };
440
441
  const s = await survey(dir);
441
442
  const hit = s.frozen.find((f) => f.file === file);
@@ -451,10 +452,10 @@ export async function add(dir, route, { now = new Date().toISOString(), actor =
451
452
  for (const p of m.packages) if (HOME[r.record] === p.id) { p.frozen = [...p.frozen, file]; p.gaps = p.gaps.filter((g) => g !== r.record); const t = p.frozen.length + p.holes.length; p.shell = t ? Math.round((p.frozen.length / t) * 100) / 100 : null; }
452
453
  m.incremental = [...(m.incremental ?? []), { route, file, from: before, at: stamp(now) }];
453
454
  m.release = releaseId(m);
454
- mkdirSync(dirname(join(dir, RECORDS, file)), { recursive: true });
455
- writeFileSync(join(dir, RECORDS, file), hit.text);
456
- writeFileSync(join(dir, MANIFEST), JSON.stringify(m, null, 2) + "\n");
457
- writeFileSync(join(dir, CARD), JSON.stringify(cardOf(dir, m), null, 2) + "\n");
455
+ mkdirSync(dirname(join(ventureFile(dir, RECORDS), file)), { recursive: true });
456
+ writeFileSync(join(ventureFile(dir, RECORDS), file), hit.text);
457
+ writeFileSync(ventureFile(dir, MANIFEST), JSON.stringify(m, null, 2) + "\n");
458
+ writeFileSync(ventureFile(dir, CARD), JSON.stringify(cardOf(dir, m), null, 2) + "\n");
458
459
  prov(dir, { entity: MANIFEST, route: "system/release", activity: "release", agent: agentFromEnv(actor === "agent" ? "agent" : "person", "cli"), note: `release ${m.release}: added ${route} to ${before} without rebuilding the rest` });
459
460
  return { ok: true, manifest: m, added: { route, file, from: before } };
460
461
  }
@@ -476,7 +477,7 @@ export function exportRelease(dir, out) {
476
477
  if (!m) return { ok: false, error: `no release in ${dir}: build one first` };
477
478
  if (existsSync(out) && readdirSync(out).length) return { ok: false, error: `${out} exists and is not empty: an export never writes over files` };
478
479
  mkdirSync(out, { recursive: true });
479
- const card = JSON.parse(readFileSync(join(dir, CARD), "utf8"));
480
+ const card = JSON.parse(readFileSync(ventureFile(dir, CARD), "utf8"));
480
481
  const lines = [`# ${m.name}`, "", `Phase: ${m.phase}. Release ${m.release}, built ${m.built_at} as of ${m.now}.`, "", "## Headline numbers", "", "| Number | Value | As of | Confirmed by |", "| --- | --- | --- | --- |",
481
482
  ...card.headline.filter((h) => h.label !== "Live holes").map((h) => `| ${h.label} | ${h.value}${h.unit ? ` ${h.unit}` : ""} | ${h.as_of ?? m.now} | ${h.confirmed_by.join(", ") || "none"} |`), "", "## Records", "", "| Record | As of | Confirmed by |", "| --- | --- | --- |"];
482
483
  for (const r of m.records) {
@@ -12,6 +12,7 @@
12
12
  import { execSync, spawnSync } from "node:child_process";
13
13
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { join, relative } from "node:path";
15
+ import { ventureFile } from "./venture-paths.mjs";
15
16
 
16
17
  const [dir, id] = process.argv.slice(2);
17
18
  if (!dir || !id) { console.error("usage: node schema/run-handoff.mjs <venture dir> <handoff id> [--runner <id>]"); process.exit(2); }
@@ -19,7 +20,7 @@ const opt = (k) => process.argv.includes(k) ? process.argv[process.argv.indexOf(
19
20
  const runner = opt("--runner") ?? "local";
20
21
  const [reviewId, verdict] = (opt("--review") ?? "").split("=");
21
22
  const root = new URL("..", import.meta.url).pathname;
22
- const hPath = join(dir, "operations/handoffs", `${id}.json`);
23
+ const hPath = join(ventureFile(dir, "operations/handoffs"), `${id}.json`);
23
24
  const h = JSON.parse(readFileSync(hPath, "utf8"));
24
25
  const dirty = execSync("git status --porcelain", { cwd: root, encoding: "utf8" }).trim() ? "+uncommitted" : "";
25
26
  const version = execSync("git rev-parse --short HEAD", { cwd: root, encoding: "utf8" }).trim() + dirty;
@@ -41,10 +42,10 @@ for (const c of h.done_criteria.filter((d) => d.kind !== "review")) {
41
42
  const at = new Date().toISOString().replace(/\.\d+Z$/, "Z");
42
43
  const eid = `${h.id}-${at.replace(/[-:TZ]/g, "").slice(0, 14)}`;
43
44
  const ev = { id: eid, subject: { kind: "handoff", id: h.id, version }, ran_at: at, runner, results, passed: results.every((r) => r.passed) };
44
- mkdirSync(join(dir, "evidence/results"), { recursive: true });
45
- const ePath = join(dir, "evidence/results", `${eid}.json`);
45
+ mkdirSync(join(ventureFile(dir, "evidence/results")), { recursive: true });
46
+ const ePath = join(ventureFile(dir, "evidence/results"), `${eid}.json`);
46
47
  writeFileSync(ePath, JSON.stringify(ev, null, 2) + "\n");
47
- h.evidence = [...(h.evidence ?? []), relative(join(dir, "operations/handoffs"), ePath)];
48
+ h.evidence = [...(h.evidence ?? []), relative(join(ventureFile(dir, "operations/handoffs")), ePath)];
48
49
  writeFileSync(hPath, JSON.stringify(h, null, 2) + "\n");
49
50
  const pending = h.done_criteria.filter((d) => d.kind === "review" && d.id !== reviewId).map((d) => `${d.id} (${d.reviewer})`);
50
51
  console.log(`${ev.passed ? "every criterion run passed" : "some criteria failed"}${pending.length ? `; review still owed: ${pending.join(", ")}` : ""}: ${relative(process.cwd(), ePath)}`);