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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Read the declaration rather than maintaining a prose copy:

Shared selector parsing and matching belongs in `@agent-device/selectors`; request cancellation
and progress in `@agent-device/host-kit/request`; cross-layer contracts in `packages/contracts/src`;
CLI flags in `src/commands/cli-grammar`; cross-surface schema composition in `src/cli-schema`.
CLI flags in `src/commands/cli-grammar`; cross-surface schema composition in `src/commands/schema`.

Resolve registry completeness failures at the missing declaration. Diagnose other gate failures
at their reported invariant; do not suppress them or add an allowlist to get a pass. Build interaction
Expand Down
14 changes: 7 additions & 7 deletions docs/agents/cli-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ Thread a flag only through the layers that consume it:
`packages/command-registry/src/flag-groups.ts`
(for example `SNAPSHOT_FLAGS`). Then update the command family metadata/schema that exposes the
flag; find the owner with
`rg -n "<command>|supportedFlags|allowedFlags" src/commands src/cli-schema src/cli/parser`. For
`rg -n "<command>|supportedFlags|allowedFlags" src/commands src/cli/parser`. For
schema-only CLI commands, the owner is `SCHEMA_ONLY_CLI_COMMAND_SCHEMAS` in
`src/cli-schema/command-overrides.ts`. Every flag declaration states `projectConfig`
`src/commands/schema/command-overrides.ts`. Every flag declaration states `projectConfig`
(may be set from a project `agent-device.json`) and `recorded` (the session recorder
copies it into `SessionAction.flags`). Both are required, so a new declaration that
omits either does not compile — that, not an allowlist, is the completeness gate. Set
Expand Down Expand Up @@ -43,16 +43,16 @@ steps 1-3, plus step 9.

## Where CLI help and schema live

- Long help prose: `src/cli-schema/cli-help.ts`. Flag definitions: `src/commands/cli-grammar/`.
- Synopsis: `src/cli-schema/usage.ts` generates the `[label]` flag tail from `allowedFlags`, so a
- Long help prose: `src/commands/schema/cli-help.ts`. Flag definitions: `src/commands/cli-grammar/`.
- Synopsis: `src/commands/schema/usage.ts` generates the `[label]` flag tail from `allowedFlags`, so a
new option reaches `--help` without any synopsis edit. Declare `usageFlags` on the command only
when its synopsis names fewer options: `[]` for a synopsis that is pure grammar (or writes its own
mutually-exclusive brackets), otherwise the subset it names. `Command flags:` always lists
everything in `allowedFlags`. Keep a cross-cutting opt-in out of every synopsis with
`usageHidden: true` on its flag definition. `src/cli-schema/usage.test.ts` fails a tail that names
`usageHidden: true` on its flag definition. `src/commands/schema/usage.test.ts` fails a tail that names
an option the command does not accept, or one the hand-written grammar already wrote.
- Command-specific usage/flag metadata lives with the command family metadata that owns the command.
- Parser/help *rendering* stays in `src/cli/parser/`; command schema metadata is derived from command
metadata, family declarations, and the schema-only merge path in
`src/cli-schema/command-overrides.ts`. Keep the two separate.
- Locating an owner: `rg -n "helpDescription|summary|supportedFlags|allowedFlags" src/commands src/cli/parser src/cli-schema`.
`src/commands/schema/command-overrides.ts`. Keep the two separate.
- Locating an owner: `rg -n "helpDescription|summary|supportedFlags|allowedFlags" src/commands src/cli/parser`.
4 changes: 2 additions & 2 deletions fallow-baselines/health.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
"count": 1
}
},
"src/cli-schema/cli-config.ts": {
"src/commands/schema/cli-config.ts": {
"crap_moderate": {
"count": 1
}
},
"src/cli-schema/option-schema.ts": {
"src/commands/schema/option-schema.ts": {
"complexity_moderate": {
"count": 1
},
Expand Down
2 changes: 1 addition & 1 deletion scripts/__tests__/help-conformance-topic-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { CASES } from '../help-conformance-cases.mjs';
import { helpTopicIds } from '../../src/cli-schema/cli-help.ts';
import { helpTopicIds } from '../../src/commands/schema/cli-help.ts';

// "What enumerates N": benchmark cases are keyed to help topics, and this gate
// keys the case list to the topic registry itself. A new help topic must gain
Expand Down
2 changes: 1 addition & 1 deletion scripts/fuzz/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { parseArgs } from '../../src/cli/parser/args.ts';
import { validateSelectorExpression } from '@agent-device/selectors';
import { parseReplayScriptDetailed } from '@agent-device/ad-script';
import { readCliBatchStepsJson } from '../../src/cli/batch-steps.ts';
import { readCliBatchStepsJson } from '../../src/commands/batch/batch-steps.ts';
import { inspectMaestroFlow } from '@agent-device/maestro';
import type { FuzzTarget } from './target-types.ts';
import { acceptCase, makeValidationCheck, rejectCase } from './validation-case.ts';
Expand Down
2 changes: 1 addition & 1 deletion scripts/fuzz/validation-arbitraries-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import fc from 'fast-check';
import { describe, expect, it, vi } from 'vitest';
import { listCliCommandNames } from '@agent-device/command-registry/catalog';
import { getFlagDefinitions } from '../../src/cli-schema/command-schema.ts';
import { getFlagDefinitions } from '../../src/commands/schema/command-schema.ts';
import { getFuzzTarget } from './registry.ts';
import { decodeValidationCase } from './validation-case.ts';
import { describeGeneratorContract } from './validation-generator-contract.ts';
Expand Down
4 changes: 2 additions & 2 deletions scripts/fuzz/validation-arbitraries-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
getCliCommandSchema,
getFlagDefinitions,
type FlagDefinition,
} from '../../src/cli-schema/command-schema.ts';
import { isFlagSupportedForCommand } from '../../src/cli-schema/option-schema.ts';
} from '../../src/commands/schema/command-schema.ts';
import { isFlagSupportedForCommand } from '../../src/commands/schema/option-schema.ts';
import { encodeValidationCase, type ValidationCase } from './validation-case.ts';
import { ACCEPT, SAFE_VALUES } from './validation-values.ts';

Expand Down
12 changes: 8 additions & 4 deletions scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
//
// Ranked target spine, as rank groups lowest to highest. `A ◄ B` means B may not
// be outranked by A (the back-edge order the gate rejects), NOT that every displayed import exists:
// { contracts, request, selectors } ◄ core ◄ { commands, cli-schema }
// { contracts, request, selectors } ◄ core ◄ commands
// ◄ { client, daemon-server } ◄ daemon-client ◄ cli
// (authoritative ranks: `TARGET_DAG_RANK` in model.ts. The former rank-0 kernel
// zone lives in packages/kernel since #1490 W0; R11 owns its boundary.)
// `commands` and `cli-schema` share a rank, so the spine cannot order them; R2 declares the
// direction instead — cli-schema renders the command facets and reads them, commands never
// imports cli-schema (#2543).
// `commands/schema/` renders the rest of commands/'s facets and reads them; commands never
// imports it back (#2543). It shares the commands zone (#2679 folded the standalone
// cli-schema zone into it), so the ranked spine and the R2 zone-policy table cannot see the
// edge — `commands-schema-boundary.ts` enforces the direction as a folder-scoped rule instead.
//
// This gate enforces five things, across four scopes:
// - GLOBALLY, across every production source file: the remaining R2 move rule and
Expand Down Expand Up @@ -103,6 +104,7 @@ import {
readTrackedPlatformPackageDeclarations,
} from './platform-package-repository.ts';
import { policyLead, policyViolation, ZONE_POLICIES } from './zone-policy.ts';
import { checkCommandsSchemaBoundary } from './commands-schema-boundary.ts';
import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts';
import { substrateDomainShapeViolations } from './substrate-domain-shape.ts';
import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownership.ts';
Expand Down Expand Up @@ -436,6 +438,7 @@ export type LayeringRule = (context: LayeringContext) => LayeringViolation[];
*/
export const LAYERING_RULE_IDS = [
'zone-policies',
'commands-schema-boundary',
'value-import-cycles',
'runtime-execution-integrity',
'source-execution-compatibility',
Expand Down Expand Up @@ -468,6 +471,7 @@ export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];

export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
'zone-policies': (context) => checkLayeringRules(context.edges),
'commands-schema-boundary': (context) => checkCommandsSchemaBoundary(context.edges),
'value-import-cycles': (context) => checkCycles(context.edges),
'runtime-execution-integrity': (context) => runtimeExecutionIntegrityViolations(context.sources),
'source-execution-compatibility': (context) =>
Expand Down
64 changes: 64 additions & 0 deletions scripts/layering/commands-schema-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { checkCommandsSchemaBoundary } from './commands-schema-boundary.ts';
import { targetDagZone, type ResolvedImportEdge } from './model.ts';

function importEdge(
file: string,
target: string,
overrides: Partial<ResolvedImportEdge> = {},
): ResolvedImportEdge {
return {
file,
target,
spec: target,
line: 1,
dynamic: false,
typeOnly: false,
symbols: [],
bindingResidue: false,
fromZone: targetDagZone(file),
toZone: targetDagZone(target),
...overrides,
};
}

test('commands outside schema/ must not import commands/schema/', () => {
const edge = importEdge('src/commands/capture/wait.ts', 'src/commands/schema/command-schema.ts');
const violations = checkCommandsSchemaBoundary([edge]);
assert.equal(violations.length, 1);
assert.equal(violations[0]!.rule, 'R2 commands-floor');
assert.match(violations[0]!.message, /must not import src\/commands\/schema\/command-schema\.ts/);
});

test('holds for value, type-only, and dynamic imports alike', () => {
for (const overrides of [{}, { typeOnly: true }, { dynamic: true }]) {
const edge = importEdge(
'src/commands/capture/wait.ts',
'src/commands/schema/command-schema.ts',
overrides,
);
assert.equal(checkCommandsSchemaBoundary([edge]).length, 1);
}
});

test('the declared direction — schema reading the rest of commands — stays silent', () => {
const edge = importEdge(
'src/commands/schema/command-schema.ts',
'src/commands/command-metadata.ts',
);
assert.deepEqual(checkCommandsSchemaBoundary([edge]), []);
});

test('schema importing its own sibling files stays silent', () => {
const edge = importEdge(
'src/commands/schema/cli-help.ts',
'src/commands/schema/command-schema.ts',
);
assert.deepEqual(checkCommandsSchemaBoundary([edge]), []);
});

test('a non-commands zone importing commands/schema/ is not this rule’s concern', () => {
const edge = importEdge('src/mcp/server-guide.ts', 'src/commands/schema/cli-help.ts');
assert.deepEqual(checkCommandsSchemaBoundary([edge]), []);
});
43 changes: 43 additions & 0 deletions scripts/layering/commands-schema-boundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Catches: a file under src/commands/ (outside commands/schema/) importing back into
// commands/schema/ — the render-the-facets direction #2543 declared (cli-schema reads
// commands, commands never reads cli-schema back). #2679 folded the standalone cli-schema
// zone into commands/schema/, so the two now share one zone and the ranked-spine / R2
// zone-policy table can no longer see the edge at all: checkLayeringRules in check.ts skips
// every same-zone edge before ZONE_POLICIES ever runs. This is the folder-scoped
// replacement for that lost R2 row.
// Evidence: #2543 declared the direction as the second R2 commands-floor row in
// zone-policy.ts; #2679 moved src/cli-schema/* into src/commands/schema/* and found the
// direction unenforced on the merged tree.
// Cost: ~30 LOC (rule + test).
// Kill criterion: none enforced today; retire only by maintainer decision that commands/
// reading its own schema layer back is fine, or that commands/schema/ should move out of
// the commands zone entirely (at which point the ranked-spine R2 table can express the
// direction again).

import { matchesDeclaredRoot } from './architecture-ownership.ts';
import type { LayeringViolation, ResolvedImportEdge } from './model.ts';

const COMMANDS_SCHEMA_ROOT = 'src/commands/schema/';
const COMMANDS_ROOT = 'src/commands/';

export function checkCommandsSchemaBoundary(
edges: readonly ResolvedImportEdge[],
): LayeringViolation[] {
return edges
.filter(
(edge) =>
matchesDeclaredRoot(edge.file, COMMANDS_ROOT) &&
!matchesDeclaredRoot(edge.file, COMMANDS_SCHEMA_ROOT) &&
matchesDeclaredRoot(edge.target, COMMANDS_SCHEMA_ROOT),
)
.map((edge) => ({
rule: 'R2 commands-floor',
file: edge.file,
line: edge.line,
message:
`${edge.file} must not import ${edge.target}: commands/schema/ is the CLI/MCP schema ` +
'layer that renders the command facets, so it sits above the rest of commands/ and ' +
'reads them; commands must not import it back (#2543). The shared CommandSchema type ' +
'and flag grammar live in @agent-device/command-registry, below both.',
}));
}
32 changes: 0 additions & 32 deletions scripts/layering/daemon-modularity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,38 +106,6 @@ test('R10 banks an R7 shrink with no edit anywhere', () => {
);
});

test('R10 forbids the daemon importing the CLI schema layer, value or type', () => {
const valueEdge: ResolvedImportEdge = {
...importEdge('src/daemon/request-execution-scope.ts', 'src/cli-schema/command-schema.ts'),
typeOnly: false,
};
const typeEdge = importEdge(
'src/daemon/session-action-recorder.ts',
'src/cli-schema/command-schema.ts',
);
const violations = checkDaemonModularityRatchets(
[...baselineEdges(), valueEdge, typeEdge],
REFERENCE,
REFERENCE,
);
assert.deepEqual(
violations.map(({ rule, file }) => ({ rule, file })),
[
{ rule: 'R10 daemon-modularity', file: 'src/daemon/request-execution-scope.ts' },
{ rule: 'R10 daemon-modularity', file: 'src/daemon/session-action-recorder.ts' },
],
);
assert.match(violations[0]!.message, /must not import src\/cli-schema\/command-schema\.ts/);
});

test('R10 permits a non-daemon importer of the CLI schema layer', () => {
const cliEdge = importEdge('src/cli/parser/args.ts', 'src/cli-schema/command-schema.ts');
assert.deepEqual(
checkDaemonModularityRatchets([...baselineEdges(), cliEdge], REFERENCE, REFERENCE),
[],
);
});

test('external daemon request/session-state importer membership changes require the baseline to change', () => {
const edges = resolveImportEdges(
new Map([
Expand Down
25 changes: 0 additions & 25 deletions scripts/layering/daemon-modularity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,35 +65,10 @@ export function checkDaemonModularityRatchets(
...checkSessionStateBaseline(measured.sessionState, reference.sessionState),
...checkTypeCycleBaseline(measured.largestTypeCycle, reference.largestTypeCycle),
...checkDaemonTypesImporters(edges),
...checkDaemonCliSchemaBoundary(edges),
...checkLogicalModuleImports(edges),
];
}

// The daemon resolves command routes through the registry and fills request defaults through the
// command registry, so it never needs the CLI schema layer. #2543 cut the last two value edges
// (`request-execution-scope.ts` and `session-action-recorder.ts`); this pins that decoupling so a
// future daemon module cannot reach `src/cli-schema/` again and re-pull the parser closure.
const DAEMON_FORBIDDEN_CLI_SCHEMA_ROOT = 'src/cli-schema/';

function checkDaemonCliSchemaBoundary(edges: readonly ResolvedImportEdge[]): LayeringViolation[] {
return edges
.filter(
(edge) =>
edge.file.startsWith('src/daemon/') &&
matchesDeclaredRoot(edge.target, DAEMON_FORBIDDEN_CLI_SCHEMA_ROOT),
)
.map((edge) => ({
rule: 'R10 daemon-modularity',
file: edge.file,
line: edge.line,
message:
`${edge.file} must not import ${edge.target}: the daemon resolves routes through ` +
'@agent-device/command-registry and must not load the CLI schema layer. Declare the shared ' +
'shape in the command registry or contracts, not in src/cli-schema/.',
}));
}

export function checkRetiredSessionLifecyclePaths(
sourceFiles: readonly string[],
): LayeringViolation[] {
Expand Down
4 changes: 2 additions & 2 deletions scripts/layering/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,13 @@ test('neutral ownership zones reject value imports into higher layers', () => {
['src/commands/cancel.ts', 'export const cancel = true;'],
['packages/selectors/src/internal/parse.ts', "import '../../../../src/client/client.ts';"],
['src/client/client.ts', 'export const client = true;'],
['src/cli-schema/schema.ts', "import '../cli/parser.ts';"],
['src/mcp/schema.ts', "import '../cli/parser.ts';"],
['src/cli/parser.ts', 'export const parser = true;'],
]),
);

assert.deepEqual(collectBackEdges(edges), {
'cli-schema -> cli': ['src/cli-schema/schema.ts -> src/cli/parser.ts'],
'mcp -> cli': ['src/mcp/schema.ts -> src/cli/parser.ts'],
'contracts -> core': ['src/contracts/result.ts -> src/core/result.ts'],
'device-selection -> commands': [
'packages/device-selection/src/selection.ts -> src/commands/cancel.ts',
Expand Down
1 change: 0 additions & 1 deletion scripts/layering/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ const TARGET_DAG_RANK = new Map([
['selectors', 1],
['session-journal', 1],
['core', 2],
['cli-schema', 3],
['commands', 3],
['mcp', 3],
['ai-sdk', 4],
Expand Down
16 changes: 0 additions & 16 deletions scripts/layering/zone-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,6 @@ test('R2 commands-floor closes the remaining zones below the command surface, wh
assert.deepEqual(firing(edge('src/mcp/tools.ts', 'mcp', 'commands')), []);
});

test('R2 commands-floor places commands below the cli-schema layer, whatever the kind', () => {
// #2543: cli-schema renders the facets commands authors, so it reads commands and commands never
// reads it back. The direction holds for value, type-only, and dynamic forms alike.
for (const kind of [{}, { typeOnly: true }, { dynamic: true }]) {
assert.ok(
firing(edge('src/commands/thing.ts', 'commands', 'cli-schema', kind)).includes(
'R2 commands-floor',
),
`commands -> cli-schema ${JSON.stringify(kind)} must violate R2`,
);
}

// The declared direction — cli-schema reading the facets — must stay silent.
assert.deepEqual(firing(edge('src/cli-schema/command-schema.ts', 'cli-schema', 'commands')), []);
});

test('the retired R3 platforms seam has no zone-policy declaration', () => {
assert.equal(
ZONE_POLICIES.some(({ rule }) => rule === 'R3 platforms-seam'),
Expand Down
Loading
Loading