CLI FEATURE
Semitexa Dev
Semitexa Dev gives people and coding agents one project-aware loop for orientation, structural inspection, runtime evidence, durable work memory, and precise verification.
Feature Guide
A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.
What this does
Semitexa Dev is the project-aware operating layer for orientation, planning, structural inspection, runtime debugging, durable work memory, and verification.
How it works
The ai:* command family connects repository state, recipes, prior art, Project Graph, the runtime Observatory, epic/work/trace memory, and diff-aware checks through stable JSON contracts.
Why it matters
People and agents make safer changes when the project can explain its current shape, show what actually ran, preserve decisions across sessions, and verify the edited surface precisely.
Key concepts
- ai:orient
- Combines Git state, active work, recent traces, last verification, and the next useful command.
- ai:observe
- Reads live and recorded process lifecycles, full waterfalls, executed source, and sandbox replay results.
- ai:verify
- Selects the relevant checks from the actual changed files and reports one structured verdict.
Project-Aware Workflow
Start with facts, act on a narrow plan, and leave durable evidence
Semitexa Dev connects repository state, structural discovery, live runtime traces, persistent work items, and diff-aware verification in one inspectable loop.
Context before code. orient, task, ask, context, and plan expose the current project shape and the safest implementation path.
Runtime evidence. The Observatory records process lifecycles, spans, queries, payload snapshots, source locations, and sandbox replays.
Recoverable work. epic, work, trace, and verify preserve decisions and prove the resulting change across sessions.
| Command | Purpose | Why it matters |
|---|---|---|
bin/semitexa ai:orient --json |
Read Git state, active work, recent traces, and the last verification in one response. | Starts a cold session from shared facts instead of repository archaeology. |
bin/semitexa ai:ask route --path=/invoices --json |
Resolve one route from payload through handler, resource, template, and access posture. | Answers a structural question without broad file searches. |
bin/semitexa ai:observe show --id=p-123 --source |
Inspect one real process with timing, spans, queries, payload, and executed source. | Turns runtime debugging into evidence instead of source-only guessing. |
bin/semitexa ai:work resume --id=tk-invoice-export --json |
Restore the task, its recent trace, and the exact next step. | Lets work survive context compaction and session boundaries. |
bin/semitexa ai:verify --files=packages/semitexa-billing/src --json |
Select syntax, lint, structure, static-analysis, and test checks from the diff. | Verifies the changed surface without running unrelated checks blindly. |
Start a cold session
bin/semitexa ai:orient --json
bin/semitexa ai:task 'add tenant-aware invoice export' --json
Inspect before editing
bin/semitexa ai:ask route --path=/invoices --method=GET --json
bin/semitexa ai:review-graph:impact 'App\Billing\InvoiceExporter' --json
Observe and verify
bin/semitexa ai:observe tail --kind=http --follow --duration=15
bin/semitexa ai:verify --files=packages/semitexa-billing/src --json
Verified against Semitexa Ultimate 2026.09.19.1020
Semitexa Dev
Semitexa Dev is the project-aware operating layer for people and coding agents working on a Semitexa application.
It answers six practical questions:
- What state is this project in?
- What kind of task is this?
- Where does the relevant behavior live?
- What is running right now?
- What should be verified after the change?
- What must survive when the current context ends?
The agent remains the reasoning system. Semitexa supplies verified project facts, execution tools, durable work state, and targeted checks. Keeping those roles separate matters: a model can propose a change, while the project can prove what routes exist, what a class affects, which process failed, and whether the edited result is valid.
The operating loop
For a normal code change, the Semitexa Dev loop is:
orient → classify → inspect → plan → edit → verify → record
| Stage | Primary command | Result |
|---|---|---|
| Orient | ai:orient |
Git state, active work, recent traces, last verification, and the next useful action |
| Classify | ai:task |
A recipe, confidence score, risk hint, and suggested generator chain |
| Inspect | ai:ask, ai:context, ai:review-graph:* |
Runtime and structural facts scoped to the task |
| Plan | ai:plan |
Risk assessment for the recipe and exact file set |
| Edit | make:* or a focused manual edit |
A change that follows discovered project conventions |
| Verify | ai:verify |
The smallest relevant syntax, lint, structure, static-analysis, and test set |
| Record | ai:trace, ai:work, ai:epic |
Durable decisions and next steps for a later session |
This is a decision loop rather than a mandatory ceremony. A focused one-file fix can stay inline. Work that spans modules, contains branching decisions, or may outlive the current context belongs in an epic with small work items.
Start a session with facts
Use ai:orient before assembling project state from unrelated Git, task, and log commands:
bin/semitexa ai:orient --jsonJSON envelope with artifact `semitexa-dev.ai-orient/v1`, current Git and work state, and contextual `next_command` suggestions.The response combines repository state with active Semitexa work artifacts. Its next_command entries explain useful follow-ups based on the current state.
For a new executable request, classify the intent:
bin/semitexa ai:task "add tenant-aware invoice export" --json
The classifier returns a recipe and a confidence level. High confidence gives a reliable starting path. Low confidence is a signal to inspect the suggested alternatives or clarify the goal before running a generator.
Choose the narrowest inspection tool
Use ai:ask for project facts that Semitexa can answer directly:
bin/semitexa ai:ask project --json
bin/semitexa ai:ask module --name=Billing --json
bin/semitexa ai:ask route --path=/invoices --method=GET --json
bin/semitexa ai:ask event --name=InvoiceIssued --json
bin/semitexa ai:ask mechanisms --area=ssr --json
bin/semitexa ai:ask logs --grep=invoice --level=ERROR --lines=200 --json
These commands describe discovered routes, handlers, resources, templates, services, events, logs, and framework mechanisms. They are preferable to broad text searches when the question is about framework structure.
Use ai:context after ai:task chooses a recipe:
bin/semitexa ai:context add_html_page --module=Billing --json
It returns nearby prior art and repository conventions, so a new page follows the module that already works instead of inventing a parallel pattern.
Use Project Graph when the question is about relationships or blast radius:
bin/semitexa ai:review-graph:query \
--usages='Semitexa\Billing\Application\Service\InvoiceExporter' \
--json
bin/semitexa ai:review-graph:impact \
'Semitexa\Billing\Application\Service\InvoiceExporter' \
--json
Project Graph follows structural edges such as uses, implements, handles, and serves_route. Full-text search remains useful for literal strings; it should not substitute for a dependency query.
Plan against the real file set
Before a multi-file or uncertain edit, give ai:plan the recipe and the files you expect to touch:
bin/semitexa ai:plan add_html_page \
--module=Billing \
--files=packages/semitexa-billing/src/Application/Payload/Request/InvoicePayload.php,packages/semitexa-billing/src/Application/Handler/PayloadHandler/InvoiceHandler.php \
--json
The result explains why the change is low, medium, or high risk. If the reported risk is higher than expected, reduce the file set, split the work, or inspect the newly exposed dependency before editing.
Debug the runtime through the Observatory
ai:observe is the first stop for runtime behavior. It reports what actually ran instead of asking you to infer behavior from source alone.
bin/semitexa ai:observe ps
bin/semitexa ai:observe tail --kind=http --follow --duration=15
bin/semitexa ai:observe show --id=p-123 --source
The three views serve different questions:
| View | Use it for |
|---|---|
ps |
Live processes, recent completions, workers, and stale activity |
tail |
A bounded stream of new HTTP, queue, scheduler, or SSE lifecycles |
show |
One process with spans, queries, payload snapshot, timing, and optional source |
A request made with ?__trace=1 records a full waterfall. The same trace is available to humans at /__trace and to agents through ai:observe show.
The Observatory also shows the code's structure: its graph mode browses the project graph, and links a class to the recent traces that ran it. See Browsing the Graph.
When a recorded request needs controlled reproduction, replay it in the development sandbox:
bin/semitexa ai:observe replay --id=p-123 --mutate status='"cancelled"'
Replay rolls writes back and records the differences from the original process. This makes it useful for testing a hypothesis without leaving mutated application state behind.
For quick handler feedback without HTTP, auth, or middleware, use ai:invoke:
bin/semitexa ai:invoke \
--route=/invoices \
--payload='{"page":1}' \
--json
The response states which pipeline layers were omitted. Treat it as a handler probe, then use an HTTP or browser check when the complete request pipeline matters.
A page's live timeline
bin/semitexa ai:observe timeline # the pages seen lately
bin/semitexa ai:observe timeline --id=sse_… # one page, in order
In development, every page with a live stream (its KISS session id) keeps a timeline:
event: a component's UI event, with its effects and how long it took;frame: each frame its stream wrote, with id and size;subscribe/unsubscribe: a feed attached or detached;rerun: a feed that re-ran, why (a write or a view change), and what it sent (a keyed patch, a whole page, or nothing);replay/reset: a reconnect resumed, or could not;deferred: a deferred component rendered, and how long it took.
The same timeline is at /__observatory/timeline for a human, linked from the Observatory panel.
It is written one short line at a time under var/observatory/timeline/, stops at 1 MB a page,
and is removed after a day. Outside development nothing is recorded; a record is one null check.
Keep review evidence private
A screenshot, a recording, a request trace or a graph export made to prove a change shows whatever was on screen or in the database. Keep it in the evidence store, not in a pull request:
bin/semitexa ai:evidence add var/tmp/cart.png --kind=screenshot --data=synthetic --note="cart after the fix"
bin/semitexa ai:evidence list
bin/semitexa ai:evidence prune --dry-run --tmp
Each item lives in var/evidence/<id>/ with a passport — what it is, whether it was made from synthetic or real data, who recorded it, when it expires (14 days by default). var/evidence/ is in the project's .gitignore, and lint:var-artifacts fails ai:verify when anything else under var/ is about to be committed.
Publishing is the operator's step, in their own terminal: ai:evidence publish <id> --to="PR acme/shop#7" checks the item — a picture of real data never goes, text is scanned for tokens and workstation paths — then asks for the id to be typed. It refuses without a terminal, so an agent cannot approve its own upload by accident. The same check guards review-prep's PR description and pr-reply.sh.
The Observatory's evidence mode (V) lists the store: search, filters by kind, data, visibility and day, sortable columns, pages, and a passport with a preview for each item. It is dev-only, and files are served sandboxed.
Keep long work recoverable
Semitexa stores long-lived work in three layers:
| Artifact | Responsibility |
|---|---|
ai:epic |
The outcome shared by several work items |
ai:work |
One executable leaf task with recipe, risk, context, and next step |
ai:trace |
Decisions, observations, failed hypotheses, and verification events |
Create an epic when work spans several modules or will not fit safely in one sitting:
bin/semitexa ai:epic start \
--id=ep-invoice-export \
--title="Ship invoice export" \
--goal="Let tenant administrators export auditable invoice data." \
--json
bin/semitexa ai:work start \
--id=tk-invoice-export-http \
--epic=ep-invoice-export \
--title="Add the export endpoint" \
--recipe=add_html_page \
--risk=medium \
--context-ref=packages/semitexa-billing \
--next-step="Inspect the invoice route and generate the smallest export page." \
--json
Write notes for the next working session: record the decision that would otherwise be debated again, the hypothesis already disproved, and the exact next command that restarts the work.
Verify the change, not the whole universe
Run ai:verify after a coherent edit:
bin/semitexa ai:verify \
--files=packages/semitexa-billing/src/Application/Handler/PayloadHandler/InvoiceHandler.php,packages/semitexa-billing/src/Application/Resource/Response/InvoiceResource.php \
--json
Semitexa classifies the changed files and selects the relevant checks. Depending on the diff, that can include PHP syntax, handler and DI lint, template validation, module structure, static-analysis rules, generated reference drift, or focused tests.
Verification runs in a fresh CLI process and sees saved files immediately. Restart the server only before exercising the long-running HTTP worker through a browser, curl, or E2E test.
What a person sees
A passing test does not see the page. When a template or a stylesheet changes, bin/semitexa ai:verify also opens the pages it shows on:
- Where: in the e2e-runner on the host, against the app that is already running. Verify never starts or restarts the app.
- How: in light and in dark. A colour that fails only in dark is a real bug.
- What it checks: axe checks contrast, labels, names, the title and the language. The page check adds what axe does not see:
- a page that uses
--ui-*tokens with no skin linked; - a form field with no edge;
- text against the window;
- console errors;
- an HTTP status of 400 or more.
- a page that uses
Which pages. Only public GET routes that need no parameter, produce HTML and do not act when opened (logout, a sign-in start, submit…):
- a file in a module opens that module's pages;
- a package's file opens that package's pages;
- a file with no pages of its own, such as the theme or a layout, opens the project's pages.
For a template, the module is what chooses the page, not proof that the page renders that template. For a stylesheet, a page counts only when it loads the stylesheet in both modes. Make sure a page that shows your template is in the list.
The shortest paths come first, and at most 8 pages are opened. Pages past the cap are not checked. bin/semitexa ai:verify --files=… --list-pages prints the list. Open the rest yourself when the change reaches further.
When it is not a pass. A page counts as checked only when the change could show on it. A finding in either mode fails the page, even when the other mode could not be checked. Without a finding, these are reported as incomplete, never as a pass:
- a page that could not be reached;
- a page seen in one mode only;
- a page that redirected elsewhere, such as to a sign-in page;
- a page that does not load the stylesheet that changed.
What the browser saw stays under var/run/verify-e2e/<run>/pages/, a screenshot per page and mode included.
The page check needs a matching bin/semitexa. The launcher, not the app, opens the pages. bin/semitexa update replaces the launcher together with semitexa/dev. A launcher you edited is left alone: bin/semitexa update --write-scaffold-candidates writes the new one beside it as bin/semitexa.new to merge. Until you merge it, every page is reported as incomplete and the reason names what is missing.
Report framework defects with evidence
When a project must work around a Semitexa defect, capture the defect as part of the fix:
bin/semitexa ai:report \
--title="Route inspection omits response template" \
--summary="The route is valid, but ai:ask route does not report its discovered Twig template." \
--evidence="bin/semitexa ai:ask route --path=/invoices --json → template is absent" \
--workaround="Inspected the resource attribute directly." \
--package=semitexa-dev \
--json
ai:report searches for duplicates and can add another sighting instead of opening a parallel issue. Review the rendered report before publication, and keep consumer configuration, credentials, and private application code out of public evidence.
Machine-readable by design
The ai:* commands expose stable JSON envelopes for automation. A typical envelope contains:
- an artifact and schema version;
- the requested result;
- confidence, omissions, or risk where relevant;
next_commandsuggestions with reasons;- verification or trace outcomes when the command produces them.
This contract lets a human read the same facts an agent consumes. It also gives integrations a stable surface without scraping colored terminal output.
The local assistant is a separate entrypoint
bin/semitexa ai starts the optional local assistant backed by registered #[AsAiSkill] commands:
bin/semitexa ai --dry-run
bin/semitexa ai --yes
bin/semitexa ai:skills --json
The assistant translates natural-language intent into declared skills. The deterministic ai:* commands remain available whether or not a local language model is configured.
Command reference
Use this page to choose the right workflow. Use the generated AI commands reference for every argument and option supported by the installed release:
bin/semitexa help ai:orient
bin/semitexa help ai:observe
bin/semitexa help ai:verify
bin/semitexa ai:ask capabilities --json
The capability manifest is the authoritative inventory for the running project because it reflects the packages and commands actually installed there.
© Jeff Sickel: "Deleted code is debugged code."
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Dev\Application\Service\Ai\Presence\StackEvents;8use Semitexa\Dev\Application\Service\Ai\Presence\WorkspaceActivity;9use Semitexa\Dev\Application\Service\Ai\Presence\AgentSession;10use Semitexa\Dev\Application\Service\Ai\Presence\AgentDocsFreshness;11use Semitexa\Dev\Application\Service\Ai\Presence\AgentRegistry;12use Semitexa\Core\Support\ProjectRoot;13use Semitexa\Core\Attribute\AsCommand;14use Semitexa\Core\Attribute\InjectAsReadonly;15use Semitexa\Core\Console\BaseCommand;16use Semitexa\Dev\Application\Service\Quality\QualityAdvisor;17use Semitexa\Dev\Application\Service\Ai\Charter\CharterStore;18use Semitexa\Dev\Application\Service\Ai\Charter\OwnerQuestions;19use Semitexa\Dev\Application\Service\Ai\Trace\TraceEventKind;20use Semitexa\Dev\Application\Service\Ai\Trace\TraceHeader;21use Semitexa\Dev\Application\Service\Ai\Trace\TraceStore;22use Semitexa\Dev\Application\Service\Ai\Work\Epic;23use Semitexa\Dev\Application\Service\Ai\Work\EpicStore;24use Semitexa\Dev\Application\Service\Ai\Work\Task;25use Semitexa\Dev\Application\Service\Ai\Work\TaskStatus;26use Semitexa\Dev\Application\Service\Ai\Work\TaskStore;27use Symfony\Component\Console\Input\InputInterface;28use Symfony\Component\Console\Input\InputOption;29use Symfony\Component\Console\Output\OutputInterface;30use Symfony\Component\Console\Style\SymfonyStyle;3132/**33 * Single-call session dashboard for an agent that just opened the repo.34 *35 * Fuses: git (branch + last commit + dirty flag) + active epic + in-progress36 * tasks + recent traces + last verify result + a next-step suggestion.37 *38 * Designed as the **first command** a cold-start agent runs. One tool call39 * should replace 5–6 probes (ai:ask project, ai:epic list, ai:work list,40 * ai:trace list, git status, git log).41 *42 * Cheap by construction — reads file-backed stores and runs two short shell43 * commands for git. Does NOT execute linters, verification, or graph rebuild.44 */45#[AsCommand(46 name: 'ai:orient',47 description: 'Session dashboard: git + active epic + in-progress tasks + recent traces + next step. Run this first on a cold start.',48)]49final class AiOrientCommand extends BaseCommand50{51 #[InjectAsReadonly]52 protected EpicStore $epicStore;5354 #[InjectAsReadonly]55 protected TaskStore $taskStore;5657 #[InjectAsReadonly]58 protected TraceStore $traceStore;5960 #[InjectAsReadonly]61 protected CharterStore $charterStore;6263 public function __construct()64 {65 parent::__construct('ai:orient');66 }6768 protected function configure(): void69 {70 $this71 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit the full JSON envelope (default for non-interactive callers)')72 ->addOption('human', null, InputOption::VALUE_NONE, 'Force human-readable output even in non-interactive mode')73 ->addOption('traces', null, InputOption::VALUE_REQUIRED, 'How many recent traces to include', '5');74 }7576 protected function execute(InputInterface $input, OutputInterface $output): int77 {78 $traceLimit = max(1, min(50, (int) $input->getOption('traces')));7980 $git = $this->collectGit();81 $epics = $this->collectEpics();82 $inProgress = $this->collectTasksByStatus(TaskStatus::IN_PROGRESS);83 $blocked = $this->collectTasksByStatus(TaskStatus::BLOCKED);84 $activeEpicId = $this->deriveActiveEpicId($inProgress, $blocked, $epics);85 $recentTraces = $this->collectRecentTraces($traceLimit);86 $lastVerify = $this->findLastVerify($recentTraces);87 try {88 $qualityNext = (new QualityAdvisor(ProjectRoot::get()))->targets(3);89 $qualityError = null;90 } catch (\RuntimeException $e) {91 // A broken ledger is said, not shown as "nothing to improve".92 [$qualityNext, $qualityError] = [[], $e->getMessage()];93 }94 $hints = $this->suggestNext($git, $activeEpicId, $inProgress, $blocked, $epics);95 $ownerQuestions = OwnerQuestions::of($this->charterStore);96 $hints = OwnerQuestions::lead($hints, $ownerQuestions);9798 $workingNow = $this->workingNow();99 if ($workingNow['you'] === null || !($workingNow['you']['joined'] ?? true)) {100 // Joining is how the others see you; it is first because an agent101 // that skips it is the one the others have to guess about. One that102 // bin/semitexa recognised is listed already, but with no intent.103 array_unshift($hints['commands'], [104 'cmd' => 'ai:agent',105 'args' => ['join', '--name=<claude|codex|…>', '--intent="<what you are about to do>"', '--repo=<repo you will edit>', '--json'],106 'why' => 'other agents see who you are and what you touch; then export SEMITEXA_AGENT_SESSION',107 ]);108 }109110 $envelope = [111 'artifact' => 'semitexa.ai-orient/v1',112 'working_now' => $workingNow,113 'generated_at' => gmdate('c'),114 'cwd' => getcwd() ?: '',115 'git' => $git,116 'backlog' => [117 'total_epics' => count($epics),118 'active_epic_id' => $activeEpicId,119 'active_epic' => $activeEpicId !== null ? $this->epicBrief($epics, $activeEpicId) : null,120 // held_by: the live agent working it, or null — an in_progress121 // task nobody live holds is one somebody walked away from.122 'in_progress_tasks' => array_map(fn(Task $t) => $this->taskBrief($t) + ['held_by' => $this->holderOf($t->id, $workingNow)], $inProgress),123 'in_progress_count' => count($inProgress),124 'blocked_tasks' => array_map(fn(Task $t) => $this->taskBrief($t), $blocked),125 'blocked_count' => count($blocked),126 ],127 'recent_traces' => $recentTraces,128 'last_verify' => $lastVerify,129 // Red runs of the last day nobody checked with ai:verify:receipt:130 // what a subagent saw and its parent may never have.131 'unread_failed_verify' => array_values(array_filter(132 (new \Semitexa\Dev\Application\Service\Ai\Verify\Receipt\VerifyReceipts($this->getProjectRoot()))->unread(86400),133 static fn (array $r): bool => $r['verdict'] !== 'pass',134 )),135 // The improvement loop's entry point: what the quality ledger says to136 // make better next. Read from the recorded baseline, measured nothing.137 'quality_next' => $qualityNext,138 // What Semitexa asks the owner next (ep-project-charter), and how.139 'owner_questions' => $ownerQuestions,140 'quality_error' => $qualityError,141 'suggest_next' => $hints['summary'],142 'next_command' => $hints['commands'],143 ];144145 $forceJson = (bool) $input->getOption('json');146 $forceHuman = (bool) $input->getOption('human');147 $useHuman = $forceHuman || (!$forceJson && $input->isInteractive());148149 if ($useHuman) {150 $this->renderHuman(new SymfonyStyle($input, $output), $envelope);151 } else {152 $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '{}', OutputInterface::OUTPUT_RAW);153 }154155 return self::SUCCESS;156 }157158 // ─── collectors ────────────────────────────────────────────────159160 /**161 * @return array{is_repo: bool, branch: ?string, head: ?string, last_commit_summary: ?string, dirty: ?bool, ahead: ?int, behind: ?int}162 */163 private function collectGit(): array164 {165 $cwd = getcwd() ?: '';166 $cdPrefix = 'cd ' . escapeshellarg($cwd) . ' && ';167168 $isRepo = trim((string) shell_exec($cdPrefix . 'git rev-parse --is-inside-work-tree 2>/dev/null')) === 'true';169170 if (!$isRepo) {171 return [172 'is_repo' => false,173 'branch' => null,174 'head' => null,175 'last_commit_summary' => null,176 'dirty' => null,177 'ahead' => null,178 'behind' => null,179 ];180 }181182 $branch = trim((string) shell_exec($cdPrefix . 'git branch --show-current 2>/dev/null')) ?: null;183 $head = trim((string) shell_exec($cdPrefix . 'git rev-parse --short HEAD 2>/dev/null')) ?: null;184 $last = trim((string) shell_exec($cdPrefix . 'git log -1 --pretty=%s 2>/dev/null')) ?: null;185 $status = (string) shell_exec($cdPrefix . 'git status --porcelain 2>/dev/null');186 $dirty = trim($status) !== '';187188 $ahead = null;189 $behind = null;190 if ($branch !== null) {191 $track = trim((string) shell_exec($cdPrefix . 'git rev-list --left-right --count @{u}...HEAD 2>/dev/null'));192 if ($track !== '' && preg_match('/^(\d+)\s+(\d+)$/', $track, $m)) {193 $behind = (int) $m[1];194 $ahead = (int) $m[2];195 }196 }197198 return [199 'is_repo' => true,200 'branch' => $branch,201 'head' => $head,202 'last_commit_summary' => $last,203 'dirty' => $dirty,204 'ahead' => $ahead,205 'behind' => $behind,206 ];207 }208209 /**210 * @return list<Epic>211 */212 private function collectEpics(): array213 {214 try {215 return $this->epicStore->list();216 } catch (\Throwable) {217 return [];218 }219 }220221 /**222 * Collect tasks of a single status, most-recently-updated first.223 *224 * IN_PROGRESS and BLOCKED are kept apart on purpose: a blocked task is NOT225 * resumable work, so conflating the two made `in_progress_count` lie and226 * made suggest_next promote a blocked task as the next action.227 *228 * @return list<Task>229 */230 private function collectTasksByStatus(TaskStatus $status): array231 {232 $out = [];233 try {234 foreach ($this->taskStore->list(null, $status) as $task) {235 $out[] = $task;236 }237 } catch (\Throwable) {238 // store not initialised yet — that's fine, return what we have239 }240 usort($out, static fn(Task $a, Task $b) => strcmp($b->updatedAt, $a->updatedAt));241 return $out;242 }243244 /**245 * Choose the epic the agent should focus on, in priority order:246 * 1. epic of the most-recent in-progress task (real WIP),247 * 2. an epic whose own status is in_progress,248 * 3. epic of the most-recent blocked task (stalled WIP — last resort, so249 * the dashboard still points somewhere instead of going blank).250 *251 * Blocked work never outranks live in-progress work, which is what the old252 * recency-only rule got wrong.253 *254 * @param list<Task> $inProgress255 * @param list<Task> $blocked256 * @param list<Epic> $epics257 */258 private function deriveActiveEpicId(array $inProgress, array $blocked, array $epics): ?string259 {260 if ($inProgress !== []) {261 return $inProgress[0]->epicId;262 }263 foreach ($epics as $epic) {264 if ($epic->status->value === 'in_progress') {265 return $epic->id;266 }267 }268 if ($blocked !== []) {269 return $blocked[0]->epicId;270 }271 return null;272 }273274 /**275 * @return list<array{id: string, created_at: string, topic: ?string, recipe: ?string, last_event: ?array{kind: string, at: string, summary: string}}>276 */277 private function collectRecentTraces(int $limit): array278 {279 try {280 $headers = $this->traceStore->list();281 } catch (\Throwable) {282 return [];283 }284285 usort($headers, static fn(TraceHeader $a, TraceHeader $b) => strcmp($b->createdAt, $a->createdAt));286 $headers = array_slice($headers, 0, $limit);287288 $out = [];289 foreach ($headers as $h) {290 $lastEvent = null;291 try {292 $trace = $this->traceStore->read($h->traceId);293 $events = $trace->events;294 if ($events !== []) {295 $tail = end($events);296 $lastEvent = [297 'kind' => $tail->eventKind,298 'at' => $tail->at,299 'summary' => $tail->summary,300 ];301 }302 } catch (\Throwable) {303 // skip unreadable traces304 }305 $out[] = [306 'id' => $h->traceId,307 'created_at' => $h->createdAt,308 'topic' => $h->topic,309 'recipe' => $h->recipe,310 'last_event' => $lastEvent,311 ];312 }313 return $out;314 }315316 /**317 * @param list<array{id: string, created_at: string, topic: ?string, recipe: ?string, last_event: ?array{kind: string, at: string, summary: string}}> $recentTraces318 * @return array{trace_id: string, at: string, summary: string, verdict: ?string}|null319 */320 private function findLastVerify(array $recentTraces): ?array321 {322 foreach ($recentTraces as $row) {323 $last = $row['last_event'];324 if ($last !== null && $last['kind'] === TraceEventKind::VERIFY_RESULT) {325 return [326 'trace_id' => $row['id'],327 'at' => $last['at'],328 'summary' => $last['summary'],329 'verdict' => $this->parseVerdictFromSummary($last['summary']),330 ];331 }332 }333 // scan deeper — look through trace bodies334 foreach ($recentTraces as $row) {335 try {336 $trace = $this->traceStore->read($row['id']);337 foreach (array_reverse($trace->events) as $e) {338 if ($e->eventKind === TraceEventKind::VERIFY_RESULT) {339 return [340 'trace_id' => $row['id'],341 'at' => $e->at,342 'summary' => $e->summary,343 'verdict' => $this->parseVerdictFromSummary($e->summary),344 ];345 }346 }347 } catch (\Throwable) {348 continue;349 }350 }351 return null;352 }353354 private function parseVerdictFromSummary(string $summary): ?string355 {356 $lower = strtolower($summary);357 if (str_contains($lower, 'pass')) {358 return 'pass';359 }360 if (str_contains($lower, 'fail')) {361 return 'fail';362 }363 return null;364 }365366 // ─── next-step suggestion ──────────────────────────────────────367368 /**369 * @param array{is_repo: bool, dirty: ?bool, ...} $git370 * @param list<Task> $inProgress371 * @param list<Task> $blocked372 * @param list<Epic> $epics373 * @return array{summary: string, commands: list<array{cmd: string, args: list<string>, why: string}>}374 */375 /**376 * Every other live agent, and what is uncommitted in the workspace — the377 * two things an agent otherwise finds out by colliding with them.378 *379 * @return array{you: ?array<string, mixed>, agents: list<array<string, mixed>>, activity: list<array<string, mixed>>, activity_known: bool, stack: list<array<string, mixed>>, agent_docs: string, project: string, unclaimed_fresh: list<string>, unreadable: list<string>}380 */381 private function workingNow(): array382 {383 $now = time();384 $registry = new AgentRegistry(ProjectRoot::get());385 $registry->beat();386 $you = $registry->current();387 $live = $registry->all(false, $now);388 $others = array_values(array_filter($live, static fn (AgentSession $a): bool => $a->id !== $you?->id));389 $radar = new WorkspaceActivity(ProjectRoot::get());390 // Repo paths are matched as host paths: an agent that joined in another391 // project and edits this one's packages claims them too.392 $activity = $radar->dirtyRepos($live, $now, $registry->project());393394 return [395 'you' => $you?->toArray(),396 // Agents of other projects on this machine too: they edit the same packages.397 'agents' => array_map(static fn (AgentSession $a): array => $a->toArray() + ['silent_s' => $a->secondsSilent($now), 'elsewhere' => $a->project !== $registry->project()], $others),398 'activity' => $activity,399 // false: git is not available here, so "no edits" means unknown, not clean.400 'activity_known' => $radar->gitAvailable(),401 // Fresh edits nobody declared: another session that never joined,402 // or your own work you did not list. Either way, look before you commit.403 // The shared stack's last lifecycle events: a restart took the server404 // and everyone's one-off containers down, and this says whose it was.405 'stack' => (new StackEvents(ProjectRoot::get()))->recent(3, $now),406 // A stale manual is how a whole project's agents missed "Say you are407 // here": nothing updated AGENTS.md before 2026-10-17.408 'agent_docs' => (new AgentDocsFreshness(ProjectRoot::get()))->agentsMd(),409 'project' => $registry->project(),410 'unclaimed_fresh' => array_values(array_map(411 static fn (array $r): string => $r['repo'],412 array_filter($activity, static fn (array $r): bool => $r['fresh'] && $r['claimed_by'] === []),413 )),414 'unreadable' => array_values(array_map(415 static fn (array $r): string => $r['repo'],416 array_filter($activity, static fn (array $r): bool => !$r['readable']),417 )),418 ];419 }420421 /**422 * @param array{agents: list<array<string, mixed>>, you: ?array<string, mixed>, project: string} $workingNow423 */424 private function holderOf(string $taskId, array $workingNow): ?string425 {426 foreach ([...$workingNow['agents'], ...($workingNow['you'] !== null ? [$workingNow['you']] : [])] as $agent) {427 // The rule TaskClaim enforces (AgentSession::holds): the task's backlog, not where the agent joined.428 if (($agent['task'] ?? null) === $taskId && (($agent['task_project'] ?? null) ?: ($agent['project'] ?? '')) === $workingNow['project']) {429 return (string) $agent['id'];430 }431 }432433 return null;434 }435436 /** @return array{summary: string, commands: list<array{cmd: string, args: list<string>, why: string}>} */437 private function suggestNext(array $git, ?string $activeEpicId, array $inProgress, array $blocked, array $epics): array438 {439 $cmds = [];440 $parts = [];441442 if ($inProgress !== []) {443 $top = $inProgress[0];444 $parts[] = "Resume task `{$top->id}` ({$top->status->value}) under epic `{$top->epicId}`.";445 $cmds[] = [446 'cmd' => 'ai:work',447 'args' => ['resume', '--id=' . $top->id, '--json'],448 'why' => "resume the in-progress task directly",449 ];450 if ($top->nextStep !== null && $top->nextStep !== '') {451 $parts[] = 'Next step on record: ' . $top->nextStep;452 }453 } elseif ($blocked !== []) {454 // No live work — only blocked tasks. A blocked task is NOT resumable:455 // surface it for unblocking, but steer the agent to pickable work.456 $top = $blocked[0];457 $n = count($blocked);458 $parts[] = $n === 1459 ? "No in-progress work. Task `{$top->id}` is blocked under epic `{$top->epicId}` — unblock it or pick new work."460 : "No in-progress work. {$n} tasks are blocked (latest `{$top->id}`) — unblock one or pick new work.";461 $cmds[] = [462 'cmd' => 'ai:work',463 'args' => ['show', '--id=' . $top->id, '--json'],464 'why' => 'inspect why the blocked task is stuck',465 ];466 $cmds[] = [467 'cmd' => 'ai:work',468 'args' => ['list', '--status=new', '--json'],469 'why' => 'find pickable (new) work instead',470 ];471 } elseif ($activeEpicId !== null) {472 $parts[] = "Epic `{$activeEpicId}` has no in-progress tasks — pick one up.";473 $cmds[] = [474 'cmd' => 'ai:work',475 'args' => ['list', '--epic=' . $activeEpicId, '--status=new', '--json'],476 'why' => 'list new tasks in the active epic',477 ];478 } else {479 $activeCount = 0;480 foreach ($epics as $e) {481 if (!in_array($e->status->value, ['discarded', 'archived'], true)) {482 $activeCount++;483 }484 }485 if ($activeCount > 0) {486 $parts[] = 'No in-progress tasks. Check the backlog.';487 $cmds[] = [488 'cmd' => 'ai:epic',489 'args' => ['list', '--json'],490 'why' => 'show active epics',491 ];492 } else {493 $parts[] = 'No active backlog. Classify the request first.';494 $cmds[] = [495 'cmd' => 'ai:task',496 'args' => ['"<the request>"', '--json'],497 'why' => 'classify the user request into a recipe',498 ];499 }500 }501502 if (($git['dirty'] ?? false) === true) {503 $parts[] = 'Working tree is dirty — verify before committing.';504 $cmds[] = [505 'cmd' => 'ai:verify',506 'args' => ['--json'],507 'why' => 'lint/test the current diff',508 ];509 }510511 $cmds[] = [512 'cmd' => 'ai:ask',513 'args' => ['project', '--json'],514 'why' => 'structural overview if you need it (27 modules)',515 ];516517 return [518 'summary' => implode(' ', $parts),519 'commands' => $cmds,520 ];521 }522523 // ─── formatters ────────────────────────────────────────────────524525 /**526 * @param list<Epic> $epics527 * @return array{id: string, title: string, status: string, task_count: int}|null528 */529 private function epicBrief(array $epics, string $id): ?array530 {531 foreach ($epics as $e) {532 if ($e->id === $id) {533 return [534 'id' => $e->id,535 'title' => $e->title,536 'status' => $e->status->value,537 'task_count' => count($e->taskIds),538 ];539 }540 }541 return null;542 }543544 /**545 * @return array{id: string, epic_id: string, title: string, status: string, recipe: string, risk: string, next_step: ?string, context_refs: list<string>, updated_at: string}546 */547 private function taskBrief(Task $task): array548 {549 return [550 'id' => $task->id,551 'epic_id' => $task->epicId,552 'title' => $task->title,553 'status' => $task->status->value,554 'recipe' => $task->recipe,555 'risk' => $task->risk,556 'next_step' => $task->nextStep,557 'context_refs' => $task->contextRefs,558 'updated_at' => $task->updatedAt,559 ];560 }561562 /**563 * @param array<string, mixed> $envelope564 */565 private function renderHuman(SymfonyStyle $io, array $envelope): void566 {567 $io->title('Session dashboard (ai:orient)');568569 $git = $envelope['git'];570 if ($git['is_repo']) {571 $dirty = $git['dirty'] ? 'dirty' : 'clean';572 $track = '';573 if ($git['ahead'] !== null || $git['behind'] !== null) {574 $track = " (ahead {$git['ahead']} / behind {$git['behind']})";575 }576 $io->section('Git');577 $io->writeln(" branch: {$git['branch']}{$track} — {$dirty}");578 $io->writeln(" head: {$git['head']} {$git['last_commit_summary']}");579 } else {580 $io->section('Git');581 $io->writeln(' (not a git repository)');582 }583584 $backlog = $envelope['backlog'];585 $io->section('Backlog');586 $blockedCount = $backlog['blocked_count'] ?? 0;587 $io->writeln(" epics: {$backlog['total_epics']} total in-progress tasks: {$backlog['in_progress_count']} blocked: {$blockedCount}");588 if ($backlog['active_epic'] !== null) {589 $ae = $backlog['active_epic'];590 $io->writeln(" active epic: {$ae['id']} [{$ae['status']}] — {$ae['title']}");591 }592 foreach ($backlog['in_progress_tasks'] as $t) {593 $next = $t['next_step'] !== null && $t['next_step'] !== '' ? " — next: {$t['next_step']}" : '';594 $io->writeln(" • [{$t['status']}] {$t['id']} ({$t['recipe']}/{$t['risk']}) {$t['title']}{$next}");595 }596 foreach ($backlog['blocked_tasks'] ?? [] as $t) {597 $io->writeln(" ⊘ [blocked] {$t['id']} ({$t['recipe']}/{$t['risk']}) {$t['title']}");598 }599600 if ($envelope['recent_traces'] !== []) {601 $io->section('Recent traces');602 foreach ($envelope['recent_traces'] as $tr) {603 $tail = $tr['last_event'];604 $line = " • {$tr['id']} ({$tr['created_at']})";605 if ($tail !== null) {606 $line .= " — last: [{$tail['kind']}] {$tail['summary']}";607 }608 $io->writeln($line);609 }610 }611612 if ($envelope['last_verify'] !== null) {613 $lv = $envelope['last_verify'];614 $io->section('Last verify');615 $io->writeln(" trace: {$lv['trace_id']} at: {$lv['at']}");616 $io->writeln(" verdict: " . ($lv['verdict'] ?? 'unknown') . " — {$lv['summary']}");617 }618619 $unreadRuns = $envelope['unread_failed_verify'] ?? [];620 if (is_array($unreadRuns) && $unreadRuns !== []) {621 $io->section('Red verify runs nobody checked (24h)');622 foreach (array_slice($unreadRuns, 0, 3) as $run) {623 if (!is_array($run)) {624 continue;625 }626 $runBy = is_array($run['run_by'] ?? null) ? $run['run_by'] : [];627 $io->writeln(sprintf(628 ' %s %s %s by %s',629 is_string($run['id'] ?? null) ? $run['id'] : '?',630 is_string($run['verdict'] ?? null) ? $run['verdict'] : '?',631 is_string($run['generated_at'] ?? null) ? substr($run['generated_at'], 0, 16) : '?',632 is_string($runBy['agent_session'] ?? null) ? $runBy['agent_session'] : 'unknown session',633 ));634 }635 $io->writeln(' → bin/semitexa ai:verify:receipt --unread # a run reported as green needs a receipt that holds');636 }637638 $wn = $envelope['working_now'];639 $io->section('Working now');640 $io->writeln(' you: ' . match (true) {641 $wn['you'] === null => 'not joined — run ai:agent join so the others can see you',642 !($wn['you']['joined'] ?? true) => $wn['you']['id'] . ' — listed, but not joined: run ai:agent join so the others know what you are doing',643 default => $wn['you']['id'] . ' — ' . $wn['you']['intent'],644 });645 $docsWarning = match ($wn['agent_docs'] ?? AgentDocsFreshness::UNKNOWN) {646 AgentDocsFreshness::BEHIND => 'AGENTS.md is an older framework version: run bin/semitexa update — the rules you just read may be stale',647 AgentDocsFreshness::EDITED => 'AGENTS.md matches no framework version (edited here): bin/semitexa update leaves AGENTS.md.new to merge',648 AgentDocsFreshness::MISSING => 'AGENTS.md is missing: run bin/semitexa update',649 default => null,650 };651 if ($docsWarning !== null) {652 $io->writeln(' ⚠ ' . $docsWarning);653 }654 foreach ($wn['agents'] as $a) {655 $io->writeln(sprintf(656 ' %s (%s%s, %d min ago): %s%s',657 $a['id'],658 $a['agent'],659 $a['elsewhere'] ? ', in ' . basename((string) $a['project']) : '',660 intdiv((int) $a['silent_s'], 60),661 $a['intent'],662 $a['task'] !== null ? ' [task ' . $a['task'] . ']' : '',663 ));664 }665 if ($wn['agents'] === []) {666 $io->writeln(' no other agent has joined');667 }668 if ($wn['stack'] !== []) {669 $io->writeln(' dev stack: last ' . StackEvents::describe($wn['stack'][0]));670 }671 if (!$wn['activity_known']) {672 $io->writeln(' ⚠ uncommitted edits unknown: git is not available here');673 }674 foreach ($wn['unreadable'] as $repo) {675 $io->writeln(" ⚠ {$repo}: git status failed — its uncommitted edits are unknown, not clean");676 }677 foreach ($wn['unclaimed_fresh'] as $repo) {678 $io->writeln(" ⚠ {$repo}: edited in the last 30 min, claimed by no agent — someone may be working there");679 }680681 if ($envelope['quality_error'] !== null) {682 $io->writeln(' ⚠ ' . $envelope['quality_error']);683 }684 if ($envelope['quality_next'] !== []) {685 $io->section('Improve next (ai:quality next)');686 foreach ($envelope['quality_next'] as $t) {687 $io->writeln(" {$t['metric']} — {$t['key']}: {$t['count']}");688 }689 }690691 $io->section('Next');692 $io->writeln(' ' . $envelope['suggest_next']);693 foreach ($envelope['next_command'] as $nc) {694 $io->writeln(" → bin/semitexa {$nc['cmd']} " . implode(' ', $nc['args']) . " # {$nc['why']}");695 }696 }697}698
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Dev\Application\Service\Console\CommandDelegator;10use Symfony\Component\Console\Input\InputArgument;11use Symfony\Component\Console\Input\InputInterface;12use Symfony\Component\Console\Input\InputOption;13use Symfony\Component\Console\Output\OutputInterface;1415/**16 * Agent-facing aggregator for read-only introspection. One entry point, one17 * argument (the "subject"), forwarded to the underlying command that owns18 * the implementation.19 *20 * ai:ask capabilities [--search=word] [--json]21 * ai:ask project [--json]22 * ai:ask module --name=Billing [--json]23 * ai:ask route --path=/billing/{id} [--method=GET] [--json]24 * ai:ask event [--name=InvoicePaid] [--json]25 * ai:ask api --name=Column | --search=regex [--json]26 * ai:ask components [--name=platform.grid] [--kind=component] [--json]27 * ai:ask logs [--file=app] [--lines=200] [--grep=…] [--json]28 *29 * Output contracts (envelope shapes, JSON keys, exit codes) belong to the30 * dispatched targets — `ai:ask` only routes; the dev:graph:* / logs:app31 * commands own behavior.32 */33#[AsCommand(name: 'ai:ask', description: 'Agent-facing introspection aggregator (capabilities, mechanisms, components, project, module, route, event, path, api, logs)')]34final class AiAskCommand extends BaseCommand35{36 /**37 * Subject → underlying command. Keeping the list short is the point: if38 * a new read-only surface is needed, add it here rather than spawning39 * another top-level command.40 *41 * @var array<string, string>42 */43 private const SUBJECT_MAP = [44 'capabilities' => 'dev:graph:capabilities',45 // Distinct from `capabilities` on purpose: that one lists the CLI46 // commands available to run, this one lists what the installed47 // FRAMEWORK can do (deferred regions, components, live transport).48 // Both answer "what can I do here"; merging them buries the handful of49 // mechanisms under a wall of command help.50 'mechanisms' => 'dev:graph:mechanisms',51 'project' => 'dev:graph:project',52 'module' => 'dev:graph:module',53 'route' => 'dev:graph:route',54 'event' => 'dev:graph:event',55 'api' => 'dev:graph:api',56 // The UI catalog: what can be rendered, its props and how to call it.57 'components' => 'platform-ui:catalog',58 'logs' => 'logs:app',59 // `path` explains a file/directory path using global module-structure60 // rules + any package-local extension. Auto-selected when --path is61 // passed without an explicit subject (see execute()).62 'path' => 'dev:graph:path',63 ];6465 public function __construct()66 {67 parent::__construct('ai:ask');68 }6970 protected function configure(): void71 {72 $this73 // Subject is now OPTIONAL: when omitted, the command auto-selects74 // `path` if --path is provided (lets `ai:ask --path=…` work75 // without naming a subject). Existing subjects keep their full76 // routing behavior.77 ->addArgument('subject', InputArgument::OPTIONAL, 'One of: ' . implode(', ', array_keys(self::SUBJECT_MAP)) . ' (omit if --path is provided to auto-select `path`)')78 // Union of options accepted by the delegated targets. Only the79 // ones the target actually declares are forwarded (see80 // CommandDelegator). Anything else is silently dropped.81 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Module/event name, a framework class, or a UI entry (for subject=module|event|api|components)')82 ->addOption('search', null, InputOption::VALUE_REQUIRED, 'Find framework classes and members by name or doc summary (for subject=api), or mechanisms and commands by a word (for subject=mechanisms|capabilities)')83 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Path: route path (subject=route) | file/directory path (subject=path, default when --path used)')84 ->addOption('method', null, InputOption::VALUE_REQUIRED, 'HTTP method (for subject=route)')85 ->addOption('file', null, InputOption::VALUE_REQUIRED, 'Log file alias (for subject=logs)')86 ->addOption('lines', null, InputOption::VALUE_REQUIRED, 'Line count (for subject=logs)')87 ->addOption('grep', null, InputOption::VALUE_REQUIRED, 'Filter (for subject=logs)')88 ->addOption('level', null, InputOption::VALUE_REQUIRED, 'Log level filter (for subject=logs)')89 ->addOption('since', null, InputOption::VALUE_REQUIRED, 'Time window (for subject=logs)')90 ->addOption('around', null, InputOption::VALUE_REQUIRED, 'Context timestamp (for subject=logs)')91 ->addOption('context', null, InputOption::VALUE_REQUIRED, 'Context radius (for subject=logs)')92 ->addOption('list', null, InputOption::VALUE_NONE, 'List available logs (for subject=logs)')93 ->addOption('kind', null, InputOption::VALUE_REQUIRED, 'primitive, component or behavior (for subject=components)')94 ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Capability id (for subject=mechanisms)')95 ->addOption('area', null, InputOption::VALUE_REQUIRED, 'Capability area prefix, e.g. ssr|ui (for subject=mechanisms)')96 ->addOption('state', null, InputOption::VALUE_REQUIRED, 'installed or available (for subject=mechanisms)')97 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit JSON envelope (target-dependent shape)');98 }99100 protected function execute(InputInterface $input, OutputInterface $output): int101 {102 $subject = (string) ($input->getArgument('subject') ?? '');103 // When subject is omitted but --path is provided, default to104 // `path`. Keeps `ai:ask --path=foo` ergonomic for AI agents while105 // preserving the explicit-subject flow for everyone else.106 if ($subject === '' && (string) ($input->getOption('path') ?? '') !== '') {107 $subject = 'path';108 }109 if ($subject === '') {110 $output->writeln(json_encode([111 'kind' => 'error',112 'error' => 'missing subject (and no --path provided to auto-select)',113 'subjects' => array_keys(self::SUBJECT_MAP),114 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);115 return self::FAILURE;116 }117 $target = self::SUBJECT_MAP[$subject] ?? null;118 if ($target === null) {119 $output->writeln(json_encode([120 'kind' => 'error',121 'error' => "unknown subject '{$subject}'",122 'subjects' => array_keys(self::SUBJECT_MAP),123 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);124 return self::FAILURE;125 }126127 $app = $this->getApplication();128 if ($app === null) {129 $output->writeln(json_encode([130 'kind' => 'error',131 'error' => 'Application not available — cannot dispatch subject',132 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);133 return self::FAILURE;134 }135136 return CommandDelegator::run($app, $target, $input, $output);137 }138}139
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Attribute\InjectAsReadonly;9use Semitexa\Core\Console\BaseCommand;10use Semitexa\Dev\Application\Service\Trace\ObservatoryMode;11use Semitexa\Dev\Application\Service\Trace\PageTimelineReader;12use Semitexa\Dev\Application\Service\Trace\ObservatoryReader;13use Semitexa\Dev\Application\Service\Trace\ReplayRunner;14use Semitexa\Dev\Application\Service\Trace\EntryMethodCatalog;15use Semitexa\Dev\Application\Service\Trace\SourceSliceReader;16use Semitexa\Dev\Application\Service\Trace\SpanTarget;17use Semitexa\Dev\Application\Service\Trace\TraceReader;18use Symfony\Component\Console\Input\InputArgument;19use Symfony\Component\Console\Input\InputInterface;20use Symfony\Component\Console\Input\InputOption;21use Symfony\Component\Console\Output\OutputInterface;2223/**24 * The AI agent's window into the Observatory — the same journal and traces the25 * human panel reads, rendered for a context budget instead of a screen.26 *27 * Design rules (ep-observatory, decision 4):28 * - the agent consumes the DATA, never the human HTML;29 * - summaries first, bytes on demand: `ps` is a snapshot, `show` is one30 * process, `tail` is raw NDJSON rows — nothing dumps everything;31 * - `tail --follow` streams new journal rows as they land and exits after32 * --duration seconds, because an agent cannot Ctrl-C.33 *34 * Everything here is read-only over files; no server round-trip, so it works35 * with the app down (the journal is still there — often exactly when a crash36 * is being investigated).37 */38#[AsCommand(39 name: 'ai:observe',40 description: 'Observatory for agents: ps (live snapshot) | tail (journal rows, --follow streams) | show --id (one process + its trace) | timeline [--id] (a page: its UI events, stream frames, re-runs)',41)]42final class AiObserveCommand extends BaseCommand43{44 #[InjectAsReadonly]45 protected ObservatoryReader $reader;4647 #[InjectAsReadonly]48 protected TraceReader $traces;4950 #[InjectAsReadonly]51 protected ReplayRunner $replayRunner;5253 #[InjectAsReadonly]54 protected SourceSliceReader $source;5556 #[InjectAsReadonly]57 protected PageTimelineReader $timelines;5859 public function __construct()60 {61 parent::__construct('ai:observe');62 }6364 protected function configure(): void65 {66 $this67 ->addArgument('action', InputArgument::REQUIRED, 'ps | tail | show | replay | sandbox | timeline')68 ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Process id (show, replay); a page\'s KISS session id (timeline)')69 ->addOption('mutate', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Override an input field, k=v; v parsed as JSON when it parses (replay, repeatable)')70 ->addOption('method', null, InputOption::VALUE_REQUIRED, 'HTTP method of the route to run (sandbox)', 'GET')71 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Concrete request path, e.g. /items/7 (sandbox)')72 ->addOption('input', null, InputOption::VALUE_REQUIRED, 'Payload input as a JSON object, path params included (sandbox)', '{}')73 ->addOption('kind', null, InputOption::VALUE_REQUIRED, 'Filter: process kind (tail)')74 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Filter: name substring (tail)')75 ->addOption('lines', null, InputOption::VALUE_REQUIRED, 'How many rows (tail, default 50, max 500)')76 ->addOption('follow', null, InputOption::VALUE_NONE, 'Stream new rows as they land (tail)')77 ->addOption('duration', null, InputOption::VALUE_REQUIRED, 'Seconds to follow before exiting (default 15, max 300)')78 ->addOption('source', null, InputOption::VALUE_NONE, 'Inline the source of the method/class each traced span ran (show; dev-only)')79 ->addOption('json', null, InputOption::VALUE_NONE, 'Accepted for ai:* symmetry; output is always JSON');80 }8182 protected function execute(InputInterface $input, OutputInterface $output): int83 {84 if (!$this->reader->isEnabled()) {85 $output->writeln((string) json_encode([86 'artifact' => 'semitexa-dev.ai-observe.error/v1',87 'error' => 'observatory-disabled',88 'hint' => 'The journal is off here: APP_ENV must be dev, or SEMITEXA_OBSERVATORY_MODE=monitor for journal-only production observability.',89 ]), OutputInterface::OUTPUT_RAW);9091 return self::FAILURE;92 }9394 return match ($input->getArgument('action')) {95 'ps' => $this->ps($output),96 'tail' => $this->tail($input, $output),97 'show' => $this->show($input, $output),98 'replay' => $this->replay($input, $output),99 'sandbox' => $this->sandbox($input, $output),100 'timeline' => $this->timeline($input, $output),101 default => $this->unknown($output),102 };103 }104105 /**106 * Sandbox replay of one recorded process: same route, the recorded payload107 * snapshot (with --mutate overrides) through the real handler — writes108 * rolled back, queue handoffs captured — then a trace-vs-trace diff.109 */110 private function replay(InputInterface $input, OutputInterface $output): int111 {112 // Monitor mode reads the journal; it never re-executes anything.113 // ReplayRunner refuses too — this early exit just says why, first.114 if (!ObservatoryMode::full()) {115 return $this->fail(116 $output,117 'replay-requires-dev',118 'Replay executes recorded requests and is dev-only; monitor mode is journal-only by design.',119 );120 }121122 $id = $this->strOption($input, 'id');123 if ($id === null) {124 return $this->fail($output, 'missing-id', 'ai:observe replay --id=<process-id> [--mutate k=v]');125 }126127 $found = $this->reader->find($id);128 $traceFile = $found['end']['trace'] ?? null;129 if (!is_string($traceFile) || $traceFile === '') {130 return $this->fail(131 $output,132 'not-traced',133 'Replay needs the recorded envelope; this process has no trace. Re-run the request with ?__trace=1 first.',134 );135 }136137 $mutations = [];138 foreach ((array) $input->getOption('mutate') as $pair) {139 if (!is_string($pair) || !str_contains($pair, '=')) {140 return $this->fail($output, 'bad-mutation', "each --mutate must be k=v, got: " . (string) $pair);141 }142 [$k, $v] = explode('=', $pair, 2);143 $decoded = json_decode($v, true);144 $mutations[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v;145 }146147 $result = $this->replayRunner->replay($traceFile, $mutations);148 if (isset($result['error'])) {149 $output->writeln((string) json_encode(['artifact' => 'semitexa-dev.ai-observe.error/v1'] + $result), OutputInterface::OUTPUT_RAW);150151 return self::FAILURE;152 }153154 $envelope = ['artifact' => 'semitexa-dev.ai-observe.replay/v1', 'id' => $id] + $result;155 if (is_string($result['replay_trace'] ?? null)) {156 $envelope['diff'] = $this->diffTraces($traceFile, $result['replay_trace']);157 }158159 $output->writeln((string) json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), OutputInterface::OUTPUT_RAW);160161 return $result['verdict'] === 'handler_threw' ? self::FAILURE : self::SUCCESS;162 }163164 /**165 * One request through the replay sandbox with no recording behind it —166 * what the API Explorer runs in a process of its own, because the sandbox167 * arms process-global registries a serving worker must never see.168 */169 private function sandbox(InputInterface $input, OutputInterface $output): int170 {171 if (!ObservatoryMode::full()) {172 return $this->fail($output, 'sandbox-requires-dev', 'The sandbox executes handlers and is dev-only.');173 }174175 $path = $this->strOption($input, 'path');176 if ($path === null || !str_starts_with($path, '/')) {177 return $this->fail($output, 'missing-path', 'ai:observe sandbox --method=POST --path=/items/7 --input=\'{"name":"x"}\'');178 }179 $data = json_decode((string) $input->getOption('input'), true);180 if (!is_array($data) || ($data !== [] && array_is_list($data))) {181 return $this->fail($output, 'bad-input', '--input must be a JSON object');182 }183184 $result = $this->replayRunner->sandbox((string) ($this->strOption($input, 'method') ?? 'GET'), $path, $data);185 if (isset($result['error'])) {186 $output->writeln((string) json_encode(['artifact' => 'semitexa-dev.ai-observe.error/v1'] + $result), OutputInterface::OUTPUT_RAW);187188 return self::FAILURE;189 }190191 $output->writeln((string) json_encode(192 ['artifact' => 'semitexa-dev.ai-observe.sandbox/v1'] + $result,193 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,194 ), OutputInterface::OUTPUT_RAW);195196 return $result['verdict'] === 'handler_threw' ? self::FAILURE : self::SUCCESS;197 }198199 /**200 * Span-level comparison of the two recordings. Timing deltas on shared201 * spans are labeled expected drift — a replay runs on a different clock202 * with different caches; STRUCTURE changes (spans or queries appearing or203 * vanishing) are what a behaviour difference actually looks like.204 *205 * @return array<string, mixed>206 */207 private function diffTraces(string $originalFile, string $replayFile): array208 {209 $profile = function (string $file): ?array {210 $trace = $this->traces->read($file);211 if ($trace === null) {212 return null;213 }214 $spans = [];215 foreach ($trace['spans'] as $span) {216 $name = (string) $span['name'];217 $spans[$name] = ($spans[$name] ?? 0) + 1;218 }219220 return ['spans' => $spans, 'queries' => count($trace['queries']), 'totalMs' => $trace['meta']['totalMs']];221 };222223 $orig = $profile($originalFile);224 $replay = $profile($replayFile);225 if ($orig === null || $replay === null) {226 return ['error' => 'diff-unavailable'];227 }228229 $added = array_diff_key($replay['spans'], $orig['spans']);230 $missing = array_diff_key($orig['spans'], $replay['spans']);231 $countChanged = [];232 foreach (array_intersect_key($orig['spans'], $replay['spans']) as $name => $n) {233 if ($replay['spans'][$name] !== $n) {234 $countChanged[$name] = ['original' => $n, 'replay' => $replay['spans'][$name]];235 }236 }237238 return [239 'spans_added' => $added,240 'spans_missing' => $missing,241 'span_count_changed' => $countChanged,242 'queries' => ['original' => $orig['queries'], 'replay' => $replay['queries']],243 'total_ms' => [244 'original' => $orig['totalMs'],245 'replay' => $replay['totalMs'],246 'note' => 'timing drift is expected; structure changes are the signal',247 ],248 'structurally_identical' => $added === [] && $missing === [] && $countChanged === []249 && $orig['queries'] === $replay['queries'],250 ];251 }252253 private function fail(OutputInterface $output, string $error, string $hint): int254 {255 $output->writeln((string) json_encode([256 'artifact' => 'semitexa-dev.ai-observe.error/v1',257 'error' => $error,258 'hint' => $hint,259 ]), OutputInterface::OUTPUT_RAW);260261 return self::FAILURE;262 }263264 private function ps(OutputInterface $output): int265 {266 $snapshot = $this->reader->snapshot();267268 $next = [269 ['cmd' => 'ai:observe', 'args' => ['show', '--id=<process-id>'], 'why' => 'inspect one process, including its trace when it was recorded'],270 ['cmd' => 'ai:observe', 'args' => ['tail', '--follow', '--duration=15'], 'why' => 'watch new journal rows arrive live'],271 ];272 // The newest failure first: it is what someone running ps is looking for.273 foreach ($snapshot['recent'] as $row) {274 if (($row['failed'] ?? false) === true) {275 array_unshift($next, [276 'cmd' => 'ai:observe',277 'args' => ['show', '--id=' . $row['id']],278 'why' => sprintf('the newest failure: %s ended %s', $row['name'], $row['exception'] ?? ('with ' . ($row['httpStatus'] ?? 'failed'))),279 ]);280 break;281 }282 }283284 $envelope = [285 'artifact' => 'semitexa-dev.ai-observe.ps/v1',286 ] + $snapshot + [287 'next_command' => $next,288 ];289290 $output->writeln((string) json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), OutputInterface::OUTPUT_RAW);291292 return self::SUCCESS;293 }294295 private function tail(InputInterface $input, OutputInterface $output): int296 {297 $kind = $this->strOption($input, 'kind');298 $name = $this->strOption($input, 'name');299 $lines = max(1, min(500, (int) ($input->getOption('lines') ?: 50)));300301 foreach ($this->reader->tailRecords($lines, $kind, $name) as $row) {302 $output->writeln((string) json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), OutputInterface::OUTPUT_RAW);303 }304305 if (!$input->getOption('follow')) {306 return self::SUCCESS;307 }308309 // Follow by file growth: remember the offset, poll for appended bytes,310 // emit whole lines. Exits on its own — an agent cannot Ctrl-C, so an311 // unbounded follow would hang the tool call that issued it.312 $duration = max(1, min(300, (int) ($input->getOption('duration') ?: 15)));313 $deadline = microtime(true) + $duration;314 $path = $this->reader->todayJournalPath();315 $offset = is_file($path) ? (int) filesize($path) : 0;316 $carry = '';317318 while (microtime(true) < $deadline) {319 usleep(200_000);320 // Re-resolve EVERY iteration: at midnight the journal rolls to a321 // new dated file while the old one still exists and stops growing322 // — a follower pinned to the old path would go silent for the323 // rest of the duration.324 $current = $this->reader->todayJournalPath();325 if ($current !== $path) {326 $path = $current;327 $offset = 0;328 $carry = '';329 }330 clearstatcache(true, $path);331 if (!is_file($path)) {332 // First write of the day still pending.333 $offset = 0;334 continue;335 }336 $size = (int) filesize($path);337 if ($size < $offset) {338 // Truncated (manual cleanup): restart from the top rather339 // than waiting forever for the size to catch up.340 $offset = 0;341 $carry = '';342 }343 if ($size <= $offset) {344 continue;345 }346 $chunk = (string) file_get_contents($path, false, null, $offset, $size - $offset);347 $offset = $size;348 $carry .= $chunk;349350 while (($nl = strpos($carry, "\n")) !== false) {351 $line = substr($carry, 0, $nl);352 $carry = substr($carry, $nl + 1);353 $row = json_decode($line, true);354 if (!is_array($row)) {355 continue;356 }357 if ($kind !== null && ($row['kind'] ?? '') !== $kind) {358 continue;359 }360 if ($name !== null && !str_contains((string) ($row['name'] ?? ''), $name)) {361 continue;362 }363 $output->writeln((string) json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), OutputInterface::OUTPUT_RAW);364 }365 }366367 return self::SUCCESS;368 }369370 private function show(InputInterface $input, OutputInterface $output): int371 {372 $id = $this->strOption($input, 'id');373 if ($id === null) {374 $output->writeln((string) json_encode([375 'artifact' => 'semitexa-dev.ai-observe.error/v1',376 'error' => 'missing-id',377 'hint' => 'ai:observe show --id=<process-id>; ids come from ps or tail.',378 ]), OutputInterface::OUTPUT_RAW);379380 return self::FAILURE;381 }382383 $found = $this->reader->find($id);384 if ($found['begin'] === null && $found['end'] === null) {385 $output->writeln((string) json_encode([386 'artifact' => 'semitexa-dev.ai-observe.error/v1',387 'error' => 'unknown-process',388 'id' => $id,389 'hint' => 'Not in the journal tail window. ai:observe ps lists what is visible.',390 ]), OutputInterface::OUTPUT_RAW);391392 return self::FAILURE;393 }394395 $envelope = [396 'artifact' => 'semitexa-dev.ai-observe.show/v1',397 'id' => $id,398 'begin' => $found['begin'],399 'end' => $found['end'],400 // lost: it never ended, but the process that ran it restarted, stopped or crashed.401 'status' => $found['end'] !== null ? 'done' : ($found['lost'] !== null ? 'lost' : 'live'),402 'lost' => $found['lost'],403 ];404405 // Bytes on demand: the full span/query resolution only when this406 // process was traced, and only for the process that was asked about.407 $traceFile = $found['end']['trace'] ?? null;408 if (is_string($traceFile) && $traceFile !== '') {409 $trace = $this->traces->read($traceFile);410 if ($trace !== null) {411 if ((bool) $input->getOption('source')) {412 if (!ObservatoryMode::full()) {413 return $this->fail(414 $output,415 'source-requires-dev',416 'Inlining source reads files from the working copy and is dev-only; monitor mode is journal-only by design.',417 );418 }419 $trace = $this->withSource($trace);420 // An object even when empty: the field is a map keyed421 // Class::method, and a consumer indexing it must not meet422 // a JSON array on the one trace that named no class.423 $envelope['source'] = (object) $trace['source'];424 unset($trace['source']);425 }426 $envelope['trace'] = $trace;427 } else {428 // The journal says a trace was written, but the file is gone429 // (rotated, or written by another instance sharing the430 // journal). Saying so beats silently looking untraced.431 $envelope['trace_missing'] = $traceFile;432 }433 } else {434 $envelope['next_command'] = [[435 'cmd' => 'browser',436 'args' => ['?__trace=1'],437 'why' => 'no trace was recorded for this process; re-run the request with the marker for full spans',438 ]];439 }440441 $output->writeln((string) json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), OutputInterface::OUTPUT_RAW);442443 return self::SUCCESS;444 }445446 /**447 * Attach the source behind every span that named a class.448 *449 * Each distinct Class::method is read once and keyed under `source`; the450 * span carries only the key (`source_ref`), so a handler that ran ten451 * times costs one slice, not ten. The same resolution the HTML node page452 * uses: the recorded method when the span has one, the conventional entry453 * method otherwise, the class when neither exists.454 *455 * The key is the RESOLVED identity, not the requested one: a span that456 * named only the class and resolved to handle() shares its entry with a457 * span that named Class::handle outright. Only an unreadable source keeps458 * the requested key, so the consumer still learns where the span pointed.459 *460 * @param array<string, mixed> $trace461 * @return array<string, mixed> the trace with `source_ref` on spans and a top-level `source` map462 */463 private function withSource(array $trace): array464 {465 $catalog = new EntryMethodCatalog();466 $sources = [];467 /** @var array<string, string> $canonical requested key → resolved key, so each target resolves once */468 $canonical = [];469470 /** @var list<array<string, mixed>> $spans */471 $spans = is_array($trace['spans'] ?? null) ? $trace['spans'] : [];472 foreach ($spans as $i => $span) {473 $target = SpanTarget::of(is_array($span['context'] ?? null) ? $span['context'] : []);474 if ($target === null) {475 continue;476 }477478 $requested = $target->key();479 if (!isset($canonical[$requested])) {480 $slice = $target->method !== null481 ? $this->source->slice($target->class, $target->method)482 : $this->source->sliceAny($target->class, $catalog->candidates($target->class, null));483 $key = match (true) {484 $slice === null => $requested,485 $slice->method === null => $slice->fqcn,486 default => $slice->fqcn . '::' . $slice->method,487 };488 $canonical[$requested] = $key;489 // null stays in the map: the span still says it pointed490 // somewhere, and the consumer learns the source was unreadable491 // instead of wondering why a key is missing.492 if (!array_key_exists($key, $sources)) {493 $sources[$key] = $slice?->toArray();494 }495 }496497 $spans[$i]['source_ref'] = $canonical[$requested];498 }499500 $trace['spans'] = $spans;501 $trace['source'] = $sources;502503 return $trace;504 }505506 /**507 * A page's live timeline: without --id the pages seen lately, with it one508 * page's events in order (dev only — the timeline is not kept in monitor509 * mode).510 */511 private function timeline(InputInterface $input, OutputInterface $output): int512 {513 if (!ObservatoryMode::full()) {514 return $this->fail($output, 'timeline-requires-dev', 'The page timeline is recorded in development only.');515 }516 $id = $this->strOption($input, 'id');517 $lines = max(1, min(2000, (int) ($input->getOption('lines') ?: 200)));518 $output->writeln((string) json_encode($id === null519 ? ['artifact' => 'semitexa-dev.ai-observe.pages/v1', 'pages' => $this->timelines->pages()]520 : ['artifact' => 'semitexa-dev.ai-observe.timeline/v1', 'session' => $id, 'events' => $this->timelines->events($id, $lines)],521 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), OutputInterface::OUTPUT_RAW);522523 return self::SUCCESS;524 }525526 private function unknown(OutputInterface $output): int527 {528 $output->writeln((string) json_encode([529 'artifact' => 'semitexa-dev.ai-observe.error/v1',530 'error' => 'unknown-action',531 'hint' => 'Actions: ps | tail [--kind= --name= --lines= --follow --duration=] | show --id= [--source] | replay --id= [--mutate k=v]',532 ]), OutputInterface::OUTPUT_RAW);533534 return self::FAILURE;535 }536537 private function strOption(InputInterface $input, string $name): ?string538 {539 $value = $input->getOption($name);540541 return is_string($value) && $value !== '' ? $value : null;542 }543}544
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Attribute\InjectAsReadonly;9use Semitexa\Core\Console\BaseCommand;10use Semitexa\Core\Support\ProjectRoot;11use Semitexa\Dev\Application\Service\Ai\Presence\AgentRegistry;12use Semitexa\Dev\Application\Service\Ai\Presence\BacklogActivity;13use Semitexa\Dev\Application\Service\Ai\Presence\TaskClaim;14use Semitexa\Dev\Application\Service\Ai\Trace\TraceEvent;15use Semitexa\Dev\Application\Service\Ai\Trace\TraceEventKind;16use Semitexa\Dev\Application\Service\Ai\Trace\TraceStore;17use Semitexa\Dev\Application\Service\Ai\Work\BacklogHygiene;18use Semitexa\Dev\Application\Service\Ai\Work\BacklogScope;19use Semitexa\Dev\Application\Service\Ai\Work\EpicStore;20use Semitexa\Dev\Application\Service\Ai\Work\ResumeService;21use Semitexa\Dev\Application\Service\Ai\Work\ResumeSnapshot;22use Semitexa\Dev\Application\Service\Ai\Work\Task;23use Semitexa\Dev\Application\Service\Ai\Work\TaskFiling;24use Semitexa\Dev\Application\Service\Ai\Work\TaskNextCommands;25use Semitexa\Dev\Application\Service\Ai\Work\TaskProofs;26use Semitexa\Dev\Application\Service\Ai\Work\TaskStatus;27use Semitexa\Dev\Application\Service\Ai\Work\TaskStore;28use Semitexa\Dev\Application\Service\Ai\Work\WorkId;29use Symfony\Component\Console\Input\InputArgument;30use Symfony\Component\Console\Input\InputInterface;31use Symfony\Component\Console\Input\InputOption;32use Symfony\Component\Console\Output\OutputInterface;3334/**35 * Work item (task) CLI — the main surface for agent decomposition and resume.36 *37 * bin/semitexa ai:work start --id=tk-fix-wm --epic=ep-wm --title="..." [--recipe=...] [--risk=...] [--phase=work|right|fast] [--trace=...] [--context-ref=...]* [--next-step=...]38 * bin/semitexa ai:work list [--epic=...] [--status=new|in_progress|blocked|done[,...]] [--json]39 * bin/semitexa ai:work show --id=tk-fix-wm [--json]40 * bin/semitexa ai:work update --id=tk-fix-wm [--status=...] [--title=...] [--recipe=...] [--risk=...] [--phase=...] [--next-step=...] [--context-ref=...]* [--epic=...]41 * bin/semitexa ai:work note --id=tk-fix-wm --note="..." [--next-step=...]42 * bin/semitexa ai:work resume --id=tk-fix-wm [--tail=5]43 *44 * `start` always links the task to a trace: if `--trace` isn't supplied the45 * task id is used as the trace id, and the trace is openOrCreate'd on the46 * fly. That way every task has a durable log, and the slugs stay aligned.47 */48#[AsCommand(name: 'ai:work', description: 'Manage work tasks: start/list/show/update/resume/note (agent-facing, NDJSON)')]49final class AiWorkCommand extends BaseCommand50{51 private const ACTION_START = 'start';52 private const ACTION_LIST = 'list';53 private const ACTION_SHOW = 'show';54 private const ACTION_UPDATE = 'update';55 private const ACTION_NOTE = 'note';56 private const ACTION_RESUME = 'resume';5758 private const ALLOWED_RISK = ['low', 'medium', 'high'];5960 #[InjectAsReadonly]61 protected TaskStore $taskStore;6263 #[InjectAsReadonly]64 protected EpicStore $epicStore;6566 #[InjectAsReadonly]67 protected TraceStore $traceStore;6869 #[InjectAsReadonly]70 protected ResumeService $resumeService;7172 #[InjectAsReadonly]73 protected BacklogHygiene $hygiene;7475 public function __construct()76 {77 parent::__construct('ai:work');78 }7980 protected function configure(): void81 {82 $this83 ->addArgument('action', InputArgument::REQUIRED, 'start | list | show | update | note | resume')84 ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Task id (a-z, 0-9, -, _)')85 ->addOption('epic', null, InputOption::VALUE_REQUIRED, 'Epic id (required on start; on update, re-parents the task)')86 ->addOption('title', null, InputOption::VALUE_REQUIRED, 'Task title')87 ->addOption('recipe', null, InputOption::VALUE_REQUIRED, 'Recipe id (see ai:task)')88 ->addOption('risk', null, InputOption::VALUE_REQUIRED, 'Risk: ' . implode('|', self::ALLOWED_RISK))89 ->addOption('phase', null, InputOption::VALUE_REQUIRED, 'work | right | fast. Filed under right or fast, or under a bug recipe, it counts at once; other new work is an idea until ai:work:approve')90 ->addOption('status', null, InputOption::VALUE_REQUIRED, 'Status: ' . implode('|', TaskStatus::all()) . ' (list accepts a comma list)')91 ->addOption('scope', null, InputOption::VALUE_REQUIRED, 'List filter: ' . implode('|', BacklogScope::all()) . ' (default: active)')92 ->addOption('take-over', null, InputOption::VALUE_NONE, 'Take an in_progress task another live agent holds (update)')93 ->addOption('context-ref', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Path or symbol the task is anchored to (repeatable)')94 ->addOption('next-step', null, InputOption::VALUE_REQUIRED, 'One-line next step for the resuming agent')95 ->addOption('note', null, InputOption::VALUE_REQUIRED, 'Free-form note (appended to the linked trace)')96 ->addOption('tail', null, InputOption::VALUE_REQUIRED, 'How many trailing trace events to surface on resume (default 5)')97 ->addOption('trace', null, InputOption::VALUE_REQUIRED, 'Trace id for this task (defaults to the task id on start)')98 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit a single JSON envelope instead of NDJSON');99 }100101 protected function execute(InputInterface $input, OutputInterface $output): int102 {103 $action = (string) $input->getArgument('action');104 $json = (bool) $input->getOption('json');105 BacklogActivity::announce($output, new AgentRegistry(ProjectRoot::get()), ProjectRoot::get());106107 return match ($action) {108 self::ACTION_START => $this->start($input, $output, $json),109 self::ACTION_LIST => $this->list($input, $output, $json),110 self::ACTION_SHOW => $this->show($input, $output, $json),111 self::ACTION_UPDATE => $this->update($input, $output, $json),112 self::ACTION_NOTE => $this->note($input, $output, $json),113 self::ACTION_RESUME => $this->resume($input, $output, $json),114 default => $this->error($output, "unknown action: '{$action}' (expected start | list | show | update | note | resume)", $json),115 };116 }117118 private function start(InputInterface $input, OutputInterface $output, bool $jsonMode): int119 {120 $id = $this->requireId($input, $output, $jsonMode);121 if ($id === null) {122 return self::FAILURE;123 }124 if ($this->taskStore->exists($id)) {125 return $this->error($output, "task '{$id}' already exists", $jsonMode);126 }127128 $epicId = (string) ($input->getOption('epic') ?? '');129 if ($epicId === '') {130 return $this->error($output, '--epic is required for start', $jsonMode);131 }132 try {133 WorkId::assertValid($epicId, 'epic id');134 } catch (\InvalidArgumentException $e) {135 return $this->error($output, $e->getMessage(), $jsonMode);136 }137 if (!$this->epicStore->exists($epicId)) {138 return $this->error($output, "epic '{$epicId}' does not exist — run ai:epic start first", $jsonMode);139 }140141 $title = (string) ($input->getOption('title') ?? '');142 if ($title === '') {143 return $this->error($output, '--title is required for start', $jsonMode);144 }145146 $recipe = (string) ($input->getOption('recipe') ?? '');147 $risk = $this->normaliseRisk((string) ($input->getOption('risk') ?? 'medium'), $output, $jsonMode);148 if ($risk === null) {149 return self::FAILURE;150 }151152 $traceId = (string) ($input->getOption('trace') ?? $id);153 try {154 WorkId::assertValid($traceId, 'trace id');155 $phase = TaskFiling::phaseOf(TaskFiling::parsePhase($this->optionalString($input, 'phase')), $recipe);156 } catch (\InvalidArgumentException $e) {157 return $this->error($output, $e->getMessage(), $jsonMode);158 }159 $this->traceStore->openOrCreate($traceId, topic: $title, recipe: $recipe !== '' ? $recipe : null);160161 $now = date('c');162 $refs = $this->contextRefs($input);163 $task = new Task(164 id: $id,165 epicId: $epicId,166 title: $title,167 status: TaskStatus::NEW,168 recipe: $recipe,169 risk: $risk,170 contextRefs: $refs,171 nextStep: $this->optionalString($input, 'next-step'),172 traceId: $traceId,173 createdAt: $now,174 updatedAt: $now,175 approval: TaskFiling::approvalOnFiling($phase, $recipe, $now),176 phase: $phase,177 );178 $this->taskStore->save($task);179180 $this->traceStore->append($traceId, TraceEventKind::TASK_RESULT, "task '{$id}' started — {$title}", [181 'artifact' => 'semitexa.ai-work.task-start/v1',182 'task_id' => $id,183 'epic_id' => $epicId,184 'recipe' => $recipe,185 'risk' => $risk,186 'phase' => $phase?->value,187 'context_refs' => $refs,188 'next_step' => $task->nextStep,189 ]);190191 return $this->emitTask($output, $task, 'task_started', $jsonMode);192 }193194 private function list(InputInterface $input, OutputInterface $output, bool $jsonMode): int195 {196 $epicId = $input->getOption('epic') !== null ? (string) $input->getOption('epic') : null;197 try {198 $statuses = TaskStatus::parseList((string) ($input->getOption('status') ?? ''));199 } catch (\InvalidArgumentException $e) {200 return $this->error($output, $e->getMessage(), $jsonMode);201 }202 $statusLabel = $statuses === [] ? null : implode(',', array_map(static fn(TaskStatus $s): string => $s->value, $statuses));203 if ($epicId !== null) {204 try {205 WorkId::assertValid($epicId, 'epic id');206 } catch (\InvalidArgumentException $e) {207 return $this->error($output, $e->getMessage(), $jsonMode);208 }209 }210211 $scopeRaw = (string) ($input->getOption('scope') ?? BacklogScope::ACTIVE->value);212 try {213 $scope = BacklogScope::parse($scopeRaw);214 } catch (\InvalidArgumentException $e) {215 return $this->error($output, $e->getMessage(), $jsonMode);216 }217218 $all = array_values(array_filter(219 $this->taskStore->list($epicId),220 static fn(Task $task): bool => $statuses === [] || in_array($task->status, $statuses, true),221 ));222 $rows = [];223 $hiddenByScope = 0;224 foreach ($all as $task) {225 $assessment = $this->hygiene->assessTask($task);226 if (!$this->hygiene->taskInScope($task, $assessment, $scope)) {227 $hiddenByScope++;228 continue;229 }230 $rows[] = $task->toArray() + [231 'quality' => $assessment->quality->value,232 'issues' => $assessment->issues,233 'suggested_status' => $assessment->suggestedStatus,234 'suggested_action' => $assessment->suggestedAction,235 ];236 }237238 if ($jsonMode) {239 $output->writeln(json_encode([240 'artifact' => 'semitexa.ai-work.task-list/v1',241 'epic' => $epicId,242 'status' => $statusLabel,243 'scope' => $scope->value,244 'tasks' => $rows,245 'task_count' => count($rows),246 'hidden_by_scope' => $hiddenByScope,247 'total_in_store' => count($all),248 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);249 return self::SUCCESS;250 }251252 $output->writeln(json_encode([253 'kind' => 'summary',254 'epic' => $epicId,255 'status' => $statusLabel,256 'scope' => $scope->value,257 'task_count' => count($rows),258 'hidden_by_scope' => $hiddenByScope,259 'total_in_store' => count($all),260 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);261 foreach ($rows as $row) {262 $output->writeln(json_encode(['kind' => 'task'] + $row, JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);263 }264 return self::SUCCESS;265 }266267 private function show(InputInterface $input, OutputInterface $output, bool $jsonMode): int268 {269 $id = $this->requireId($input, $output, $jsonMode);270 if ($id === null) {271 return self::FAILURE;272 }273 try {274 $task = $this->taskStore->get($id);275 } catch (\RuntimeException $e) {276 return $this->error($output, $e->getMessage(), $jsonMode);277 }278 return $this->emitTask($output, $task, 'task', $jsonMode);279 }280281 private function update(InputInterface $input, OutputInterface $output, bool $jsonMode): int282 {283 $id = $this->requireId($input, $output, $jsonMode);284 if ($id === null) {285 return self::FAILURE;286 }287 try {288 $task = $this->taskStore->get($id);289 } catch (\RuntimeException $e) {290 return $this->error($output, $e->getMessage(), $jsonMode);291 }292293 $statusRaw = $input->getOption('status');294 $status = null;295 if ($statusRaw !== null && $statusRaw !== '') {296 try {297 $status = TaskStatus::parse((string) $statusRaw);298 } catch (\InvalidArgumentException $e) {299 return $this->error($output, $e->getMessage(), $jsonMode);300 }301 }302303 $title = $this->optionalString($input, 'title');304 $recipe = $this->optionalString($input, 'recipe');305 $riskRaw = $this->optionalString($input, 'risk');306 $risk = null;307 if ($riskRaw !== null) {308 $risk = $this->normaliseRisk($riskRaw, $output, $jsonMode);309 if ($risk === null) {310 return self::FAILURE;311 }312 }313 try {314 $phase = TaskFiling::parsePhase($this->optionalString($input, 'phase'));315 } catch (\InvalidArgumentException $e) {316 return $this->error($output, $e->getMessage(), $jsonMode);317 }318 $nextStep = $this->optionalString($input, 'next-step');319 $refs = $this->contextRefs($input);320 $refsOpt = $refs === [] ? null : $refs;321322 // Re-parenting. Epic membership is derived from the task, so a move is323 // one field — and without this it was one field edited in the JSON by324 // hand, which leaves no trace event and no record of why. Validated325 // against the store because an unknown epic id is the orphan case326 // BacklogHygiene already reports, and creating one here is worse than327 // refusing: the task disappears from every listing that starts at an328 // epic.329 $epicId = $this->optionalString($input, 'epic');330 if ($epicId !== null) {331 try {332 WorkId::assertValid($epicId, 'epic id');333 } catch (\InvalidArgumentException $e) {334 return $this->error($output, $e->getMessage(), $jsonMode);335 }336 if (!$this->epicStore->exists($epicId)) {337 return $this->error($output, "epic '{$epicId}' does not exist — run ai:epic start first", $jsonMode);338 }339 if ($epicId === $task->epicId) {340 $epicId = null; // already there; not a change to report341 }342 }343 $movedFrom = $epicId === null ? null : $task->epicId;344345 // Read here, not only in the note action. --note is declared on the346 // command and its description promises the trace; update() ignored it,347 // so closing a task WITH its reasoning changed the status, printed the348 // task as updated, exited 0 and threw the reasoning away. Eighteen349 // closures in one session were recorded that way and kept none of it.350 $note = $this->optionalString($input, 'note');351352 if ($title === null && $recipe === null && $risk === null && $status === null353 && $nextStep === null && $refsOpt === null && $note === null && $epicId === null && $phase === null) {354 return $this->error($output, 'update requires at least one of --title, --recipe, --risk, --status, --phase, --next-step, --context-ref, --note, --epic', $jsonMode);355 }356357 if (($refused = TaskClaim::claim(ProjectRoot::get(), $id, $status, (bool) $input->getOption('take-over'))) !== null) {358 return $this->error($output, $refused, $jsonMode);359 }360 $updated = $this->taskStore->change($id, static fn (Task $current): Task => (new TaskProofs(ProjectRoot::get()))->settle(TaskFiling::afterEdit($current->with(361 title: $title,362 status: $status,363 recipe: $recipe,364 risk: $risk,365 contextRefs: $refsOpt,366 nextStep: $nextStep,367 updatedAt: date('c'),368 epicId: $epicId,369 phase: $phase,370 ), date('c')), $status));371372 $kind = $status !== null ? TraceEventKind::NEXT_STEP : TraceEventKind::NOTE;373 $summary = match (true) {374 $status !== null => "task '{$id}' status → {$status->value}",375 // The move is named in the summary rather than left in the payload:376 // a reader scanning a trace for where a task went reads summaries.377 $epicId !== null => "task '{$id}' moved {$movedFrom} → {$epicId}",378 default => "task '{$id}' updated",379 };380 $changes = array_filter([381 'title' => $title,382 'recipe' => $recipe,383 'risk' => $risk,384 'status' => $status?->value,385 'phase' => $phase?->value,386 'next_step' => $nextStep,387 'context_refs' => $refsOpt,388 'epic_id' => $epicId,389 'moved_from' => $movedFrom,390 ], static fn($v) => $v !== null);391392 if ($changes !== []) {393 $this->safeAppend($updated->traceId, $kind, $summary, [394 'artifact' => 'semitexa.ai-work.task-update/v1',395 'task_id' => $id,396 'changes' => $changes,397 ]);398 }399400 // Its own event, and the same shape the note action writes: a reader of401 // the trace should not have to know which command spelling produced a402 // note, and the two must not drift into two shapes.403 if ($note !== null) {404 try {405 $this->appendNote($updated->traceId, $id, $note);406 } catch (\InvalidArgumentException|\RuntimeException $e) {407 return $this->noteError($output, $updated, true, $e->getMessage(), $jsonMode);408 }409 }410411 return $this->emitTask($output, $updated, 'task_updated', $jsonMode);412 }413414 private function note(InputInterface $input, OutputInterface $output, bool $jsonMode): int415 {416 $id = $this->requireId($input, $output, $jsonMode);417 if ($id === null) {418 return self::FAILURE;419 }420 try {421 $task = $this->taskStore->get($id);422 } catch (\RuntimeException $e) {423 return $this->error($output, $e->getMessage(), $jsonMode);424 }425426 $note = $this->optionalString($input, 'note');427 $nextStep = $this->optionalString($input, 'next-step');428 if ($note === null && $nextStep === null) {429 return $this->error($output, 'note requires --note and/or --next-step', $jsonMode);430 }431432 if ($nextStep !== null) {433 $task = $this->taskStore->change($id, static fn (Task $current): Task => $current->with(nextStep: $nextStep, updatedAt: date('c')));434 }435436 if ($note !== null) {437 try {438 $this->appendNote($task->traceId, $id, $note, $nextStep);439 } catch (\InvalidArgumentException|\RuntimeException $e) {440 return $this->noteError($output, $task, $nextStep !== null, $e->getMessage(), $jsonMode);441 }442 } else {443 $this->safeAppend($task->traceId, TraceEventKind::NOTE, "task '{$id}' next step updated", array_filter([444 'artifact' => 'semitexa.ai-work.task-note/v1',445 'task_id' => $id,446 'next_step' => $nextStep,447 ], static fn($v) => $v !== null));448 }449450 return $this->emitTask($output, $task, 'task_noted', $jsonMode);451 }452453 /**454 * One place that writes a note to a trace, so `ai:work note` and455 * `ai:work update --note` cannot end up writing two different shapes of the456 * same thing.457 */458 private function appendNote(string $traceId, string $id, string $note, ?string $nextStep = null): void459 {460 // An explicitly requested note is the primary action, not best-effort461 // trace hygiene. Missing traces and failed writes must reach the caller.462 $this->traceStore->append(463 $traceId,464 TraceEventKind::NOTE,465 "note on task '{$id}': " . $this->truncate($note, 80),466 array_filter([467 'artifact' => 'semitexa.ai-work.task-note/v1',468 'task_id' => $id,469 'note' => $note,470 'next_step' => $nextStep,471 ], static fn($v) => $v !== null),472 );473 }474475 private function resume(InputInterface $input, OutputInterface $output, bool $jsonMode): int476 {477 $id = $this->requireId($input, $output, $jsonMode);478 if ($id === null) {479 return self::FAILURE;480 }481 $tail = (int) ($input->getOption('tail') ?? 5);482483 try {484 $snapshot = $this->resumeService->resume($id, $tail);485 } catch (\RuntimeException $e) {486 return $this->error($output, $e->getMessage(), $jsonMode);487 }488489 $this->safeAppend(490 $snapshot->task->traceId,491 TraceEventKind::NEXT_STEP,492 "resuming task '{$id}' (status={$snapshot->task->status->value})",493 [494 'artifact' => 'semitexa.ai-work.task-resume/v1',495 'task_id' => $id,496 'status' => $snapshot->task->status->value,497 'next_step' => $snapshot->task->nextStep,498 ],499 );500501 return $this->emitResume($output, $snapshot, $jsonMode);502 }503504 private function emitResume(OutputInterface $output, ResumeSnapshot $snapshot, bool $jsonMode): int505 {506 $envelope = [507 'artifact' => 'semitexa.ai-work.task-resume/v1',508 'task' => $snapshot->task->toArray(),509 'trace_header' => $snapshot->traceHeader?->toArray(),510 'tail_events' => array_map(static fn(TraceEvent $e) => $e->toArray(), $snapshot->tailEvents),511 'trace_warning' => $snapshot->traceWarning,512 ];513514 if ($jsonMode) {515 $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);516 return self::SUCCESS;517 }518519 $output->writeln(json_encode([520 'kind' => 'summary',521 'task_id' => $snapshot->task->id,522 'status' => $snapshot->task->status->value,523 'epic_id' => $snapshot->task->epicId,524 'trace_id' => $snapshot->task->traceId,525 'tail_events' => count($snapshot->tailEvents),526 'next_step' => $snapshot->task->nextStep,527 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);528 $output->writeln(json_encode(['kind' => 'task'] + $snapshot->task->toArray(), JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);529 if ($snapshot->traceHeader !== null) {530 $output->writeln(json_encode(['kind' => 'trace_header'] + $snapshot->traceHeader->toArray(), JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);531 }532 foreach ($snapshot->tailEvents as $event) {533 $output->writeln(json_encode(['kind' => 'trace_event'] + $event->toArray(), JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);534 }535 if ($snapshot->traceWarning !== null) {536 $output->writeln(json_encode([537 'kind' => 'warning',538 'warning' => $snapshot->traceWarning,539 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);540 }541 $output->writeln(json_encode([542 'kind' => 'next',543 'action' => 'proceed',544 'hint' => $snapshot->task->nextStep ?? 'no next_step recorded — inspect trace with ai:trace show --id=' . $snapshot->task->traceId,545 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);546 return self::SUCCESS;547 }548549 private function emitTask(OutputInterface $output, Task $task, string $kind, bool $jsonMode): int550 {551 if ($jsonMode) {552 $output->writeln(json_encode([553 'artifact' => 'semitexa.ai-work.task/v1',554 'task' => $task->toArray(),555 'next_command' => TaskNextCommands::for($task),556 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);557 return self::SUCCESS;558 }559 $output->writeln(json_encode(['kind' => $kind] + $task->toArray(), JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);560 return self::SUCCESS;561 }562563 /**564 * @param array<string, mixed> $payload565 */566 private function safeAppend(string $traceId, string $kind, string $summary, array $payload): void567 {568 if ($traceId === '') {569 return;570 }571 if (!$this->traceStore->exists($traceId)) {572 return;573 }574 try {575 $this->traceStore->append($traceId, $kind, $summary, $payload);576 } catch (\RuntimeException) {577 // Trace hygiene is best-effort; never let it break the primary action.578 }579 }580581 private function requireId(InputInterface $input, OutputInterface $output, bool $jsonMode): ?string582 {583 $id = (string) ($input->getOption('id') ?? '');584 if ($id === '') {585 $this->error($output, '--id is required', $jsonMode);586 return null;587 }588 try {589 WorkId::assertValid($id, 'task id');590 } catch (\InvalidArgumentException $e) {591 $this->error($output, $e->getMessage(), $jsonMode);592 return null;593 }594 return $id;595 }596597 private function normaliseRisk(string $value, OutputInterface $output, bool $jsonMode): ?string598 {599 $lower = strtolower(trim($value));600 if (!in_array($lower, self::ALLOWED_RISK, true)) {601 $this->error(602 $output,603 "invalid risk '{$value}': expected one of " . implode(', ', self::ALLOWED_RISK),604 $jsonMode,605 );606 return null;607 }608 return $lower;609 }610611 /**612 * @return list<string>613 */614 private function contextRefs(InputInterface $input): array615 {616 $raw = $input->getOption('context-ref');617 if (!is_array($raw)) {618 return [];619 }620 $out = [];621 foreach ($raw as $ref) {622 $trimmed = trim((string) $ref);623 if ($trimmed !== '') {624 $out[] = $trimmed;625 }626 }627 return $out;628 }629630 private function optionalString(InputInterface $input, string $name): ?string631 {632 if (!$input->hasOption($name)) {633 return null;634 }635 $value = $input->getOption($name);636 if ($value === null) {637 return null;638 }639 $value = (string) $value;640 return $value === '' ? null : $value;641 }642643 private function truncate(string $value, int $max): string644 {645 // The limit is in Unicode characters; the full note stays in payload.646 return mb_strlen($value, 'UTF-8') > $max ? mb_substr($value, 0, $max - 1, 'UTF-8') . '…' : $value;647 }648649 private function noteError(OutputInterface $output, Task $task, bool $taskSaved, string $reason, bool $jsonMode): int650 {651 $prefix = $taskSaved ? 'Task changes were saved, but the note' : 'The note';652653 return $this->error($output, $prefix . ' could not be saved: ' . $reason, $jsonMode, [654 'task_saved' => $taskSaved,655 'note_saved' => false,656 'task' => $task->toArray(),657 ]);658 }659660 /** @param array<string, mixed> $details */661 private function error(OutputInterface $output, string $message, bool $jsonMode, array $details = []): int662 {663 $record = $jsonMode664 ? ['artifact' => 'semitexa.ai-work.task/v1', 'status' => 'error']665 : ['kind' => 'error'];666 $output->writeln(json_encode($record + ['error' => $message] + $details, JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);667 return self::FAILURE;668 }669}670
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Attribute\InjectAsReadonly;9use Semitexa\Core\Console\BaseCommand;10use Semitexa\Dev\Application\Service\Ai\Trace\TraceAutoAppender;11use Semitexa\Dev\Application\Service\Ai\Trace\TraceEventKind;12use Semitexa\Dev\Application\Service\Ai\Verify\ChangedFile;13use Semitexa\Dev\Application\Service\Ai\Verify\CoverageGap;14use Semitexa\Dev\Application\Service\Ai\Verify\UnselectedChanges;15use Semitexa\Dev\Application\Service\Ai\Verify\DirtyWorkspaceScanner;16use Semitexa\Dev\Application\Service\Ai\Verify\ChangedFileClassifier;17use Semitexa\Dev\Application\Service\Ai\Verify\VerificationExecutor;18use Semitexa\Dev\Application\Service\Ai\Verify\VerificationPlan;19use Semitexa\Dev\Application\Service\Ai\Verify\VerificationPlanner;20use Semitexa\Dev\Application\Service\Ai\Verify\VerificationResult;21use Semitexa\Dev\Application\Service\Ai\Verify\VerificationTarget;22use Semitexa\Dev\Application\Service\Ai\Verify\VerifyReportSerializer;23use Semitexa\Dev\Application\Service\Ai\Verify\Impact\ImpactProbe;24use Semitexa\Dev\Application\Service\Ai\Verify\Impact\ImpactReport;25use Semitexa\Orm\Application\Service\Connection\ConnectionRegistry;26use Symfony\Component\Console\Input\InputInterface;27use Symfony\Component\Console\Input\InputOption;28use Symfony\Component\Console\Output\OutputInterface;29use Symfony\Component\Console\Output\BufferedOutput;3031/**32 * Agent-facing verifier: takes a diff or file list, plans the precise lint /33 * syntax / phpunit / module-structure subset to run, executes it, and emits34 * an NDJSON envelope.35 *36 * bin/semitexa ai:verify --files=src/modules/Foo/src/Application/Handler/PayloadHandler/Bar.php37 * bin/semitexa ai:verify --git-ref=HEAD~1 --scope=standard38 * git diff --name-only HEAD~1 | bin/semitexa ai:verify --diff-stdin39 *40 * Output (NDJSON, one JSON object per line):41 * {"kind":"summary", recipe-style header}42 * {"kind":"expansion", note-of-why-scope-bumped} (zero or more)43 * {"kind":"target", pre-execution per-target metadata}44 * {"kind":"result", post-execution per-target outcome}45 * {"kind":"violation", per-target structured diagnostic} (zero or more)46 * {"kind":"verdict", pass/fail rollup}47 *48 * `--json` mode flips the output into a single envelope (artifact:49 * `semitexa-dev.verify-report/v1`) for callers that prefer one blob over a50 * stream — both modes carry exactly the same data, including the51 * `violations` aggregate from the `module_structure` check (rules and52 * remediation guidance live in53 * `packages/semitexa-docs/docs/MODULE_STRUCTURE.md`).54 */55#[AsCommand(name: 'ai:verify', description: 'Run the precise lint+test+module-structure subset for a diff/file list (NDJSON, agent-facing)')]56final class AiVerifyCommand extends BaseCommand57{58 #[InjectAsReadonly]59 protected TraceAutoAppender $traceAppender;6061 // Optional (nullable) so ai:verify still runs where no DB is bound62 // (e.g. the command's own test harness); only --impact needs it.63 #[InjectAsReadonly]64 protected ?ConnectionRegistry $connections = null;6566 // Optional too: only the page check needs the routes, and a harness may have none.67 #[InjectAsReadonly]68 protected ?\Semitexa\Core\Discovery\AttributeDiscovery $attributeDiscovery = null;6970 #[InjectAsReadonly]71 protected ?\Semitexa\Core\ModuleRegistry $moduleRegistry = null;7273 public function __construct()74 {75 parent::__construct('ai:verify');76 }7778 protected function configure(): void79 {80 $this81 ->addOption('files', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Repo-relative path(s) to verify. Repeat the flag and/or comma-separate; both forms combine.')82 ->addOption('git-ref', null, InputOption::VALUE_REQUIRED, 'Compare working tree against this git ref (e.g. HEAD~1, origin/main)')83 ->addOption('diff-stdin', null, InputOption::VALUE_NONE, 'Read newline-separated paths from stdin (output of `git diff --name-only`)')84 ->addOption('dirty', null, InputOption::VALUE_NONE, 'Every uncommitted change this workspace can see: each packages/semitexa-* repository, plus the project root when it is one. Says which roots it could not ask. The default when no scope is given.')85 ->addOption('all', null, InputOption::VALUE_NONE, 'Scan every Semitexa package under packages/semitexa-* and every local module under src/modules/* (deterministic repo-wide module-structure check)')86 ->addOption('scope', null, InputOption::VALUE_REQUIRED, 'Verification scope: minimal, standard, broad', VerificationPlan::SCOPE_STANDARD)87 ->addOption('trace', null, InputOption::VALUE_REQUIRED, 'Append a verify_result event to this ai:trace id (falls back to $SEMITEXA_AI_TRACE_ID)')88 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit a single JSON envelope instead of NDJSON')89 ->addOption('impact', null, InputOption::VALUE_NONE, 'Annotate each changed file with a project-graph blast-radius band (low|medium|high). Read-only: does not change which checks run.')90 ->addOption('list-e2e', null, InputOption::VALUE_NONE, 'Print the changed Playwright specs this plan would check, one per line, and run nothing (bin/semitexa runs them on the host first).')91 ->addOption('list-pages', null, InputOption::VALUE_NONE, 'Print the pages this plan would open in light and dark, one path per line, and run nothing (bin/semitexa opens them on the host first).');92 }9394 protected function execute(InputInterface $input, OutputInterface $output): int95 {96 $jsonMode = (bool) $input->getOption('json');97 $scope = (string) $input->getOption('scope');9899 // No scope named: the working tree, as --dirty (bench-verify-bare-errors: a bare run was an error).100 if ((array) $input->getOption('files') === [] && !$input->getOption('git-ref') && !(bool) $input->getOption('diff-stdin') && !(bool) $input->getOption('all')) {101 $input->setOption('dirty', true);102 }103 try {104 $paths = $this->collectPaths($input);105 } catch (\RuntimeException $e) {106 $this->emitError($output, $e->getMessage(), $jsonMode);107 return self::FAILURE;108 }109110 if ($paths === [] && !(bool) $input->getOption('list-e2e') && !(bool) $input->getOption('list-pages')) {111 // `--dirty` finding nothing is an ANSWER, not a misuse: the tree is112 // clean. Telling that caller to "pass --dirty" is advice they just113 // took, and failing the run would make `ai:verify --dirty` red on114 // every clean checkout. It is not a pass either — nothing was115 // verified — so it gets a verdict of its own, with the scan's reach116 // attached so the reader can see what was asked.117 if ((bool) $input->getOption('dirty')) {118 $scan = (new DirtyWorkspaceScanner($this->getProjectRoot()))->report();119120 // In the SAME shape the mode promises. Default mode is NDJSON121 // whose records are dispatched by `kind`, so an envelope122 // without one is a record such a consumer cannot place — and123 // every ordinary run ends with a `verdict` record.124 // An empty plan, so this answer reaches `--trace` the way every125 // other one does. Returning before maybeAppendToTrace() left a126 // traced workflow with no `verify_result` and no trace status127 // for the run at all — a gap in an audit trail reads as a step128 // that was never taken. Raised in review of dev#84.129 //130 // AND the report keeps its CONTRACT. This branch used to write131 // its own envelope, so a clean run was the one answer missing132 // `completed`, `counts` and `restart` — a consumer reading the133 // stable v1 schema had to special-case it, or fail on the134 // absent keys. The fields come off VerifyReportSerializer, the135 // same source the other two paths use, so there is one136 // definition of what an empty result looks like. Raised in137 // review of dev#84 by both reviewers.138 $emptyPlan = new VerificationPlan($scope, $scope, [], []);139 $report = new VerifyReportSerializer();140 // A receipt for the clean run too: "the latest receipt" must be this run (review of dev#130).141 $envelope = (new \Semitexa\Dev\Application\Service\Ai\Verify\Receipt\VerifyReceipts($this->getProjectRoot()))->attach([142 'artifact' => 'semitexa-dev.verify-report/v1',143 'generated_at' => date('c'),144 'verdict' => 'nothing_to_verify',145 'completed' => $report->completed([]),146 'counts' => $report->countByStatus([]),147 'changed_files' => [],148 'dirty_scan' => $scan,149 'restart' => $report->restartAdvice([]),150 ], []);151152 if ($jsonMode) {153 $traceOutput = new BufferedOutput();154 $this->maybeAppendToTrace($input, $traceOutput, $emptyPlan, [], 'nothing_to_verify', $envelope);155 $envelope['trace'] = array_map(156 static fn(string $line): mixed => json_decode($line, true, 512, JSON_THROW_ON_ERROR),157 array_values(array_filter(explode("\n", trim($traceOutput->fetch())))),158 );159 $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);160161 return self::SUCCESS;162 }163164 // The same two records a run WITH changes emits, so a consumer165 // reads the scan out of one place regardless of the answer.166 $output->writeln(json_encode(['kind' => 'dirty_scan'] + $scan, JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);167 $output->writeln((string) json_encode(168 ['kind' => 'restart'] + $report->restartAdvice([]),169 JSON_UNESCAPED_SLASHES,170 ), OutputInterface::OUTPUT_RAW);171 $output->writeln(json_encode([172 'kind' => 'verdict',173 'verdict' => 'nothing_to_verify',174 // FALSE, and deliberately so. Nothing ran, and `completed`175 // is what a consumer reads to decide whether the required176 // checks happened — saying true here offered an empty run177 // as verification evidence, and contradicted178 // VerifyReportSerializer::completed([]) besides. Raised in179 // review of dev#84.180 'completed' => $report->completed([]),181 'counts' => $report->countByStatus([]),182 'dirty_scan' => $scan,183 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);184185 $this->maybeAppendToTrace($input, $output, $emptyPlan, [], 'nothing_to_verify', $envelope);186187 return self::SUCCESS;188 }189190 $this->emitError($output, 'no changed files supplied — pass --files, --git-ref, --diff-stdin or --dirty', $jsonMode);191 return self::FAILURE;192 }193194 $projectRoot = $this->getProjectRoot();195 $classifier = new ChangedFileClassifier();196 $changed = array_map(197 static fn(array $entry): ChangedFile => $classifier->classify(198 $entry['path'],199 $entry['status'],200 ($entry['originalPath'] ?? '') !== '' ? $entry['originalPath'] : null,201 ),202 $paths,203 );204 /** @var list<ChangedFile> $changed */205206 $planner = new VerificationPlanner($projectRoot, $classifier, pageRoutes: \Semitexa\Dev\Application\Service\Ai\Verify\PageRoutes::of($this->attributeDiscovery, $this->moduleRegistry));207 $plan = $planner->plan($changed, $scope, (bool) $input->getOption('all'), $baseRef = is_string($input->getOption('git-ref')) && $input->getOption('git-ref') !== '' ? $input->getOption('git-ref') : null);208 if ((bool) $input->getOption('list-pages')) {209 $output->writeln(implode("\n", array_map(static fn (VerificationTarget $t): string => (string) ($t->commandInput['path'] ?? ''), array_values(array_filter($plan->targets, static fn (VerificationTarget $t): bool => $t->type === VerificationTarget::TYPE_PAGE)))), OutputInterface::OUTPUT_RAW);210 return self::SUCCESS;211 }212 if ((bool) $input->getOption('list-e2e')) {213 $output->writeln(implode("\n", array_map(static fn (VerificationTarget $t): string => (string) $t->filePath, array_values(array_filter($plan->targets, static fn (VerificationTarget $t): bool => $t->type === VerificationTarget::TYPE_E2E)))), OutputInterface::OUTPUT_RAW);214 return self::SUCCESS;215 }216217 $app = $this->getApplication();218 if ($app === null) {219 $this->emitError($output, 'Application not available — cannot dispatch lint commands', $jsonMode);220 return self::FAILURE;221 }222 $executor = new VerificationExecutor($app, $projectRoot, $recorder = new \Semitexa\Dev\Application\Service\Ai\Verify\Receipt\RecordingProcessRunner(new \Semitexa\Dev\Application\Service\Ai\Verify\ShellProcessRunner()), baseRef: $baseRef);223 // What the targets are about to check: a file edited before the receipt is written must not ride on this verdict.224 $treeBefore = (new \Semitexa\Dev\Application\Service\Ai\Verify\Receipt\VerifyReceipts($projectRoot))->fingerprintOf(array_map(static fn (ChangedFile $f): string => $f->path, $plan->changedFiles));225 $results = $executor->execute($plan);226227 $verdict = CoverageGap::of($plan, $results, $this->getProjectRoot())->adjust($this->verdict($results));228 $exit = in_array($verdict, [VerificationResult::STATUS_PASS, VerificationResult::STATUS_SKIPPED], true) ? self::SUCCESS : self::FAILURE;229230 $impact = null;231 if ((bool) $input->getOption('impact')) {232 $impact = $this->probeImpact($plan);233 }234235 $unselected = UnselectedChanges::ofRun($input, $projectRoot, $plan);236 $envelope = (new \Semitexa\Dev\Application\Service\Ai\Verify\Receipt\VerifyReceipts($projectRoot))->attach($this->buildEnvelope($plan, $results, $verdict, $impact, $unselected), $recorder->calls(), $treeBefore, is_string($input->getOption('trace')) ? $input->getOption('trace') : null); // ai:verify:receipt checks it237 $dirtyScan = null;238 if ((bool) $input->getOption('dirty')) {239 // The reach of the answer, beside the answer. A scan that could not240 // ask half the tree must not read as "half the tree is clean".241 $dirtyScan = (new DirtyWorkspaceScanner($this->getProjectRoot()))->report();242 $envelope['dirty_scan'] = $dirtyScan;243 }244 if ($jsonMode) {245 $traceOutput = new BufferedOutput();246 $this->maybeAppendToTrace($input, $traceOutput, $plan, $results, $verdict, $envelope);247 $envelope['trace'] = array_map(248 static fn(string $line): mixed => json_decode($line, true, 512, JSON_THROW_ON_ERROR),249 array_values(array_filter(explode("\n", trim($traceOutput->fetch())))),250 );251 $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);252 } else {253 $this->emitNdjson($output, $plan, $results, $verdict, $impact, $dirtyScan, $unselected);254 $this->maybeAppendToTrace($input, $output, $plan, $results, $verdict, $envelope);255 }256257 return $exit;258 }259260 /**261 * @param list<VerificationResult> $results262 * @param array<string, mixed> $envelope263 */264 private function maybeAppendToTrace(265 InputInterface $input,266 OutputInterface $output,267 VerificationPlan $plan,268 array $results,269 string $verdict,270 array $envelope,271 ): void {272 $counts = (new VerifyReportSerializer())->countByStatus($results);273 (new \Semitexa\Dev\Application\Service\Ai\Verify\RuleStats\RuleFireLedger($this->getProjectRoot()))->record($envelope); // every run, traced or not: ai:verify:rules274 $summary = sprintf(275 'verify %s — scope=%s targets=%d pass=%d fail=%d skipped=%d incomplete=%d',276 $verdict,277 $plan->effectiveScope,278 count($plan->targets),279 $counts['pass'] ?? 0,280 $counts['fail'] ?? 0,281 $counts['skipped'] ?? 0,282 $counts['incomplete'] ?? 0,283 );284 $this->traceAppender->appendIfActive($input, $output, TraceEventKind::VERIFY_RESULT, $summary, $envelope);285 }286287 /**288 * @return list<array{path: string, status: string, originalPath?: string}>289 */290 private function collectPaths(InputInterface $input): array291 {292 $sources = [];293 // --files is VALUE_IS_ARRAY: repeated flags arrive as a list, and each294 // element may itself be a comma-separated batch. Both forms combine, so295 // no path is ever silently dropped (the repeated-flag form previously296 // kept only the last value and returned a false-green partial verify).297 foreach ((array) $input->getOption('files') as $files) {298 foreach (explode(',', (string) $files) as $p) {299 $p = trim($p);300 if ($p !== '') {301 $sources[] = ['path' => $p, 'status' => ChangedFile::STATUS_MODIFIED];302 }303 }304 }305 if (($ref = $input->getOption('git-ref')) !== null && $ref !== '') {306 foreach ($this->gitDiffNameStatus((string) $ref) as $entry) {307 $sources[] = $entry;308 }309 }310 if ((bool) $input->getOption('diff-stdin')) {311 foreach ($this->readStdinPaths() as $entry) {312 $sources[] = $entry;313 }314 }315 if ((bool) $input->getOption('dirty')) {316 foreach ((new DirtyWorkspaceScanner($this->getProjectRoot()))->changedFiles() as $entry) {317 $sources[] = $entry;318 }319 }320 if ((bool) $input->getOption('all')) {321 foreach ($this->repoWidePaths() as $entry) {322 $sources[] = $entry;323 }324 }325 return $this->dedupe($sources);326 }327328 /**329 * Repo-wide module / package roots: every `packages/semitexa-X` whose330 * package root contains `composer.json`, plus every `src/modules/X`.331 * Used by `--all` for deterministic AI-facing repo-wide structure332 * verification. Output is sorted lexicographically so NDJSON is stable.333 *334 * @return list<array{path: string, status: string}>335 */336 private function repoWidePaths(): array337 {338 $root = rtrim($this->getProjectRoot(), '/');339 $paths = [];340341 $packagesDir = $root . '/packages';342 if (is_dir($packagesDir)) {343 foreach (glob($packagesDir . '/semitexa-*', GLOB_ONLYDIR) ?: [] as $abs) {344 if (!is_file($abs . '/composer.json')) {345 continue;346 }347 $rel = ltrim(substr($abs, strlen($root)), '/');348 $paths[] = $rel;349 }350 }351352 $modulesDir = $root . '/src/modules';353 if (is_dir($modulesDir)) {354 foreach (glob($modulesDir . '/*', GLOB_ONLYDIR) ?: [] as $abs) {355 $rel = ltrim(substr($abs, strlen($root)), '/');356 $paths[] = $rel;357 }358 }359360 sort($paths);361 return array_map(362 static fn(string $p) => ['path' => $p, 'status' => ChangedFile::STATUS_MODIFIED],363 $paths,364 );365 }366367 /**368 * @return list<array{path: string, status: string, originalPath?: string}>369 */370 private function gitDiffNameStatus(string $ref): array371 {372 // Every repository of the project: the workspace root is none of them.373 return (new \Semitexa\Dev\Application\Service\Ai\Verify\GitRefChanges($this->getProjectRoot()))->changes($ref);374 }375376 /**377 * @return list<array{path: string, status: string, originalPath?: string}>378 */379 private function readStdinPaths(): array380 {381 $raw = (string) stream_get_contents(STDIN);382 $lines = preg_split('/\R/', trim($raw)) ?: [];383 return $this->parseNameStatus($lines);384 }385386 /**387 * @param list<string> $lines git --name-status output OR plain `git diff --name-only` lines388 * @return list<array{path: string, status: string, originalPath?: string}>389 */390 private function parseNameStatus(array $lines): array391 {392 $out = [];393 foreach ($lines as $line) {394 $line = trim($line);395 if ($line === '') {396 continue;397 }398 $parts = preg_split('/\s+/', $line, 3) ?: [];399 if (count($parts) >= 2 && preg_match('/^[AMDR][0-9]*$/', $parts[0])) {400 $status = match ($parts[0][0]) {401 'A' => ChangedFile::STATUS_ADDED,402 'M' => ChangedFile::STATUS_MODIFIED,403 'D' => ChangedFile::STATUS_DELETED,404 'R' => ChangedFile::STATUS_RENAMED,405 default => ChangedFile::STATUS_MODIFIED,406 };407 // For R entries, `git diff --name-status` emits OLD<TAB>NEW; take the408 // new path AND carry the old path so the broken-FQCN guard can query409 // the renamed symbol's previous FQCN too.410 $path = $parts[count($parts) - 1];411 $entry = ['path' => $path, 'status' => $status];412 if ($status === ChangedFile::STATUS_RENAMED && count($parts) >= 3) {413 $entry['originalPath'] = $parts[1];414 }415 $out[] = $entry;416 continue;417 }418 $out[] = ['path' => $line, 'status' => ChangedFile::STATUS_MODIFIED];419 }420 return $out;421 }422423 /**424 * @param list<array{path: string, status: string, originalPath?: string}> $entries425 * @return list<array{path: string, status: string, originalPath?: string}>426 */427 private function dedupe(array $entries): array428 {429 $seen = [];430 $out = [];431 foreach ($entries as $entry) {432 $key = $entry['path'];433 if (isset($seen[$key])) {434 // First-wins, EXCEPT where the first entry knows less. Two435 // ways that happens, and both produced a false green.436 //437 // A RENAME arriving second: `--files=<renamed destination>438 // --dirty` supplies the path by hand as a plain modification439 // and the scanner then finds the same path as a rename.440 // Dropping the second entry discarded `originalPath`, so441 // ContractMoveResolver never expanded consumers of the old442 // contract.443 //444 // A RECREATED file arriving second: staged for deletion and445 // then written again at the same path, git reports `D path`446 // followed by `?? path`. Keeping only the deletion made the447 // planner skip a file that is sitting right there, so a syntax448 // error in its new contents verified clean. If ANY source says449 // the path exists, it exists — a deletion never wins over a450 // record of a live file. Both raised in review of dev#84.451 $at = $seen[$key];452 $original = $entry['originalPath'] ?? '';453 $existingOriginal = $out[$at]['originalPath'] ?? '';454 $learnsOrigin = $original !== '' && $existingOriginal === '';455 $wasDeleted = $out[$at]['status'] === ChangedFile::STATUS_DELETED;456 $isAlive = $entry['status'] !== ChangedFile::STATUS_DELETED;457458 if ($learnsOrigin || ($wasDeleted && $isAlive)) {459 $merged = ['path' => $key, 'status' => $entry['status']];460 $keptOriginal = $original !== '' ? $original : $existingOriginal;461 if ($keptOriginal !== '') {462 $merged['originalPath'] = $keptOriginal;463 }464 $out[$at] = $merged;465 }466 continue;467 }468 $seen[$key] = count($out);469 $out[] = $entry;470 }471 return $out;472 }473474 /**475 * @param list<VerificationResult> $results476 */477 private function verdict(array $results): string478 {479 $report = new VerifyReportSerializer();480 if ($results === []) {481 return VerificationResult::STATUS_INCOMPLETE;482 }483 $hasFail = false;484 $allSkipped = true;485 foreach ($results as $r) {486 if ($r->status === VerificationResult::STATUS_FAIL) {487 $hasFail = true;488 }489 if ($r->status !== VerificationResult::STATUS_SKIPPED) {490 $allSkipped = false;491 }492 }493 if ($hasFail) {494 return VerificationResult::STATUS_FAIL;495 }496 if (!$report->completed($results)) {497 return VerificationResult::STATUS_INCOMPLETE;498 }499 return $allSkipped ? VerificationResult::STATUS_SKIPPED : VerificationResult::STATUS_PASS;500 }501502 /**503 * Read-only blast-radius probe. Never throws into the verify flow — a504 * graph problem must degrade to an "unknown" band, not fail verification.505 */506 private function probeImpact(VerificationPlan $plan): ImpactReport507 {508 $paths = [];509 foreach ($plan->changedFiles as $file) {510 if (str_ends_with($file->path, '.php')) {511 $paths[] = $file->path;512 }513 }514 if ($paths === []) {515 return ImpactReport::empty();516 }517518 if ($this->connections === null) {519 return ImpactReport::stale($paths, 'impact unavailable: no database connection bound in this context');520 }521522 try {523 return (new ImpactProbe($this->connections))->probe(array_values($paths));524 } catch (\Throwable $e) {525 return ImpactReport::stale($paths, 'impact probe failed: ' . $e->getMessage());526 }527 }528529 /**530 * @return array<string, string> path → band, for merging into changed_files531 */532 private function bandsByPath(?ImpactReport $impact): array533 {534 if ($impact === null) {535 return [];536 }537 $bands = [];538 foreach ($impact->files as $file) {539 $bands[$file->path] = $file->band;540 }541 return $bands;542 }543544 /**545 * @param list<VerificationResult> $results546 * @param list<array{path: string, status: string}> $unselected547 */548 private function buildEnvelope(VerificationPlan $plan, array $results, string $verdict, ?ImpactReport $impact = null, array $unselected = []): array549 {550 $report = new VerifyReportSerializer();551 $violations = $this->collectViolations($results);552 $bands = $this->bandsByPath($impact);553 return [554 'artifact' => 'semitexa-dev.verify-report/v1',555 'generated_at' => date('c'),556 'requested_scope' => $plan->scope,557 'effective_scope' => $plan->effectiveScope,558 'expansions' => $plan->expansions,559 'changed_files' => array_map(560 static function (ChangedFile $f) use ($bands): array {561 $entry = ['path' => $f->path, 'kind' => $f->kind, 'status' => $f->status];562 if (isset($bands[$f->path])) {563 $entry['impact'] = $bands[$f->path];564 }565 return $entry;566 },567 $plan->changedFiles,568 ),569 'targets' => array_map(fn($t) => $report->serializeTarget($t), $plan->targets),570 'results' => array_map(fn($r) => $report->serializeResult($r), $results),571 'violations' => $violations,572 'verdict' => $verdict,573 'completed' => $report->completed($results),574 'counts' => $report->countByStatus($results),575 'impact' => $impact?->toSummary(),576 'next_command' => $this->buildNextCommands($verdict, $results),577 'restart' => $report->restartAdvice($plan->changedFiles, $plan->opensPages()),578 ] + UnselectedChanges::annotate(CoverageGap::of($plan, $results, $this->getProjectRoot())->toArray(), $unselected);579 }580581 /**582 * @param list<VerificationResult> $results583 * @return list<array<string, mixed>>584 */585 private function collectViolations(array $results): array586 {587 $out = [];588 foreach ($results as $r) {589 foreach ($r->diagnostics as $diag) {590 $out[] = $diag;591 }592 }593 return $out;594 }595596 /**597 * @param list<VerificationResult> $results598 * @return list<array{cmd: string, args: list<string>, why: string}>599 */600 private function buildNextCommands(string $verdict, array $results): array601 {602 if ($verdict === VerificationResult::STATUS_PASS) {603 return [604 ['cmd' => 'ai:work', 'args' => ['update', '--id=<task-id>', '--status=done', '--json'], 'why' => 'close the active task — verify passed'],605 ['cmd' => 'ai:orient', 'args' => ['--json'], 'why' => 'pick up the next task'],606 ];607 }608 if (in_array($verdict, [VerificationResult::STATUS_FAIL, VerificationResult::STATUS_INCOMPLETE], true)) {609 $failingFile = null;610 $hasStructureFailure = false;611 $hasDiFailure = false;612 foreach ($results as $r) {613 if (in_array($r->status, [VerificationResult::STATUS_FAIL, VerificationResult::STATUS_INCOMPLETE], true)) {614 if ($r->target->type === VerificationTarget::TYPE_MODULE_STRUCTURE) {615 $hasStructureFailure = true;616 }617 if ($r->target->type === VerificationTarget::TYPE_PHPSTAN_DI) {618 $hasDiFailure = true;619 }620 if ($failingFile === null && $r->target->filePath !== null) {621 $failingFile = $r->target->filePath;622 }623 }624 }625 $out = [626 ['cmd' => 'ai:work', 'args' => ['update', '--id=<task-id>', '--status=blocked', '--note="verify failed"'], 'why' => 'record the block before iterating (§9.3)'],627 ];628 if ($hasStructureFailure) {629 $out[] = [630 'cmd' => 'cat',631 'args' => ['packages/semitexa-docs/docs/MODULE_STRUCTURE.md'],632 'why' => 'module structure violations — re-read the rule table before moving files',633 ];634 }635 if ($hasDiFailure) {636 $out[] = [637 'cmd' => 'cat',638 'args' => ['packages/semitexa-docs/docs/AI_BEST_PRACTICES.md'],639 'why' => 'DI violations — Semitexa is attribute-only DI; re-read before reshaping a class',640 ];641 }642 if ($failingFile !== null) {643 $out[] = ['cmd' => 'ai:ask', 'args' => ['path', '--path=' . $failingFile, '--json'], 'why' => 'inspect the failing file before querying symbol impact'];644 }645 $out[] = ['cmd' => 'logs:app', 'args' => ['--grep=error', '--lines=200', '--level=ERROR', '--json'], 'why' => 'runtime errors that might explain the failure'];646 return $out;647 }648 return [649 ['cmd' => 'ai:verify', 'args' => ['--files=<paths>', '--scope=standard', '--json'], 'why' => 'no actionable verification ran — select files and try standard scope'],650 ];651 }652653 /**654 * @param list<VerificationResult> $results655 * @param array{scanned: list<string>, unscannable: list<string>}|null $dirtyScan656 * @param list<array{path: string, status: string}> $unselected657 */658 private function emitNdjson(659 OutputInterface $output,660 VerificationPlan $plan,661 array $results,662 string $verdict,663 ?ImpactReport $impact = null,664 ?array $dirtyScan = null,665 array $unselected = [],666 ): void {667 $report = new VerifyReportSerializer();668 // NDJSON is the DEFAULT mode, and the reach of a `--dirty` scan existed669 // only in the `--json` envelope -- so a run that could not ask half the670 // tree said so exclusively to the readers who had not asked for this671 // mode. A record of its own, dispatchable by `kind` like every other.672 // Raised in review of dev#84.673 if ($dirtyScan !== null) {674 $output->writeln(json_encode(675 ['kind' => 'dirty_scan'] + $dirtyScan,676 JSON_UNESCAPED_SLASHES,677 ), OutputInterface::OUTPUT_RAW);678 }679680 $output->writeln(json_encode([681 'kind' => 'summary',682 'requested_scope' => $plan->scope,683 'effective_scope' => $plan->effectiveScope,684 'changed_files' => count($plan->changedFiles),685 'targets' => count($plan->targets),686 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);687688 if ($impact !== null) {689 $output->writeln(json_encode([690 'kind' => 'impact',691 'impact' => $impact->toSummary(),692 'files' => array_map(static fn ($f) => $f->toArray(), $impact->files),693 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);694 }695696 foreach ($plan->expansions as $note) {697 $output->writeln(json_encode([698 'kind' => 'expansion',699 'note' => $note,700 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);701 }702703 foreach ($plan->targets as $target) {704 $output->writeln(json_encode([705 'kind' => 'target',706 'target' => $report->serializeTarget($target),707 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);708 }709710 foreach ($results as $result) {711 $output->writeln(json_encode([712 'kind' => 'result',713 'result' => $report->serializeResult($result),714 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);715 foreach ($result->diagnostics as $diagnostic) {716 $output->writeln(json_encode([717 'kind' => 'violation',718 'target_id' => $result->target->id,719 ...$diagnostic,720 ], JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);721 }722 }723724 // Before the verdict, so it is read rather than scrolled past.725 //726 // The advice existed only in the `--json` envelope, and NDJSON is the727 // default — so the habit this was written to correct (wrapping every728 // verify in a 16s server:restart) never met the sentence that corrects729 // it. An unstated assumption is not fixed by being wrong somewhere the730 // reader does not look.731 $output->writeln((string) json_encode(732 ['kind' => 'restart'] + $report->restartAdvice($plan->changedFiles, $plan->opensPages()),733 JSON_UNESCAPED_SLASHES,734 ), OutputInterface::OUTPUT_RAW);735736 $verdictLine = [737 'kind' => 'verdict',738 'verdict' => $verdict,739 'completed' => $report->completed($results),740 'counts' => $report->countByStatus($results),741 ] + UnselectedChanges::annotate(CoverageGap::of($plan, $results, $this->getProjectRoot())->toArray(), $unselected);742 if ($impact !== null) {743 $verdictLine['impact'] = $impact->toSummary();744 }745 $output->writeln(json_encode($verdictLine, JSON_UNESCAPED_SLASHES), OutputInterface::OUTPUT_RAW);746 }747748 private function emitError(OutputInterface $output, string $message, bool $jsonMode): void749 {750 if ($jsonMode) {751 $output->writeln((string) json_encode([752 'artifact' => 'semitexa-dev.verify-report/v1',753 'generated_at' => date('c'), 'verdict' => 'fail',754 'error' => $message, // may carry a git ref or path that is not UTF-8755 ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE), OutputInterface::OUTPUT_RAW);756 return;757 }758 $output->writeln((string) json_encode(['kind' => 'error', 'error' => $message], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE), OutputInterface::OUTPUT_RAW);759 }760}761
Operating Rules
What makes the workflow trustworthy
The tools stay useful when each one answers a narrow question and reports its limits in a machine-readable form.
Use ai:ask and Project Graph before broad source searches when the question is structural.
Use ai:observe before forming a runtime hypothesis; the process journal records what actually ran.
Keep long work in epic, work, and trace artifacts with a concrete next step for the next session.
Run ai:verify after a coherent edit, then restart workers only when exercising the live server.
How it works
The ai:* command family connects repository state, recipes, prior art, Project Graph, the runtime Observatory, epic/work/trace memory, and diff-aware checks through stable JSON contracts.
Why it matters
People and agents make safer changes when the project can explain its current shape, show what actually ran, preserve decisions across sessions, and verify the edited surface precisely.
Key concepts
- ai:orient
- Combines Git state, active work, recent traces, last verification, and the next useful command.
- ai:observe
- Reads live and recorded process lifecycles, full waterfalls, executed source, and sandbox replay results.
- ai:verify
- Selects the relevant checks from the actual changed files and reports one structured verdict.