Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@

# logs
yarn-error.log
.pnpm-debug.log
.pnpm-debug.log
**/.flatbread-efforts/.journal/
7 changes: 7 additions & 0 deletions packages/effort-graph/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# `@flatbread/effort-graph`

The standalone semantic writer for Flatbread's git-native Effort Graph. It stores typed reasoning primitives as markdown and uses a journal for multi-file mutations.

The v1 mutation surface is exactly: `CreateEffort`, `SetEffortStatus`, `WriteIssue`, `WriteFinding`, `WriteDecision`, `WriteConstraint`, `WriteRisk`, `Supersede`, `Invalidate`, `ResolveIssue`, `AcceptDecision`, `MitigateRisk`, and `SetRiskState`.

The journal lives at `<root>/.journal` and is ignored by git. Use `effortGraphContent()` to add the six collections to a Flatbread configuration. Cross-collection union fields remain writer-validated rather than Flatbread `refs`.
44 changes: 44 additions & 0 deletions packages/effort-graph/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@flatbread/effort-graph",
"version": "0.1.0-alpha.0",
"description": "Standalone semantic writer for the Flatbread Effort Graph — journaled multi-file mutations over epistemic markdown artifacts.",
"type": "module",
"scripts": {
"build": "tsup",
"dev": "tsup --watch src",
"test": "pnpm --dir ../.. exec ava \"packages/effort-graph/src/__tests__/**/*.test.ts\"",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"repository": {
"type": "git",
"url": "git+https://github.com/FlatbreadLabs/flatbread.git",
"directory": "packages/effort-graph"
},
"homepage": "https://github.com/FlatbreadLabs/flatbread#readme",
"author": "Tony Ketcham <ketcham.dev@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/FlatbreadLabs/flatbread/issues"
},
"exports": {
".": "./dist/index.js"
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"*.d.ts"
],
"dependencies": {
"gray-matter": "^4.0.3",
"zod": "^3.24.1"
},
"devDependencies": {
"@flatbread/core": "workspace:*",
"@flatbread/transformer-markdown": "workspace:*",
"@types/node": "25.6.2",
"tsup": "8.5.1",
"typescript": "6.0.3"
}
}
89 changes: 89 additions & 0 deletions packages/effort-graph/src/__tests__/ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import test from 'ava';
import { mkdtemp, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { generateArtifactId, slugify, validateArtifactId } from '../ids.js';
import { createEffortGraphWriter } from '../writer.js';
import { EffortGraphValidationError } from '../errors.js';
import type { PrimitiveKind } from '../types.js';

const kinds: [PrimitiveKind, string][] = [
['effort', 'eff'],
['issue', 'iss'],
['finding', 'fnd'],
['decision', 'dec'],
['constraint', 'con'],
['risk', 'rsk'],
];

test('generates valid ids for all six prefixes', (t) => {
for (const [kind, prefix] of kinds) {
const id = generateArtifactId(kind, 'Some Great Title');
t.true(id.startsWith(`${prefix}-some-great-title--`));
t.true(validateArtifactId(id, kind));
}
});

test('suffix is exactly 16 chars of the lowercase Crockford alphabet', (t) => {
for (let i = 0; i < 25; i++) {
const id = generateArtifactId('finding', 'x');
const suffix = id.split('--')[1];
t.is(suffix.length, 16);
t.regex(suffix, /^[0123456789abcdefghjkmnpqrstvwxyz]{16}$/);
t.notRegex(suffix, /[ilou]/);
}
});

test('slug is capped at 48 characters', (t) => {
const slug = slugify('word '.repeat(40));
t.true(slug.length <= 48);
t.true(slug.length >= 40);
t.notRegex(slug, /^-|-$/);
});

test('empty title slugifies to untitled', (t) => {
t.is(slugify(''), 'untitled');
t.is(slugify('!!! ???'), 'untitled');
const id = generateArtifactId('issue', ' ');
t.true(id.startsWith('iss-untitled--'));
});

test('validateArtifactId rejects malformed ids', (t) => {
const suffix = '0123456789abcdef';
t.true(validateArtifactId(`dec-good--${suffix}`));
// Wrong / unknown prefix.
t.false(validateArtifactId(`xxx-good--${suffix}`));
// Prefix that does not match the expected kind.
t.false(validateArtifactId(`dec-good--${suffix}`, 'finding'));
// Uppercase anywhere.
t.false(validateArtifactId(`DEC-good--${suffix}`));
t.false(validateArtifactId(`dec-Good--${suffix}`));
t.false(validateArtifactId(`dec-good--0123456789ABCDEF`));
// Bad alphabet chars in suffix (i, l, o, u are excluded).
t.false(validateArtifactId('dec-good--0123456789abcdei'));
t.false(validateArtifactId('dec-good--l123456789abcdef'));
t.false(validateArtifactId('dec-good--o123456789abcdef'));
t.false(validateArtifactId('dec-good--u123456789abcdef'));
// Malformed separators.
t.false(validateArtifactId(`dec-good-${suffix}`));
t.false(validateArtifactId(`dec--good--${suffix}`));
t.false(validateArtifactId(`dec-good--${suffix}x`));
t.false(validateArtifactId(`dec---${suffix}`));
t.false(validateArtifactId(''));
});

test('writer rejects a stubbed randomBytes collision before writing', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'eg-ids-'));
const writer = createEffortGraphWriter({
rootDir: root,
randomBytes: () => new Uint8Array(10).fill(7),
});
await writer.mutate({ type: 'CreateEffort', title: 'Same Title', body: '' });
const error = await t.throwsAsync(
writer.mutate({ type: 'CreateEffort', title: 'Same Title', body: '' }),
{ instanceOf: EffortGraphValidationError }
);
t.truthy(error);
const files = await readdir(join(root, 'efforts'));
t.is(files.length, 1);
});
137 changes: 137 additions & 0 deletions packages/effort-graph/src/__tests__/journal-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import test from 'ava';
import { mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { recoverJournal } from '../journal.js';
import { EffortGraphCorruptJournalError } from '../errors.js';

function writeEntry(
relativePath: string,
before: string | undefined,
after: string
) {
return {
relativePath,
before: {
exists: before !== undefined,
...(before !== undefined
? { base64: Buffer.from(before).toString('base64') }
: {}),
},
after: {
sha256: createHash('sha256').update(after).digest('hex'),
base64: Buffer.from(after).toString('base64'),
},
};
}

async function makeRoot() {
const root = await mkdtemp(join(tmpdir(), 'eg-journal-'));
await mkdir(join(root, 'efforts'), { recursive: true });
await mkdir(join(root, '.journal', 'txns'), { recursive: true });
return root;
}

async function writeIntent(
root: string,
txnId: string,
writes: unknown[],
markers: Array<'committed' | 'published'> = []
) {
const td = join(root, '.journal', 'txns', txnId);
await mkdir(td, { recursive: true });
await writeFile(
join(td, 'intent.json'),
JSON.stringify({
transactionId: txnId,
createdAt: new Date().toISOString(),
targetGeneration: 7,
lockToken: 'test-token',
writes,
touchedIds: ['eff-a--0123456789abcdef'],
})
);
for (const marker of markers) await writeFile(join(td, marker), '');
return td;
}

test('uncommitted txn rolls back before-images and removes temp remnants', async (t) => {
const root = await makeRoot();
const updatedPath = join(root, 'efforts', 'a.md');
const createdPath = join(root, 'efforts', 'b.md');
// Simulate a partially applied transaction: a.md overwritten, b.md created.
await writeFile(updatedPath, 'NEW A');
await writeFile(createdPath, 'NEW B');
await writeFile(join(root, 'efforts', 'b.tmp-remnant.md'), 'partial');
await writeIntent(root, 't-uncommitted', [
writeEntry('efforts/a.md', 'OLD A', 'NEW A'),
writeEntry('efforts/b.md', undefined, 'NEW B'),
]);
const result = await recoverJournal(root, async () => {});
t.is(result.action, 'rolled_back');
t.is(result.transactionId, 't-uncommitted');
t.is(await readFile(updatedPath, 'utf8'), 'OLD A');
t.false(existsSync(createdPath));
const remnants = (await readdir(join(root, 'efforts'))).filter((n) =>
n.includes('.tmp-')
);
t.deepEqual(remnants, []);
t.deepEqual(await readdir(join(root, '.journal', 'txns')), []);
});

test('committed txn re-applies after-images, reindexes once, and publishes', async (t) => {
const root = await makeRoot();
const path = join(root, 'efforts', 'a.md');
// Stale content that does not match the after-image sha.
await writeFile(path, 'STALE');
await writeIntent(
root,
't-committed',
[writeEntry('efforts/a.md', 'OLD A', 'FINAL A')],
['committed']
);
let reindexCalls = 0;
const result = await recoverJournal(root, async (request) => {
reindexCalls += 1;
t.is(request.targetGeneration, '7');
t.deepEqual([...request.changedPaths], ['efforts/a.md']);
});
t.is(result.action, 'completed');
t.is(reindexCalls, 1);
t.is(await readFile(path, 'utf8'), 'FINAL A');
const generation = JSON.parse(
await readFile(join(root, '.journal', 'generation.json'), 'utf8')
);
t.is(generation.generation, 7);
t.deepEqual(await readdir(join(root, '.journal', 'txns')), []);
});

test('recovery is idempotent when run twice', async (t) => {
const root = await makeRoot();
await writeFile(join(root, 'efforts', 'a.md'), 'NEW A');
await writeIntent(root, 't-repeat', [
writeEntry('efforts/a.md', 'OLD A', 'NEW A'),
]);
const first = await recoverJournal(root, async () => {});
t.is(first.action, 'rolled_back');
const second = await recoverJournal(root, async () => {});
t.is(second.action, 'none');
t.is(await readFile(join(root, 'efforts', 'a.md'), 'utf8'), 'OLD A');
});

test('corrupt intent.json fails closed', async (t) => {
const root = await makeRoot();
const td = join(root, '.journal', 'txns', 't-corrupt');
await mkdir(td, { recursive: true });
await writeFile(join(td, 'intent.json'), '{ this is not json');
await t.throwsAsync(
recoverJournal(root, async () => {}),
{
instanceOf: EffortGraphCorruptJournalError,
}
);
// The corrupt transaction is left untouched — never guess.
t.true(existsSync(join(td, 'intent.json')));
});
Loading
Loading