From ca32e1c9c78f2155e3ad99336a0f47e19cda3d06 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 4 Mar 2026 12:15:31 +0800 Subject: [PATCH 1/3] feat: integrate IssueX with issue-render layer Add IssueX issue tracking system to RoleX: - Platform and LocalPlatform support IssueX provider injection - 11 issue operations (publish/get/list/update/close/reopen/assign/comment/comments/label/unlabel) - issue-render module in rolexjs for human-readable output - Role.use() intercepts !issue.* commands and renders results - Fix ParamType to support "number" for issue schemas - Fix focus() to reject non-goal ids - Add issue-management skill Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-focus-goal-only.md | 8 + .changeset/issuex-integration.md | 13 ++ .github/workflows/changesets.yml | 8 + bun.lock | 27 ++- packages/core/package.json | 1 + packages/core/src/platform.ts | 4 + packages/local-platform/package.json | 1 + packages/local-platform/src/LocalPlatform.ts | 18 ++ packages/prototype/package.json | 2 + packages/prototype/src/descriptions/index.ts | 2 +- .../src/descriptions/role/focus.feature | 8 +- packages/prototype/src/instructions.ts | 130 +++++++++++++++ packages/prototype/src/ops.ts | 98 ++++++++++- packages/prototype/src/schema.ts | 2 +- packages/rolexjs/package.json | 1 + packages/rolexjs/src/index.ts | 9 + packages/rolexjs/src/issue-render.ts | 154 ++++++++++++++++++ packages/rolexjs/src/role.ts | 21 ++- packages/rolexjs/src/rolex.ts | 18 ++ packages/rolexjs/tests/rolex.test.ts | 15 +- skills/issue-management/SKILL.md | 107 ++++++++++++ skills/issue-management/resource.json | 7 + 22 files changed, 637 insertions(+), 17 deletions(-) create mode 100644 .changeset/fix-focus-goal-only.md create mode 100644 .changeset/issuex-integration.md create mode 100644 packages/rolexjs/src/issue-render.ts create mode 100644 skills/issue-management/SKILL.md create mode 100644 skills/issue-management/resource.json diff --git a/.changeset/fix-focus-goal-only.md b/.changeset/fix-focus-goal-only.md new file mode 100644 index 0000000..e6a9dbb --- /dev/null +++ b/.changeset/fix-focus-goal-only.md @@ -0,0 +1,8 @@ +--- +"rolexjs": patch +"@rolexjs/prototype": patch +--- + +fix(focus): reject non-goal ids passed to focus + +focus() now validates that the provided id is a goal node. Passing a plan, task, or other node type returns a clear error instead of silently corrupting the focused state. diff --git a/.changeset/issuex-integration.md b/.changeset/issuex-integration.md new file mode 100644 index 0000000..211c59e --- /dev/null +++ b/.changeset/issuex-integration.md @@ -0,0 +1,13 @@ +--- +"rolexjs": minor +"@rolexjs/prototype": minor +"@rolexjs/core": minor +--- + +feat: integrate IssueX for issue tracking between AI individuals + +- Add IssueX support to Platform and LocalPlatform +- Add issue operations (publish, get, list, update, close, reopen, assign, comment, label, unlabel) to prototype ops +- Add issue-render module in rolexjs for human-readable output formatting +- Role.use() now renders issue results as readable text instead of raw JSON +- Add "number" to ParamType for issue instruction schemas diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml index 1229425..4f6fa9d 100644 --- a/.github/workflows/changesets.yml +++ b/.github/workflows/changesets.yml @@ -98,3 +98,11 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "### Published packages:" >> $GITHUB_STEP_SUMMARY echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | "- `\(.name)@\(.version)`"' >> $GITHUB_STEP_SUMMARY + + - name: Sync main back to dev + if: steps.changesets.outputs.published == 'true' + run: | + git fetch origin dev + git checkout dev + git merge main --no-edit + git push origin dev diff --git a/bun.lock b/bun.lock index c8bdf63..debfaf9 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ }, "apps/mcp-server": { "name": "@rolexjs/mcp-server", - "version": "0.11.0", + "version": "1.0.0", "bin": { "rolex-mcp": "./dist/index.js", }, @@ -56,22 +56,24 @@ }, "packages/core": { "name": "@rolexjs/core", - "version": "0.11.0", + "version": "1.0.0", "dependencies": { + "@issuexjs/core": "^0.2.0", "@resourcexjs/core": "^2.14.0", "@rolexjs/system": "workspace:*", }, }, "packages/genesis": { "name": "@rolexjs/genesis", - "version": "0.1.0", + "version": "1.0.0", }, "packages/local-platform": { "name": "@rolexjs/local-platform", - "version": "0.11.0", + "version": "1.0.0", "dependencies": { "@deepracticex/drizzle": "^0.2.0", "@deepracticex/sqlite": "^0.2.0", + "@issuexjs/node": "^0.2.0", "@resourcexjs/node-provider": "^2.14.0", "@rolexjs/core": "workspace:*", "@rolexjs/system": "workspace:*", @@ -80,7 +82,7 @@ }, "packages/parser": { "name": "@rolexjs/parser", - "version": "0.11.0", + "version": "1.0.0", "dependencies": { "@cucumber/gherkin": "^38.0.0", "@cucumber/messages": "^32.0.0", @@ -88,22 +90,25 @@ }, "packages/prototype": { "name": "@rolexjs/prototype", - "version": "0.11.0", + "version": "1.0.0", "dependencies": { + "@issuexjs/core": "^0.2.0", "@rolexjs/core": "workspace:*", "@rolexjs/parser": "workspace:*", "@rolexjs/system": "workspace:*", + "issuexjs": "^0.2.0", "resourcexjs": "^2.14.0", }, }, "packages/rolexjs": { "name": "rolexjs", - "version": "0.11.0", + "version": "1.0.0", "dependencies": { "@rolexjs/core": "workspace:*", "@rolexjs/parser": "workspace:*", "@rolexjs/prototype": "workspace:*", "@rolexjs/system": "workspace:*", + "issuexjs": "^0.2.0", "resourcexjs": "^2.14.0", }, "devDependencies": { @@ -112,7 +117,7 @@ }, "packages/system": { "name": "@rolexjs/system", - "version": "0.11.0", + "version": "1.0.0", }, }, "overrides": { @@ -296,6 +301,10 @@ "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + "@issuexjs/core": ["@issuexjs/core@0.2.0", "", {}, "sha512-6TZqxHJtGv8SMDlr81KOhmAcZIjNkPZS7g748YDJnkwr5lvNZv5NnkjE6y94Co93g0l8xptV7hw9yL6nYBKU7w=="], + + "@issuexjs/node": ["@issuexjs/node@0.2.0", "", { "dependencies": { "@issuexjs/core": "^0.2.0" } }, "sha512-dfa1KCcewe9HJaQbrNTP8wdTPETmA+6oqeZalT0xTwaFMpk/aGqnKkxvVk93v9X4wdqIskK6VUkgTKn0uTqxeg=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -756,6 +765,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "issuexjs": ["issuexjs@0.2.0", "", { "dependencies": { "@issuexjs/core": "^0.2.0" } }, "sha512-DFSRjtLCgtopBqlBRfPJOfLIqES2yagirEYeRMJPyZrWPeRlgCCbI9W6TEevXnnoOR1k3n21+2YuRCkxnPl08w=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], diff --git a/packages/core/package.json b/packages/core/package.json index 983820d..01614cd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,6 +39,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@issuexjs/core": "^0.2.0", "@rolexjs/system": "workspace:*", "@resourcexjs/core": "^2.14.0" }, diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts index 747506b..4b92b23 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -11,6 +11,7 @@ * (ResourceX, bootstrap config) to form a complete runtime environment. */ +import type { IssueXProvider } from "@issuexjs/core"; import type { CustomExecutor, ResourceXProvider } from "@resourcexjs/core"; import type { Initializer, Runtime } from "@rolexjs/system"; @@ -57,6 +58,9 @@ export interface Platform { /** Custom executor for ResourceX resolver execution (e.g., QuickJS Wasm for Workers). */ readonly resourcexExecutor?: CustomExecutor; + /** IssueX provider — injected storage backend for issue tracking. */ + readonly issuexProvider?: IssueXProvider; + /** Initializer — bootstrap the world on first run. */ readonly initializer?: Initializer; diff --git a/packages/local-platform/package.json b/packages/local-platform/package.json index 42f114f..9f7f7c5 100644 --- a/packages/local-platform/package.json +++ b/packages/local-platform/package.json @@ -22,6 +22,7 @@ "dependencies": { "@deepracticex/drizzle": "^0.2.0", "@deepracticex/sqlite": "^0.2.0", + "@issuexjs/node": "^0.2.0", "@resourcexjs/node-provider": "^2.14.0", "@rolexjs/core": "workspace:*", "@rolexjs/system": "workspace:*", diff --git a/packages/local-platform/src/LocalPlatform.ts b/packages/local-platform/src/LocalPlatform.ts index 8ace72e..19e5ea4 100644 --- a/packages/local-platform/src/LocalPlatform.ts +++ b/packages/local-platform/src/LocalPlatform.ts @@ -12,6 +12,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { drizzle } from "@deepracticex/drizzle"; import { openDatabase } from "@deepracticex/sqlite"; +import { NodeProvider as IssueXNodeProvider } from "@issuexjs/node"; import { NodeProvider } from "@resourcexjs/node-provider"; import type { Platform } from "@rolexjs/core"; import type { Initializer } from "@rolexjs/system"; @@ -61,6 +62,22 @@ export function localPlatform(config: LocalPlatformConfig = {}): Platform { const resourcexProvider = config.resourceDir !== null ? new NodeProvider() : undefined; + // ===== IssueX Provider ===== + + const issuexProvider = new IssueXNodeProvider({ + db: { + run(sql: string, ...params: unknown[]) { + rawDb.prepare(sql).run(...params); + }, + get(sql: string, ...params: unknown[]): T | null { + return rawDb.prepare(sql).get(...params) as T | null; + }, + all(sql: string, ...params: unknown[]): T[] { + return rawDb.prepare(sql).all(...params) as T[]; + }, + }, + }); + // ===== Initializer ===== const initializer: Initializer = { @@ -70,6 +87,7 @@ export function localPlatform(config: LocalPlatformConfig = {}): Platform { return { repository, resourcexProvider, + issuexProvider, initializer, bootstrap: config.bootstrap, }; diff --git a/packages/prototype/package.json b/packages/prototype/package.json index 1029f00..c50ed77 100644 --- a/packages/prototype/package.json +++ b/packages/prototype/package.json @@ -39,9 +39,11 @@ "clean": "rm -rf dist" }, "dependencies": { + "@issuexjs/core": "^0.2.0", "@rolexjs/core": "workspace:*", "@rolexjs/system": "workspace:*", "@rolexjs/parser": "workspace:*", + "issuexjs": "^0.2.0", "resourcexjs": "^2.14.0" }, "publishConfig": { diff --git a/packages/prototype/src/descriptions/index.ts b/packages/prototype/src/descriptions/index.ts index 77c1815..e974bb8 100644 --- a/packages/prototype/src/descriptions/index.ts +++ b/packages/prototype/src/descriptions/index.ts @@ -42,7 +42,7 @@ export const processes: Record = { finish: "Feature: finish — complete a task\n Mark a task as done and create an encounter.\n The encounter records what happened and can be reflected on for learning.\n\n Scenario: Finish a task\n Given a task exists\n When finish is called on the task\n Then the task is tagged #done and stays in the tree\n And an encounter is created under the role\n\n Scenario: Finish with experience\n Given a task is completed with a notable learning\n When finish is called with an optional experience parameter\n Then the experience text is attached to the encounter\n\n Scenario: Finish without encounter\n Given a task is completed with no notable learning\n When finish is called without the encounter parameter\n Then the task is tagged #done but no encounter is created\n And the task stays in the tree — visible via focus on the parent goal\n\n Scenario: Writing the encounter Gherkin\n Given the encounter records what happened — a raw account of the experience\n Then the Feature title describes what was done\n And Scenarios capture what was done, what was encountered, and what resulted\n And the tone is concrete and specific — tied to this particular task", focus: - "Feature: focus — view or switch focused goal\n View the current goal's state, or switch focus to a different goal.\n Subsequent plan and todo operations target the focused goal.\n\n Scenario: View current goal\n Given an active goal exists\n When focus is called without a name\n Then the current goal's state tree is projected\n And plans and tasks under the goal are visible\n\n Scenario: Switch focus\n Given multiple goals exist\n When focus is called with a goal name\n Then the focused goal switches to the named goal\n And subsequent plan and todo operations target this goal", + "Feature: focus — view or switch focused goal\n View the current goal's state, or switch focus to a different goal.\n Subsequent plan and todo operations target the focused goal.\n Only goal ids are accepted — plan, task, or other node types are rejected.\n\n Scenario: View current goal\n Given an active goal exists\n When focus is called without a name\n Then the current goal's state tree is projected\n And plans and tasks under the goal are visible\n\n Scenario: Switch focus\n Given multiple goals exist\n When focus is called with a goal id\n Then the focused goal switches to the named goal\n And subsequent plan and todo operations target this goal\n\n Scenario: Reject non-goal ids\n Given a plan or task id is passed to focus\n Then focus returns an error indicating the node type\n And suggests using the correct goal id instead", forget: "Feature: forget — remove a node from the individual\n Remove any node under the individual by its id.\n Use forget to discard outdated knowledge, stale encounters, or obsolete skills.\n\n Scenario: Forget a node\n Given a node exists under the individual (principle, procedure, experience, encounter, etc.)\n When forget is called with the node's id\n Then the node and its subtree are removed\n And the individual no longer carries that knowledge or record\n\n Scenario: When to use forget\n Given a principle has become outdated or incorrect\n And a procedure references a skill that no longer exists\n And an encounter or experience has no further learning value\n When the role decides to discard it\n Then call forget with the node id", master: diff --git a/packages/prototype/src/descriptions/role/focus.feature b/packages/prototype/src/descriptions/role/focus.feature index a611973..a6f9bbe 100644 --- a/packages/prototype/src/descriptions/role/focus.feature +++ b/packages/prototype/src/descriptions/role/focus.feature @@ -1,6 +1,7 @@ Feature: focus — view or switch focused goal View the current goal's state, or switch focus to a different goal. Subsequent plan and todo operations target the focused goal. + Only goal ids are accepted — plan, task, or other node types are rejected. Scenario: View current goal Given an active goal exists @@ -10,6 +11,11 @@ Feature: focus — view or switch focused goal Scenario: Switch focus Given multiple goals exist - When focus is called with a goal name + When focus is called with a goal id Then the focused goal switches to the named goal And subsequent plan and todo operations target this goal + + Scenario: Reject non-goal ids + Given a plan or task id is passed to focus + Then focus returns an error indicating the node type + And suggests using the correct goal id instead diff --git a/packages/prototype/src/instructions.ts b/packages/prototype/src/instructions.ts index df52056..11b5bec 100644 --- a/packages/prototype/src/instructions.ts +++ b/packages/prototype/src/instructions.ts @@ -576,6 +576,123 @@ const resourceClearCache = def( ["registry"] ); +// ================================================================ +// Issue — IssueX proxy +// ================================================================ + +const issuePublish = def( + "issue", + "publish", + { + title: { type: "string", required: true, description: "Issue title" }, + body: { type: "string", required: true, description: "Issue body/description" }, + author: { type: "string", required: true, description: "Author individual id" }, + assignee: { type: "string", required: false, description: "Assignee individual id" }, + }, + ["title", "body", "author", "assignee"] +); + +const issueGet = def( + "issue", + "get", + { + number: { type: "number", required: true, description: "Issue number" }, + }, + ["number"] +); + +const issueList = def( + "issue", + "list", + { + status: { type: "string", required: false, description: "Filter by status (open/closed)" }, + author: { type: "string", required: false, description: "Filter by author" }, + assignee: { type: "string", required: false, description: "Filter by assignee" }, + label: { type: "string", required: false, description: "Filter by label name" }, + }, + ["status", "author", "assignee", "label"] +); + +const issueUpdate = def( + "issue", + "update", + { + number: { type: "number", required: true, description: "Issue number" }, + title: { type: "string", required: false, description: "New title" }, + body: { type: "string", required: false, description: "New body" }, + assignee: { type: "string", required: false, description: "New assignee" }, + }, + ["number", "title", "body", "assignee"] +); + +const issueClose = def( + "issue", + "close", + { + number: { type: "number", required: true, description: "Issue number to close" }, + }, + ["number"] +); + +const issueReopen = def( + "issue", + "reopen", + { + number: { type: "number", required: true, description: "Issue number to reopen" }, + }, + ["number"] +); + +const issueAssign = def( + "issue", + "assign", + { + number: { type: "number", required: true, description: "Issue number" }, + assignee: { type: "string", required: true, description: "Individual id to assign" }, + }, + ["number", "assignee"] +); + +const issueComment = def( + "issue", + "comment", + { + number: { type: "number", required: true, description: "Issue number" }, + body: { type: "string", required: true, description: "Comment body" }, + author: { type: "string", required: true, description: "Author individual id" }, + }, + ["number", "body", "author"] +); + +const issueComments = def( + "issue", + "comments", + { + number: { type: "number", required: true, description: "Issue number" }, + }, + ["number"] +); + +const issueLabel = def( + "issue", + "label", + { + number: { type: "number", required: true, description: "Issue number" }, + label: { type: "string", required: true, description: "Label name" }, + }, + ["number", "label"] +); + +const issueUnlabel = def( + "issue", + "unlabel", + { + number: { type: "number", required: true, description: "Issue number" }, + label: { type: "string", required: true, description: "Label name to remove" }, + }, + ["number", "label"] +); + // ================================================================ // Instruction registry — keyed by "namespace.method" // ================================================================ @@ -635,4 +752,17 @@ export const instructions: Record = { "resource.push": resourcePush, "resource.pull": resourcePull, "resource.clearCache": resourceClearCache, + + // issue + "issue.publish": issuePublish, + "issue.get": issueGet, + "issue.list": issueList, + "issue.update": issueUpdate, + "issue.close": issueClose, + "issue.reopen": issueReopen, + "issue.assign": issueAssign, + "issue.comment": issueComment, + "issue.comments": issueComments, + "issue.label": issueLabel, + "issue.unlabel": issueUnlabel, }; diff --git a/packages/prototype/src/ops.ts b/packages/prototype/src/ops.ts index 1c62bed..bf6d543 100644 --- a/packages/prototype/src/ops.ts +++ b/packages/prototype/src/ops.ts @@ -12,6 +12,7 @@ import * as C from "@rolexjs/core"; import { parse } from "@rolexjs/parser"; import type { Runtime, State, Structure } from "@rolexjs/system"; +import type { IssueX } from "issuexjs"; import type { ResourceX } from "resourcexjs"; // ================================================================ @@ -30,6 +31,7 @@ export interface OpsContext { resolve(id: string): Structure | Promise; find(id: string): (Structure | null) | Promise; resourcex?: ResourceX; + issuex?: IssueX; prototype?: { settle(id: string, source: string): void; evict(id: string): void; @@ -46,7 +48,7 @@ export type Ops = Record any>; // ================================================================ export function createOps(ctx: OpsContext): Ops { - const { rt, society, past, resolve, resourcex } = ctx; + const { rt, society, past, resolve, resourcex, issuex } = ctx; // ---- Helpers ---- @@ -94,6 +96,11 @@ export function createOps(ctx: OpsContext): Ops { return resourcex; } + function requireIssueX(): IssueX { + if (!issuex) throw new Error("IssueX is not available."); + return issuex; + } + // ================================================================ // Operations // ================================================================ @@ -531,6 +538,95 @@ export function createOps(ctx: OpsContext): Ops { "resource.clearCache"(registry?: string) { return requireResourceX().clearCache(registry); }, + + // ---- Issue (proxy to IssueX) ---- + + async "issue.publish"(title: string, body: string, author: string, assignee?: string) { + const ix = requireIssueX(); + return ix.createIssue({ title, body, author, assignee }); + }, + + async "issue.get"(number: number) { + return requireIssueX().getIssueByNumber(number); + }, + + async "issue.list"(status?: string, author?: string, assignee?: string, label?: string) { + const filter: Record = {}; + if (status) filter.status = status; + if (author) filter.author = author; + if (assignee) filter.assignee = assignee; + if (label) filter.label = label; + return requireIssueX().listIssues( + Object.keys(filter).length > 0 ? (filter as any) : undefined + ); + }, + + async "issue.update"(number: number, title?: string, body?: string, assignee?: string) { + const ix = requireIssueX(); + const issue = await ix.getIssueByNumber(number); + if (!issue) throw new Error(`Issue #${number} not found.`); + const patch: Record = {}; + if (title !== undefined) patch.title = title; + if (body !== undefined) patch.body = body; + if (assignee !== undefined) patch.assignee = assignee; + return ix.updateIssue(issue.id, patch); + }, + + async "issue.close"(number: number) { + const ix = requireIssueX(); + const issue = await ix.getIssueByNumber(number); + if (!issue) throw new Error(`Issue #${number} not found.`); + return ix.closeIssue(issue.id); + }, + + async "issue.reopen"(number: number) { + const ix = requireIssueX(); + const issue = await ix.getIssueByNumber(number); + if (!issue) throw new Error(`Issue #${number} not found.`); + return ix.reopenIssue(issue.id); + }, + + async "issue.assign"(number: number, assignee: string) { + const ix = requireIssueX(); + const issue = await ix.getIssueByNumber(number); + if (!issue) throw new Error(`Issue #${number} not found.`); + return ix.updateIssue(issue.id, { assignee }); + }, + + async "issue.comment"(number: number, body: string, author: string) { + const ix = requireIssueX(); + const issue = await ix.getIssueByNumber(number); + if (!issue) throw new Error(`Issue #${number} not found.`); + return ix.createComment(issue.id, body, author); + }, + + async "issue.comments"(number: number) { + const ix = requireIssueX(); + const issue = await ix.getIssueByNumber(number); + if (!issue) throw new Error(`Issue #${number} not found.`); + return ix.listComments(issue.id); + }, + + async "issue.label"(number: number, label: string) { + const ix = requireIssueX(); + const issue = await ix.getIssueByNumber(number); + if (!issue) throw new Error(`Issue #${number} not found.`); + // Find or create label by name + let labelObj = await ix.getLabelByName(label); + if (!labelObj) labelObj = await ix.createLabel({ name: label }); + await ix.addLabel(issue.id, labelObj.id); + return ix.getIssueByNumber(number); + }, + + async "issue.unlabel"(number: number, label: string) { + const ix = requireIssueX(); + const issue = await ix.getIssueByNumber(number); + if (!issue) throw new Error(`Issue #${number} not found.`); + const labelObj = await ix.getLabelByName(label); + if (!labelObj) throw new Error(`Label "${label}" not found.`); + await ix.removeLabel(issue.id, labelObj.id); + return ix.getIssueByNumber(number); + }, }; } diff --git a/packages/prototype/src/schema.ts b/packages/prototype/src/schema.ts index 40ba3d4..2c632fe 100644 --- a/packages/prototype/src/schema.ts +++ b/packages/prototype/src/schema.ts @@ -6,7 +6,7 @@ */ /** Supported parameter types for instruction definitions. */ -export type ParamType = "string" | "gherkin" | "string[]" | "record"; +export type ParamType = "string" | "number" | "gherkin" | "string[]" | "record"; /** Definition of a single parameter in an instruction. */ export interface ParamDef { diff --git a/packages/rolexjs/package.json b/packages/rolexjs/package.json index e3aff12..6c7431b 100644 --- a/packages/rolexjs/package.json +++ b/packages/rolexjs/package.json @@ -43,6 +43,7 @@ "@rolexjs/prototype": "workspace:*", "@rolexjs/system": "workspace:*", "@rolexjs/parser": "workspace:*", + "issuexjs": "^0.2.0", "resourcexjs": "^2.14.0" }, "devDependencies": { diff --git a/packages/rolexjs/src/index.ts b/packages/rolexjs/src/index.ts index d58b09e..201d0a1 100644 --- a/packages/rolexjs/src/index.ts +++ b/packages/rolexjs/src/index.ts @@ -19,6 +19,15 @@ export type { DataTableRow, Feature, Scenario, Step } from "./feature.js"; export { parse, serialize } from "./feature.js"; // Find export { findInState } from "./find.js"; +// Issue Render +export type { IssueAction, LabelResolver } from "./issue-render.js"; +export { + renderComment, + renderCommentList, + renderIssue, + renderIssueList, + renderIssueResult, +} from "./issue-render.js"; export type { RenderOptions, RenderStateOptions } from "./render.js"; // Render export { describe, detail, directive, hint, render, renderState, world } from "./render.js"; diff --git a/packages/rolexjs/src/issue-render.ts b/packages/rolexjs/src/issue-render.ts new file mode 100644 index 0000000..9132a33 --- /dev/null +++ b/packages/rolexjs/src/issue-render.ts @@ -0,0 +1,154 @@ +/** + * Issue Render — format IssueX data as readable text. + * + * Lightweight rendering for issue operations. + * Unlike the core render layer (which projects State trees), + * this module formats flat IssueX objects into human-readable strings. + */ +import type { Comment, Issue } from "issuexjs"; + +// ================================================================ +// Types +// ================================================================ + +export type IssueAction = + | "publish" + | "get" + | "list" + | "close" + | "reopen" + | "update" + | "assign" + | "comment" + | "comments" + | "label" + | "unlabel"; + +export type LabelResolver = (ids: string[]) => Promise; + +// ================================================================ +// Single Issue +// ================================================================ + +export function renderIssue(issue: Issue, labelNames?: string[]): string { + const lines: string[] = []; + + lines.push(`#${issue.number} ${issue.title} [${issue.status}]`); + + const meta: string[] = [`Author: ${issue.author}`]; + if (issue.assignee) meta.push(`Assignee: ${issue.assignee}`); + if (labelNames && labelNames.length > 0) { + meta.push(`Labels: ${labelNames.join(", ")}`); + } + lines.push(meta.join(" | ")); + + if (issue.body) { + lines.push("───"); + lines.push(issue.body); + } + + return lines.join("\n"); +} + +// ================================================================ +// Issue List +// ================================================================ + +export function renderIssueList(issues: Issue[]): string { + if (issues.length === 0) return "No issues found."; + + const lines: string[] = []; + for (const issue of issues) { + const assignee = issue.assignee ? ` → ${issue.assignee}` : ""; + lines.push(`#${issue.number} [${issue.status}] ${issue.title} (${issue.author}${assignee})`); + } + return lines.join("\n"); +} + +// ================================================================ +// Comments +// ================================================================ + +export function renderComment(comment: Comment): string { + const time = formatTime(comment.createdAt); + return `${comment.author} (${time}):\n${comment.body}`; +} + +export function renderCommentList(comments: Comment[]): string { + if (comments.length === 0) return "No comments."; + return comments.map(renderComment).join("\n───\n"); +} + +// ================================================================ +// Status line +// ================================================================ + +const statusTemplates: Record string> = { + publish: (i) => `Issue #${i.number} created.`, + close: (i) => `Issue #${i.number} closed.`, + reopen: (i) => `Issue #${i.number} reopened.`, + update: (i) => `Issue #${i.number} updated.`, + assign: (i) => `Issue #${i.number} assigned to ${i.assignee ?? "nobody"}.`, + comment: (i) => `Comment added to #${i.number}.`, + label: (i) => `Label added to #${i.number}.`, + unlabel: (i) => `Label removed from #${i.number}.`, +}; + +// ================================================================ +// Compose — the main entry point for Role.use() +// ================================================================ + +/** + * Render an issue operation result as readable text. + * Dispatches to the right renderer based on action. + */ +export async function renderIssueResult( + action: IssueAction, + result: unknown, + resolveLabels?: LabelResolver +): Promise { + switch (action) { + case "list": + return renderIssueList(result as Issue[]); + + case "comments": + return renderCommentList(result as Comment[]); + + case "comment": { + const comment = result as Comment; + return `Comment added to issue.\n\n${renderComment(comment)}`; + } + + case "get": { + if (!result) return "Issue not found."; + const issue = result as Issue; + const labelNames = await resolveLabelNames(issue, resolveLabels); + return renderIssue(issue, labelNames); + } + + default: { + // All other actions return an Issue with a status line + const issue = result as Issue; + const labelNames = await resolveLabelNames(issue, resolveLabels); + const status = statusTemplates[action]?.(issue) ?? `Issue #${issue.number} ${action}.`; + return `${status}\n\n${renderIssue(issue, labelNames)}`; + } + } +} + +// ================================================================ +// Helpers +// ================================================================ + +function formatTime(iso: string): string { + return iso.replace("T", " ").replace(/\.\d+Z$/, ""); +} + +async function resolveLabelNames( + issue: Issue, + resolveLabels?: LabelResolver +): Promise { + if (!issue.labels || issue.labels.length === 0) return undefined; + if (!resolveLabels) return undefined; + return resolveLabels(issue.labels); +} diff --git a/packages/rolexjs/src/role.ts b/packages/rolexjs/src/role.ts index 6ddcd8b..dc0ce5c 100644 --- a/packages/rolexjs/src/role.ts +++ b/packages/rolexjs/src/role.ts @@ -14,6 +14,7 @@ import type { OpResult, Ops } from "@rolexjs/prototype"; import type { RoleContext } from "./context.js"; +import { type IssueAction, type LabelResolver, renderIssueResult } from "./issue-render.js"; import { render } from "./render.js"; /** @@ -24,6 +25,7 @@ export interface RolexInternal { ops: Ops; saveCtx(ctx: RoleContext): void | Promise; direct(locator: string, args?: Record): Promise; + resolveLabels?: LabelResolver; } export class Role { @@ -69,13 +71,18 @@ export class Role { // ---- Execution ---- - /** Focus: view or switch focused goal. */ + /** Focus: view or switch focused goal. Only accepts goal ids. */ async focus(goal?: string): Promise { const goalId = goal ?? this.ctx.requireGoalId(); + const result = await this.api.ops["role.focus"](goalId); + if (result.state.name !== "goal") { + throw new Error( + `"${goalId}" is a ${result.state.name}, not a goal. focus only accepts goal ids.` + ); + } const switched = goalId !== this.ctx.focusedGoalId; this.ctx.focusedGoalId = goalId; if (switched) this.ctx.focusedPlanId = null; - const result = await this.api.ops["role.focus"](goalId); await this.save(); return this.fmt("focus", goalId, result); } @@ -214,7 +221,13 @@ export class Role { } /** Use: subjective execution — `!ns.method` or ResourceX locator. */ - use(locator: string, args?: Record): Promise { - return this.api.direct(locator, args); + async use(locator: string, args?: Record): Promise { + const result = await this.api.direct(locator, args); + // Render issue results as readable text + if (locator.startsWith("!issue.")) { + const action = locator.slice("!issue.".length) as IssueAction; + return (await renderIssueResult(action, result, this.api.resolveLabels)) as T; + } + return result; } } diff --git a/packages/rolexjs/src/rolex.ts b/packages/rolexjs/src/rolex.ts index 3611ccb..3040bfc 100644 --- a/packages/rolexjs/src/rolex.ts +++ b/packages/rolexjs/src/rolex.ts @@ -14,6 +14,7 @@ import type { Platform, RoleXRepository } from "@rolexjs/core"; import * as C from "@rolexjs/core"; import { createOps, directives, type Ops, toArgs } from "@rolexjs/prototype"; import type { Initializer, Runtime, Structure } from "@rolexjs/system"; +import { createIssueX, type IssueX } from "issuexjs"; import type { ResourceX } from "resourcexjs"; import { createResourceX, setProvider } from "resourcexjs"; import { RoleContext } from "./context.js"; @@ -31,6 +32,7 @@ export class Rolex { private rt: Runtime; private ops!: Ops; private resourcex?: ResourceX; + private issuex?: IssueX; private repo: RoleXRepository; private readonly initializer?: Initializer; @@ -53,6 +55,11 @@ export class Rolex { : undefined ); } + + // Create IssueX from injected provider + if (platform.issuexProvider) { + this.issuex = createIssueX({ provider: platform.issuexProvider }); + } } /** Create a Rolex instance from a Platform (async due to Runtime initialization). */ @@ -85,6 +92,7 @@ export class Rolex { }, find: (id: string) => this.find(id), resourcex: this.resourcex, + issuex: this.issuex, prototype: this.repo.prototype, direct: (locator: string, args?: Record) => this.direct(locator, args), }); @@ -145,6 +153,16 @@ export class Rolex { ops, saveCtx, direct: this.direct.bind(this), + resolveLabels: this.issuex + ? async (ids: string[]) => { + const names: string[] = []; + for (const id of ids) { + const label = await this.issuex!.getLabel(id); + if (label) names.push(label.name); + } + return names; + } + : undefined, }; return new Role(individual, ctx, api); diff --git a/packages/rolexjs/tests/rolex.test.ts b/packages/rolexjs/tests/rolex.test.ts index 02137f5..1d6d793 100644 --- a/packages/rolexjs/tests/rolex.test.ts +++ b/packages/rolexjs/tests/rolex.test.ts @@ -66,12 +66,14 @@ describe("use dispatch", () => { // ================================================================ describe("activate", () => { - test("returns Role with ctx", async () => { + test("returns Role with ctx and project renders state", async () => { const rolex = await setup(); await rolex.direct("!individual.born", { content: "Feature: Sean", id: "sean" }); const role = await rolex.activate("sean"); expect(role.roleId).toBe("sean"); expect(role.ctx).toBeDefined(); + const output = await role.project(); + expect(output).toContain("[individual]"); }); test("throws on non-existent individual", async () => { @@ -101,6 +103,17 @@ describe("activate", () => { expect(finishR).toContain("[encounter]"); }); + test("focus rejects non-goal ids", async () => { + const rolex = await setup(); + await rolex.direct("!individual.born", { id: "sean" }); + const role = await rolex.activate("sean"); + await role.want("Feature: Auth", "auth"); + await role.plan("Feature: JWT", "jwt"); + await expect(role.focus("jwt")).rejects.toThrow( + '"jwt" is a plan, not a goal. focus only accepts goal ids.' + ); + }); + test("Role.use delegates to Rolex.use", async () => { const rolex = await setup(); await rolex.direct("!individual.born", { id: "sean" }); diff --git a/skills/issue-management/SKILL.md b/skills/issue-management/SKILL.md new file mode 100644 index 0000000..05cc018 --- /dev/null +++ b/skills/issue-management/SKILL.md @@ -0,0 +1,107 @@ +--- +name: issue-management +description: Manage issues between individuals using IssueX. Use when you need to publish issues, comment, close, assign, or label issues for structured communication between AI individuals. +--- + +Feature: IssueX Concepts + IssueX is the issue tracking system for RoleX individuals. + It follows the GitHub Issues model — issues, comments, and labels. + Issues enable structured asynchronous communication between individuals. + + Scenario: What is an issue + Given an issue is a titled piece of structured communication + Then it has a number (auto-increment, like GitHub #1 #2) + And it has a title, body, status (open/closed), author, and optional assignee + And it can have labels for categorization + And it can have comments for threaded discussion + + Scenario: Author is the active role + Given the author field identifies which individual published the issue + When using issue commands through a role + Then the author should be the active individual's id + +Feature: Issue Lifecycle Commands + Commands for creating, viewing, updating, and closing issues. + + Scenario: Publish a new issue + Given I need to create a new issue + When I call use("!issue.publish", { title, body, author }) + Then a new issue is created with auto-incremented number + And status defaults to "open" + And optional: assignee can be set at creation + + Scenario: Get issue details + Given I need to view a specific issue + When I call use("!issue.get", { number }) + Then the full issue is returned including labels + + Scenario: List issues with filters + Given I need to browse issues + When I call use("!issue.list", { status?, author?, assignee?, label? }) + Then matching issues are returned ordered by number descending + And all filter parameters are optional — omit for all issues + + Scenario: Update an issue + Given I need to change an issue's title, body, or assignee + When I call use("!issue.update", { number, title?, body?, assignee? }) + Then the specified fields are updated + + Scenario: Close an issue + Given an issue is resolved + When I call use("!issue.close", { number }) + Then status changes to "closed" and closedAt is set + + Scenario: Reopen an issue + Given a closed issue needs more work + When I call use("!issue.reopen", { number }) + Then status changes back to "open" and closedAt is cleared + + Scenario: Assign an issue + Given I need to assign an issue to another individual + When I call use("!issue.assign", { number, assignee }) + Then the issue's assignee is updated + +Feature: Comment Commands + Commands for adding and viewing comments on issues. + + Scenario: Add a comment + Given I want to discuss an issue + When I call use("!issue.comment", { number, body, author }) + Then a comment is added to the issue + + Scenario: List comments + Given I want to see the discussion on an issue + When I call use("!issue.comments", { number }) + Then all comments are returned ordered by creation time + +Feature: Label Commands + Commands for labeling and unlabeling issues. + + Scenario: Add a label to an issue + Given I want to categorize an issue + When I call use("!issue.label", { number, label }) + Then the label is attached to the issue + And if the label doesn't exist yet, it is auto-created + + Scenario: Remove a label from an issue + Given I want to recategorize an issue + When I call use("!issue.unlabel", { number, label }) + Then the label is removed from the issue + +Feature: Command Reference + Quick reference for all issue commands. + + Scenario: All commands + Given the following commands are available: + | command | required args | optional args | + | !issue.publish | title, body, author | assignee | + | !issue.get | number | | + | !issue.list | | status, author, assignee, label | + | !issue.update | number | title, body, assignee | + | !issue.close | number | | + | !issue.reopen | number | | + | !issue.assign | number, assignee | | + | !issue.comment | number, body, author | | + | !issue.comments | number | | + | !issue.label | number, label | | + | !issue.unlabel | number, label | | diff --git a/skills/issue-management/resource.json b/skills/issue-management/resource.json new file mode 100644 index 0000000..33b533c --- /dev/null +++ b/skills/issue-management/resource.json @@ -0,0 +1,7 @@ +{ + "name": "issue-management", + "type": "skill", + "description": "Manage issues between individuals — publish, comment, close, label, and filter issues", + "author": "deepractice", + "keywords": ["rolex", "issue", "issuex", "collaboration"] +} From 62fea0ff447e787be53aca0398245bc608c6f248 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 4 Mar 2026 14:23:31 +0800 Subject: [PATCH 2/3] fix(skills): replace use() function call syntax with locator/args format The use("!locator", { args }) syntax in SKILL.md examples misled AI into passing args as a JSON string instead of an object. Changed all 8 skill files to use explicit locator/args parameter annotation. Co-Authored-By: Claude Opus 4.6 --- skills/individual-management/SKILL.md | 49 +++++++----- skills/issue-management/SKILL.md | 102 +++++++++++++++++++++--- skills/organization-management/SKILL.md | 87 ++++++++++++-------- skills/prototype-authoring/SKILL.md | 4 +- skills/prototype-management/SKILL.md | 14 +++- skills/resource-management/SKILL.md | 78 +++++++++++++----- skills/skill-creator/SKILL.md | 8 +- skills/version-migration/SKILL.md | 62 +++++++------- 8 files changed, 287 insertions(+), 117 deletions(-) diff --git a/skills/individual-management/SKILL.md b/skills/individual-management/SKILL.md index de109d2..2d36103 100644 --- a/skills/individual-management/SKILL.md +++ b/skills/individual-management/SKILL.md @@ -14,11 +14,11 @@ Feature: Individual Lifecycle And the individual can be activated, hired into organizations, and taught skills And parameters are: """ - use("!individual.born", { - content: "Feature: ...", // Gherkin persona (optional) - id: "sean", // kebab-case identifier - alias: ["小明", "xm"] // aliases (optional) - }) + locator: "!individual.born" + args: + content: "Feature: ..." # Gherkin persona (optional) + id: "sean" # kebab-case identifier + alias: ["小明", "xm"] # aliases (optional) """ Scenario: born — persona writing guidelines @@ -35,7 +35,9 @@ Feature: Individual Lifecycle And all data is preserved for potential restoration via rehire And parameters are: """ - use("!individual.retire", { individual: "sean" }) + locator: "!individual.retire" + args: + individual: "sean" """ Scenario: die — permanently remove an individual @@ -45,7 +47,9 @@ Feature: Individual Lifecycle And this is semantically permanent — rehire is technically possible but not intended And parameters are: """ - use("!individual.die", { individual: "sean" }) + locator: "!individual.die" + args: + individual: "sean" """ Scenario: retire vs die — when to use which @@ -62,7 +66,9 @@ Feature: Individual Lifecycle And all previous knowledge, experience, and history are intact And parameters are: """ - use("!individual.rehire", { individual: "sean" }) + locator: "!individual.rehire" + args: + individual: "sean" """ Feature: Knowledge Injection @@ -77,11 +83,11 @@ Feature: Knowledge Injection And if a principle with the same id already exists, it is replaced (upsert) And parameters are: """ - use("!individual.teach", { - individual: "sean", - content: "Feature: Always validate input\n ...", + locator: "!individual.teach" + args: + individual: "sean" + content: "Feature: Always validate input\n ..." id: "always-validate-input" - }) """ Scenario: train — inject a procedure (skill reference) @@ -92,11 +98,11 @@ Feature: Knowledge Injection And the procedure Feature description MUST contain the ResourceX locator for the skill And parameters are: """ - use("!individual.train", { - individual: "sean", - content: "Feature: Skill Creator\n https://github.com/Deepractice/DeepracticeX/tree/main/skills/skill-creator\n\n Scenario: When to use\n Given I need to create a skill\n Then load this skill", + locator: "!individual.train" + args: + individual: "sean" + content: "Feature: Skill Creator\n https://github.com/Deepractice/DeepracticeX/tree/main/skills/skill-creator\n\n Scenario: When to use\n Given I need to create a skill\n Then load this skill" id: "skill-creator" - }) """ Scenario: teach vs realize — when to use which @@ -136,10 +142,13 @@ Feature: Common Workflows When setting up a new role from scratch Then follow this sequence: """ - 1. use("!individual.born", { id: "sean", content: "Feature: ..." }) - 2. use("!individual.teach", { individual: "sean", content: "...", id: "..." }) // repeat - 3. use("!individual.train", { individual: "sean", content: "...", id: "..." }) // repeat - 4. activate({ roleId: "sean" }) // verify the individual's state + 1. locator: "!individual.born" + args: { id: "sean", content: "Feature: ..." } + 2. locator: "!individual.teach" + args: { individual: "sean", content: "...", id: "..." } # repeat + 3. locator: "!individual.train" + args: { individual: "sean", content: "...", id: "..." } # repeat + 4. activate with roleId: "sean" # verify the individual's state """ Scenario: Transfer knowledge between individuals diff --git a/skills/issue-management/SKILL.md b/skills/issue-management/SKILL.md index 05cc018..fc1ba36 100644 --- a/skills/issue-management/SKILL.md +++ b/skills/issue-management/SKILL.md @@ -25,68 +25,148 @@ Feature: Issue Lifecycle Commands Scenario: Publish a new issue Given I need to create a new issue - When I call use("!issue.publish", { title, body, author }) + When I call use with !issue.publish Then a new issue is created with auto-incremented number And status defaults to "open" And optional: assignee can be set at creation + And parameters are: + """ + locator: "!issue.publish" + args: + title: "Issue title" + body: "Issue description" + author: "individual-id" + assignee: "other-id" # optional + """ Scenario: Get issue details Given I need to view a specific issue - When I call use("!issue.get", { number }) + When I call use with !issue.get Then the full issue is returned including labels + And parameters are: + """ + locator: "!issue.get" + args: + number: 1 + """ Scenario: List issues with filters Given I need to browse issues - When I call use("!issue.list", { status?, author?, assignee?, label? }) + When I call use with !issue.list Then matching issues are returned ordered by number descending And all filter parameters are optional — omit for all issues + And parameters are: + """ + locator: "!issue.list" + args: + status: "open" # optional + author: "id" # optional + assignee: "id" # optional + label: "bug" # optional + """ Scenario: Update an issue Given I need to change an issue's title, body, or assignee - When I call use("!issue.update", { number, title?, body?, assignee? }) + When I call use with !issue.update Then the specified fields are updated + And parameters are: + """ + locator: "!issue.update" + args: + number: 1 + title: "New title" # optional + body: "New body" # optional + assignee: "id" # optional + """ Scenario: Close an issue Given an issue is resolved - When I call use("!issue.close", { number }) + When I call use with !issue.close Then status changes to "closed" and closedAt is set + And parameters are: + """ + locator: "!issue.close" + args: + number: 1 + """ Scenario: Reopen an issue Given a closed issue needs more work - When I call use("!issue.reopen", { number }) + When I call use with !issue.reopen Then status changes back to "open" and closedAt is cleared + And parameters are: + """ + locator: "!issue.reopen" + args: + number: 1 + """ Scenario: Assign an issue Given I need to assign an issue to another individual - When I call use("!issue.assign", { number, assignee }) + When I call use with !issue.assign Then the issue's assignee is updated + And parameters are: + """ + locator: "!issue.assign" + args: + number: 1 + assignee: "individual-id" + """ Feature: Comment Commands Commands for adding and viewing comments on issues. Scenario: Add a comment Given I want to discuss an issue - When I call use("!issue.comment", { number, body, author }) + When I call use with !issue.comment Then a comment is added to the issue + And parameters are: + """ + locator: "!issue.comment" + args: + number: 1 + body: "Comment text" + author: "individual-id" + """ Scenario: List comments Given I want to see the discussion on an issue - When I call use("!issue.comments", { number }) + When I call use with !issue.comments Then all comments are returned ordered by creation time + And parameters are: + """ + locator: "!issue.comments" + args: + number: 1 + """ Feature: Label Commands Commands for labeling and unlabeling issues. Scenario: Add a label to an issue Given I want to categorize an issue - When I call use("!issue.label", { number, label }) + When I call use with !issue.label Then the label is attached to the issue And if the label doesn't exist yet, it is auto-created + And parameters are: + """ + locator: "!issue.label" + args: + number: 1 + label: "bug" + """ Scenario: Remove a label from an issue Given I want to recategorize an issue - When I call use("!issue.unlabel", { number, label }) + When I call use with !issue.unlabel Then the label is removed from the issue + And parameters are: + """ + locator: "!issue.unlabel" + args: + number: 1 + label: "bug" + """ Feature: Command Reference Quick reference for all issue commands. diff --git a/skills/organization-management/SKILL.md b/skills/organization-management/SKILL.md index 3d62171..f123e51 100644 --- a/skills/organization-management/SKILL.md +++ b/skills/organization-management/SKILL.md @@ -15,11 +15,11 @@ Feature: Organization Lifecycle And a charter can be defined for it And parameters are: """ - use("!org.found", { - content: "Feature: Deepractice\n An AI agent framework company", - id: "dp", - alias: ["deepractice"] // optional - }) + locator: "!org.found" + args: + content: "Feature: Deepractice\n An AI agent framework company" + id: "dp" + alias: ["deepractice"] # optional """ Scenario: charter — define the organization's mission @@ -28,10 +28,10 @@ Feature: Organization Lifecycle Then the charter is stored under the organization And parameters are: """ - use("!org.charter", { - org: "dp", + locator: "!org.charter" + args: + org: "dp" content: "Feature: Build great AI\n Scenario: Mission\n Given we believe AI agents need identity\n Then we build frameworks for role-based agents" - }) """ Scenario: dissolve — dissolve an organization @@ -40,7 +40,9 @@ Feature: Organization Lifecycle Then the organization is archived to past And parameters are: """ - use("!org.dissolve", { org: "dp" }) + locator: "!org.dissolve" + args: + org: "dp" """ Feature: Membership @@ -54,7 +56,10 @@ Feature: Membership And the individual can then be appointed to positions And parameters are: """ - use("!org.hire", { org: "dp", individual: "sean" }) + locator: "!org.hire" + args: + org: "dp" + individual: "sean" """ Scenario: fire — remove a member @@ -63,7 +68,10 @@ Feature: Membership Then the membership link is removed And parameters are: """ - use("!org.fire", { org: "dp", individual: "sean" }) + locator: "!org.fire" + args: + org: "dp" + individual: "sean" """ Feature: Position Lifecycle @@ -78,10 +86,10 @@ Feature: Position Lifecycle And it can be charged with duties and individuals can be appointed to it And parameters are: """ - use("!position.establish", { - content: "Feature: Backend Architect\n Responsible for system design and API architecture", + locator: "!position.establish" + args: + content: "Feature: Backend Architect\n Responsible for system design and API architecture" id: "architect" - }) """ Scenario: charge — assign a duty to a position @@ -91,11 +99,11 @@ Feature: Position Lifecycle And individuals appointed to this position inherit the duty And parameters are: """ - use("!position.charge", { - position: "architect", - content: "Feature: Design systems\n Scenario: API design\n Given a new service is needed\n Then design the API contract first", + locator: "!position.charge" + args: + position: "architect" + content: "Feature: Design systems\n Scenario: API design\n Given a new service is needed\n Then design the API contract first" id: "design-systems" - }) """ Scenario: require — declare a required skill for a position @@ -106,11 +114,11 @@ Feature: Position Lifecycle And upserts by id — if the same id exists, it replaces the old one And parameters are: """ - use("!position.require", { - position: "architect", - content: "Feature: System Design\n Scenario: When to apply\n Given a new service is planned\n Then design the architecture before coding", + locator: "!position.require" + args: + position: "architect" + content: "Feature: System Design\n Scenario: When to apply\n Given a new service is planned\n Then design the architecture before coding" id: "system-design" - }) """ Scenario: abolish — abolish a position @@ -119,7 +127,9 @@ Feature: Position Lifecycle Then the position is archived to past And parameters are: """ - use("!position.abolish", { position: "architect" }) + locator: "!position.abolish" + args: + position: "architect" """ Feature: Appointment @@ -134,7 +144,10 @@ Feature: Appointment And existing skills with the same id are replaced (upsert) And parameters are: """ - use("!position.appoint", { position: "architect", individual: "sean" }) + locator: "!position.appoint" + args: + position: "architect" + individual: "sean" """ Scenario: dismiss — remove an individual from a position @@ -143,7 +156,10 @@ Feature: Appointment Then the appointment link is removed And parameters are: """ - use("!position.dismiss", { position: "architect", individual: "sean" }) + locator: "!position.dismiss" + args: + position: "architect" + individual: "sean" """ Feature: Common Workflows @@ -152,12 +168,19 @@ Feature: Common Workflows Given you need an organization with positions and members Then follow this sequence: """ - 1. use("!org.found", { id: "dp", content: "Feature: Deepractice" }) - 2. use("!org.charter", { org: "dp", content: "Feature: Mission\n ..." }) - 3. use("!position.establish", { id: "architect", content: "Feature: Architect" }) - 4. use("!position.charge", { position: "architect", content: "Feature: Design\n ...", id: "design" }) - 5. use("!position.require", { position: "architect", content: "Feature: Skill\n ...", id: "skill" }) - 6. use("!org.hire", { org: "dp", individual: "sean" }) - 7. use("!position.appoint", { position: "architect", individual: "sean" }) + 1. locator: "!org.found" + args: { id: "dp", content: "Feature: Deepractice" } + 2. locator: "!org.charter" + args: { org: "dp", content: "Feature: Mission\n ..." } + 3. locator: "!position.establish" + args: { id: "architect", content: "Feature: Architect" } + 4. locator: "!position.charge" + args: { position: "architect", content: "Feature: Design\n ...", id: "design" } + 5. locator: "!position.require" + args: { position: "architect", content: "Feature: Skill\n ...", id: "skill" } + 6. locator: "!org.hire" + args: { org: "dp", individual: "sean" } + 7. locator: "!position.appoint" + args: { position: "architect", individual: "sean" } """ And step 7 appoint will auto-train the required skill into sean diff --git a/skills/prototype-authoring/SKILL.md b/skills/prototype-authoring/SKILL.md index 155b8f4..fe3cd2b 100644 --- a/skills/prototype-authoring/SKILL.md +++ b/skills/prototype-authoring/SKILL.md @@ -134,7 +134,9 @@ Feature: Example — Individual Prototype Given the directory is ready When you run: """ - use("!prototype.settle", { source: "./prototypes/dev" }) + locator: "!prototype.settle" + args: + source: "./prototypes/dev" """ Then the dev individual is born and trained with the code-review procedure diff --git a/skills/prototype-management/SKILL.md b/skills/prototype-management/SKILL.md index 7208bdc..e56a2ad 100644 --- a/skills/prototype-management/SKILL.md +++ b/skills/prototype-management/SKILL.md @@ -16,8 +16,14 @@ Feature: Prototype Registry And the prototype id and source are registered in the prototype registry And parameters are: """ - use("!prototype.settle", { source: "./prototypes/rolex" }) - use("!prototype.settle", { source: "deepractice/rolex" }) + locator: "!prototype.settle" + args: + source: "./prototypes/rolex" + + # or by registry locator: + locator: "!prototype.settle" + args: + source: "deepractice/rolex" """ Scenario: evict — remove a prototype from the registry @@ -27,7 +33,9 @@ Feature: Prototype Registry And runtime entities created by the prototype are NOT removed And parameters are: """ - use("!prototype.evict", { id: "rolex" }) + locator: "!prototype.evict" + args: + id: "rolex" """ Scenario: Settle is idempotent diff --git a/skills/resource-management/SKILL.md b/skills/resource-management/SKILL.md index 5beab4f..40c5f47 100644 --- a/skills/resource-management/SKILL.md +++ b/skills/resource-management/SKILL.md @@ -65,45 +65,83 @@ Feature: Resource Operations Scenario: add — import a resource from a local directory Given you have a resource directory with resource.json - When you call use("!resource.add", { path: "/absolute/path/to/resource" }) + When you call use with !resource.add Then the resource is archived and stored in local CAS And it gets a digest computed from its content And it can then be pushed to a remote registry + And parameters are: + """ + locator: "!resource.add" + args: + path: "/absolute/path/to/resource" + """ Scenario: push — publish a resource to a remote registry Given a resource has been added to local CAS - When you call use("!resource.push", { locator: "name:tag" }) + When you call use with !resource.push Then the resource archive is uploaded to the configured registry And the registry stores it by name, tag, and digest - And optionally specify a registry: { locator: "name:tag", registry: "https://..." } + And parameters are: + """ + locator: "!resource.push" + args: + locator: "name:tag" + registry: "https://..." # optional + """ Scenario: pull — download a resource from a remote registry Given a resource exists in a remote registry - When you call use("!resource.pull", { locator: "name:tag" }) + When you call use with !resource.pull Then the resource is downloaded and cached in local CAS And subsequent resolves use the local cache + And parameters are: + """ + locator: "!resource.pull" + args: + locator: "name:tag" + """ Scenario: search — find resources in local CAS Given you want to find resources stored locally - When you call use("!resource.search", { query: "keyword" }) + When you call use with !resource.search Then matching resources are returned as locator strings + And parameters are: + """ + locator: "!resource.search" + args: + query: "keyword" + """ Scenario: has — check if a resource exists locally Given you want to verify a resource is in local CAS - When you call use("!resource.has", { locator: "name:tag" }) + When you call use with !resource.has Then returns whether the resource exists + And parameters are: + """ + locator: "!resource.has" + args: + locator: "name:tag" + """ Scenario: remove — delete a resource from local CAS Given you want to remove a resource from local storage - When you call use("!resource.remove", { locator: "name:tag" }) + When you call use with !resource.remove Then the resource manifest is removed from local CAS + And parameters are: + """ + locator: "!resource.remove" + args: + locator: "name:tag" + """ Scenario: Typical workflow — add then push Given you want to publish a resource to a registry Then the sequence is: """ - 1. use("!resource.add", { path: "./my-resource" }) - 2. use("!resource.push", { locator: "my-resource" }) + 1. locator: "!resource.add" + args: { path: "./my-resource" } + 2. locator: "!resource.push" + args: { locator: "my-resource" } """ And add imports to local CAS, push uploads to registry And tag defaults to latest when omitted @@ -118,8 +156,8 @@ Feature: Resource Loading via use Then the resource is resolved through ResourceX and its content returned And parameters are: """ - use("hello-prompt") // by registry locator (tag defaults to latest) - use("./path/to/resource") // by local path + locator: "hello-prompt" # by registry locator (tag defaults to latest) + locator: "./path/to/resource" # by local path """ Scenario: skill — load full skill content by locator @@ -130,8 +168,8 @@ Feature: Resource Loading via use And this is progressive disclosure layer 2 — on-demand knowledge injection And parameters are: """ - skill("skill-creator") // tag defaults to latest - skill("/absolute/path/to/skill-directory") + skill locator: "skill-creator" # tag defaults to latest + skill locator: "/absolute/path/to/skill-directory" """ Scenario: Progressive disclosure — three layers @@ -190,8 +228,10 @@ Feature: Common Workflows When you want to make it available via registry Then the sequence is: """ - 1. use("!resource.add", { path: "/path/to/roles/nuwa" }) - 2. use("!resource.push", { locator: "nuwa" }) + 1. locator: "!resource.add" + args: { path: "/path/to/roles/nuwa" } + 2. locator: "!resource.push" + args: { locator: "nuwa" } """ And the prototype is now pullable by anyone with registry access @@ -200,8 +240,10 @@ Feature: Common Workflows When you re-add and push with the same tag Then the registry updates the tag to point to the new digest """ - 1. use("!resource.add", { path: "/path/to/roles/nuwa" }) - 2. use("!resource.push", { locator: "nuwa" }) + 1. locator: "!resource.add" + args: { path: "/path/to/roles/nuwa" } + 2. locator: "!resource.push" + args: { locator: "nuwa" } """ And consumers pulling the same tag get the updated content @@ -211,5 +253,5 @@ Feature: Common Workflows Then the full SKILL.md content should be returned And example: """ - skill("skill-creator") + skill locator: "skill-creator" """ diff --git a/skills/skill-creator/SKILL.md b/skills/skill-creator/SKILL.md index 91bff9e..8ece28b 100644 --- a/skills/skill-creator/SKILL.md +++ b/skills/skill-creator/SKILL.md @@ -85,11 +85,11 @@ Feature: Skill Creation Process And the Feature body summarizes capabilities for activate-time awareness And example: """ - use("!individual.train", { - individual: "sean", - id: "my-skill", + locator: "!individual.train" + args: + individual: "sean" + id: "my-skill" content: "Feature: My Skill\n https://github.com/org/repo/tree/main/skills/my-skill\n\n Scenario: When to use\n Given I need to do X\n Then load this skill" - }) """ Scenario: Step 5 — Test the skill diff --git a/skills/version-migration/SKILL.md b/skills/version-migration/SKILL.md index 5bd564c..244d4d6 100644 --- a/skills/version-migration/SKILL.md +++ b/skills/version-migration/SKILL.md @@ -101,10 +101,10 @@ Feature: Migration Process Then for each role: """ 1. Read persona.identity.feature → use as born content - 2. Call: use("!individual.born", { - id: "", + 2. locator: "!individual.born" + args: + id: "" content: "" - }) """ And the role-name from the directory becomes the individual id @@ -115,11 +115,11 @@ Feature: Migration Process """ 1. Extract topic from filename: "role-creation.knowledge.identity.feature" → "role-creation" 2. Read the file content (Gherkin Feature) - 3. Call: use("!individual.teach", { - individual: "", - content: "", + 3. locator: "!individual.teach" + args: + individual: "" + content: "" id: "" - }) """ And each knowledge file becomes one principle @@ -128,15 +128,15 @@ Feature: Migration Process When the organizations object is not empty Then for each organization: """ - 1. Call: use("!org.found", { - id: "", + 1. locator: "!org.found" + args: + id: "" content: "" - }) 2. If charter exists: - Call: use("!org.charter", { - org: "", + locator: "!org.charter" + args: + org: "" content: "" - }) """ Scenario: Step 5 — Migrate assignments (membership + appointments) @@ -144,15 +144,15 @@ Feature: Migration Process When the assignments object is not empty Then for each assignment: """ - 1. Call: use("!org.hire", { - org: "", + 1. locator: "!org.hire" + args: + org: "" individual: "" - }) 2. If position exists: - Call: use("!position.appoint", { - position: "", + locator: "!position.appoint" + args: + position: "" individual: "" - }) """ Scenario: Step 6 — Migrate goals (optional) @@ -161,8 +161,8 @@ Feature: Migration Process Then for each goal file: """ 1. Read the goal Gherkin content - 2. Activate the individual: activate({ roleId: "" }) - 3. Call: want({ goal: "", id: "" }) + 2. Activate the individual with roleId: "" + 3. Call want with goal: "", id: "" """ And goal ids are derived from the goal filename @@ -171,7 +171,7 @@ Feature: Migration Process When verification is needed Then activate each migrated individual and check: """ - 1. activate({ roleId: "" }) + 1. Activate with roleId: "" 2. Verify identity content matches the old persona 3. Verify principles match the old knowledge files 4. Verify organization memberships if applicable @@ -186,7 +186,8 @@ Feature: Entity Mapping Reference Then the mapping is: """ Old: roles//identity/persona.identity.feature - New: use("!individual.born", { id: "", content: "" }) + New: locator: "!individual.born" + args: { id: "", content: "" } """ Scenario: Knowledge mapping @@ -194,7 +195,8 @@ Feature: Entity Mapping Reference Then the mapping is: """ Old: roles//identity/.knowledge.identity.feature - New: use("!individual.teach", { individual: "", content: "", id: "" }) + New: locator: "!individual.teach" + args: { individual: "", content: "", id: "" } """ Scenario: Organization mapping @@ -202,8 +204,10 @@ Feature: Entity Mapping Reference Then the mapping is: """ Old: rolex.json → organizations. - New: use("!org.found", { id: "", content: "" }) - use("!org.charter", { org: "", content: "" }) + New: locator: "!org.found" + args: { id: "", content: "" } + locator: "!org.charter" + args: { org: "", content: "" } """ Scenario: Assignment mapping @@ -211,8 +215,10 @@ Feature: Entity Mapping Reference Then the mapping is: """ Old: rolex.json → assignments.. - New: use("!org.hire", { org: "", individual: "" }) - use("!position.appoint", { position: "", individual: "" }) + New: locator: "!org.hire" + args: { org: "", individual: "" } + locator: "!position.appoint" + args: { position: "", individual: "" } """ Feature: Edge Cases and Troubleshooting From e35aa19910821455711c0250f6bf57857dc7e4e4 Mon Sep 17 00:00:00 2001 From: sean Date: Wed, 4 Mar 2026 16:13:27 +0800 Subject: [PATCH 3/3] feat: add Project as a top-level organizational primitive - Add project structure with scope, milestone, deliverable, wiki sub-concepts and participation relation to individual - Add 9 project.* operations: launch, scope, milestone, achieve, enroll, remove, deliver, wiki, archive - Add project-render module for human-readable output - Add project-lifecycle.feature BDD tests (9 scenarios) - Fix BDD test suite for async Runtime refactor (await createRoleX, async Role methods, writeContext via SQLite) Co-Authored-By: Claude Opus 4.6 --- .changeset/project-primitive.md | 14 +++ apps/mcp-server/src/index.ts | 8 +- bdd/features/project-lifecycle.feature | 95 ++++++++++++++++ bdd/package.json | 2 +- bdd/run.test.ts | 37 +------ bdd/steps/context.steps.ts | 20 ++-- bdd/steps/direct.steps.ts | 22 ++++ bdd/steps/role.steps.ts | 75 +++++++------ bdd/support/world.ts | 21 ++-- bun.lock | 12 +- packages/core/src/index.ts | 41 ++++++- packages/core/src/lifecycle.ts | 6 +- packages/core/src/project.ts | 46 ++++++++ packages/core/src/structures.ts | 26 +++++ packages/prototype/src/instructions.ts | 142 +++++++++++++++++++++++- packages/prototype/src/ops.ts | 58 ++++++++++ packages/rolexjs/src/index.ts | 3 + packages/rolexjs/src/project-render.ts | 148 +++++++++++++++++++++++++ packages/rolexjs/src/render.ts | 27 +++++ packages/rolexjs/src/role.ts | 7 ++ 20 files changed, 716 insertions(+), 94 deletions(-) create mode 100644 .changeset/project-primitive.md create mode 100644 bdd/features/project-lifecycle.feature create mode 100644 packages/core/src/project.ts create mode 100644 packages/rolexjs/src/project-render.ts diff --git a/.changeset/project-primitive.md b/.changeset/project-primitive.md new file mode 100644 index 0000000..820f640 --- /dev/null +++ b/.changeset/project-primitive.md @@ -0,0 +1,14 @@ +--- +"rolexjs": minor +"@rolexjs/prototype": minor +"@rolexjs/core": minor +"@rolexjs/mcp-server": minor +--- + +feat: add Project as a top-level organizational primitive + +- Add project structure with 5 sub-concepts: scope, milestone, deliverable, wiki, and participation relation +- Add 9 project operations: launch, scope, milestone, achieve, enroll, remove, deliver, wiki, archive +- Add project-render module for human-readable project output (compact member display, milestone progress) +- Add project lifecycle BDD feature with full test coverage +- Fix BDD test suite for async Runtime refactor (await createRoleX, Role methods, writeContext via SQLite) diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 037351a..371b2ff 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -7,7 +7,7 @@ import { localPlatform } from "@rolexjs/local-platform"; import { FastMCP } from "fastmcp"; -import { createRoleX, detail } from "rolexjs"; +import { createRoleX, detail, type ProjectAction, renderProjectResult, type State } from "rolexjs"; import { z } from "zod"; import { instructions } from "./instructions.js"; @@ -257,6 +257,12 @@ server.addTool({ const result = await rolex.direct(locator, args); if (result == null) return `${locator} done.`; if (typeof result === "string") return result; + // Render project results as readable text + if (locator.startsWith("!project.")) { + const action = locator.slice("!project.".length) as ProjectAction; + const opResult = result as { state: State }; + return renderProjectResult(action, opResult.state); + } return JSON.stringify(result, null, 2); }, }); diff --git a/bdd/features/project-lifecycle.feature b/bdd/features/project-lifecycle.feature new file mode 100644 index 0000000..e30139d --- /dev/null +++ b/bdd/features/project-lifecycle.feature @@ -0,0 +1,95 @@ +@project +Feature: Project lifecycle + Launch, scope, milestone, enroll, deliver, wiki, archive. + + Background: + Given a fresh Rolex instance + + # ===== launch ===== + + Scenario: Launch creates a project + When I direct "!project.launch" with: + | content | Feature: RoleX v2 | + | id | rolex-v2 | + Then the result process should be "launch" + And the result state name should be "project" + And the result state id should be "rolex-v2" + + # ===== scope ===== + + Scenario: Scope defines project boundary + Given project "rolex-v2" exists + When I direct "!project.scope" with: + | project | rolex-v2 | + | content | Feature: Add Project primitive to RoleX | + | id | rolex-v2-scope | + Then the result process should be "scope" + And the result state name should be "scope" + + # ===== milestone ===== + + Scenario: Milestone adds a checkpoint to project + Given project "rolex-v2" exists + When I direct "!project.milestone" with: + | project | rolex-v2 | + | content | Feature: Core structures complete | + | id | core-done | + Then the result process should be "milestone" + And the result state name should be "milestone" + + Scenario: Achieve marks a milestone as done + Given project "rolex-v2" exists + And milestone "core-done" exists in project "rolex-v2" + When I direct "!project.achieve" with: + | milestone | core-done | + Then the result process should be "achieve" + + # ===== enroll / remove ===== + + Scenario: Enroll adds individual to project + Given individual "sean" exists + And project "rolex-v2" exists + When I direct "!project.enroll" with: + | project | rolex-v2 | + | individual | sean | + Then the result process should be "enroll" + + Scenario: Remove removes individual from project + Given individual "sean" exists + And project "rolex-v2" exists + And "sean" is enrolled in "rolex-v2" + When I direct "!project.remove" with: + | project | rolex-v2 | + | individual | sean | + Then the result process should be "remove" + + # ===== deliver ===== + + Scenario: Deliver adds a deliverable to project + Given project "rolex-v2" exists + When I direct "!project.deliver" with: + | project | rolex-v2 | + | content | Feature: @rolexjs/core v2.0.0 published | + | id | core-v2-release | + Then the result process should be "deliver" + And the result state name should be "deliverable" + + # ===== wiki ===== + + Scenario: Wiki adds a knowledge entry to project + Given project "rolex-v2" exists + When I direct "!project.wiki" with: + | project | rolex-v2 | + | content | Feature: Tech debt - parser needs refactoring | + | id | parser-tech-debt | + Then the result process should be "wiki" + And the result state name should be "wiki" + + # ===== archive ===== + + Scenario: Archive moves project to past + Given project "rolex-v2" exists + When I direct "!project.archive" with: + | project | rolex-v2 | + Then the result process should be "archive" + And the result state name should be "past" diff --git a/bdd/package.json b/bdd/package.json index c5a0776..b8f322d 100644 --- a/bdd/package.json +++ b/bdd/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "devDependencies": { - "@deepracticex/bdd": "^0.2.0", + "@deepracticex/bdd": "^0.3.0", "@modelcontextprotocol/sdk": "^1.27.1", "@rolexjs/core": "workspace:*", "@rolexjs/local-platform": "workspace:*", diff --git a/bdd/run.test.ts b/bdd/run.test.ts index 688c043..2a1dbcf 100644 --- a/bdd/run.test.ts +++ b/bdd/run.test.ts @@ -1,32 +1,7 @@ -/** - * BDD test entry point. - * - * Imports step definitions and support, then loads feature files. - * Bun's test runner executes the generated describe/test blocks natively. - */ +import { configure } from "@deepracticex/bdd"; -import { loadFeature, setDefaultTimeout } from "@deepracticex/bdd"; - -// Support (unified world) -import "./support/world"; - -// Steps -import "./steps/mcp.steps"; -import "./steps/context.steps"; -import "./steps/direct.steps"; -import "./steps/role.steps"; - -// Timeout: MCP/npx startup can take a while -setDefaultTimeout(60_000); - -// ===== Journeys ===== -loadFeature("bdd/journeys/mcp-startup.feature"); -loadFeature("bdd/journeys/onboarding.feature"); - -// ===== Features ===== -loadFeature("bdd/features/context-persistence.feature"); -loadFeature("bdd/features/individual-lifecycle.feature"); -loadFeature("bdd/features/organization-lifecycle.feature"); -loadFeature("bdd/features/position-lifecycle.feature"); -loadFeature("bdd/features/execution-loop.feature"); -loadFeature("bdd/features/cognition-loop.feature"); +await configure({ + features: ["bdd/journeys/**/*.feature", "bdd/features/**/*.feature"], + steps: ["bdd/support/**/*.ts", "bdd/steps/**/*.ts"], + timeout: 60_000, +}); diff --git a/bdd/steps/context.steps.ts b/bdd/steps/context.steps.ts index d32c213..3975eee 100644 --- a/bdd/steps/context.steps.ts +++ b/bdd/steps/context.steps.ts @@ -8,8 +8,8 @@ import type { BddWorld } from "../support/world"; // ===== Setup ===== -Given("a fresh Rolex instance", function (this: BddWorld) { - this.initRolex(); +Given("a fresh Rolex instance", async function (this: BddWorld) { + await this.initRolex(); }); Given( @@ -43,19 +43,19 @@ Given( // ===== Persistence setup ===== -Given("persisted focusedGoalId is null", function (this: BddWorld) { - this.writeContext("sean", { focusedGoalId: null, focusedPlanId: null }); - this.newSession(); +Given("persisted focusedGoalId is null", async function (this: BddWorld) { + await this.writeContext("sean", { focusedGoalId: null, focusedPlanId: null }); + await this.newSession(); }); -Given("persisted focusedGoalId is {string}", function (this: BddWorld, goalId: string) { - this.writeContext("sean", { focusedGoalId: goalId, focusedPlanId: null }); - this.newSession(); +Given("persisted focusedGoalId is {string}", async function (this: BddWorld, goalId: string) { + await this.writeContext("sean", { focusedGoalId: goalId, focusedPlanId: null }); + await this.newSession(); }); -Given("no persisted context exists", function (this: BddWorld) { +Given("no persisted context exists", async function (this: BddWorld) { // Don't write any context file — just create a new session - this.newSession(); + await this.newSession(); }); // ===== Actions ===== diff --git a/bdd/steps/direct.steps.ts b/bdd/steps/direct.steps.ts index b09265e..9e2a07c 100644 --- a/bdd/steps/direct.steps.ts +++ b/bdd/steps/direct.steps.ts @@ -38,6 +38,28 @@ Given( } ); +Given("project {string} exists", async function (this: BddWorld, id: string) { + await this.rolex!.direct("!project.launch", { content: `Feature: ${id}`, id }); +}); + +Given( + "milestone {string} exists in project {string}", + async function (this: BddWorld, milestone: string, project: string) { + await this.rolex!.direct("!project.milestone", { + project, + content: `Feature: ${milestone}`, + id: milestone, + }); + } +); + +Given( + "{string} is enrolled in {string}", + async function (this: BddWorld, individual: string, project: string) { + await this.rolex!.direct("!project.enroll", { project, individual }); + } +); + // ===== Direct call ===== When("I direct {string} with:", async function (this: BddWorld, command: string, table: DataTable) { diff --git a/bdd/steps/role.steps.ts b/bdd/steps/role.steps.ts index ee2ae33..d27e6e8 100644 --- a/bdd/steps/role.steps.ts +++ b/bdd/steps/role.steps.ts @@ -19,66 +19,75 @@ Given("I activate role {string}", async function (this: BddWorld, id: string) { // ===== Execution ===== -Given("I want goal {string} with {string}", function (this: BddWorld, id: string, content: string) { - this.toolResult = this.role!.want(content, id); -}); +Given( + "I want goal {string} with {string}", + async function (this: BddWorld, id: string, content: string) { + this.toolResult = await this.role!.want(content, id); + } +); -Given("I plan {string} with {string}", function (this: BddWorld, id: string, content: string) { - this.toolResult = this.role!.plan(content, id); -}); +Given( + "I plan {string} with {string}", + async function (this: BddWorld, id: string, content: string) { + this.toolResult = await this.role!.plan(content, id); + } +); -Given("I todo {string} with {string}", function (this: BddWorld, id: string, content: string) { - this.toolResult = this.role!.todo(content, id); -}); +Given( + "I todo {string} with {string}", + async function (this: BddWorld, id: string, content: string) { + this.toolResult = await this.role!.todo(content, id); + } +); // ===== Finish ===== Given( "I finish {string} with encounter {string}", - function (this: BddWorld, taskId: string, encounter: string) { - this.toolResult = this.role!.finish(taskId, encounter); + async function (this: BddWorld, taskId: string, encounter: string) { + this.toolResult = await this.role!.finish(taskId, encounter); } ); -When("I finish {string} without encounter", function (this: BddWorld, taskId: string) { - this.toolResult = this.role!.finish(taskId); +When("I finish {string} without encounter", async function (this: BddWorld, taskId: string) { + this.toolResult = await this.role!.finish(taskId); }); // ===== Complete / Abandon ===== When( "I complete plan {string} with encounter {string}", - function (this: BddWorld, planId: string, encounter: string) { - this.toolResult = this.role!.complete(planId, encounter); + async function (this: BddWorld, planId: string, encounter: string) { + this.toolResult = await this.role!.complete(planId, encounter); } ); When( "I abandon plan {string} with encounter {string}", - function (this: BddWorld, planId: string, encounter: string) { - this.toolResult = this.role!.abandon(planId, encounter); + async function (this: BddWorld, planId: string, encounter: string) { + this.toolResult = await this.role!.abandon(planId, encounter); } ); // ===== Focus ===== -When("I focus on {string}", function (this: BddWorld, goalId: string) { - this.toolResult = this.role!.focus(goalId); +When("I focus on {string}", async function (this: BddWorld, goalId: string) { + this.toolResult = await this.role!.focus(goalId); }); // ===== Cognition: Reflect ===== Given( "I reflect on {string} as {string} with {string}", - function (this: BddWorld, encounterId: string, expId: string, content: string) { - this.toolResult = this.role!.reflect([encounterId], content, expId); + async function (this: BddWorld, encounterId: string, expId: string, content: string) { + this.toolResult = await this.role!.reflect([encounterId], content, expId); } ); When( "I reflect directly as {string} with {string}", - function (this: BddWorld, expId: string, content: string) { - this.toolResult = this.role!.reflect([], content, expId); + async function (this: BddWorld, expId: string, content: string) { + this.toolResult = await this.role!.reflect([], content, expId); } ); @@ -86,15 +95,15 @@ When( Given( "I realize from {string} as {string} with {string}", - function (this: BddWorld, expId: string, principleId: string, content: string) { - this.toolResult = this.role!.realize([expId], content, principleId); + async function (this: BddWorld, expId: string, principleId: string, content: string) { + this.toolResult = await this.role!.realize([expId], content, principleId); } ); When( "I realize directly as {string} with {string}", - function (this: BddWorld, principleId: string, content: string) { - this.toolResult = this.role!.realize([], content, principleId); + async function (this: BddWorld, principleId: string, content: string) { + this.toolResult = await this.role!.realize([], content, principleId); } ); @@ -102,22 +111,22 @@ When( Given( "I master from {string} as {string} with {string}", - function (this: BddWorld, expId: string, procId: string, content: string) { - this.toolResult = this.role!.master(content, procId, [expId]); + async function (this: BddWorld, expId: string, procId: string, content: string) { + this.toolResult = await this.role!.master(content, procId, [expId]); } ); When( "I master directly as {string} with {string}", - function (this: BddWorld, procId: string, content: string) { - this.toolResult = this.role!.master(content, procId); + async function (this: BddWorld, procId: string, content: string) { + this.toolResult = await this.role!.master(content, procId); } ); // ===== Knowledge management ===== -When("I forget {string}", function (this: BddWorld, nodeId: string) { - this.toolResult = this.role!.forget(nodeId); +When("I forget {string}", async function (this: BddWorld, nodeId: string) { + this.toolResult = await this.role!.forget(nodeId); }); // ===== Output assertions ===== diff --git a/bdd/support/world.ts b/bdd/support/world.ts index 148c643..e1eff84 100644 --- a/bdd/support/world.ts +++ b/bdd/support/world.ts @@ -9,7 +9,7 @@ * Each scenario gets a fresh World instance. MCP clients are shared (expensive startup). */ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { After, AfterAll, setWorldConstructor, World } from "@deepracticex/bdd"; @@ -96,24 +96,23 @@ export class BddWorld extends World { } /** Initialize Rolex with a temp data directory for persistence tests. */ - initRolex(): void { + async initRolex(): Promise { this.dataDir = join(tmpdir(), `rolex-bdd-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(this.dataDir, { recursive: true }); - this.rolex = createRoleX(localPlatform({ dataDir: this.dataDir, resourceDir: null })); + this.rolex = await createRoleX(localPlatform({ dataDir: this.dataDir, resourceDir: null })); } - /** Write persisted context JSON directly (simulate a previous session). */ - writeContext(roleId: string, data: Record): void { - if (!this.dataDir) throw new Error("Call initRolex() first"); - const contextDir = join(this.dataDir, "context"); - mkdirSync(contextDir, { recursive: true }); - writeFileSync(join(contextDir, `${roleId}.json`), JSON.stringify(data, null, 2), "utf-8"); + /** Write persisted context directly via Rolex repository (simulate a previous session). */ + async writeContext(roleId: string, data: Record): Promise { + if (!this.rolex) throw new Error("Call initRolex() first"); + // Use the internal repository to persist context data to SQLite + await (this.rolex as any).repo.saveContext(roleId, data); } /** Re-create Rolex instance (simulate new session with same dataDir). */ - newSession(): void { + async newSession(): Promise { if (!this.dataDir) throw new Error("Call initRolex() first"); - this.rolex = createRoleX(localPlatform({ dataDir: this.dataDir, resourceDir: null })); + this.rolex = await createRoleX(localPlatform({ dataDir: this.dataDir, resourceDir: null })); } } diff --git a/bun.lock b/bun.lock index debfaf9..64792cc 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ "name": "@rolexjs/bdd", "version": "0.0.0", "devDependencies": { - "@deepracticex/bdd": "^0.2.0", + "@deepracticex/bdd": "^0.3.0", "@modelcontextprotocol/sdk": "^1.27.1", "@rolexjs/core": "workspace:*", "@rolexjs/local-platform": "workspace:*", @@ -1153,6 +1153,8 @@ "@resourcexjs/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@rolexjs/bdd/@deepracticex/bdd": ["@deepracticex/bdd@0.3.0", "", { "dependencies": { "@cucumber/cucumber-expressions": "^18.0.1", "@cucumber/gherkin": "^31.0.0", "@cucumber/messages": "^27.2.0" } }, "sha512-JNljvjAJIZknE8MBjzLfhPr/WRGe/hz6d+nYaEY7TqPVtWiXRPnN2iT8vP7DqTdDqIOw6B5NiUHh8g+V5Ffy+A=="], + "@sandboxxjs/state/resourcexjs": ["resourcexjs@2.14.0", "https://registry.npmmirror.com/resourcexjs/-/resourcexjs-2.14.0.tgz", { "dependencies": { "@resourcexjs/arp": "^2.14.0", "@resourcexjs/core": "^2.14.0", "sandboxxjs": "^0.5.1" } }, "sha512-EGM88QusNIBNKv+PIeyYWBwTxmUK81F+A7jx/SEW6J+CI0JMuPAEPfQ+/i751CJbMlNwHd5ynPiLQ+/WGrDkeA=="], "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], @@ -1187,6 +1189,10 @@ "@deepracticex/bdd/@cucumber/gherkin/@cucumber/messages": ["@cucumber/messages@26.0.1", "https://registry.npmmirror.com/@cucumber/messages/-/messages-26.0.1.tgz", { "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", "reflect-metadata": "0.2.2", "uuid": "10.0.0" } }, "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg=="], + "@rolexjs/bdd/@deepracticex/bdd/@cucumber/gherkin": ["@cucumber/gherkin@31.0.0", "https://registry.npmmirror.com/@cucumber/gherkin/-/gherkin-31.0.0.tgz", { "dependencies": { "@cucumber/messages": ">=19.1.4 <=26" } }, "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw=="], + + "@rolexjs/bdd/@deepracticex/bdd/@cucumber/messages": ["@cucumber/messages@27.2.0", "https://registry.npmmirror.com/@cucumber/messages/-/messages-27.2.0.tgz", { "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", "reflect-metadata": "0.2.2", "uuid": "11.0.5" } }, "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA=="], + "@sandboxxjs/state/resourcexjs/@resourcexjs/arp": ["@resourcexjs/arp@2.14.0", "https://registry.npmmirror.com/@resourcexjs/arp/-/arp-2.14.0.tgz", {}, "sha512-Kw3g7R3/Fkv3ce2Nl31vgv6PLwNJPXkbeya5//rpuR32LyXcWogF0lrGCskcepqhyJnMeAXOO5ZX0paJBVU1Yw=="], "@sandboxxjs/state/resourcexjs/@resourcexjs/core": ["@resourcexjs/core@2.14.0", "https://registry.npmmirror.com/@resourcexjs/core/-/core-2.14.0.tgz", { "dependencies": { "modern-tar": "^0.7.3", "zod": "^4.3.6" } }, "sha512-3QFWVzeKpVtLVMjEb5Qvsg1xFT0aWGutNBndRNS235itM7MhkDLC+tYrebZNJdBp1QnUlVc8TnWGieLhRD17FA=="], @@ -1217,6 +1223,8 @@ "@deepracticex/bdd/@cucumber/gherkin/@cucumber/messages/uuid": ["uuid@10.0.0", "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "@rolexjs/bdd/@deepracticex/bdd/@cucumber/gherkin/@cucumber/messages": ["@cucumber/messages@26.0.1", "https://registry.npmmirror.com/@cucumber/messages/-/messages-26.0.1.tgz", { "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", "reflect-metadata": "0.2.2", "uuid": "10.0.0" } }, "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg=="], + "@sandboxxjs/state/resourcexjs/@resourcexjs/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "fastmcp/yargs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -1237,6 +1245,8 @@ "pipenet/yargs/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "@rolexjs/bdd/@deepracticex/bdd/@cucumber/gherkin/@cucumber/messages/uuid": ["uuid@10.0.0", "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "fastmcp/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "fastmcp/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 31512ca..51d4826 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,13 +3,14 @@ * * Domain-specific structures and processes built on @rolexjs/system. * - * Structures — the concept tree (18 concepts, 2 relations) - * Processes — how the world changes (24 processes, 4 layers) + * Structures — the concept tree (23 concepts, 3 relations) + * Processes — how the world changes (32 processes, 5 layers) * * Layer 1: Execution — want, plan, todo, finish, complete, abandon * Layer 2: Cognition — reflect, realize, master * Layer 3: Organization — hire, fire, appoint, dismiss, charter, charge - * Layer 4: Lifecycle — born, found, establish, retire, die, dissolve, abolish, rehire + * Layer 3b: Project — enroll, remove, scope, milestone, deliver, wiki + * Layer 4: Lifecycle — born, found, establish, launch, retire, die, dissolve, abolish, archive, rehire * + Role: activate */ @@ -46,6 +47,8 @@ export { background, // Organization charter, + // Project + deliverable, duty, // Individual — Cognition encounter, @@ -56,6 +59,8 @@ export { identity, // Level 1 individual, + // Project + milestone, // Individual — Knowledge mindset, organization, @@ -64,12 +69,18 @@ export { position, principle, procedure, + // Project + project, // Organization — Position requirement, + // Project + scope, // Level 0 society, task, tone, + // Project + wiki, } from "./structures.js"; // ===== Processes — Layer 1: Execution ===== @@ -84,9 +95,31 @@ export { master, realize, reflect } from "./cognition.js"; export { appoint, charge, charterOrg, dismiss, fire, hire } from "./organization.js"; +// ===== Processes — Layer 3b: Project ===== + +export { + deliverProject, + enroll, + milestoneProject, + removeParticipant, + scopeProject, + wikiProject, +} from "./project.js"; + // ===== Processes — Layer 4: Lifecycle ===== -export { abolish, born, die, dissolve, establish, found, rehire, retire } from "./lifecycle.js"; +export { + abolish, + archive, + born, + die, + dissolve, + establish, + found, + launch, + rehire, + retire, +} from "./lifecycle.js"; // ===== Role ===== diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts index d70e633..2f66aba 100644 --- a/packages/core/src/lifecycle.ts +++ b/packages/core/src/lifecycle.ts @@ -10,7 +10,7 @@ * No real deletion — everything transforms to the "past" branch. */ import { create, process, transform } from "@rolexjs/system"; -import { individual, organization, past, position, society } from "./structures.js"; +import { individual, organization, past, position, project, society } from "./structures.js"; // Creation export const born = process( @@ -21,6 +21,7 @@ export const born = process( ); export const found = process("found", "Found an organization", society, create(organization)); export const establish = process("establish", "Establish a position", society, create(position)); +export const launch = process("launch", "Launch a project", society, create(project)); // Retirement & death export const retire = process( @@ -31,6 +32,9 @@ export const retire = process( ); export const die = process("die", "An individual dies", individual, transform(individual, past)); +// Archive project +export const archive = process("archive", "Archive a project", project, transform(project, past)); + // Dissolution export const dissolve = process( "dissolve", diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts new file mode 100644 index 0000000..cf5a069 --- /dev/null +++ b/packages/core/src/project.ts @@ -0,0 +1,46 @@ +/** + * Project management — scoping, milestones, enrollment, deliverables, wiki. + * + * enroll / remove — participation (who is involved) + * scope — define project boundary + * milestone — add checkpoint + * deliver — add deliverable + * wiki — add knowledge entry + */ +import { create, link, process, unlink } from "@rolexjs/system"; +import { deliverable, milestone, project, scope, wiki } from "./structures.js"; + +// Participation +export const enroll = process( + "enroll", + "Enroll an individual into the project", + project, + link(project, "participation") +); +export const removeParticipant = process( + "remove", + "Remove an individual from the project", + project, + unlink(project, "participation") +); + +// Structure +export const scopeProject = process( + "scope", + "Define the scope for a project", + project, + create(scope) +); +export const milestoneProject = process( + "milestone", + "Add a milestone to a project", + project, + create(milestone) +); +export const deliverProject = process( + "deliver", + "Add a deliverable to a project", + project, + create(deliverable) +); +export const wikiProject = process("wiki", "Add a wiki entry to a project", project, create(wiki)); diff --git a/packages/core/src/structures.ts b/packages/core/src/structures.ts index 65c74d0..5cd62c0 100644 --- a/packages/core/src/structures.ts +++ b/packages/core/src/structures.ts @@ -25,6 +25,12 @@ * │ ├── position "A role held by an individual" │ * │ │ │ ∿ appointment → individual │ * │ │ └── duty "Responsibilities of position" │ + * │ ├── project "A process container" │ + * │ │ │ ∿ participation → individual │ + * │ │ ├── scope "Project boundary" │ + * │ │ ├── milestone "Key checkpoint" │ + * │ │ ├── deliverable "Project output" │ + * │ │ └── wiki "Project knowledge base" │ * │ └── past "Things no longer active" │ * └─────────────────────────────────────────────────────────┘ */ @@ -92,3 +98,23 @@ export const position = structure("position", "A role held by an individual", so ]); export const duty = structure("duty", "Responsibilities of this position", position); export const requirement = structure("requirement", "Required skill for this position", position); + +// ================================================================ +// Project — process management entity +// ================================================================ + +export const project = structure("project", "A process container for organized work", society, [ + relation("participation", "Who participates in this project", individual), +]); +export const scope = structure( + "scope", + "Project boundary — what to do and what not to do", + project +); +export const milestone = structure( + "milestone", + "Key checkpoint with achievement criteria", + project +); +export const deliverable = structure("deliverable", "Project output and delivery", project); +export const wiki = structure("wiki", "Project-level knowledge base entry", project); diff --git a/packages/prototype/src/instructions.ts b/packages/prototype/src/instructions.ts index 11b5bec..10ae028 100644 --- a/packages/prototype/src/instructions.ts +++ b/packages/prototype/src/instructions.ts @@ -455,6 +455,135 @@ const positionDismiss = def( ["position", "individual"] ); +// ================================================================ +// Project — project management +// ================================================================ + +const projectLaunch = def( + "project", + "launch", + { + content: { + type: "gherkin", + required: false, + description: "Gherkin Feature source for the project", + }, + id: { type: "string", required: false, description: "User-facing identifier (kebab-case)" }, + alias: { type: "string[]", required: false, description: "Alternative names" }, + }, + ["content", "id", "alias"] +); + +const projectScope = def( + "project", + "scope", + { + project: { type: "string", required: true, description: "Project id" }, + content: { + type: "gherkin", + required: true, + description: "Gherkin Feature source for the scope", + }, + id: { type: "string", required: false, description: "Scope id" }, + }, + ["project", "content", "id"] +); + +const projectMilestone = def( + "project", + "milestone", + { + project: { type: "string", required: true, description: "Project id" }, + content: { + type: "gherkin", + required: true, + description: "Gherkin Feature source for the milestone", + }, + id: { + type: "string", + required: false, + description: "Milestone id (keywords joined by hyphens)", + }, + }, + ["project", "content", "id"] +); + +const projectAchieve = def( + "project", + "achieve", + { + milestone: { type: "string", required: true, description: "Milestone id to mark as done" }, + }, + ["milestone"] +); + +const projectEnroll = def( + "project", + "enroll", + { + project: { type: "string", required: true, description: "Project id" }, + individual: { type: "string", required: true, description: "Individual id" }, + }, + ["project", "individual"] +); + +const projectRemove = def( + "project", + "remove", + { + project: { type: "string", required: true, description: "Project id" }, + individual: { type: "string", required: true, description: "Individual id" }, + }, + ["project", "individual"] +); + +const projectDeliver = def( + "project", + "deliver", + { + project: { type: "string", required: true, description: "Project id" }, + content: { + type: "gherkin", + required: true, + description: "Gherkin Feature source for the deliverable", + }, + id: { + type: "string", + required: false, + description: "Deliverable id (keywords joined by hyphens)", + }, + }, + ["project", "content", "id"] +); + +const projectWiki = def( + "project", + "wiki", + { + project: { type: "string", required: true, description: "Project id" }, + content: { + type: "gherkin", + required: true, + description: "Gherkin Feature source for the wiki entry", + }, + id: { + type: "string", + required: false, + description: "Wiki entry id (keywords joined by hyphens)", + }, + }, + ["project", "content", "id"] +); + +const projectArchive = def( + "project", + "archive", + { + project: { type: "string", required: true, description: "Project id" }, + }, + ["project"] +); + // ================================================================ // Census — society-level queries // ================================================================ @@ -466,7 +595,7 @@ const censusList = def( type: { type: "string", required: false, - description: "Filter by type (individual, organization, position, past)", + description: "Filter by type (individual, organization, position, project, past)", }, }, ["type"] @@ -736,6 +865,17 @@ export const instructions: Record = { "position.appoint": positionAppoint, "position.dismiss": positionDismiss, + // project + "project.launch": projectLaunch, + "project.scope": projectScope, + "project.milestone": projectMilestone, + "project.achieve": projectAchieve, + "project.enroll": projectEnroll, + "project.remove": projectRemove, + "project.deliver": projectDeliver, + "project.wiki": projectWiki, + "project.archive": projectArchive, + // census "census.list": censusList, diff --git a/packages/prototype/src/ops.ts b/packages/prototype/src/ops.ts index bf6d543..2eec1f1 100644 --- a/packages/prototype/src/ops.ts +++ b/packages/prototype/src/ops.ts @@ -317,6 +317,64 @@ export function createOps(ctx: OpsContext): Ops { } }, + // ---- Project ---- + + async "project.launch"( + content?: string, + id?: string, + alias?: readonly string[] + ): Promise { + validateGherkin(content); + const node = await rt.create(society, C.project, content, id, alias); + return ok(node, "launch"); + }, + + async "project.scope"(project: string, scope: string, id?: string): Promise { + validateGherkin(scope); + const node = await rt.create(await resolve(project), C.scope, scope, id); + return ok(node, "scope"); + }, + + async "project.milestone"(project: string, milestone: string, id?: string): Promise { + validateGherkin(milestone); + const node = await rt.create(await resolve(project), C.milestone, milestone, id); + return ok(node, "milestone"); + }, + + async "project.achieve"(milestone: string): Promise { + const node = await resolve(milestone); + await rt.tag(node, "done"); + return ok(node, "achieve"); + }, + + async "project.enroll"(project: string, individual: string): Promise { + const projNode = await resolve(project); + await rt.link(projNode, await resolve(individual), "participation", "participate"); + return ok(projNode, "enroll"); + }, + + async "project.remove"(project: string, individual: string): Promise { + const projNode = await resolve(project); + await rt.unlink(projNode, await resolve(individual), "participation", "participate"); + return ok(projNode, "remove"); + }, + + async "project.deliver"(project: string, deliverable: string, id?: string): Promise { + validateGherkin(deliverable); + const node = await rt.create(await resolve(project), C.deliverable, deliverable, id); + return ok(node, "deliver"); + }, + + async "project.wiki"(project: string, wiki: string, id?: string): Promise { + validateGherkin(wiki); + const node = await rt.create(await resolve(project), C.wiki, wiki, id); + return ok(node, "wiki"); + }, + + async "project.archive"(project: string): Promise { + return archive(await resolve(project), "archive"); + }, + // ---- Org ---- async "org.found"(content?: string, id?: string, alias?: readonly string[]): Promise { diff --git a/packages/rolexjs/src/index.ts b/packages/rolexjs/src/index.ts index 201d0a1..9b97efd 100644 --- a/packages/rolexjs/src/index.ts +++ b/packages/rolexjs/src/index.ts @@ -28,6 +28,9 @@ export { renderIssueList, renderIssueResult, } from "./issue-render.js"; +// Project Render +export type { ProjectAction } from "./project-render.js"; +export { renderProject, renderProjectResult } from "./project-render.js"; export type { RenderOptions, RenderStateOptions } from "./render.js"; // Render export { describe, detail, directive, hint, render, renderState, world } from "./render.js"; diff --git a/packages/rolexjs/src/project-render.ts b/packages/rolexjs/src/project-render.ts new file mode 100644 index 0000000..687706e --- /dev/null +++ b/packages/rolexjs/src/project-render.ts @@ -0,0 +1,148 @@ +/** + * Project Render — format project state as readable text. + * + * Renders project operations into human-readable summaries. + * Participation links show member names only (not full individual trees). + * Milestones show progress status (#done or pending). + */ +import type { State } from "@rolexjs/system"; +import { describe, hint } from "./render.js"; + +// ================================================================ +// Types +// ================================================================ + +export type ProjectAction = + | "launch" + | "scope" + | "milestone" + | "achieve" + | "enroll" + | "remove" + | "deliver" + | "wiki" + | "archive"; + +// ================================================================ +// Project Overview +// ================================================================ + +export function renderProject(state: State): string { + const lines: string[] = []; + const id = state.id ?? "(no id)"; + const tag = state.tag ? ` #${state.tag}` : ""; + + // Title + lines.push(`# ${id}${tag}`); + + // Feature body + if (state.information) { + lines.push(""); + lines.push(state.information); + } + + // Members (participation links — compact) + const members = state.links?.filter((l) => l.relation === "participation") ?? []; + if (members.length > 0) { + lines.push(""); + lines.push("## Members"); + for (const m of members) { + const alias = m.target.alias?.length ? ` (${m.target.alias.join(", ")})` : ""; + lines.push(`- ${m.target.id ?? "(no id)"}${alias}`); + } + } + + // Children by type + const children = state.children ?? []; + + const scopes = children.filter((c) => c.name === "scope"); + const milestones = children.filter((c) => c.name === "milestone"); + const deliverables = children.filter((c) => c.name === "deliverable"); + const wikis = children.filter((c) => c.name === "wiki"); + + if (scopes.length > 0) { + lines.push(""); + lines.push("## Scope"); + for (const s of scopes) { + if (s.information) lines.push(s.information); + } + } + + if (milestones.length > 0) { + lines.push(""); + lines.push("## Milestones"); + for (const m of milestones) { + const tag = m.tag ? ` #${m.tag}` : ""; + const marker = m.tag === "done" ? "[x]" : "[ ]"; + const title = extractFeatureTitle(m.information); + lines.push(`- ${marker} ${m.id ?? title}${tag}`); + if (m.information && m.id) { + const desc = extractFeatureTitle(m.information); + if (desc && desc !== m.id) lines.push(` ${desc}`); + } + } + } + + if (deliverables.length > 0) { + lines.push(""); + lines.push("## Deliverables"); + for (const d of deliverables) { + const title = d.id ?? extractFeatureTitle(d.information); + lines.push(`- ${title}`); + } + } + + if (wikis.length > 0) { + lines.push(""); + lines.push("## Wiki"); + for (const w of wikis) { + const title = w.id ?? extractFeatureTitle(w.information); + lines.push(`- ${title}`); + } + } + + return lines.join("\n"); +} + +// ================================================================ +// Compose — main entry point +// ================================================================ + +/** + * Render a project operation result as readable text. + * Returns status + hint + project overview. + */ +export function renderProjectResult(action: ProjectAction, state: State): string { + const name = state.id ?? state.name; + const lines: string[] = []; + + // Layer 1: Status + lines.push(describe(action, name, state)); + + // Layer 2: Hint + lines.push(hint(action)); + + // Layer 3: Project overview + // For child operations (scope, milestone, etc.), find the project parent + const projectState = isProjectNode(state) ? state : null; + if (projectState) { + lines.push(""); + lines.push(renderProject(projectState)); + } + + return lines.join("\n"); +} + +// ================================================================ +// Helpers +// ================================================================ + +function isProjectNode(state: State): boolean { + return state.name === "project"; +} + +function extractFeatureTitle(information?: string | null): string { + if (!information) return "(untitled)"; + const match = information.match(/^Feature:\s*(.+)$/m); + return match?.[1]?.trim() ?? information.split("\n")[0].trim(); +} diff --git a/packages/rolexjs/src/render.ts b/packages/rolexjs/src/render.ts index de7c6f0..5262da7 100644 --- a/packages/rolexjs/src/render.ts +++ b/packages/rolexjs/src/render.ts @@ -32,6 +32,17 @@ const descriptions: Record string> = { appoint: (n) => `"${n}" appointed.`, dismiss: (n) => `"${n}" dismissed.`, + // Project + launch: (n) => `Project "${n}" launched.`, + scope: (n) => `Scope defined for "${n}".`, + milestone: (n) => `Milestone "${n}" added.`, + achieve: (n) => `Milestone "${n}" achieved.`, + enroll: (n) => `"${n}" enrolled.`, + remove: (n) => `"${n}" removed.`, + deliver: (n) => `Deliverable "${n}" added.`, + wiki: (n) => `Wiki entry "${n}" added.`, + archive: (n) => `Project "${n}" archived.`, + // Role activate: (n) => `Role "${n}" activated.`, focus: (n) => `Focused on goal "${n}".`, @@ -81,6 +92,17 @@ const hints: Record = { appoint: "the individual now holds this position.", dismiss: "the position is now vacant.", + // Project + launch: "define scope, add milestones, or enroll members.", + scope: "add milestones to break down the project.", + milestone: "enroll members and start working.", + achieve: "continue with remaining milestones, or archive the project.", + enroll: "assign milestones and start working.", + remove: "the individual is no longer a participant.", + deliver: "deliverable recorded.", + wiki: "knowledge entry recorded.", + archive: "the project is archived.", + // Role activate: "want a goal, or check the current state.", focus: "plan how to work toward it, or add tasks.", @@ -231,6 +253,11 @@ const CONCEPT_ORDER: readonly string[] = [ // Position "position", "duty", + // Project + "scope", + "milestone", + "deliverable", + "wiki", ]; /** Summarize plan/task completion for a goal heading. */ diff --git a/packages/rolexjs/src/role.ts b/packages/rolexjs/src/role.ts index dc0ce5c..f0e8e41 100644 --- a/packages/rolexjs/src/role.ts +++ b/packages/rolexjs/src/role.ts @@ -15,6 +15,7 @@ import type { OpResult, Ops } from "@rolexjs/prototype"; import type { RoleContext } from "./context.js"; import { type IssueAction, type LabelResolver, renderIssueResult } from "./issue-render.js"; +import { type ProjectAction, renderProjectResult } from "./project-render.js"; import { render } from "./render.js"; /** @@ -228,6 +229,12 @@ export class Role { const action = locator.slice("!issue.".length) as IssueAction; return (await renderIssueResult(action, result, this.api.resolveLabels)) as T; } + // Render project results as readable text + if (locator.startsWith("!project.")) { + const action = locator.slice("!project.".length) as ProjectAction; + const opResult = result as { state: import("@rolexjs/system").State }; + return renderProjectResult(action, opResult.state) as T; + } return result; } }