From 0681afd48d7825a7a1a0112e248f3013d0123743 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Wed, 29 Jul 2026 01:07:28 -0500 Subject: [PATCH] Generate Foundation Plan subject IDs Foundation Plan authors need stable identities before adding independently mutable subjects, but the Skill should not invent or persist them itself. Expose the existing UUIDv7 generator as a pure leaf command with no project or network access. Document the continuity rules and exercise both direct and packed-package paths at the minimum runtime. --- README.md | 16 +++- scripts/smoke-package.js | 11 +++ src/cli.js | 80 ++++++++++++++++-- test/plan-init.test.js | 7 +- test/plan-subject-id.test.js | 153 +++++++++++++++++++++++++++++++++++ 5 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 test/plan-subject-id.test.js diff --git a/README.md b/README.md index 8a035c9..e70790b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ built for agents that author and review Foundation Plans with their users. The package is not released yet. This repository contains the auditable command shell, local Foundation Plan -initialization, and conditional whole-document push; release behavior will arrive in reviewed increments. +initialization, subject identity generation, and conditional whole-document push; release behavior will arrive in +reviewed increments. ## Requirements @@ -31,6 +32,19 @@ This creates an empty `sketch/0.19` Plan and client-generated Project ID under ` keeps that local scratch area out of Git without changing the project's own `.gitignore`. Initialization makes no network request and refuses to replace an existing `.firstdraft` path. +## Add Foundation Plan subjects + +Generate an identity before adding each new independently mutable authored subject: + +```sh +firstdraft plan subject-id +``` + +The command prints one UUIDv7 for the subject's `subject_uuid`. It does not read or modify the Plan, reserve the +value, or make a network request. Preserve that UUID when renaming the subject or moving it to a different semantic +owner without changing its kind. Use a new UUID for a replacement concept. Readable keys and paths may change and +remain the document's links; the UUID preserves continuity between complete-document pushes. + ## Push a Foundation Plan From the initialized project: diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 7184fcb..5d2d548 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -56,6 +56,17 @@ try { assert.equal(execution.stdout, `${packageMetadata.version}\n`); assert.equal(execution.stderr, ""); + + const subjectId = runNpm( + ["exec", "--offline", "--", "firstdraft", "plan", "subject-id"], + installationDirectory, + ); + + assert.match( + subjectId.stdout, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n$/, + ); + assert.equal(subjectId.stderr, ""); } finally { rmSync(temporaryDirectory, { recursive: true, force: true }); } diff --git a/src/cli.js b/src/cli.js index ce6685a..dc74945 100644 --- a/src/cli.js +++ b/src/cli.js @@ -33,8 +33,9 @@ Usage: firstdraft plan [options] Commands: - init Create a local empty Foundation Plan - push Send the local Foundation Plan to First Draft + init Create a local empty Foundation Plan + subject-id Generate a UUIDv7 for a new Plan subject + push Send the local Foundation Plan to First Draft Options: -h, --help Show help @@ -55,6 +56,18 @@ The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. `; +const PLAN_SUBJECT_ID_HELP = `First Draft CLI + +Usage: + firstdraft plan subject-id + +Prints one UUIDv7 for a new independently mutable Plan subject. +The command reads no files and makes no network request. + +Options: + -h, --help Show help +`; + const PLAN_INIT_HELP = `First Draft CLI Usage: @@ -89,6 +102,8 @@ const PLAN_PUSH_NETWORK_ERROR = "Could not complete the First Draft request. The Plan may have been accepted; local state was not changed.\n"; const PLAN_PUSH_PROTOCOL_ERROR = "First Draft returned an unexpected response. The Plan may have been accepted; local state was not changed.\n"; +const PLAN_SUBJECT_ID_USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; /** * @typedef {object} Writer @@ -101,7 +116,9 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @property {Writer} stdout * @property {Writer} stderr * @property {string} [cwd] + * @property {() => string} [getCwd] * @property {() => string} [createProjectId] + * @property {() => string} [createSubjectId] * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] * @property {typeof globalThis.fetch} [fetchFunction] * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] @@ -117,6 +134,7 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @property {Writer} stderr * @property {string} cwd * @property {() => string} createProjectId + * @property {() => string} createSubjectId * @property {import("./commands/plan-init.js").FileSystem} [fileSystem] * @property {typeof globalThis.fetch} [fetchFunction] * @property {import("./commands/plan-push.js").PlanPushFileSystem} [planPushFileSystem] @@ -125,13 +143,19 @@ const PLAN_PUSH_PROTOCOL_ERROR = * @property {string} [apiUrl] */ +/** + * @typedef {Omit & {cwd?: string, getCwd: () => string}} PlanCommandOptions + */ + /** @param {RunOptions} options */ export async function run({ argv, stdout, stderr, - cwd = process.cwd(), + cwd, + getCwd = process.cwd, createProjectId = generateUuidV7, + createSubjectId = generateUuidV7, fileSystem, fetchFunction, planPushFileSystem, @@ -145,7 +169,9 @@ export async function run({ stdout, stderr, cwd, + getCwd, createProjectId, + createSubjectId, fileSystem, fetchFunction, planPushFileSystem, @@ -201,13 +227,15 @@ function runRoot({ argv, stdout, stderr }) { return 0; } -/** @param {CommandOptions} options */ +/** @param {PlanCommandOptions} options */ async function runPlan({ argv, stdout, stderr, cwd, + getCwd, createProjectId, + createSubjectId, fileSystem, fetchFunction, planPushFileSystem, @@ -220,7 +248,7 @@ async function runPlan({ argv: argv.slice(1), stdout, stderr, - cwd, + cwd: cwd ?? getCwd(), createProjectId, fileSystem, }); @@ -231,7 +259,7 @@ async function runPlan({ argv: argv.slice(1), stdout, stderr, - cwd, + cwd: cwd ?? getCwd(), fetchFunction, planPushFileSystem, createTemporaryId, @@ -240,6 +268,15 @@ async function runPlan({ }); } + if (argv[0] === "subject-id") { + return runPlanSubjectId({ + argv: argv.slice(1), + stdout, + stderr, + createSubjectId, + }); + } + const parsed = parseArguments(() => parseArgs({ args: [...argv], @@ -268,6 +305,33 @@ async function runPlan({ return 0; } +/** + * @param {Pick} options + */ +function runPlanSubjectId({ argv, stdout, stderr, createSubjectId }) { + const parsed = parseArguments(() => + parseArgs({ + args: [...argv], + options: { help: { type: "boolean", short: "h" } }, + allowPositionals: false, + strict: true, + }), + ); + + if (!parsed) { + stderr.write(PLAN_SUBJECT_ID_USAGE_ERROR); + return 2; + } + + if (parsed.values.help) { + stdout.write(PLAN_SUBJECT_ID_HELP); + return 0; + } + + stdout.write(`${createSubjectId()}\n`); + return 0; +} + /** * @param {Pick} options */ @@ -365,7 +429,9 @@ async function runPlanPush({ return 0; } -/** @param {CommandOptions} options */ +/** + * @param {Pick} options + */ function runPlanInit({ argv, stdout, diff --git a/test/plan-init.test.js b/test/plan-init.test.js index 025c716..5a01e4c 100644 --- a/test/plan-init.test.js +++ b/test/plan-init.test.js @@ -26,8 +26,9 @@ Usage: firstdraft plan [options] Commands: - init Create a local empty Foundation Plan - push Send the local Foundation Plan to First Draft + init Create a local empty Foundation Plan + subject-id Generate a UUIDv7 for a new Plan subject + push Send the local Foundation Plan to First Draft Options: -h, --help Show help @@ -72,7 +73,7 @@ const EXPECTED_STATE = `{ } `; -test("plan help describes the available command", async () => { +test("plan help describes the available commands", async () => { assert.deepEqual(await invoke(["plan"]), { status: 0, stdout: PLAN_HELP, diff --git a/test/plan-subject-id.test.js b/test/plan-subject-id.test.js new file mode 100644 index 0000000..45dca68 --- /dev/null +++ b/test/plan-subject-id.test.js @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { run } from "../src/cli.js"; + +const SUBJECT_ID = "01900000-0000-7000-8000-000000000302"; +const HELP = `First Draft CLI + +Usage: + firstdraft plan subject-id + +Prints one UUIDv7 for a new independently mutable Plan subject. +The command reads no files and makes no network request. + +Options: + -h, --help Show help +`; +const USAGE_ERROR = + "Invalid arguments.\nRun 'firstdraft plan subject-id --help' for usage.\n"; +const UUID_V7 = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\n$/; +/** @satisfies {Partial} */ +const INACCESSIBLE_DEPENDENCIES = { + getCwd: () => { + throw new Error("Working directory must not be read"); + }, + fileSystem: { + mkdirSync() { + throw new Error("Filesystem must not run"); + }, + writeFileSync() { + throw new Error("Filesystem must not run"); + }, + }, + planPushFileSystem: { + lstatSync() { + throw new Error("Filesystem must not run"); + }, + readFileSync() { + throw new Error("Filesystem must not run"); + }, + renameSync() { + throw new Error("Filesystem must not run"); + }, + writeFileSync() { + throw new Error("Filesystem must not run"); + }, + }, + fetchFunction: async () => { + throw new Error("Network must not run"); + }, + createRequestSignal: () => { + throw new Error("Network setup must not run"); + }, +}; + +test("plan subject-id prints exactly one generated UUID", async () => { + let calls = 0; + const result = await invoke(["plan", "subject-id"], { + ...INACCESSIBLE_DEPENDENCIES, + createProjectId: () => { + throw new Error("Project ID generation must not run"); + }, + createSubjectId: () => { + calls += 1; + return SUBJECT_ID; + }, + }); + + assert.deepEqual(result, { + status: 0, + stdout: `${SUBJECT_ID}\n`, + stderr: "", + }); + assert.equal(calls, 1); +}); + +test("plan subject-id uses the production generator for fresh UUIDv7s", async () => { + const first = await invoke(["plan", "subject-id"], INACCESSIBLE_DEPENDENCIES); + const second = await invoke( + ["plan", "subject-id"], + INACCESSIBLE_DEPENDENCIES, + ); + + for (const result of [first, second]) { + assert.equal(result.status, 0); + assert.match(result.stdout, UUID_V7); + assert.equal(result.stderr, ""); + } + assert.notEqual(first.stdout, second.stdout); +}); + +test("plan subject-id help has no generation prerequisites", async () => { + for (const argv of [ + ["plan", "subject-id", "--help"], + ["plan", "subject-id", "-h"], + ["plan", "subject-id", "--help", "--help"], + ["plan", "subject-id", "-h", "-h"], + ]) { + assert.deepEqual( + await invoke(argv, { + ...INACCESSIBLE_DEPENDENCIES, + createSubjectId: () => { + throw new Error("Subject ID generation must not run"); + }, + }), + { status: 0, stdout: HELP, stderr: "" }, + ); + } +}); + +test("plan subject-id validates arguments before generating an ID", async () => { + const canary = "canary-secret-argument"; + + for (const argv of [ + ["plan", "subject-id", canary], + ["plan", "subject-id", `--${canary}`], + ["plan", "subject-id", "--help", canary], + ["plan", "subject-id", canary, "--help"], + ]) { + const result = await invoke(argv, { + ...INACCESSIBLE_DEPENDENCIES, + createSubjectId: () => { + throw new Error("Subject ID generation must not run"); + }, + }); + + assert.deepEqual(result, { + status: 2, + stdout: "", + stderr: USAGE_ERROR, + }); + assert.doesNotMatch(result.stderr, /canary-secret/); + } +}); + +/** + * @param {readonly string[]} argv + * @param {Partial} [overrides] + */ +async function invoke(argv, overrides = {}) { + let stdout = ""; + let stderr = ""; + + const status = await run({ + argv, + stdout: { write: (text) => (stdout += text) }, + stderr: { write: (text) => (stderr += text) }, + ...overrides, + }); + + return { status, stdout, stderr }; +}