From 433204eae0e8325522fb2b8238b67cef256ce75a Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Fri, 11 Sep 2026 09:14:29 -0400 Subject: [PATCH] feat(workdays): expose repeatable targeted planning selectors --- docs/command-reference.md | 9 ++-- package-lock.json | 12 +++--- package.json | 4 +- schemas/command-tree.json | 42 ++++++++++++++++-- src/cli/commands/operator.ts | 17 +++++--- src/cli/support/operations/input-fields.ts | 27 ++++++++++++ .../workdays/selection.test.ts | 43 +++++++++++++++++++ 7 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 src/cli/support/operations/input-fields.ts create mode 100644 tests/unit/command-boundary/workdays/selection.test.ts diff --git a/docs/command-reference.md b/docs/command-reference.md index 2e6c000..06d0503 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1995,9 +1995,9 @@ Availability: fail-closed (`standards_migration_not_enabled`). This capability i ### trsd workdays plan -Plan the selected resource. +Plan a workday with optional targeted cooperative planning; acting stays decision-governed. -Operation: mutation. Result schema: `treeseed.command.plan/v1`. +Operation: mutation. Result schema: `treeseed.command.workdays.plan/v1`. Control-plane operation: `workdays.plan`. - `--server `: Control-plane server profile or URL. @@ -2010,7 +2010,10 @@ Control-plane operation: `workdays.plan`. - `--objective `: Objective filter. - `--json`: Emit the stable JSON envelope. - `--idempotency-key `: Reuse the same request identity when retrying this mutation. -- `--plan`: Return the exact proposed outcome without mutation. +- `--plan`: Return the request without creating a preflight. +- `--agent `: Planning agent slug; repeat or comma-separate. Intersects with class/activity selectors. +- `--activity `: Planning activity: planning, estimating, reviewing, reporting, or chat; repeat or comma-separate. +- `--class `: Planning class slug; repeat or comma-separate. Acting remains governed by accepted decisions. ### trsd workdays start diff --git a/package-lock.json b/package-lock.json index c618202..8211b1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treeseed/cli", - "version": "0.13.0-rc.82", + "version": "0.13.0-rc.83", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/cli", - "version": "0.13.0-rc.82", + "version": "0.13.0-rc.83", "bundleDependencies": [ "ink", "react", @@ -16,7 +16,7 @@ "license": "Apache-2.0", "dependencies": { "@treeseed/identity": "0.1.0-rc.23", - "@treeseed/sdk": "0.13.0-rc.116", + "@treeseed/sdk": "0.13.0-rc.117", "ink": "^7.1.1", "react": "^19.2.8", "string-width": "^8.2.2", @@ -5105,9 +5105,9 @@ } }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.116", - "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.116.tgz", - "integrity": "sha512-cUj0y+P91RJs2IC7/tITQY28x2q8NydCo/Etf8ai0qUzkzwCXvw6JkGqN5qLxMjA2wTclqX3MuRdK396EqQg6Q==", + "version": "0.13.0-rc.117", + "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.117.tgz", + "integrity": "sha512-4RZfUe2KcG9qR3JPSYyedPZviB88WlkTHl4G1nUchRVTDFxjmkEqGd8Ja4x7ONmfcvlvwxljEyARacq7pVOBDg==", "license": "Apache-2.0", "dependencies": { "@treeseed/treedx": "0.3.0-rc.17", diff --git a/package.json b/package.json index 646994a..ab7e6e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/cli", - "version": "0.13.0-rc.82", + "version": "0.13.0-rc.83", "description": "Operator-facing Treeseed CLI package.", "license": "Apache-2.0", "repository": { @@ -52,7 +52,7 @@ }, "dependencies": { "@treeseed/identity": "0.1.0-rc.23", - "@treeseed/sdk": "0.13.0-rc.116", + "@treeseed/sdk": "0.13.0-rc.117", "ink": "^7.1.1", "react": "^19.2.8", "string-width": "^8.2.2", diff --git a/schemas/command-tree.json b/schemas/command-tree.json index 75d9bcb..4a701a1 100644 --- a/schemas/command-tree.json +++ b/schemas/command-tree.json @@ -4935,20 +4935,35 @@ { "nodeType": "leaf", "segment": "plan", - "description": "Plan the selected resource.", + "description": "Plan a workday with optional targeted cooperative planning; acting stays decision-governed.", "kind": "mutation", "options": [ { "name": "--plan", - "description": "Return the exact proposed outcome without mutation.", + "description": "Return the request without creating a preflight.", "type": "boolean" + }, + { + "name": "--agent", + "description": "Planning agent slug; repeat or comma-separate. Intersects with class/activity selectors.", + "type": "string[]" + }, + { + "name": "--activity", + "description": "Planning activity: planning, estimating, reviewing, reporting, or chat; repeat or comma-separate.", + "type": "string[]" + }, + { + "name": "--class", + "description": "Planning class slug; repeat or comma-separate. Acting remains governed by accepted decisions.", + "type": "string[]" } ], "authorization": { "capability": "command.plan", "confirmation": "never" }, - "resultSchemaId": "treeseed.command.plan/v1", + "resultSchemaId": "treeseed.command.workdays.plan/v1", "execution": { "kind": "operation", "operationId": "workdays.plan", @@ -5008,6 +5023,27 @@ "name": "objective", "required": false, "transform": "csv" + }, + { + "target": "body", + "field": "agentSelection.agentSlugs", + "source": "option", + "name": "agent", + "transform": "csv" + }, + { + "target": "body", + "field": "agentSelection.activityTypes", + "source": "option", + "name": "activity", + "transform": "csv" + }, + { + "target": "body", + "field": "agentSelection.classSlugs", + "source": "option", + "name": "class", + "transform": "csv" } ] } diff --git a/src/cli/commands/operator.ts b/src/cli/commands/operator.ts index 027d412..02fe910 100644 --- a/src/cli/commands/operator.ts +++ b/src/cli/commands/operator.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; -import { controlPlaneOperation, encodeConfirmationState, parseCommunicationAddresses, type CommandInputBinding } from '@treeseed/sdk/operator-contracts'; +import { controlPlaneOperation, encodeConfirmationState, parseCommunicationAddresses, validateWorkdayIntentSelection, normalizeWorkdayAgentSelection, type CommandInputBinding } from '@treeseed/sdk/operator-contracts'; import { ControlPlaneClientError, resolveControlPlaneServer } from '@treeseed/sdk/control-plane-client'; import type { CommandContext, ParsedInvocation } from '../types.js'; import { launchApplication } from '../application/launch.js'; @@ -11,6 +11,7 @@ import { controlPlaneServerRegistry, createControlPlaneClient } from '../support import { loadServerSession } from '../support/server-custody.js'; import { renderCommunicationResponses } from '../support/human-renderer.js'; import { resolveExplicitTeam } from '../support/selectors/team.js'; +import { getOperationInputField, setOperationInputField } from '../support/operations/input-fields.js'; function activeTeam(invocation: ParsedInvocation, context: CommandContext) { const registry = controlPlaneServerRegistry(context); @@ -30,13 +31,14 @@ function sourceValue(binding: CommandInputBinding, invocation: ParsedInvocation, } function transform(value: unknown, binding: CommandInputBinding) { - if (value === undefined || value === null || value === '') return undefined; + if (value === undefined || value === null) return undefined; + if (binding.transform === 'csv') return (Array.isArray(value) ? value : [value]).flatMap(item => String(item).split(',').map(part => part.trim())); + if (value === '') return undefined; if (binding.transform === 'integer') { const parsed = Number(value); if (!Number.isInteger(parsed)) throw new Error(`${binding.name} must be an integer.`); return parsed; } - if (binding.transform === 'csv') return Array.isArray(value) ? value : String(value).split(',').map((item) => item.trim()).filter(Boolean); return value; } @@ -65,9 +67,14 @@ async function operationInput(invocation: ParsedInvocation, context: CommandCont for (const binding of invocation.command.execution.input) { const value = transform(sourceValue(binding, invocation, context), binding); if (binding.required && value === undefined) { deferred.push(binding); continue; } - if (value !== undefined) input[binding.target][binding.field] = value; + if (value !== undefined) setOperationInputField(input[binding.target], binding.field, value); } const operation = controlPlaneOperation(invocation.command.execution.operationId); + if (operation.descriptor.operationId === 'workdays.plan' && input.body.agentSelection !== undefined) { + const diagnostics = validateWorkdayIntentSelection(input.body.agentSelection); + if (diagnostics.length) throw Object.assign(new Error(diagnostics.map(item => `${item.path}: ${item.message}`).join(' ')), { category: 'invalid_input', code: 'workday_agent_selection_invalid' }); + input.body.agentSelection = normalizeWorkdayAgentSelection(input.body.agentSelection); + } if (operation.descriptor.operationId.startsWith('seeds.') && typeof input.body.file === 'string') { const parsed = await portableSeedBundle(input.body.file, context); delete input.body.file; @@ -92,7 +99,7 @@ async function operationInput(invocation: ParsedInvocation, context: CommandCont delete input.body.file; Object.assign(input.body, parsed); } - for (const binding of deferred) if (input[binding.target][binding.field] === undefined) { + for (const binding of deferred) if (getOperationInputField(input[binding.target], binding.field) === undefined) { throw Object.assign(new Error(`Missing required ${binding.source}: ${binding.name}`), { category: 'ambiguous_context', code: `${binding.name}_required` }); } const body = Object.keys(input.body).length diff --git a/src/cli/support/operations/input-fields.ts b/src/cli/support/operations/input-fields.ts new file mode 100644 index 0000000..d3ac6a7 --- /dev/null +++ b/src/cli/support/operations/input-fields.ts @@ -0,0 +1,27 @@ +function parts(field: string) { + const keys = field.split('.'); + if (keys.length > 8 || keys.some(key => !/^[A-Za-z][A-Za-z0-9_]*$/u.test(key) || ['__proto__', 'prototype', 'constructor'].includes(key))) throw new Error('Unsafe operation input field.'); + return keys; +} + +export function setOperationInputField(target: Record, field: string, value: unknown) { + const keys = parts(field); let current = target; + for (const key of keys.slice(0, -1)) { + if (!Object.hasOwn(current, key)) current[key] = {}; + const child = current[key]; + if (!child || typeof child !== 'object' || Array.isArray(child)) throw new Error('Conflicting operation input field.'); + current = child as Record; + } + const leaf = keys.at(-1)!; + if (Object.hasOwn(current, leaf)) throw new Error('Duplicate operation input field.'); + current[leaf] = value; +} + +export function getOperationInputField(target: Record, field: string): unknown { + let value: unknown = target; + for (const key of parts(field)) { + if (!value || typeof value !== 'object' || !Object.hasOwn(value, key)) return undefined; + value = (value as Record)[key]; + } + return value; +} diff --git a/tests/unit/command-boundary/workdays/selection.test.ts b/tests/unit/command-boundary/workdays/selection.test.ts new file mode 100644 index 0000000..0cf59c0 --- /dev/null +++ b/tests/unit/command-boundary/workdays/selection.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runCommandLine } from '../../../../src/cli/runtime.ts'; +import { getOperationInputField, setOperationInputField } from '../../../../src/cli/support/operations/input-fields.ts'; + +const base = ['workdays', 'plan', '--team', '11111111-1111-4111-8111-111111111111', '--profile', 'documentation', '--projects', 'sdk', '--start', '2030-01-01T00:00:00Z', '--duration', '600', '--json']; + +test('repeated and CSV selectors become a normalized intersecting nested intent', async () => { + const calls: Array<{ operationId: string; input: any }> = []; + const exit = await runCommandLine([...base, '--agent', 'reviewer,architect', '--agent', 'reviewer', '--activity', 'reviewing', '--class', 'engineering'], { + interactiveUi: false, write() {}, operationInvoke: async (operationId, input) => { calls.push({ operationId, input }); return { data: {} }; }, + }); + assert.equal(exit, 0); assert.equal(calls.length, 1); assert.equal(calls[0]!.operationId, 'workdays.plan'); + assert.deepEqual(calls[0]!.input.body.agentSelection, { classIds: [], classSlugs: ['engineering'], agentSlugs: ['architect', 'reviewer'], activityTypes: ['reviewing'], mode: 'intersection' }); + assert.equal(Object.keys(calls[0]!.input.body).some(key => key.includes('.')), false); +}); + +test('omitted selection leaves the full intent unchanged', async () => { + let body: any; + assert.equal(await runCommandLine(base, { interactiveUi: false, write() {}, operationInvoke: async (_id, input) => { body = input.body; return { data: {} }; } }), 0); + assert.equal(Object.hasOwn(body, 'agentSelection'), false); +}); + +for (const selector of [['--agent', ''], ['--agent', 'reviewer,'], ['--activity', 'acting'], ['--activity', 'reviewng']]) { + test(`invalid selection ${JSON.stringify(selector)} never invokes the API`, async () => { + let calls = 0; const output: string[] = []; + assert.equal(await runCommandLine([...base, ...selector], { interactiveUi: false, write: value => output.push(value), operationInvoke: async () => { calls++; } }), 1); + assert.equal(calls, 0); + const error = JSON.parse(output[0]!).error; + assert.equal(error.category, 'invalid_input'); + assert.equal(error.code, selector[1] === '' ? 'invalid_input' : 'workday_agent_selection_invalid'); + }); +} + +test('nested bindings reject prototype traversal, collisions, and excessive depth', () => { + const input = {}; setOperationInputField(input, 'agentSelection.agentSlugs', ['reviewer']); + assert.deepEqual(getOperationInputField(input, 'agentSelection.agentSlugs'), ['reviewer']); + assert.equal(getOperationInputField(input, 'missing.value'), undefined); + for (const path of ['__proto__.polluted', 'constructor.prototype', 'x..y', 'a.b.c.d.e.f.g.h.i']) assert.throws(() => setOperationInputField(input, path, true), /Unsafe/u); + assert.throws(() => setOperationInputField(input, 'agentSelection.agentSlugs', []), /Duplicate/u); + assert.throws(() => setOperationInputField(input, 'agentSelection.agentSlugs.x', true), /Conflicting/u); + assert.equal(Object.hasOwn(Object.prototype, 'polluted'), false); +});