project: records layout, init, check and writer (row 147, #1641)

Round 2 candidate 7a585aa9606863a0d44a26094b585160914e3d8fd5f13436b56470cd69e6d867
(Rocko, packet fafee2f22), approved by Filbert in comment 28707.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jason.woltjeandClaude Opus 5.5 committed 2026-10-15 19:10:52 -05:00
1 parent d8487b3af8
commit 686fc1cb6d
24 files changed
+987 -5

No files matched your search

+18
View File
@@ -594,3 +594,21 @@ Mapping JSON contains `models`,
`families` and optional per-role `instances` selections. Jason checks and installs
the output in Mos's window with the PM run idle; re-pin in that same window.
Never migrate the live file from an agent session.
### Local project records (`mosaic project`)
`mosaic project init <business> <project>` creates the declared project's
separate local record repository, with layout version 1, package-validated JSON
templates, Markdown spec/docs and its first commit authored as the business's
human. Init refuses an agent environment, an undeclared project or a non-empty
directory. No bus or remote is involved.
`mosaic project check <business> <project>` validates project metadata, every
record, filename/ID agreement and the fixed layout, naming each problem. It
exits 0 when valid, 2 for project/record problems (including an undeclared
project), 3 for configuration loading problems and 4 for usage. The optional business project `repo` is a normalized absolute
record-repository path; the default is
`<dataRoot>/projects/<business>/<project>/`. `root` remains the code checkout.
The package README describes schemas and the shared agent record writer; agent
writes never enter `spec/`, never absorb pending edits and refuse remotes.
+6 -2
View File
@@ -176,11 +176,15 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
projects[id] = { root: entry.root };
if (Object.hasOwn(entry, "repo") && (typeof entry.repo !== "string" || !isAbsolute(entry.repo) ||
normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
refuse(`${file} projects.${id}.repo must be a normalized absolute path`);
}
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}) };
}
const tracker = checkTracker(document.tracker, file);
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
import { validateBusinessDocument } from '../src/business.mjs';
test('optional project repo is preserved and must be a normalized absolute path', () => {
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
const check = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
assert.ok(!Object.hasOwn(check().projects.stack, 'repo'));
doc.projects.stack.repo = join(root, 'records');
assert.equal(check().projects.stack.repo, join(root, 'records'));
for (const value of ['relative', '/records/../outside', '/records\0bad', 1, null, {}]) {
doc.projects.stack.repo = value;
assert.throws(check, /repo.*normalized absolute path/);
}
});
+3
View File
@@ -13,6 +13,7 @@ import { join } from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { BINDING_NAME } from "../../discord/src/binding.mjs";
import { projectCommand, PROJECT_USAGE } from "./project.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { CliError } from "./errors.mjs";
@@ -30,6 +31,7 @@ import { SeatError } from "../../seat/src/seat.mjs";
export const USAGE = `usage:
${LIMITS_USAGE}
${PLUGINS_USAGE}
${PROJECT_USAGE}
mosaic inbox [--business <id>] [--json] [--tester]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json] [--tester]
@@ -413,6 +415,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project") return projectCommand(rest, io, deps);
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
+29
View File
@@ -0,0 +1,29 @@
import { configDir, loadBusiness } from '../../business/src/index.mjs';
import { checkProject, initProject, ProjectError } from '../../project/src/index.mjs';
import { loadSystem, rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const PROJECT_USAGE = 'mosaic project init|check <business> <project>';
export async function projectCommand(args, io, deps = {}) {
if (args.length !== 3 || !['init', 'check'].includes(args[0]) || args.some(arg => arg.startsWith('--')))
throw new CliError(PROJECT_USAGE, 4);
const [verb, id, project] = args;
if (verb === 'init') refuseInsideAgent(io.env);
try {
const system = deps.system ?? loadSystem({ env: io.env });
const business = loadBusiness(id, { dir: configDir(io.env), rolesDir: rolesDir(io.env) });
const context = { business, project, dataRoot: system.dataRoot };
if (verb === 'init') {
const result = await initProject(context);
io.stdout.write(`initialized ${result.repo}\ncommit ${result.commit}\n`);
} else {
const result = await checkProject(context);
if (!result.ok) throw new CliError(result.problems.join('\n'), 2);
io.stdout.write(`checked ${result.repo}: valid\n`);
}
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, error instanceof ProjectError ? 2 : 3);
}
}
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { main, run } from '../src/cli.mjs';
import { fixture, humanEnv, io, tmp } from './helpers.mjs';
import { writeJson } from '../../business/tests/helpers.mjs';
test('project init/check use declared business repo and never contact the bus', async t => {
const root = tmp(t), f = fixture(root);
f.doc.projects.stack.repo = join(root, 'records');
writeJson(join(root, 'config/businesses/acme.json'), f.doc);
const x = io(humanEnv(f.env));
const deps = { transport: () => assert.fail('local project command contacted bus') };
await main(['project', 'init', 'acme', 'stack'], x, deps);
assert.ok(existsSync(join(root, 'records/spec/north-star.md')));
const checked = io(humanEnv(f.env));
assert.equal(await run(['project', 'check', 'acme', 'stack'], checked, deps), 0);
assert.match(checked.out.text, /valid|checked/);
writeFileSync(join(root, 'records/unknown'), 'bad');
const bad = io(humanEnv(f.env));
assert.equal(await run(['project', 'check', 'acme', 'stack'], bad, deps), 2);
assert.match(bad.err.text, /unknown/);
});
test('project commands validate args and refuse undeclared projects and agent init', async t => {
const root = tmp(t), f = fixture(root);
await assert.rejects(main(['project', 'init', 'acme', 'missing'], io(humanEnv(f.env)), {}), /declare/);
await assert.rejects(main(['project', 'init', 'acme', 'stack'], io({ ...f.env, CODEX_THREAD_ID: 'fixture-agent' }), {}), /agent/i);
for (const args of [[], ['other', 'acme', 'stack'], ['init', 'acme'], ['check', 'acme', 'stack', '--write']])
assert.equal(await run(['project', ...args], io(humanEnv(f.env)), {}), 4);
});
test('project dispatcher reaches the human CLI', async t => {
const root = tmp(t), f = fixture(root);
const result = spawnSync(new URL('../../../scripts/mosaic', import.meta.url).pathname, ['project'], {
encoding: 'utf8', env: humanEnv(f.env),
});
assert.equal(result.status, 4);
assert.match(result.stderr, /mosaic project/);
});
+1 -1
View File
@@ -34,7 +34,7 @@ export function setup(t, realPin=false){
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
+108
View File
@@ -0,0 +1,108 @@
# Project record repository — layout 1
A project's records live in a separate local git repository. This is distinct
from its code checkout (`business.projects[project].root`) and the working bare
repository used for code review. No remote is created or contacted.
Set an optional normalized absolute `repo` in the business project entry:
```json
{"projects":{"stack":{"root":"/work/code","repo":"/work/project-records"}}}
```
Without `repo`, records live at
`<dataRoot>/projects/<business>/<project>/`. The code and records paths must not
overlap. Record paths and their existing ancestors must not be symlinks.
```sh
mosaic project init acme stack
mosaic project check acme stack
```
Both commands use the existing system and business config loaders. Init requires
an outside-agent CLI environment, a declared project and an empty directory. It
creates `main` and the first commit as the business's human, with a local
`<human>@mosaic.invalid` email. Check is read-only; it exits 2 and lists all
problems found. Usage exits 4; configuration problems exit 3. No bus is needed.
## Layout
| Path | Contents |
| --- | --- |
| `project.json` | `layoutVersion: 1`, business, project, name, createdAt |
| `spec/north-star.md` | Human-editable Markdown north star |
| `docs/` | Markdown documentation; check accepts nested docs |
| `requirements/ID.json` | One requirement per file |
| `decisions/ID.json` | One decision per file |
| `templates/` | project.json, requirement.json, decision.json, document.md, north-star.md |
Empty requirements and decisions directories contain an empty `.gitkeep`, so the
layout survives git checkout. That marker is checked separately from records.
Init makes no example requirements or fictional decisions. The initial project
name is its project ID; the user can edit the display name in project.json.
A requirement has `recordVersion: 1`, `id`, `title`, `text`, and `status`
(`draft`, `accepted`, `complete`). A decision has `recordVersion: 1`, `id`,
`question`, `options` (2–9 distinct `{key,text}` entries), `recommendation`,
`status` (`open`, `accepted`, `rejected`) and `choice` (null while open, an option
key when closed). These are local reference records, not bus authorization or
approval. Decision spec citations and settlement policy belong to row 138.
Schemas are fixed in `packages/project/schemas/`, never loaded from the project.
Every JSON template validates against its record schema. Editing templates
cannot weaken validation; adding a project-local schema is refused. The schema
validator implements a closed subset of JSON Schema 2020-12 matching these
schemas and refuses any unsupported schema keyword. Dates use canonical
millisecond UTC. Files are bounded to 1 MiB, valid UTF-8 without NULs, regular,
and not hard-linked. JSON record names must equal their IDs. Unknown top-level
paths, missing required paths, unexpected templates and non-Markdown docs refuse.
The git directory is inspected separately, without reading project data as git
configuration or running hooks.
## Shared APIs
Import from `packages/project/src/index.mjs`:
```js
const context = { business, project: 'stack', dataRoot };
projectRepo(context); // absolute repo path; no writes
await initProject(context); // {repo, commit}
await checkProject(context); // {ok, repo, problems}
validateRecord('requirement', value); // array of problem strings
await writeRecord({
...context,
path: 'requirements/REQ-PROJECT-1.json',
record: value,
author: { definition: 'coder', instance: 'builder' },
}); // {repo, path, commit}
```
`writeRecord` is the single agent record writer. It accepts `docs/NAME.md`,
`requirements/ID.json` and `decisions/ID.json`, validates first, and makes a
local commit authored and committed as `<definition>.<instance>` with a local
`.invalid` email. Actor metadata is trusted caller input whose shape is
validated here; row 138's broker must derive it from the bound role. This
package neither authenticates an agent nor grants it authority.
Every agent path under `spec/` refuses with `project-spec-human-only`. Agents
cannot write project.json, templates or git metadata through this API. A valid
but unchanged record refuses `project-record-unchanged` without an empty commit.
Stricter safeguards agreed with Sage (sage-0400): a remote refuses
`project-remote`; pending tracked, staged, untracked or ignored files and hidden
index flags refuse `project-dirty`. The writer never folds human edits into its
commit. Concurrent writers refuse `project-busy` through an exclusive local
lock; no stale lock is automatically removed. Links, indirect git stores and
unsupported local git config refuse. Git uses no inherited global/system
configuration, no hooks, fsmonitor, filters or transports, and runs in bounded
process groups using the existing seat git runner.
Records publish with a temporary file, fsync and rename, then a commit of only
that path. On git failure with HEAD unchanged, only the writer's file/index
change is restored. If HEAD changed while an error occurred, the writer refuses
`project-commit-uncertain` and preserves evidence for reconciliation; it never
rewinds history. Locking serializes this writer, not arbitrary human editors:
callers must coordinate simultaneous human saves. No destructive repair, push,
remote creation, role grant, spec-confirmation mechanism or live action ships.
Tests: `node --test packages/project/tests/*.test.mjs`. Fixtures only.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@mosaic/project",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "UNLICENSED",
"exports": "./src/index.mjs",
"scripts": {
"test": "node --test tests/*.test.mjs"
}
}
@@ -0,0 +1,70 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:project-layout:1:decision",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"id",
"question",
"options",
"recommendation",
"status",
"choice"
],
"properties": {
"recordVersion": {
"const": 1
},
"id": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"question": {
"type": "string",
"minLength": 1,
"maxLength": 262144
},
"options": {
"type": "array",
"minItems": 2,
"maxItems": 9,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"text"
],
"properties": {
"key": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 4096
}
}
}
},
"recommendation": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"status": {
"enum": [
"open",
"accepted",
"rejected"
]
},
"choice": {
"type": [
"string",
"null"
]
}
}
}
@@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:project-layout:1:project",
"type": "object",
"additionalProperties": false,
"required": [
"layoutVersion",
"business",
"project",
"name",
"createdAt"
],
"properties": {
"layoutVersion": {
"const": 1
},
"business": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"project": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
}
@@ -0,0 +1,39 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:project-layout:1:requirement",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"id",
"title",
"text",
"status"
],
"properties": {
"recordVersion": {
"const": 1
},
"id": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 262144
},
"status": {
"enum": [
"draft",
"accepted",
"complete"
]
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import { lstatSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { gitEnv, runGit } from '../../seat/src/checkout.mjs';
import { ProjectError } from './paths.mjs';
const SAFE = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.ignorestat=false',
'-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', '-c', 'commit.gpgsign=false',
'-c', 'maintenance.auto=false', '-c', 'gc.auto=0'];
export async function git(repo, args, identity = {}) {
const result = await runGit([...SAFE, '-C', repo, ...args], {
...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(repo), ...identity,
});
if (!result.ok) throw new ProjectError('project-git', `local git ${args[0]} refused (${result.why})`);
return result.out.trimEnd();
}
export async function inspectGit(repo) {
// Refuse gitdir indirection, alternates, links or hardlinks before git reads metadata.
const walk = directory => {
const st = lstatSync(directory);
if (!st.isDirectory() || st.isSymbolicLink()) throw new ProjectError('project-repository', 'expected local .git directory');
for (const name of readdirSync(directory)) {
const path = join(directory, name), entry = lstatSync(path);
if (entry.isDirectory()) walk(path);
else if (!entry.isFile() || entry.nlink !== 1)
throw new ProjectError('project-repository', 'git metadata must contain regular private files');
}
};
walk(join(repo, '.git'));
for (const name of ['commondir', 'objects/info/alternates', 'objects/info/http-alternates']) {
try { lstatSync(join(repo, '.git', name)); }
catch (e) { if (e.code === 'ENOENT') continue; throw e; }
throw new ProjectError('project-repository', 'git metadata indirection refused');
}
const config = lstatSync(join(repo, '.git/config'));
if (config.size > 65536) throw new ProjectError('project-repository', 'git config is too large');
const list = await git(repo, ['config', '--no-includes', '--file', join(repo, '.git/config'), '--list', '--null']);
const allowed = {
'core.repositoryformatversion': ['0'], 'core.bare': ['false'], 'core.filemode': ['true', 'false'],
'core.logallrefupdates': ['true'], 'core.ignorecase': ['true', 'false'],
};
for (const entry of list.split('\0').filter(Boolean)) {
const split = entry.indexOf('\n'), key = entry.slice(0, split), value = entry.slice(split + 1);
if (key.startsWith('remote.')) throw new ProjectError('project-remote', 'project repository must have no remote');
if (['user.name', 'user.email'].includes(key)) continue;
if (!allowed[key]?.includes(value)) throw new ProjectError('project-repository', `unsupported local git config: ${key}`);
}
}
export async function requireClean(repo) {
const status = await git(repo, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching']);
const flags = await git(repo, ['ls-files', '-v']);
if (status || flags.split('\n').some(line => /^[a-zS] /.test(line)))
throw new ProjectError('project-dirty', 'pending edits, staged changes, ignored content or hidden index flags refuse');
}
export const identity = name => ({ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: `${name}@mosaic.invalid`,
GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: `${name}@mosaic.invalid` });
+186
View File
@@ -0,0 +1,186 @@
import {
closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync,
renameSync, unlinkSync, writeFileSync, fsyncSync,
} from 'node:fs';
import { dirname, join, normalize } from 'node:path';
import { randomUUID } from 'node:crypto';
import { ProjectError, checkedId, noLinks, projectRepo } from './paths.mjs';
import { git, identity, inspectGit, requireClean } from './git.mjs';
import { validateRecord } from './validate.mjs';
export { ProjectError, projectRepo, validateRecord };
const DIRECTORIES = ['spec', 'docs', 'requirements', 'decisions', 'templates'];
const TEMPLATES = ['project.json', 'requirement.json', 'decision.json', 'document.md', 'north-star.md'];
const MAX_BYTES = 1024 * 1024;
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
function readText(path) {
const st = lstatSync(path);
if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || st.size > MAX_BYTES)
throw Error('expected bounded regular file, not a link');
const value = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path));
if (value.includes('\0')) throw Error('NUL is forbidden');
return value;
}
function jsonProblems(kind, text, relative, problems) {
try {
const value = JSON.parse(text);
for (const problem of validateRecord(kind, value)) problems.push(`${relative}: ${problem}`);
return value;
} catch (e) { problems.push(`${relative}: invalid JSON (${e.message})`); return null; }
}
export async function checkProject(context) {
const repo = projectRepo(context), problems = [];
const problem = (path, message) => problems.push(`${path}: ${message}`);
try {
const top = readdirSync(repo);
for (const name of top) if (!['.git', 'project.json', ...DIRECTORIES].includes(name)) problem(name, 'unknown top-level path');
for (const name of ['project.json', '.git', ...DIRECTORIES]) if (!top.includes(name)) problem(name, 'missing required path');
if (top.includes('project.json')) {
try {
const value = jsonProblems('project', readText(join(repo, 'project.json')), 'project.json', problems);
if (value && value.business !== context.business.id) problem('project.json', 'business does not match context');
if (value && value.project !== context.project) problem('project.json', 'project does not match context');
} catch (e) { problem('project.json', e.message); }
}
for (const directory of DIRECTORIES) {
const base = join(repo, directory);
try {
if (!lstatSync(base).isDirectory() || lstatSync(base).isSymbolicLink()) throw Error('expected directory, never a link');
const visit = (folder, prefix = '') => {
for (const name of readdirSync(folder)) {
const relative = prefix + name, file = join(folder, name), label = `${directory}/${relative}`;
try {
const st = lstatSync(file);
if (st.isDirectory()) {
if (directory !== 'docs') problem(label, 'unexpected nested directory');
else visit(file, relative + '/');
continue;
}
const text = readText(file);
if (['requirements', 'decisions'].includes(directory)) {
if (name === '.gitkeep') { if (text !== '') problem(label, 'layout marker must be empty'); continue; }
if (!name.endsWith('.json')) { problem(label, 'expected a JSON record'); continue; }
const kind = directory === 'requirements' ? 'requirement' : 'decision';
const value = jsonProblems(kind, text, label, problems);
if (value && name !== `${value.id}.json`) problem(label, 'file name must match record id');
} else if (directory === 'templates') {
if (!TEMPLATES.includes(name)) problem(label, 'unknown template');
else if (name.endsWith('.json')) jsonProblems(name.slice(0, -5), text, label, problems);
} else if (directory === 'spec' ? relative !== 'north-star.md' : !name.endsWith('.md')) {
problem(label, 'unexpected Markdown path');
}
} catch (e) { problem(label, e.message); }
}
};
visit(base);
if (directory === 'spec' && !readdirSync(base).includes('north-star.md')) problem('spec/north-star.md', 'missing');
if (directory === 'templates') for (const name of TEMPLATES)
if (!readdirSync(base).includes(name)) problem(`templates/${name}`, 'missing');
} catch (e) { problem(directory, e.message); }
}
try { await inspectGit(repo); } catch (e) { problem('.git', e.message); }
} catch (e) { problem(repo, e.message); }
return { ok: problems.length === 0, repo, problems };
}
export async function initProject(context) {
const repo = projectRepo(context), human = checkedId(context.business.human);
if (existsSync(repo) && readdirSync(repo).length) throw new ProjectError('project-not-empty', 'refusing existing non-empty directory');
mkdirSync(repo, { recursive: true, mode: 0o700 });
// Exclusive directory creation prevents two initializers from racing past the empty check.
try { mkdirSync(join(repo, '.git'), { mode: 0o700 }); }
catch { throw new ProjectError('project-not-empty', 'another initializer already owns this directory'); }
await git(repo, ['init', '--initial-branch=main', '--template=']);
for (const directory of DIRECTORIES) mkdirSync(join(repo, directory), { mode: 0o700 });
for (const name of TEMPLATES)
copyFileSync(new URL(`../templates/${name}`, import.meta.url), join(repo, 'templates', name));
copyFileSync(new URL('../templates/north-star.md', import.meta.url), join(repo, 'spec/north-star.md'));
writeFileSync(join(repo, 'docs/README.md'), '# Project documentation\n\nKeep project Markdown documents here.\n');
for (const directory of ['requirements', 'decisions']) writeFileSync(join(repo, directory, '.gitkeep'), '');
const metadata = { layoutVersion: 1, business: context.business.id, project: context.project,
name: context.project, createdAt: new Date().toISOString() };
writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n');
const result = await checkProject(context);
if (!result.ok) throw new ProjectError('project-invalid', result.problems.join('\n'));
await git(repo, ['add', '--', 'project.json', ...DIRECTORIES]);
await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(human));
return { repo, commit: await git(repo, ['rev-parse', 'HEAD']) };
}
function recordBytes(path, record) {
if (typeof path !== 'string' || path.startsWith('/') || normalize(path) !== path || path.includes('\\') || path.includes('\0'))
throw new ProjectError('project-record-path', 'expected a canonical relative record path');
if (path === 'spec' || path.startsWith('spec/')) throw new ProjectError('project-spec-human-only', 'agents never write spec/');
const match = /^(docs|requirements|decisions)\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.(md|json)$/.exec(path);
if (!match || (match[1] === 'docs' ? match[3] !== 'md' : match[3] !== 'json'))
throw new ProjectError('project-record-path', 'record must be a Markdown doc or a named JSON requirement/decision');
let text;
if (match[1] === 'docs') {
if (typeof record !== 'string' || !record.length) throw new ProjectError('project-record-invalid', 'document must be nonempty Markdown');
text = record;
} else {
const kind = match[1] === 'requirements' ? 'requirement' : 'decision';
const problems = validateRecord(kind, record);
if (problems.length || record.id !== match[2])
throw new ProjectError('project-record-invalid', problems.join('; ') || 'file name must match record id');
text = JSON.stringify(record, null, 2) + '\n';
}
if (text.includes('\0') || Buffer.byteLength(text) > MAX_BYTES)
throw new ProjectError('project-record-invalid', 'record exceeds size bound or contains NUL');
return Buffer.from(text, 'utf8');
}
function publish(file, bytes) {
const temp = join(dirname(file), `.mosaic-record-${randomUUID()}.tmp`);
let fd;
try {
fd = openSync(temp, 'wx', 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined;
renameSync(temp, file);
const dir = openSync(dirname(file), 'r'); try { fsyncSync(dir); } finally { closeSync(dir); }
} finally {
if (fd !== undefined) closeSync(fd);
try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
export async function writeRecord({ path, record, author, ...context }) {
const repo = projectRepo(context), bytes = recordBytes(path, record);
if (!object(author) || Object.keys(author).sort().join(',') !== 'definition,instance')
throw new ProjectError('project-author', 'expected definition and instance');
const name = `${checkedId(author.definition)}.${checkedId(author.instance)}`;
if (author.definition === 'human') throw new ProjectError('project-author', 'writer requires an agent author');
const metadata = lstatSync(join(repo, '.git'));
if (!metadata.isDirectory() || metadata.isSymbolicLink())
throw new ProjectError('project-repository', 'expected a local .git directory');
const lock = join(repo, '.git/mosaic-record.lock');
let fd;
try { fd = openSync(lock, 'wx', 0o600); }
catch (e) { throw new ProjectError(e.code === 'EEXIST' ? 'project-busy' : 'project-repository', 'writer lock unavailable'); }
const lockStat = lstatSync(lock);
const file = join(repo, path);
let before, head, published = false;
try {
await inspectGit(repo);
await requireClean(repo);
const checked = await checkProject(context);
if (!checked.ok) throw new ProjectError('project-invalid', checked.problems.join('\n'));
noLinks(dirname(file));
before = existsSync(file) ? Buffer.from(readText(file)) : null;
if (before?.equals(bytes)) throw new ProjectError('project-record-unchanged', 'record already has these bytes');
head = await git(repo, ['rev-parse', 'HEAD']);
publish(file, bytes); published = true;
await git(repo, ['add', '--', path]);
await git(repo, ['commit', '--only', '-m', `Record ${path}`, '--', path], identity(name));
const commit = await git(repo, ['rev-parse', 'HEAD']);
return { repo, path, commit };
} catch (error) {
if (published) {
const current = await git(repo, ['rev-parse', 'HEAD']);
if (current !== head) throw new ProjectError('project-commit-uncertain', 'HEAD changed; preserve evidence for reconciliation');
await git(repo, ['reset', '--quiet', 'HEAD', '--', path]);
if (before === null) unlinkSync(file); else publish(file, before);
}
throw error;
} finally {
closeSync(fd);
if (lstatSync(lock).ino === lockStat.ino) unlinkSync(lock);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { lstatSync } from 'node:fs';
import { dirname, isAbsolute, join, normalize, relative, sep } from 'node:path';
export class ProjectError extends Error {
constructor(code, message) { super(`${code}: ${message}`); this.code = code; }
}
export const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
export const checkedId = value => {
if (typeof value !== 'string' || !ID.test(value)) throw new ProjectError('project-id', 'invalid id');
return value;
};
export function absolute(path) {
if (typeof path !== 'string' || !isAbsolute(path) || normalize(path) !== path || path.includes('\0'))
throw new ProjectError('project-path', 'expected normalized absolute path');
return path;
}
export function noLinks(path) {
for (let part = path; ; part = dirname(part)) {
try {
const st = lstatSync(part);
if (!st.isDirectory() || st.isSymbolicLink()) throw new ProjectError('project-path', 'path ancestor must be a directory, never a link');
} catch (e) { if (e.code !== 'ENOENT') throw e; }
if (part === dirname(part)) break;
}
}
export function projectRepo({ business, project, dataRoot }) {
checkedId(business?.id); checkedId(project);
if (!Object.hasOwn(business?.projects ?? {}, project))
throw new ProjectError('project-not-declared', `business does not declare ${project}`);
absolute(dataRoot);
const entry = business.projects[project], root = absolute(entry.root);
const repo = absolute(entry.repo ?? join(dataRoot, 'projects', business.id, project));
const contains = (base, child) => {
const rel = relative(base, child);
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
};
if (contains(root, repo) || contains(repo, root))
throw new ProjectError('project-path', 'record repository and code checkout overlap');
noLinks(repo);
return repo;
}
+59
View File
@@ -0,0 +1,59 @@
import { readFileSync } from 'node:fs';
const KINDS = ['project', 'requirement', 'decision'];
const KEYWORDS = new Set(['$schema', '$id', 'type', 'const', 'enum', 'properties', 'required',
'additionalProperties', 'items', 'minItems', 'maxItems', 'minLength', 'maxLength', 'pattern', 'format']);
const schemas = Object.fromEntries(KINDS.map(kind => [kind,
JSON.parse(readFileSync(new URL(`../schemas/${kind}.schema.json`, import.meta.url), 'utf8')),
]));
// Package-owned schemas use this deliberately small closed subset. Never silently ignore a new constraint.
function known(schema) {
for (const key of Object.keys(schema)) if (!KEYWORDS.has(key)) throw Error(`unsupported schema keyword: ${key}`);
for (const child of Object.values(schema.properties ?? {})) known(child);
if (schema.items) known(schema.items);
}
for (const schema of Object.values(schemas)) known(schema);
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
function visit(schema, value, path, problems) {
const bad = text => problems.push(`${path}: ${text}`);
if (Object.hasOwn(schema, 'const') && value !== schema.const) bad(`must equal ${schema.const}`);
if (schema.enum && !schema.enum.includes(value)) bad(`must be one of ${schema.enum.join(', ')}`);
const types = schema.type ? [schema.type].flat() : [];
const matches = type => type === 'null' ? value === null : type === 'object' ? isObject(value)
: type === 'array' ? Array.isArray(value) : typeof value === type;
if (types.length && !types.some(matches)) { bad(`must be ${types.join(' or ')}`); return; }
if (typeof value === 'string') {
if (schema.minLength !== undefined && value.length < schema.minLength) bad('too short');
if (schema.maxLength !== undefined && value.length > schema.maxLength) bad('too long');
if (schema.pattern && !new RegExp(schema.pattern).test(value)) bad('invalid format');
if (schema.format === 'date-time' && (!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value || value.length !== 24)) bad('invalid canonical UTC date-time');
if (value.includes('\0')) bad('NUL is forbidden');
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) bad('too few items');
if (schema.maxItems !== undefined && value.length > schema.maxItems) bad('too many items');
if (schema.items) value.forEach((item, index) => visit(schema.items, item, `${path}[${index}]`, problems));
}
if (isObject(value)) {
for (const key of schema.required ?? []) if (!Object.hasOwn(value, key)) bad(`missing ${key}`);
for (const [key, child] of Object.entries(value)) {
if (Object.hasOwn(schema.properties ?? {}, key)) visit(schema.properties[key], child, `${path}.${key}`, problems);
else if (schema.additionalProperties === false) bad(`unknown key ${key}`);
}
}
}
export function validateRecord(kind, value) {
if (!Object.hasOwn(schemas, kind)) return [`unknown record kind: ${kind}`];
const problems = [];
visit(schemas[kind], value, kind, problems);
if (kind === 'decision' && problems.length === 0) {
const keys = value.options.map(option => option.key);
if (new Set(keys).size !== keys.length) problems.push('decision.options: duplicate keys');
if (!keys.includes(value.recommendation)) problems.push('decision.recommendation: unknown option');
if (value.status === 'open' ? value.choice !== null : !keys.includes(value.choice))
problems.push('decision.choice: must be null while open, or name the settled option');
}
return problems;
}
+18
View File
@@ -0,0 +1,18 @@
{
"recordVersion": 1,
"id": "DEC-1",
"question": "What needs to be decided?",
"options": [
{
"key": "yes",
"text": "Proceed"
},
{
"key": "no",
"text": "Decline"
}
],
"recommendation": "yes",
"status": "open",
"choice": null
}
+3
View File
@@ -0,0 +1,3 @@
# Document title
Write the project documentation here.
+3
View File
@@ -0,0 +1,3 @@
# North star
Describe the outcome this project should achieve and the boundaries it must respect.
+7
View File
@@ -0,0 +1,7 @@
{
"layoutVersion": 1,
"business": "example",
"project": "example",
"name": "Example project",
"createdAt": "2026-01-01T00:00:00.000Z"
}
@@ -0,0 +1,7 @@
{
"recordVersion": 1,
"id": "REQ-PROJECT-1",
"title": "Requirement title",
"text": "Describe the required outcome and acceptance evidence.",
"status": "draft"
}
+228
View File
@@ -0,0 +1,228 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
const api = await import('../src/index.mjs').catch(e => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e;
});
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
const actor = { definition: 'coder', instance: 'builder' };
const requirement = { recordVersion: 1, id: 'REQ-PROJECT-1', title: 'A requirement', text: 'Keep local evidence', status: 'draft' };
const decision = { recordVersion: 1, id: 'DEC-1', question: 'Which path?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', status: 'open', choice: null };
function context(t, repo) {
const root = mkdtempSync(join(tmpdir(), 'project-record-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return { root, business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), ...(repo ? { repo: join(root, repo) } : {}) } } }, project: 'stack', dataRoot: join(root, 'data') };
}
async function ready(t, custom) {
assert.equal(typeof api.initProject, 'function');
const c = context(t, custom);
const initialized = await api.initProject(c);
return { c, repo: initialized.repo, commit: initialized.commit };
}
test('init creates a checked versioned layout, valid templates and human-authored local history', async t => {
const { c, repo, commit } = await ready(t);
assert.equal(repo, join(c.dataRoot, 'projects/acme/stack'));
assert.deepEqual(await api.checkProject(c), { ok: true, repo, problems: [] });
const project = JSON.parse(readFileSync(join(repo, 'project.json')));
assert.equal(project.layoutVersion, 1); assert.equal(project.business, 'acme'); assert.equal(project.project, 'stack');
assert.equal(new Date(project.createdAt).toISOString(), project.createdAt);
for (const kind of ['project', 'requirement', 'decision'])
assert.deepEqual(api.validateRecord(kind, JSON.parse(readFileSync(join(repo, 'templates', kind + '.json')))), []);
assert.equal(git(repo, 'show', '-s', '--format=%an', commit), 'jason');
assert.equal(git(repo, 'remote'), '');
assert.ok(git(repo, 'ls-files').includes('spec/north-star.md'));
assert.ok(git(repo, 'ls-files').includes('requirements/.gitkeep'));
});
test('init honors repo, refuses a non-empty directory and an undeclared project without touching files', async t => {
const { c, repo } = await ready(t, 'records');
assert.equal(repo, join(c.root, 'records'));
const before = git(repo, 'rev-parse', 'HEAD');
await assert.rejects(api.initProject(c), e => e.code === 'project-not-empty');
assert.equal(git(repo, 'rev-parse', 'HEAD'), before);
await assert.rejects(api.initProject({ ...c, project: 'missing' }), e => e.code === 'project-not-declared');
const dirty = context(t, 'occupied'); mkdirSync(join(dirty.root, 'occupied'));
writeFileSync(join(dirty.root, 'occupied/keep.txt'), 'keep');
await assert.rejects(api.initProject(dirty), e => e.code === 'project-not-empty');
assert.equal(readFileSync(join(dirty.root, 'occupied/keep.txt'), 'utf8'), 'keep');
});
test('check reports every bad record, filename mismatch and unknown top-level path', async t => {
const { c, repo } = await ready(t);
writeFileSync(join(repo, 'requirements/bad.json'), JSON.stringify({ ...requirement, id: 'OTHER', status: 'invalid' }));
writeFileSync(join(repo, 'decisions/wrong.json'), JSON.stringify(decision));
writeFileSync(join(repo, 'unexpected.txt'), 'unknown');
const result = await api.checkProject(c);
assert.equal(result.ok, false);
assert.match(result.problems.join('\n'), /requirements\/bad.json.*status/);
assert.match(result.problems.join('\n'), /decisions\/wrong.json.*id/);
assert.match(result.problems.join('\n'), /unexpected.txt/);
});
test('one writer commits validated docs, requirements and decisions with the bound agent author', async t => {
const { c, repo } = await ready(t);
for (const [path, record] of [['docs/design.md', '# Design\n\nKept here.\n'], ['requirements/REQ-PROJECT-1.json', requirement], ['decisions/DEC-1.json', decision]]) {
const written = await api.writeRecord({ ...c, path, record, author: actor });
assert.equal(git(repo, 'show', '-s', '--format=%an', written.commit), 'coder.builder');
assert.deepEqual(git(repo, 'show', '--format=', '--name-only', written.commit).split('\n'), [path]);
}
assert.equal((await api.checkProject(c)).ok, true);
assert.equal(git(repo, 'status', '--porcelain'), '');
});
test('writer refuses spec paths, bad schemas, mismatched IDs and invalid actor metadata before changing history', async t => {
const { c, repo } = await ready(t), before = git(repo, 'rev-parse', 'HEAD');
for (const [path, record, author] of [
['spec/north-star.md', '# Agent overwrite', actor], ['../outside.md', '# Escape', actor],
['docs/../../outside.md', '# Escape', actor], ['project.json', {}, actor],
['requirements/REQ-PROJECT-1.json', { ...requirement, extra: true }, actor],
['requirements/WRONG.json', requirement, actor], ['decisions/DEC-1.json', { ...decision, choice: 'yes' }, actor],
['docs/a.md', '# A', { definition: 'coder', instance: 'x\nHuman' }],
['docs/a.md', '# A', { ...actor, extra: true }],
]) await assert.rejects(api.writeRecord({ ...c, path, record, author }),
path.startsWith('spec/') ? { code: 'project-spec-human-only' } : undefined);
assert.equal(git(repo, 'rev-parse', 'HEAD'), before);
assert.equal(git(repo, 'status', '--porcelain'), '');
});
test('writer refuses human pending edits and remotes instead of absorbing or contacting them', async t => {
const { c, repo } = await ready(t);
writeFileSync(join(repo, 'spec/north-star.md'), '# Pending human edit\n');
await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }), e => e.code === 'project-dirty');
assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), '# Pending human edit\n');
git(repo, 'checkout', '--', 'spec/north-star.md');
git(repo, 'remote', 'add', 'origin', 'https://fixture.invalid/no-network');
await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }), e => e.code === 'project-remote');
});
test('links, repo/code overlap, identity mismatch and a busy writer refuse safely', async t => {
const { c, repo } = await ready(t);
const overlap = { ...c, business: { ...c.business, projects: { stack: { root: repo, repo } } } };
assert.throws(() => api.projectRepo(overlap), /overlap/);
const external = join(c.root, 'outside.json'); writeFileSync(external, JSON.stringify(requirement));
symlinkSync(external, join(repo, 'requirements/REQ-PROJECT-1.json'));
assert.equal((await api.checkProject(c)).ok, false);
await assert.rejects(api.writeRecord({ ...c, path: 'requirements/REQ-PROJECT-1.json', record: requirement, author: actor }));
rmSync(join(repo, 'requirements/REQ-PROJECT-1.json'));
writeFileSync(join(repo, '.git/mosaic-record.lock'), 'another writer');
await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }), e => e.code === 'project-busy');
rmSync(join(repo, '.git/mosaic-record.lock'));
const metadata = JSON.parse(readFileSync(join(repo, 'project.json'))); metadata.business = 'other';
writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata));
assert.match((await api.checkProject(c)).problems.join('\n'), /project.json.*business/);
assert.equal(readFileSync(external, 'utf8'), JSON.stringify(requirement));
});
test('schemas remain package-owned and reject invalid nested data and dates', async () => {
assert.equal(typeof api.validateRecord, 'function');
assert.ok(api.validateRecord('requirement', { ...requirement, title: '' }).length);
assert.ok(api.validateRecord('requirement', { ...requirement, recordVersion: '1' }).length);
assert.ok(api.validateRecord('decision', { ...decision, options: [decision.options[0]] }).length);
assert.ok(api.validateRecord('decision', { ...decision, options: [decision.options[0], decision.options[0]] }).length);
assert.ok(api.validateRecord('decision', { ...decision, recommendation: 'unknown' }).length);
assert.ok(api.validateRecord('project', { layoutVersion: 1, business: 'acme', project: 'stack', name: 'Name', createdAt: '2026-02-30T00:00:00.000Z' }).length);
});
test('git commit failure restores only the attempted record and leaves human history unchanged', async t => {
const { c, repo } = await ready(t);
const { chmodSync } = await import('node:fs');
const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
const bin = join(c.root, 'bin'); mkdirSync(bin);
writeFileSync(join(bin, 'git'), `#!/bin/sh\nfor arg do [ "$arg" = commit ] && exit 71; done\nexec '${realGit}' "$@"\n`);
chmodSync(join(bin, 'git'), 0o755);
const before = git(repo, 'rev-parse', 'HEAD'), saved = process.env.PATH;
try {
process.env.PATH = bin + ':' + saved;
await assert.rejects(api.writeRecord({ ...c, path: 'docs/failure.md', record: '# Refused', author: actor }), e => e.code === 'project-git');
} finally { process.env.PATH = saved; }
assert.equal(git(repo, 'rev-parse', 'HEAD'), before);
assert.equal(git(repo, 'status', '--porcelain'), '');
assert.ok(!readdirSync(join(repo, 'docs')).includes('failure.md'));
});
test('git hooks and ambient git identity cannot alter the record writer or execute outside actions', async t => {
const { c, repo } = await ready(t);
const { chmodSync } = await import('node:fs');
mkdirSync(join(repo, '.git/hooks'));
const marker = join(c.root, 'hook-ran');
writeFileSync(join(repo, '.git/hooks/pre-commit'), `#!/bin/sh\nprintf hook > '${marker}'\n`);
chmodSync(join(repo, '.git/hooks/pre-commit'), 0o755);
const saved = process.env.GIT_AUTHOR_NAME;
process.env.GIT_AUTHOR_NAME = 'wrong-author';
try {
const result = await api.writeRecord({ ...c, path: 'docs/clean.md', record: '# Clean', author: actor });
assert.equal(git(repo, 'show', '-s', '--format=%an', result.commit), 'coder.builder');
assert.ok(!readdirSync(c.root).includes('hook-ran'));
} finally { if (saved === undefined) delete process.env.GIT_AUTHOR_NAME; else process.env.GIT_AUTHOR_NAME = saved; }
});
test('project-local schemas cannot weaken checks; invalid UTF-8 and linked git directories refuse', async t => {
const { c, repo } = await ready(t);
const { existsSync } = await import('node:fs');
writeFileSync(join(repo, 'templates/requirement.schema.json'), '{"type":"object"}');
writeFileSync(join(repo, 'docs/invalid.md'), Buffer.from([0xff]));
assert.match((await api.checkProject(c)).problems.join('\n'), /unknown template/);
assert.match((await api.checkProject(c)).problems.join('\n'), /invalid.md/);
rmSync(join(repo, 'templates/requirement.schema.json')); rmSync(join(repo, 'docs/invalid.md'));
const outside = join(c.root, 'linked-git'); mkdirSync(outside);
rmSync(join(repo, '.git'), { recursive: true }); symlinkSync(outside, join(repo, '.git'));
await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }));
assert.ok(!existsSync(join(outside, 'mosaic-record.lock')));
});
test('schema constraints and layout omissions are reported independently', async t => {
const { c, repo } = await ready(t);
const { title, ...missingTitle } = requirement;
for (const value of [missingTitle, { ...requirement, id: '../escape' }, { ...requirement, text: 3 }])
assert.ok(api.validateRecord('requirement', value).length);
assert.ok(api.validateRecord('decision', { ...decision, options: Array.from({ length: 10 }, (_, i) => ({ key: 'k' + i, text: 'Option' })), recommendation: 'k0' }).length);
const tooLarge = '# ' + 'x'.repeat(1024 * 1024);
await assert.rejects(api.writeRecord({ ...c, path: 'docs/large.md', record: tooLarge, author: actor }));
writeFileSync(join(repo, 'docs/large.md'), tooLarge);
writeFileSync(join(repo, 'docs/nul.md'), 'bad\0content');
writeFileSync(join(repo, 'templates/requirement.json'), JSON.stringify({ ...requirement, status: 'invalid' }));
rmSync(join(repo, 'templates/document.md'));
const errors = (await api.checkProject(c)).problems.join('\n');
assert.match(errors, /large.md/); assert.match(errors, /nul.md/);
assert.match(errors, /templates\/requirement.json.*status/);
assert.match(errors, /templates\/document.md.*missing/);
});
test('unsupported schema keywords, malformed contexts, ignored local config and nonempty markers refuse', async t => {
const { c, repo } = await ready(t);
const { cpSync } = await import('node:fs');
const { pathToFileURL } = await import('node:url');
assert.throws(() => api.projectRepo({ ...c, dataRoot: 'relative' }), /normalized absolute/);
assert.ok(api.validateRecord('requirement', { ...requirement, title: 'x'.repeat(513) }).length);
const moduleRoot = join(c.root, 'schema-fixture'); mkdirSync(join(moduleRoot, 'src'), { recursive: true });
cpSync(new URL('../src/validate.mjs', import.meta.url), join(moduleRoot, 'src/validate.mjs'));
cpSync(new URL('../schemas/', import.meta.url), join(moduleRoot, 'schemas'), { recursive: true });
const schema = JSON.parse(readFileSync(join(moduleRoot, 'schemas/requirement.schema.json')));
schema.minProperties = 100;
writeFileSync(join(moduleRoot, 'schemas/requirement.schema.json'), JSON.stringify(schema));
await assert.rejects(import(pathToFileURL(join(moduleRoot, 'src/validate.mjs'))), /unsupported schema keyword/);
const included = join(c.root, 'included.conf'); writeFileSync(included, '[user]\nname = other\n');
git(repo, 'config', 'include.path', included);
await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }), e => e.code === 'project-repository');
git(repo, 'config', '--unset', 'include.path');
writeFileSync(join(repo, 'requirements/.gitkeep'), 'not empty');
assert.match((await api.checkProject(c)).problems.join('\n'), /requirements\/\.gitkeep.*empty/);
});
test('a linked git directory is refused before a writer lock can touch its target', async t => {
const { c, repo } = await ready(t);
const { watch } = await import('node:fs');
const outside = join(c.root, 'external-git'); mkdirSync(outside);
rmSync(join(repo, '.git'), { recursive: true }); symlinkSync(outside, join(repo, '.git'));
const events = [], watcher = watch(outside, (_event, name) => events.push(name));
try {
await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }));
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(events, []);
} finally { watcher.close(); }
});
+1 -1
View File
@@ -30,6 +30,6 @@ if [ "${1:-}" = business ]; then
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
+1 -1
View File
@@ -23,7 +23,7 @@ copy_root() { [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-cha
COPY_PATHS=(
scripts/mosaic scripts/mosaic-config.mjs
adapters roles RELEASE package.json package-lock.json
packages/bus/src packages/bus/schema.sql packages/business/src packages/cli/src
packages/bus/src packages/bus/schema.sql packages/business/src packages/cli/src packages/project
packages/conversation/src packages/discord/src packages/discord/bin packages/discord/extension packages/harness/src
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
packages/webui/src packages/webui/README.md packages/cli/README.md