From 23552e09d738c08fa165c268121454edecc74458 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 4 Mar 2026 18:49:06 +0800 Subject: [PATCH] feat: add bidirectional ownership relation between project and organization Project can optionally belong to an organization via ownership link. census.list tree view displays projects under their owning organization. Co-Authored-By: Claude Opus 4.6 --- .changeset/project-org-ownership.md | 11 +++++++++++ bdd/features/project-lifecycle.feature | 10 ++++++++++ bdd/steps/direct.steps.ts | 13 +++++++++++++ packages/core/src/structures.ts | 2 ++ packages/prototype/src/instructions.ts | 7 ++++++- packages/prototype/src/ops.ts | 16 +++++++++++++--- 6 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 .changeset/project-org-ownership.md diff --git a/.changeset/project-org-ownership.md b/.changeset/project-org-ownership.md new file mode 100644 index 0000000..984b856 --- /dev/null +++ b/.changeset/project-org-ownership.md @@ -0,0 +1,11 @@ +--- +"@rolexjs/core": minor +"@rolexjs/prototype": minor +--- + +feat: add bidirectional ownership relation between project and organization + +- Add ownership relation on project structure pointing to organization +- project.launch accepts optional `org` parameter to link project to an organization +- census.list tree view displays projects under their owning organization +- Add BDD scenario and link assertion step for ownership verification diff --git a/bdd/features/project-lifecycle.feature b/bdd/features/project-lifecycle.feature index e30139d..106407d 100644 --- a/bdd/features/project-lifecycle.feature +++ b/bdd/features/project-lifecycle.feature @@ -15,6 +15,16 @@ Feature: Project lifecycle And the result state name should be "project" And the result state id should be "rolex-v2" + Scenario: Launch creates a project owned by an organization + Given organization "deepractice" exists + When I direct "!project.launch" with: + | content | Feature: RoleX v2 | + | id | rolex-v2-org | + | org | deepractice | + Then the result process should be "launch" + And the result state id should be "rolex-v2-org" + And the result state should have link "ownership" to "deepractice" + # ===== scope ===== Scenario: Scope defines project boundary diff --git a/bdd/steps/direct.steps.ts b/bdd/steps/direct.steps.ts index 9e2a07c..63e5aa9 100644 --- a/bdd/steps/direct.steps.ts +++ b/bdd/steps/direct.steps.ts @@ -114,6 +114,19 @@ Then("the direct result should contain {string}", function (this: BddWorld, text ); }); +Then( + "the result state should have link {string} to {string}", + function (this: BddWorld, relation: string, targetId: string) { + assert.ok(this.directRaw, `Expected a result but got error: ${this.error?.message ?? "none"}`); + const links = this.directRaw.state.links ?? []; + const found = links.some((l: any) => l.relation === relation && l.target.id === targetId); + assert.ok( + found, + `Expected link "${relation}" to "${targetId}" but got: ${JSON.stringify(links.map((l: any) => `${l.relation} → ${l.target.id}`))}` + ); + } +); + Then("it should fail", function (this: BddWorld) { assert.ok(this.error, "Expected an error but operation succeeded"); }); diff --git a/packages/core/src/structures.ts b/packages/core/src/structures.ts index 5cd62c0..0300d8b 100644 --- a/packages/core/src/structures.ts +++ b/packages/core/src/structures.ts @@ -27,6 +27,7 @@ * │ │ └── duty "Responsibilities of position" │ * │ ├── project "A process container" │ * │ │ │ ∿ participation → individual │ + * │ │ │ ∿ ownership → organization │ * │ │ ├── scope "Project boundary" │ * │ │ ├── milestone "Key checkpoint" │ * │ │ ├── deliverable "Project output" │ @@ -105,6 +106,7 @@ export const requirement = structure("requirement", "Required skill for this pos export const project = structure("project", "A process container for organized work", society, [ relation("participation", "Who participates in this project", individual), + relation("ownership", "Which organization owns this project", organization), ]); export const scope = structure( "scope", diff --git a/packages/prototype/src/instructions.ts b/packages/prototype/src/instructions.ts index 10ae028..928d8a8 100644 --- a/packages/prototype/src/instructions.ts +++ b/packages/prototype/src/instructions.ts @@ -470,8 +470,13 @@ const projectLaunch = def( }, id: { type: "string", required: false, description: "User-facing identifier (kebab-case)" }, alias: { type: "string[]", required: false, description: "Alternative names" }, + org: { + type: "string", + required: false, + description: "Organization id that owns this project", + }, }, - ["content", "id", "alias"] + ["content", "id", "alias", "org"] ); const projectScope = def( diff --git a/packages/prototype/src/ops.ts b/packages/prototype/src/ops.ts index 2eec1f1..be58256 100644 --- a/packages/prototype/src/ops.ts +++ b/packages/prototype/src/ops.ts @@ -322,10 +322,12 @@ export function createOps(ctx: OpsContext): Ops { async "project.launch"( content?: string, id?: string, - alias?: readonly string[] + alias?: readonly string[], + org?: string ): Promise { validateGherkin(content); const node = await rt.create(society, C.project, content, id, alias); + if (org) await rt.link(node, await resolve(org), "ownership", "project"); return ok(node, "launch"); }, @@ -507,10 +509,18 @@ export function createOps(ctx: OpsContext): Ops { const tag = org.tag ? ` #${org.tag}` : ""; lines.push(`${org.id}${alias}${tag}`); + // Projects owned by this org + const projects = org.links?.filter((l) => l.relation === "project") ?? []; + for (const p of projects) { + const pAlias = p.target.alias?.length ? ` (${p.target.alias.join(", ")})` : ""; + const pTag = p.target.tag ? ` #${p.target.tag}` : ""; + lines.push(` 📦 ${p.target.id ?? "(no id)"}${pAlias}${pTag}`); + } + // Members of this org const members = org.links?.filter((l) => l.relation === "membership") ?? []; - if (members.length === 0) { - lines.push(" (no members)"); + if (members.length === 0 && projects.length === 0) { + lines.push(" (empty)"); } for (const m of members) { affiliatedIndividuals.add(m.target.id ?? "");