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:
1 parent
d8487b3af8
commit
686fc1cb6d
24 files changed
+987
-5
No files matched your search
@@ -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.
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` });
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Document title
|
||||
|
||||
Write the project documentation here.
|
||||
@@ -0,0 +1,3 @@
|
||||
# North star
|
||||
|
||||
Describe the outcome this project should achieve and the boundaries it must respect.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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" "$@"
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user