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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>`: Control-plane server profile or URL.
Expand All @@ -2010,7 +2010,10 @@ Control-plane operation: `workdays.plan`.
- `--objective <value>`: Objective filter.
- `--json`: Emit the stable JSON envelope.
- `--idempotency-key <value>`: 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 <value>`: Planning agent slug; repeat or comma-separate. Intersects with class/activity selectors.
- `--activity <value>`: Planning activity: planning, estimating, reviewing, reporting, or chat; repeat or comma-separate.
- `--class <value>`: Planning class slug; repeat or comma-separate. Acting remains governed by accepted decisions.

### trsd workdays start

Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
42 changes: 39 additions & 3 deletions schemas/command-tree.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
]
}
Expand Down
17 changes: 12 additions & 5 deletions src/cli/commands/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/cli/support/operations/input-fields.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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<string, unknown>;
}
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<string, unknown>, 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<string, unknown>)[key];
}
return value;
}
43 changes: 43 additions & 0 deletions tests/unit/command-boundary/workdays/selection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading