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.
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.
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.
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.
© Harold Abelson: "Programs must be written for people to read, and only incidentally for machines to execute."
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\AgentRegistry;11use Semitexa\Core\Support\ProjectRoot;12use Semitexa\Core\Attribute\AsCommand;13use Semitexa\Core\Attribute\InjectAsReadonly;14use Semitexa\Core\Console\BaseCommand;15use Semitexa\Dev\Application\Service\Quality\QualityAdvisor;16use Semitexa\Dev\Application\Service\Ai\Trace\TraceEventKind;17use Semitexa\Dev\Application\Service\Ai\Trace\TraceHeader;18use Semitexa\Dev\Application\Service\Ai\Trace\TraceStore;19use Semitexa\Dev\Application\Service\Ai\Work\Epic;20use Semitexa\Dev\Application\Service\Ai\Work\EpicStore;21use Semitexa\Dev\Application\Service\Ai\Work\Task;22use Semitexa\Dev\Application\Service\Ai\Work\TaskStatus;23use Semitexa\Dev\Application\Service\Ai\Work\TaskStore;24use Symfony\Component\Console\Input\InputInterface;25use Symfony\Component\Console\Input\InputOption;26use Symfony\Component\Console\Output\OutputInterface;27use Symfony\Component\Console\Style\SymfonyStyle;2829/**30 * Single-call session dashboard for an agent that just opened the repo.31 *32 * Fuses: git (branch + last commit + dirty flag) + active epic + in-progress33 * tasks + recent traces + last verify result + a next-step suggestion.34 *35 * Designed as the **first command** a cold-start agent runs. One tool call36 * should replace 5–6 probes (ai:ask project, ai:epic list, ai:work list,37 * ai:trace list, git status, git log).38 *39 * Cheap by construction — reads file-backed stores and runs two short shell40 * commands for git. Does NOT execute linters, verification, or graph rebuild.41 */42#[AsCommand(43 name: 'ai:orient',44 description: 'Session dashboard: git + active epic + in-progress tasks + recent traces + next step. Run this first on a cold start.',45)]46final class AiOrientCommand extends BaseCommand47{48 #[InjectAsReadonly]49 protected EpicStore $epicStore;5051 #[InjectAsReadonly]52 protected TaskStore $taskStore;5354 #[InjectAsReadonly]55 protected TraceStore $traceStore;5657 public function __construct()58 {59 parent::__construct('ai:orient');60 }6162 protected function configure(): void63 {64 $this65 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit the full JSON envelope (default for non-interactive callers)')66 ->addOption('human', null, InputOption::VALUE_NONE, 'Force human-readable output even in non-interactive mode')67 ->addOption('traces', null, InputOption::VALUE_REQUIRED, 'How many recent traces to include', '5');68 }6970 protected function execute(InputInterface $input, OutputInterface $output): int71 {72 $traceLimit = max(1, min(50, (int) $input->getOption('traces')));7374 $git = $this->collectGit();75 $epics = $this->collectEpics();76 $inProgress = $this->collectTasksByStatus(TaskStatus::IN_PROGRESS);77 $blocked = $this->collectTasksByStatus(TaskStatus::BLOCKED);78 $activeEpicId = $this->deriveActiveEpicId($inProgress, $blocked, $epics);79 $recentTraces = $this->collectRecentTraces($traceLimit);80 $lastVerify = $this->findLastVerify($recentTraces);81 try {82 $qualityNext = (new QualityAdvisor(ProjectRoot::get()))->targets(3);83 $qualityError = null;84 } catch (\RuntimeException $e) {85 // A broken ledger is said, not shown as "nothing to improve".86 [$qualityNext, $qualityError] = [[], $e->getMessage()];87 }88 $hints = $this->suggestNext($git, $activeEpicId, $inProgress, $blocked, $epics);8990 $workingNow = $this->workingNow();91 if ($workingNow['you'] === null) {92 // Joining is how the others see you; it is first because an agent93 // that skips it is the one the others have to guess about.94 array_unshift($hints['commands'], [95 'cmd' => 'ai:agent',96 'args' => ['join', '--name=<claude|codex|…>', '--intent="<what you are about to do>"', '--repo=<repo you will edit>', '--json'],97 'why' => 'other agents see who you are and what you touch; then export SEMITEXA_AGENT_SESSION',98 ]);99 }100101 $envelope = [102 'artifact' => 'semitexa.ai-orient/v1',103 'working_now' => $workingNow,104 'generated_at' => gmdate('c'),105 'cwd' => getcwd() ?: '',106 'git' => $git,107 'backlog' => [108 'total_epics' => count($epics),109 'active_epic_id' => $activeEpicId,110 'active_epic' => $activeEpicId !== null ? $this->epicBrief($epics, $activeEpicId) : null,111 // held_by: the live agent working it, or null — an in_progress112 // task nobody live holds is one somebody walked away from.113 'in_progress_tasks' => array_map(fn(Task $t) => $this->taskBrief($t) + ['held_by' => $this->holderOf($t->id, $workingNow)], $inProgress),114 'in_progress_count' => count($inProgress),115 'blocked_tasks' => array_map(fn(Task $t) => $this->taskBrief($t), $blocked),116 'blocked_count' => count($blocked),117 ],118 'recent_traces' => $recentTraces,119 'last_verify' => $lastVerify,120 // The improvement loop's entry point: what the quality ledger says to121 // make better next. Read from the recorded baseline, measured nothing.122 'quality_next' => $qualityNext,123 'quality_error' => $qualityError,124 'suggest_next' => $hints['summary'],125 'next_command' => $hints['commands'],126 ];127128 $forceJson = (bool) $input->getOption('json');129 $forceHuman = (bool) $input->getOption('human');130 $useHuman = $forceHuman || (!$forceJson && $input->isInteractive());131132 if ($useHuman) {133 $this->renderHuman(new SymfonyStyle($input, $output), $envelope);134 } else {135 $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '{}');136 }137138 return self::SUCCESS;139 }140141 // ─── collectors ────────────────────────────────────────────────142143 /**144 * @return array{is_repo: bool, branch: ?string, head: ?string, last_commit_summary: ?string, dirty: ?bool, ahead: ?int, behind: ?int}145 */146 private function collectGit(): array147 {148 $cwd = getcwd() ?: '';149 $cdPrefix = 'cd ' . escapeshellarg($cwd) . ' && ';150151 $isRepo = trim((string) shell_exec($cdPrefix . 'git rev-parse --is-inside-work-tree 2>/dev/null')) === 'true';152153 if (!$isRepo) {154 return [155 'is_repo' => false,156 'branch' => null,157 'head' => null,158 'last_commit_summary' => null,159 'dirty' => null,160 'ahead' => null,161 'behind' => null,162 ];163 }164165 $branch = trim((string) shell_exec($cdPrefix . 'git branch --show-current 2>/dev/null')) ?: null;166 $head = trim((string) shell_exec($cdPrefix . 'git rev-parse --short HEAD 2>/dev/null')) ?: null;167 $last = trim((string) shell_exec($cdPrefix . 'git log -1 --pretty=%s 2>/dev/null')) ?: null;168 $status = (string) shell_exec($cdPrefix . 'git status --porcelain 2>/dev/null');169 $dirty = trim($status) !== '';170171 $ahead = null;172 $behind = null;173 if ($branch !== null) {174 $track = trim((string) shell_exec($cdPrefix . 'git rev-list --left-right --count @{u}...HEAD 2>/dev/null'));175 if ($track !== '' && preg_match('/^(\d+)\s+(\d+)$/', $track, $m)) {176 $behind = (int) $m[1];177 $ahead = (int) $m[2];178 }179 }180181 return [182 'is_repo' => true,183 'branch' => $branch,184 'head' => $head,185 'last_commit_summary' => $last,186 'dirty' => $dirty,187 'ahead' => $ahead,188 'behind' => $behind,189 ];190 }191192 /**193 * @return list<Epic>194 */195 private function collectEpics(): array196 {197 try {198 return $this->epicStore->list();199 } catch (\Throwable) {200 return [];201 }202 }203204 /**205 * Collect tasks of a single status, most-recently-updated first.206 *207 * IN_PROGRESS and BLOCKED are kept apart on purpose: a blocked task is NOT208 * resumable work, so conflating the two made `in_progress_count` lie and209 * made suggest_next promote a blocked task as the next action.210 *211 * @return list<Task>212 */213 private function collectTasksByStatus(TaskStatus $status): array214 {215 $out = [];216 try {217 foreach ($this->taskStore->list(null, $status) as $task) {218 $out[] = $task;219 }220 } catch (\Throwable) {221 // store not initialised yet — that's fine, return what we have222 }223 usort($out, static fn(Task $a, Task $b) => strcmp($b->updatedAt, $a->updatedAt));224 return $out;225 }226227 /**228 * Choose the epic the agent should focus on, in priority order:229 * 1. epic of the most-recent in-progress task (real WIP),230 * 2. an epic whose own status is in_progress,231 * 3. epic of the most-recent blocked task (stalled WIP — last resort, so232 * the dashboard still points somewhere instead of going blank).233 *234 * Blocked work never outranks live in-progress work, which is what the old235 * recency-only rule got wrong.236 *237 * @param list<Task> $inProgress238 * @param list<Task> $blocked239 * @param list<Epic> $epics240 */241 private function deriveActiveEpicId(array $inProgress, array $blocked, array $epics): ?string242 {243 if ($inProgress !== []) {244 return $inProgress[0]->epicId;245 }246 foreach ($epics as $epic) {247 if ($epic->status->value === 'in_progress') {248 return $epic->id;249 }250 }251 if ($blocked !== []) {252 return $blocked[0]->epicId;253 }254 return null;255 }256257 /**258 * @return list<array{id: string, created_at: string, topic: ?string, recipe: ?string, last_event: ?array{kind: string, at: string, summary: string}}>259 */260 private function collectRecentTraces(int $limit): array261 {262 try {263 $headers = $this->traceStore->list();264 } catch (\Throwable) {265 return [];266 }267268 usort($headers, static fn(TraceHeader $a, TraceHeader $b) => strcmp($b->createdAt, $a->createdAt));269 $headers = array_slice($headers, 0, $limit);270271 $out = [];272 foreach ($headers as $h) {273 $lastEvent = null;274 try {275 $trace = $this->traceStore->read($h->traceId);276 $events = $trace->events;277 if ($events !== []) {278 $tail = end($events);279 $lastEvent = [280 'kind' => $tail->eventKind,281 'at' => $tail->at,282 'summary' => $tail->summary,283 ];284 }285 } catch (\Throwable) {286 // skip unreadable traces287 }288 $out[] = [289 'id' => $h->traceId,290 'created_at' => $h->createdAt,291 'topic' => $h->topic,292 'recipe' => $h->recipe,293 'last_event' => $lastEvent,294 ];295 }296 return $out;297 }298299 /**300 * @param list<array{id: string, created_at: string, topic: ?string, recipe: ?string, last_event: ?array{kind: string, at: string, summary: string}}> $recentTraces301 * @return array{trace_id: string, at: string, summary: string, verdict: ?string}|null302 */303 private function findLastVerify(array $recentTraces): ?array304 {305 foreach ($recentTraces as $row) {306 $last = $row['last_event'];307 if ($last !== null && $last['kind'] === TraceEventKind::VERIFY_RESULT) {308 return [309 'trace_id' => $row['id'],310 'at' => $last['at'],311 'summary' => $last['summary'],312 'verdict' => $this->parseVerdictFromSummary($last['summary']),313 ];314 }315 }316 // scan deeper — look through trace bodies317 foreach ($recentTraces as $row) {318 try {319 $trace = $this->traceStore->read($row['id']);320 foreach (array_reverse($trace->events) as $e) {321 if ($e->eventKind === TraceEventKind::VERIFY_RESULT) {322 return [323 'trace_id' => $row['id'],324 'at' => $e->at,325 'summary' => $e->summary,326 'verdict' => $this->parseVerdictFromSummary($e->summary),327 ];328 }329 }330 } catch (\Throwable) {331 continue;332 }333 }334 return null;335 }336337 private function parseVerdictFromSummary(string $summary): ?string338 {339 $lower = strtolower($summary);340 if (str_contains($lower, 'pass')) {341 return 'pass';342 }343 if (str_contains($lower, 'fail')) {344 return 'fail';345 }346 return null;347 }348349 // ─── next-step suggestion ──────────────────────────────────────350351 /**352 * @param array{is_repo: bool, dirty: ?bool, ...} $git353 * @param list<Task> $inProgress354 * @param list<Task> $blocked355 * @param list<Epic> $epics356 * @return array{summary: string, commands: list<array{cmd: string, args: list<string>, why: string}>}357 */358 /**359 * Every other live agent, and what is uncommitted in the workspace — the360 * two things an agent otherwise finds out by colliding with them.361 *362 * @return array{you: ?array<string, mixed>, agents: list<array<string, mixed>>, activity: list<array<string, mixed>>, activity_known: bool, stack: list<array<string, mixed>>, unclaimed_fresh: list<string>, unreadable: list<string>}363 */364 private function workingNow(): array365 {366 $now = time();367 $registry = new AgentRegistry(ProjectRoot::get());368 $registry->beat();369 $you = $registry->current();370 $live = $registry->all(false, $now);371 $others = array_values(array_filter($live, static fn (AgentSession $a): bool => $a->id !== $you?->id));372 $radar = new WorkspaceActivity(ProjectRoot::get());373 $activity = $radar->dirtyRepos($live, $now);374375 return [376 'you' => $you?->toArray(),377 'agents' => array_map(static fn (AgentSession $a): array => $a->toArray() + ['silent_s' => $a->secondsSilent($now)], $others),378 'activity' => $activity,379 // false: git is not available here, so "no edits" means unknown, not clean.380 'activity_known' => $radar->gitAvailable(),381 // Fresh edits nobody declared: another session that never joined,382 // or your own work you did not list. Either way, look before you commit.383 // The shared stack's last lifecycle events: a restart took the server384 // and everyone's one-off containers down, and this says whose it was.385 'stack' => (new StackEvents(ProjectRoot::get()))->recent(3, $now),386 'unclaimed_fresh' => array_values(array_map(387 static fn (array $r): string => $r['repo'],388 array_filter($activity, static fn (array $r): bool => $r['fresh'] && $r['claimed_by'] === []),389 )),390 'unreadable' => array_values(array_map(391 static fn (array $r): string => $r['repo'],392 array_filter($activity, static fn (array $r): bool => !$r['readable']),393 )),394 ];395 }396397 /**398 * @param array{agents: list<array<string, mixed>>, you: ?array<string, mixed>} $workingNow399 */400 private function holderOf(string $taskId, array $workingNow): ?string401 {402 foreach ([...$workingNow['agents'], ...($workingNow['you'] !== null ? [$workingNow['you']] : [])] as $agent) {403 if (($agent['task'] ?? null) === $taskId) {404 return (string) $agent['id'];405 }406 }407408 return null;409 }410411 private function suggestNext(array $git, ?string $activeEpicId, array $inProgress, array $blocked, array $epics): array412 {413 $cmds = [];414 $parts = [];415416 if ($inProgress !== []) {417 $top = $inProgress[0];418 $parts[] = "Resume task `{$top->id}` ({$top->status->value}) under epic `{$top->epicId}`.";419 $cmds[] = [420 'cmd' => 'ai:work',421 'args' => ['resume', '--id=' . $top->id, '--json'],422 'why' => "resume the in-progress task directly",423 ];424 if ($top->nextStep !== null && $top->nextStep !== '') {425 $parts[] = 'Next step on record: ' . $top->nextStep;426 }427 } elseif ($blocked !== []) {428 // No live work — only blocked tasks. A blocked task is NOT resumable:429 // surface it for unblocking, but steer the agent to pickable work.430 $top = $blocked[0];431 $n = count($blocked);432 $parts[] = $n === 1433 ? "No in-progress work. Task `{$top->id}` is blocked under epic `{$top->epicId}` — unblock it or pick new work."434 : "No in-progress work. {$n} tasks are blocked (latest `{$top->id}`) — unblock one or pick new work.";435 $cmds[] = [436 'cmd' => 'ai:work',437 'args' => ['show', '--id=' . $top->id, '--json'],438 'why' => 'inspect why the blocked task is stuck',439 ];440 $cmds[] = [441 'cmd' => 'ai:work',442 'args' => ['list', '--status=new', '--json'],443 'why' => 'find pickable (new) work instead',444 ];445 } elseif ($activeEpicId !== null) {446 $parts[] = "Epic `{$activeEpicId}` has no in-progress tasks — pick one up.";447 $cmds[] = [448 'cmd' => 'ai:work',449 'args' => ['list', '--epic=' . $activeEpicId, '--status=new', '--json'],450 'why' => 'list new tasks in the active epic',451 ];452 } else {453 $activeCount = 0;454 foreach ($epics as $e) {455 if (!in_array($e->status->value, ['discarded', 'archived'], true)) {456 $activeCount++;457 }458 }459 if ($activeCount > 0) {460 $parts[] = 'No in-progress tasks. Check the backlog.';461 $cmds[] = [462 'cmd' => 'ai:epic',463 'args' => ['list', '--json'],464 'why' => 'show active epics',465 ];466 } else {467 $parts[] = 'No active backlog. Classify the request first.';468 $cmds[] = [469 'cmd' => 'ai:task',470 'args' => ['"<the request>"', '--json'],471 'why' => 'classify the user request into a recipe',472 ];473 }474 }475476 if (($git['dirty'] ?? false) === true) {477 $parts[] = 'Working tree is dirty — verify before committing.';478 $cmds[] = [479 'cmd' => 'ai:verify',480 'args' => ['--json'],481 'why' => 'lint/test the current diff',482 ];483 }484485 $cmds[] = [486 'cmd' => 'ai:ask',487 'args' => ['project', '--json'],488 'why' => 'structural overview if you need it (27 modules)',489 ];490491 return [492 'summary' => implode(' ', $parts),493 'commands' => $cmds,494 ];495 }496497 // ─── formatters ────────────────────────────────────────────────498499 /**500 * @param list<Epic> $epics501 * @return array{id: string, title: string, status: string, task_count: int}|null502 */503 private function epicBrief(array $epics, string $id): ?array504 {505 foreach ($epics as $e) {506 if ($e->id === $id) {507 return [508 'id' => $e->id,509 'title' => $e->title,510 'status' => $e->status->value,511 'task_count' => count($e->taskIds),512 ];513 }514 }515 return null;516 }517518 /**519 * @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}520 */521 private function taskBrief(Task $task): array522 {523 return [524 'id' => $task->id,525 'epic_id' => $task->epicId,526 'title' => $task->title,527 'status' => $task->status->value,528 'recipe' => $task->recipe,529 'risk' => $task->risk,530 'next_step' => $task->nextStep,531 'context_refs' => $task->contextRefs,532 'updated_at' => $task->updatedAt,533 ];534 }535536 /**537 * @param array<string, mixed> $envelope538 */539 private function renderHuman(SymfonyStyle $io, array $envelope): void540 {541 $io->title('Session dashboard (ai:orient)');542543 $git = $envelope['git'];544 if ($git['is_repo']) {545 $dirty = $git['dirty'] ? 'dirty' : 'clean';546 $track = '';547 if ($git['ahead'] !== null || $git['behind'] !== null) {548 $track = " (ahead {$git['ahead']} / behind {$git['behind']})";549 }550 $io->section('Git');551 $io->writeln(" branch: {$git['branch']}{$track} — {$dirty}");552 $io->writeln(" head: {$git['head']} {$git['last_commit_summary']}");553 } else {554 $io->section('Git');555 $io->writeln(' (not a git repository)');556 }557558 $backlog = $envelope['backlog'];559 $io->section('Backlog');560 $blockedCount = $backlog['blocked_count'] ?? 0;561 $io->writeln(" epics: {$backlog['total_epics']} total in-progress tasks: {$backlog['in_progress_count']} blocked: {$blockedCount}");562 if ($backlog['active_epic'] !== null) {563 $ae = $backlog['active_epic'];564 $io->writeln(" active epic: {$ae['id']} [{$ae['status']}] — {$ae['title']}");565 }566 foreach ($backlog['in_progress_tasks'] as $t) {567 $next = $t['next_step'] !== null && $t['next_step'] !== '' ? " — next: {$t['next_step']}" : '';568 $io->writeln(" • [{$t['status']}] {$t['id']} ({$t['recipe']}/{$t['risk']}) {$t['title']}{$next}");569 }570 foreach ($backlog['blocked_tasks'] ?? [] as $t) {571 $io->writeln(" ⊘ [blocked] {$t['id']} ({$t['recipe']}/{$t['risk']}) {$t['title']}");572 }573574 if ($envelope['recent_traces'] !== []) {575 $io->section('Recent traces');576 foreach ($envelope['recent_traces'] as $tr) {577 $tail = $tr['last_event'];578 $line = " • {$tr['id']} ({$tr['created_at']})";579 if ($tail !== null) {580 $line .= " — last: [{$tail['kind']}] {$tail['summary']}";581 }582 $io->writeln($line);583 }584 }585586 if ($envelope['last_verify'] !== null) {587 $lv = $envelope['last_verify'];588 $io->section('Last verify');589 $io->writeln(" trace: {$lv['trace_id']} at: {$lv['at']}");590 $io->writeln(" verdict: " . ($lv['verdict'] ?? 'unknown') . " — {$lv['summary']}");591 }592593 $wn = $envelope['working_now'];594 $io->section('Working now');595 $io->writeln(' you: ' . ($wn['you'] !== null ? $wn['you']['id'] . ' — ' . $wn['you']['intent'] : 'not joined — run ai:agent join so the others can see you'));596 foreach ($wn['agents'] as $a) {597 $io->writeln(sprintf(' %s (%s, %d min ago): %s%s', $a['id'], $a['agent'], intdiv((int) $a['silent_s'], 60), $a['intent'], $a['task'] !== null ? ' [task ' . $a['task'] . ']' : ''));598 }599 if ($wn['agents'] === []) {600 $io->writeln(' no other agent has joined');601 }602 if ($wn['stack'] !== []) {603 $io->writeln(' dev stack: last ' . StackEvents::describe($wn['stack'][0]));604 }605 if (!$wn['activity_known']) {606 $io->writeln(' ⚠ uncommitted edits unknown: git is not available here');607 }608 foreach ($wn['unreadable'] as $repo) {609 $io->writeln(" ⚠ {$repo}: git status failed — its uncommitted edits are unknown, not clean");610 }611 foreach ($wn['unclaimed_fresh'] as $repo) {612 $io->writeln(" ⚠ {$repo}: edited in the last 30 min, claimed by no agent — someone may be working there");613 }614615 if ($envelope['quality_error'] !== null) {616 $io->writeln(' ⚠ ' . $envelope['quality_error']);617 }618 if ($envelope['quality_next'] !== []) {619 $io->section('Improve next (ai:quality next)');620 foreach ($envelope['quality_next'] as $t) {621 $io->writeln(" {$t['metric']} — {$t['key']}: {$t['count']}");622 }623 }624625 $io->section('Next');626 $io->writeln(' ' . $envelope['suggest_next']);627 foreach ($envelope['next_command'] as $nc) {628 $io->writeln(" → bin/semitexa {$nc['cmd']} " . implode(' ', $nc['args']) . " # {$nc['why']}");629 }630 }631}632
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 [--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 logs [--file=app] [--lines=200] [--grep=…] [--json]26 *27 * Output contracts (envelope shapes, JSON keys, exit codes) belong to the28 * dispatched targets — `ai:ask` only routes; the dev:graph:* / logs:app29 * commands own behavior.30 */31#[AsCommand(name: 'ai:ask', description: 'Agent-facing introspection aggregator (capabilities, mechanisms, project, module, route, event, path, logs)')]32final class AiAskCommand extends BaseCommand33{34 /**35 * Subject → underlying command. Keeping the list short is the point: if36 * a new read-only surface is needed, add it here rather than spawning37 * another top-level command.38 *39 * @var array<string, string>40 */41 private const SUBJECT_MAP = [42 'capabilities' => 'dev:graph:capabilities',43 // Distinct from `capabilities` on purpose: that one lists the CLI44 // commands available to run, this one lists what the installed45 // FRAMEWORK can do (deferred regions, components, live transport).46 // Both answer "what can I do here"; merging them buries the handful of47 // mechanisms under a wall of command help.48 'mechanisms' => 'dev:graph:mechanisms',49 'project' => 'dev:graph:project',50 'module' => 'dev:graph:module',51 'route' => 'dev:graph:route',52 'event' => 'dev:graph:event',53 'logs' => 'logs:app',54 // `path` explains a file/directory path using global module-structure55 // rules + any package-local extension. Auto-selected when --path is56 // passed without an explicit subject (see execute()).57 'path' => 'dev:graph:path',58 ];5960 public function __construct()61 {62 parent::__construct('ai:ask');63 }6465 protected function configure(): void66 {67 $this68 // Subject is now OPTIONAL: when omitted, the command auto-selects69 // `path` if --path is provided (lets `ai:ask --path=…` work70 // without naming a subject). Existing subjects keep their full71 // routing behavior.72 ->addArgument('subject', InputArgument::OPTIONAL, 'One of: ' . implode(', ', array_keys(self::SUBJECT_MAP)) . ' (omit if --path is provided to auto-select `path`)')73 // Union of options accepted by the delegated targets. Only the74 // ones the target actually declares are forwarded (see75 // CommandDelegator). Anything else is silently dropped.76 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Module/event name (for subject=module|event)')77 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Path: route path (subject=route) | file/directory path (subject=path, default when --path used)')78 ->addOption('method', null, InputOption::VALUE_REQUIRED, 'HTTP method (for subject=route)')79 ->addOption('file', null, InputOption::VALUE_REQUIRED, 'Log file alias (for subject=logs)')80 ->addOption('lines', null, InputOption::VALUE_REQUIRED, 'Line count (for subject=logs)')81 ->addOption('grep', null, InputOption::VALUE_REQUIRED, 'Filter (for subject=logs)')82 ->addOption('level', null, InputOption::VALUE_REQUIRED, 'Log level filter (for subject=logs)')83 ->addOption('since', null, InputOption::VALUE_REQUIRED, 'Time window (for subject=logs)')84 ->addOption('around', null, InputOption::VALUE_REQUIRED, 'Context timestamp (for subject=logs)')85 ->addOption('context', null, InputOption::VALUE_REQUIRED, 'Context radius (for subject=logs)')86 ->addOption('list', null, InputOption::VALUE_NONE, 'List available logs (for subject=logs)')87 ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Capability id (for subject=mechanisms)')88 ->addOption('area', null, InputOption::VALUE_REQUIRED, 'Capability area prefix, e.g. ssr|ui (for subject=mechanisms)')89 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit JSON envelope (target-dependent shape)');90 }9192 protected function execute(InputInterface $input, OutputInterface $output): int93 {94 $subject = (string) ($input->getArgument('subject') ?? '');95 // When subject is omitted but --path is provided, default to96 // `path`. Keeps `ai:ask --path=foo` ergonomic for AI agents while97 // preserving the explicit-subject flow for everyone else.98 if ($subject === '' && (string) ($input->getOption('path') ?? '') !== '') {99 $subject = 'path';100 }101 if ($subject === '') {102 $output->writeln(json_encode([103 'kind' => 'error',104 'error' => 'missing subject (and no --path provided to auto-select)',105 'subjects' => array_keys(self::SUBJECT_MAP),106 ], JSON_UNESCAPED_SLASHES));107 return self::FAILURE;108 }109 $target = self::SUBJECT_MAP[$subject] ?? null;110 if ($target === null) {111 $output->writeln(json_encode([112 'kind' => 'error',113 'error' => "unknown subject '{$subject}'",114 'subjects' => array_keys(self::SUBJECT_MAP),115 ], JSON_UNESCAPED_SLASHES));116 return self::FAILURE;117 }118119 $app = $this->getApplication();120 if ($app === null) {121 $output->writeln(json_encode([122 'kind' => 'error',123 'error' => 'Application not available — cannot dispatch subject',124 ], JSON_UNESCAPED_SLASHES));125 return self::FAILURE;126 }127128 return CommandDelegator::run($app, $target, $input, $output);129 }130}131
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\ObservatoryReader;12use Semitexa\Dev\Application\Service\Trace\ReplayRunner;13use Semitexa\Dev\Application\Service\Trace\EntryMethodCatalog;14use Semitexa\Dev\Application\Service\Trace\SourceSliceReader;15use Semitexa\Dev\Application\Service\Trace\SpanTarget;16use Semitexa\Dev\Application\Service\Trace\TraceReader;17use Symfony\Component\Console\Input\InputArgument;18use Symfony\Component\Console\Input\InputInterface;19use Symfony\Component\Console\Input\InputOption;20use Symfony\Component\Console\Output\OutputInterface;2122/**23 * The AI agent's window into the Observatory — the same journal and traces the24 * human panel reads, rendered for a context budget instead of a screen.25 *26 * Design rules (ep-observatory, decision 4):27 * - the agent consumes the DATA, never the human HTML;28 * - summaries first, bytes on demand: `ps` is a snapshot, `show` is one29 * process, `tail` is raw NDJSON rows — nothing dumps everything;30 * - `tail --follow` streams new journal rows as they land and exits after31 * --duration seconds, because an agent cannot Ctrl-C.32 *33 * Everything here is read-only over files; no server round-trip, so it works34 * with the app down (the journal is still there — often exactly when a crash35 * is being investigated).36 */37#[AsCommand(38 name: 'ai:observe',39 description: 'Observatory for agents: ps (live snapshot) | tail (journal rows, --follow streams) | show --id (one process + its trace)',40)]41final class AiObserveCommand extends BaseCommand42{43 #[InjectAsReadonly]44 protected ObservatoryReader $reader;4546 #[InjectAsReadonly]47 protected TraceReader $traces;4849 #[InjectAsReadonly]50 protected ReplayRunner $replayRunner;5152 #[InjectAsReadonly]53 protected SourceSliceReader $source;5455 public function __construct()56 {57 parent::__construct('ai:observe');58 }5960 protected function configure(): void61 {62 $this63 ->addArgument('action', InputArgument::REQUIRED, 'ps | tail | show | replay')64 ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Process id (show, replay)')65 ->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)')66 ->addOption('kind', null, InputOption::VALUE_REQUIRED, 'Filter: process kind (tail)')67 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Filter: name substring (tail)')68 ->addOption('lines', null, InputOption::VALUE_REQUIRED, 'How many rows (tail, default 50, max 500)')69 ->addOption('follow', null, InputOption::VALUE_NONE, 'Stream new rows as they land (tail)')70 ->addOption('duration', null, InputOption::VALUE_REQUIRED, 'Seconds to follow before exiting (default 15, max 300)')71 ->addOption('source', null, InputOption::VALUE_NONE, 'Inline the source of the method/class each traced span ran (show; dev-only)')72 ->addOption('json', null, InputOption::VALUE_NONE, 'Accepted for ai:* symmetry; output is always JSON');73 }7475 protected function execute(InputInterface $input, OutputInterface $output): int76 {77 if (!$this->reader->isEnabled()) {78 $output->writeln((string) json_encode([79 'artifact' => 'semitexa-dev.ai-observe.error/v1',80 'error' => 'observatory-disabled',81 'hint' => 'The journal is off here: APP_ENV must be dev, or SEMITEXA_OBSERVATORY_MODE=monitor for journal-only production observability.',82 ]));8384 return self::FAILURE;85 }8687 return match ($input->getArgument('action')) {88 'ps' => $this->ps($output),89 'tail' => $this->tail($input, $output),90 'show' => $this->show($input, $output),91 'replay' => $this->replay($input, $output),92 default => $this->unknown($output),93 };94 }9596 /**97 * Sandbox replay of one recorded process: same route, the recorded payload98 * snapshot (with --mutate overrides) through the real handler — writes99 * rolled back, queue handoffs captured — then a trace-vs-trace diff.100 */101 private function replay(InputInterface $input, OutputInterface $output): int102 {103 // Monitor mode reads the journal; it never re-executes anything.104 // ReplayRunner refuses too — this early exit just says why, first.105 if (!ObservatoryMode::full()) {106 return $this->fail(107 $output,108 'replay-requires-dev',109 'Replay executes recorded requests and is dev-only; monitor mode is journal-only by design.',110 );111 }112113 $id = $this->strOption($input, 'id');114 if ($id === null) {115 return $this->fail($output, 'missing-id', 'ai:observe replay --id=<process-id> [--mutate k=v]');116 }117118 $found = $this->reader->find($id);119 $traceFile = $found['end']['trace'] ?? null;120 if (!is_string($traceFile) || $traceFile === '') {121 return $this->fail(122 $output,123 'not-traced',124 'Replay needs the recorded envelope; this process has no trace. Re-run the request with ?__trace=1 first.',125 );126 }127128 $mutations = [];129 foreach ((array) $input->getOption('mutate') as $pair) {130 if (!is_string($pair) || !str_contains($pair, '=')) {131 return $this->fail($output, 'bad-mutation', "each --mutate must be k=v, got: " . (string) $pair);132 }133 [$k, $v] = explode('=', $pair, 2);134 $decoded = json_decode($v, true);135 $mutations[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v;136 }137138 $result = $this->replayRunner->replay($traceFile, $mutations);139 if (isset($result['error'])) {140 $output->writeln((string) json_encode(['artifact' => 'semitexa-dev.ai-observe.error/v1'] + $result));141142 return self::FAILURE;143 }144145 $envelope = ['artifact' => 'semitexa-dev.ai-observe.replay/v1', 'id' => $id] + $result;146 if (is_string($result['replay_trace'] ?? null)) {147 $envelope['diff'] = $this->diffTraces($traceFile, $result['replay_trace']);148 }149150 $output->writeln((string) json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));151152 return $result['verdict'] === 'handler_threw' ? self::FAILURE : self::SUCCESS;153 }154155 /**156 * Span-level comparison of the two recordings. Timing deltas on shared157 * spans are labeled expected drift — a replay runs on a different clock158 * with different caches; STRUCTURE changes (spans or queries appearing or159 * vanishing) are what a behaviour difference actually looks like.160 *161 * @return array<string, mixed>162 */163 private function diffTraces(string $originalFile, string $replayFile): array164 {165 $profile = function (string $file): ?array {166 $trace = $this->traces->read($file);167 if ($trace === null) {168 return null;169 }170 $spans = [];171 foreach ($trace['spans'] as $span) {172 $name = (string) $span['name'];173 $spans[$name] = ($spans[$name] ?? 0) + 1;174 }175176 return ['spans' => $spans, 'queries' => count($trace['queries']), 'totalMs' => $trace['meta']['totalMs']];177 };178179 $orig = $profile($originalFile);180 $replay = $profile($replayFile);181 if ($orig === null || $replay === null) {182 return ['error' => 'diff-unavailable'];183 }184185 $added = array_diff_key($replay['spans'], $orig['spans']);186 $missing = array_diff_key($orig['spans'], $replay['spans']);187 $countChanged = [];188 foreach (array_intersect_key($orig['spans'], $replay['spans']) as $name => $n) {189 if ($replay['spans'][$name] !== $n) {190 $countChanged[$name] = ['original' => $n, 'replay' => $replay['spans'][$name]];191 }192 }193194 return [195 'spans_added' => $added,196 'spans_missing' => $missing,197 'span_count_changed' => $countChanged,198 'queries' => ['original' => $orig['queries'], 'replay' => $replay['queries']],199 'total_ms' => [200 'original' => $orig['totalMs'],201 'replay' => $replay['totalMs'],202 'note' => 'timing drift is expected; structure changes are the signal',203 ],204 'structurally_identical' => $added === [] && $missing === [] && $countChanged === []205 && $orig['queries'] === $replay['queries'],206 ];207 }208209 private function fail(OutputInterface $output, string $error, string $hint): int210 {211 $output->writeln((string) json_encode([212 'artifact' => 'semitexa-dev.ai-observe.error/v1',213 'error' => $error,214 'hint' => $hint,215 ]));216217 return self::FAILURE;218 }219220 private function ps(OutputInterface $output): int221 {222 $snapshot = $this->reader->snapshot();223224 $next = [225 ['cmd' => 'ai:observe', 'args' => ['show', '--id=<process-id>'], 'why' => 'inspect one process, including its trace when it was recorded'],226 ['cmd' => 'ai:observe', 'args' => ['tail', '--follow', '--duration=15'], 'why' => 'watch new journal rows arrive live'],227 ];228 // The newest failure first: it is what someone running ps is looking for.229 foreach ($snapshot['recent'] as $row) {230 if (($row['failed'] ?? false) === true) {231 array_unshift($next, [232 'cmd' => 'ai:observe',233 'args' => ['show', '--id=' . $row['id']],234 'why' => sprintf('the newest failure: %s ended %s', $row['name'], $row['exception'] ?? ('with ' . ($row['httpStatus'] ?? 'failed'))),235 ]);236 break;237 }238 }239240 $envelope = [241 'artifact' => 'semitexa-dev.ai-observe.ps/v1',242 ] + $snapshot + [243 'next_command' => $next,244 ];245246 $output->writeln((string) json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));247248 return self::SUCCESS;249 }250251 private function tail(InputInterface $input, OutputInterface $output): int252 {253 $kind = $this->strOption($input, 'kind');254 $name = $this->strOption($input, 'name');255 $lines = max(1, min(500, (int) ($input->getOption('lines') ?: 50)));256257 foreach ($this->reader->tailRecords($lines, $kind, $name) as $row) {258 $output->writeln((string) json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));259 }260261 if (!$input->getOption('follow')) {262 return self::SUCCESS;263 }264265 // Follow by file growth: remember the offset, poll for appended bytes,266 // emit whole lines. Exits on its own — an agent cannot Ctrl-C, so an267 // unbounded follow would hang the tool call that issued it.268 $duration = max(1, min(300, (int) ($input->getOption('duration') ?: 15)));269 $deadline = microtime(true) + $duration;270 $path = $this->reader->todayJournalPath();271 $offset = is_file($path) ? (int) filesize($path) : 0;272 $carry = '';273274 while (microtime(true) < $deadline) {275 usleep(200_000);276 // Re-resolve EVERY iteration: at midnight the journal rolls to a277 // new dated file while the old one still exists and stops growing278 // — a follower pinned to the old path would go silent for the279 // rest of the duration.280 $current = $this->reader->todayJournalPath();281 if ($current !== $path) {282 $path = $current;283 $offset = 0;284 $carry = '';285 }286 clearstatcache(true, $path);287 if (!is_file($path)) {288 // First write of the day still pending.289 $offset = 0;290 continue;291 }292 $size = (int) filesize($path);293 if ($size < $offset) {294 // Truncated (manual cleanup): restart from the top rather295 // than waiting forever for the size to catch up.296 $offset = 0;297 $carry = '';298 }299 if ($size <= $offset) {300 continue;301 }302 $chunk = (string) file_get_contents($path, false, null, $offset, $size - $offset);303 $offset = $size;304 $carry .= $chunk;305306 while (($nl = strpos($carry, "\n")) !== false) {307 $line = substr($carry, 0, $nl);308 $carry = substr($carry, $nl + 1);309 $row = json_decode($line, true);310 if (!is_array($row)) {311 continue;312 }313 if ($kind !== null && ($row['kind'] ?? '') !== $kind) {314 continue;315 }316 if ($name !== null && !str_contains((string) ($row['name'] ?? ''), $name)) {317 continue;318 }319 $output->writeln((string) json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));320 }321 }322323 return self::SUCCESS;324 }325326 private function show(InputInterface $input, OutputInterface $output): int327 {328 $id = $this->strOption($input, 'id');329 if ($id === null) {330 $output->writeln((string) json_encode([331 'artifact' => 'semitexa-dev.ai-observe.error/v1',332 'error' => 'missing-id',333 'hint' => 'ai:observe show --id=<process-id>; ids come from ps or tail.',334 ]));335336 return self::FAILURE;337 }338339 $found = $this->reader->find($id);340 if ($found['begin'] === null && $found['end'] === null) {341 $output->writeln((string) json_encode([342 'artifact' => 'semitexa-dev.ai-observe.error/v1',343 'error' => 'unknown-process',344 'id' => $id,345 'hint' => 'Not in the journal tail window. ai:observe ps lists what is visible.',346 ]));347348 return self::FAILURE;349 }350351 $envelope = [352 'artifact' => 'semitexa-dev.ai-observe.show/v1',353 'id' => $id,354 'begin' => $found['begin'],355 'end' => $found['end'],356 'status' => $found['end'] !== null ? 'done' : 'live',357 ];358359 // Bytes on demand: the full span/query resolution only when this360 // process was traced, and only for the process that was asked about.361 $traceFile = $found['end']['trace'] ?? null;362 if (is_string($traceFile) && $traceFile !== '') {363 $trace = $this->traces->read($traceFile);364 if ($trace !== null) {365 if ((bool) $input->getOption('source')) {366 if (!ObservatoryMode::full()) {367 return $this->fail(368 $output,369 'source-requires-dev',370 'Inlining source reads files from the working copy and is dev-only; monitor mode is journal-only by design.',371 );372 }373 $trace = $this->withSource($trace);374 // An object even when empty: the field is a map keyed375 // Class::method, and a consumer indexing it must not meet376 // a JSON array on the one trace that named no class.377 $envelope['source'] = (object) $trace['source'];378 unset($trace['source']);379 }380 $envelope['trace'] = $trace;381 } else {382 // The journal says a trace was written, but the file is gone383 // (rotated, or written by another instance sharing the384 // journal). Saying so beats silently looking untraced.385 $envelope['trace_missing'] = $traceFile;386 }387 } else {388 $envelope['next_command'] = [[389 'cmd' => 'browser',390 'args' => ['?__trace=1'],391 'why' => 'no trace was recorded for this process; re-run the request with the marker for full spans',392 ]];393 }394395 $output->writeln((string) json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));396397 return self::SUCCESS;398 }399400 /**401 * Attach the source behind every span that named a class.402 *403 * Each distinct Class::method is read once and keyed under `source`; the404 * span carries only the key (`source_ref`), so a handler that ran ten405 * times costs one slice, not ten. The same resolution the HTML node page406 * uses: the recorded method when the span has one, the conventional entry407 * method otherwise, the class when neither exists.408 *409 * The key is the RESOLVED identity, not the requested one: a span that410 * named only the class and resolved to handle() shares its entry with a411 * span that named Class::handle outright. Only an unreadable source keeps412 * the requested key, so the consumer still learns where the span pointed.413 *414 * @param array<string, mixed> $trace415 * @return array<string, mixed> the trace with `source_ref` on spans and a top-level `source` map416 */417 private function withSource(array $trace): array418 {419 $catalog = new EntryMethodCatalog();420 $sources = [];421 /** @var array<string, string> $canonical requested key → resolved key, so each target resolves once */422 $canonical = [];423424 /** @var list<array<string, mixed>> $spans */425 $spans = is_array($trace['spans'] ?? null) ? $trace['spans'] : [];426 foreach ($spans as $i => $span) {427 $target = SpanTarget::of(is_array($span['context'] ?? null) ? $span['context'] : []);428 if ($target === null) {429 continue;430 }431432 $requested = $target->key();433 if (!isset($canonical[$requested])) {434 $slice = $target->method !== null435 ? $this->source->slice($target->class, $target->method)436 : $this->source->sliceAny($target->class, $catalog->candidates($target->class, null));437 $key = match (true) {438 $slice === null => $requested,439 $slice->method === null => $slice->fqcn,440 default => $slice->fqcn . '::' . $slice->method,441 };442 $canonical[$requested] = $key;443 // null stays in the map: the span still says it pointed444 // somewhere, and the consumer learns the source was unreadable445 // instead of wondering why a key is missing.446 if (!array_key_exists($key, $sources)) {447 $sources[$key] = $slice?->toArray();448 }449 }450451 $spans[$i]['source_ref'] = $canonical[$requested];452 }453454 $trace['spans'] = $spans;455 $trace['source'] = $sources;456457 return $trace;458 }459460 private function unknown(OutputInterface $output): int461 {462 $output->writeln((string) json_encode([463 'artifact' => 'semitexa-dev.ai-observe.error/v1',464 'error' => 'unknown-action',465 'hint' => 'Actions: ps | tail [--kind= --name= --lines= --follow --duration=] | show --id= [--source] | replay --id= [--mutate k=v]',466 ]));467468 return self::FAILURE;469 }470471 private function strOption(InputInterface $input, string $name): ?string472 {473 $value = $input->getOption($name);474475 return is_string($value) && $value !== '' ? $value : null;476 }477}478
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Support\ProjectRoot;8use Semitexa\Dev\Application\Service\Ai\Presence\TaskClaim;9use Semitexa\Core\Attribute\AsCommand;10use Semitexa\Core\Attribute\InjectAsReadonly;11use Semitexa\Core\Console\BaseCommand;12use Semitexa\Dev\Application\Service\Ai\Trace\TraceEvent;13use Semitexa\Dev\Application\Service\Ai\Trace\TraceEventKind;14use Semitexa\Dev\Application\Service\Ai\Trace\TraceStore;15use Semitexa\Dev\Application\Service\Ai\Work\BacklogHygiene;16use Semitexa\Dev\Application\Service\Ai\Work\BacklogScope;17use Semitexa\Dev\Application\Service\Ai\Work\EpicStore;18use Semitexa\Dev\Application\Service\Ai\Work\ResumeService;19use Semitexa\Dev\Application\Service\Ai\Work\ResumeSnapshot;20use Semitexa\Dev\Application\Service\Ai\Work\Task;21use Semitexa\Dev\Application\Service\Ai\Work\TaskStatus;22use Semitexa\Dev\Application\Service\Ai\Work\TaskStore;23use Semitexa\Dev\Application\Service\Ai\Work\WorkId;24use Symfony\Component\Console\Input\InputArgument;25use Symfony\Component\Console\Input\InputInterface;26use Symfony\Component\Console\Input\InputOption;27use Symfony\Component\Console\Output\OutputInterface;2829/**30 * Work item (task) CLI — the main surface for agent decomposition and resume.31 *32 * bin/semitexa ai:work start --id=tk-fix-wm --epic=ep-wm --title="..." [--recipe=...] [--risk=...] [--trace=...] [--context-ref=...]* [--next-step=...]33 * bin/semitexa ai:work list [--epic=...] [--status=new|in_progress|blocked|done[,...]] [--json]34 * bin/semitexa ai:work show --id=tk-fix-wm [--json]35 * bin/semitexa ai:work update --id=tk-fix-wm [--status=...] [--title=...] [--recipe=...] [--risk=...] [--next-step=...] [--context-ref=...]* [--epic=...]36 * bin/semitexa ai:work note --id=tk-fix-wm --note="..." [--next-step=...]37 * bin/semitexa ai:work resume --id=tk-fix-wm [--tail=5]38 *39 * `start` always links the task to a trace: if `--trace` isn't supplied the40 * task id is used as the trace id, and the trace is openOrCreate'd on the41 * fly. That way every task has a durable log, and the slugs stay aligned.42 */43#[AsCommand(name: 'ai:work', description: 'Manage work tasks: start/list/show/update/resume/note (agent-facing, NDJSON)')]44final class AiWorkCommand extends BaseCommand45{46 private const ACTION_START = 'start';47 private const ACTION_LIST = 'list';48 private const ACTION_SHOW = 'show';49 private const ACTION_UPDATE = 'update';50 private const ACTION_NOTE = 'note';51 private const ACTION_RESUME = 'resume';5253 private const ALLOWED_RISK = ['low', 'medium', 'high'];5455 #[InjectAsReadonly]56 protected TaskStore $taskStore;5758 #[InjectAsReadonly]59 protected EpicStore $epicStore;6061 #[InjectAsReadonly]62 protected TraceStore $traceStore;6364 #[InjectAsReadonly]65 protected ResumeService $resumeService;6667 #[InjectAsReadonly]68 protected BacklogHygiene $hygiene;6970 public function __construct()71 {72 parent::__construct('ai:work');73 }7475 protected function configure(): void76 {77 $this78 ->addArgument('action', InputArgument::REQUIRED, 'start | list | show | update | note | resume')79 ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Task id (a-z, 0-9, -, _)')80 ->addOption('epic', null, InputOption::VALUE_REQUIRED, 'Epic id (required on start; on update, re-parents the task)')81 ->addOption('title', null, InputOption::VALUE_REQUIRED, 'Task title')82 ->addOption('recipe', null, InputOption::VALUE_REQUIRED, 'Recipe id (see ai:task)')83 ->addOption('risk', null, InputOption::VALUE_REQUIRED, 'Risk: ' . implode('|', self::ALLOWED_RISK))84 ->addOption('status', null, InputOption::VALUE_REQUIRED, 'Status: ' . implode('|', TaskStatus::all()) . ' (list accepts a comma list)')85 ->addOption('scope', null, InputOption::VALUE_REQUIRED, 'List filter: ' . implode('|', BacklogScope::all()) . ' (default: active)')86 ->addOption('take-over', null, InputOption::VALUE_NONE, 'Take an in_progress task another live agent holds (update)')87 ->addOption('context-ref', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Path or symbol the task is anchored to (repeatable)')88 ->addOption('next-step', null, InputOption::VALUE_REQUIRED, 'One-line next step for the resuming agent')89 ->addOption('note', null, InputOption::VALUE_REQUIRED, 'Free-form note (appended to the linked trace)')90 ->addOption('tail', null, InputOption::VALUE_REQUIRED, 'How many trailing trace events to surface on resume (default 5)')91 ->addOption('trace', null, InputOption::VALUE_REQUIRED, 'Trace id for this task (defaults to the task id on start)')92 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit a single JSON envelope instead of NDJSON');93 }9495 protected function execute(InputInterface $input, OutputInterface $output): int96 {97 $action = (string) $input->getArgument('action');98 $json = (bool) $input->getOption('json');99100 return match ($action) {101 self::ACTION_START => $this->start($input, $output, $json),102 self::ACTION_LIST => $this->list($input, $output, $json),103 self::ACTION_SHOW => $this->show($input, $output, $json),104 self::ACTION_UPDATE => $this->update($input, $output, $json),105 self::ACTION_NOTE => $this->note($input, $output, $json),106 self::ACTION_RESUME => $this->resume($input, $output, $json),107 default => $this->error($output, "unknown action: '{$action}' (expected start | list | show | update | note | resume)", $json),108 };109 }110111 private function start(InputInterface $input, OutputInterface $output, bool $jsonMode): int112 {113 $id = $this->requireId($input, $output, $jsonMode);114 if ($id === null) {115 return self::FAILURE;116 }117 if ($this->taskStore->exists($id)) {118 return $this->error($output, "task '{$id}' already exists", $jsonMode);119 }120121 $epicId = (string) ($input->getOption('epic') ?? '');122 if ($epicId === '') {123 return $this->error($output, '--epic is required for start', $jsonMode);124 }125 try {126 WorkId::assertValid($epicId, 'epic id');127 } catch (\InvalidArgumentException $e) {128 return $this->error($output, $e->getMessage(), $jsonMode);129 }130 if (!$this->epicStore->exists($epicId)) {131 return $this->error($output, "epic '{$epicId}' does not exist — run ai:epic start first", $jsonMode);132 }133134 $title = (string) ($input->getOption('title') ?? '');135 if ($title === '') {136 return $this->error($output, '--title is required for start', $jsonMode);137 }138139 $recipe = (string) ($input->getOption('recipe') ?? '');140 $risk = $this->normaliseRisk((string) ($input->getOption('risk') ?? 'medium'), $output, $jsonMode);141 if ($risk === null) {142 return self::FAILURE;143 }144145 $traceId = (string) ($input->getOption('trace') ?? $id);146 try {147 WorkId::assertValid($traceId, 'trace id');148 } catch (\InvalidArgumentException $e) {149 return $this->error($output, $e->getMessage(), $jsonMode);150 }151 $this->traceStore->openOrCreate($traceId, topic: $title, recipe: $recipe !== '' ? $recipe : null);152153 $now = date('c');154 $refs = $this->contextRefs($input);155 $task = new Task(156 id: $id,157 epicId: $epicId,158 title: $title,159 status: TaskStatus::NEW,160 recipe: $recipe,161 risk: $risk,162 contextRefs: $refs,163 nextStep: $this->optionalString($input, 'next-step'),164 traceId: $traceId,165 createdAt: $now,166 updatedAt: $now,167 );168 $this->taskStore->save($task);169170 $this->traceStore->append($traceId, TraceEventKind::TASK_RESULT, "task '{$id}' started — {$title}", [171 'artifact' => 'semitexa.ai-work.task-start/v1',172 'task_id' => $id,173 'epic_id' => $epicId,174 'recipe' => $recipe,175 'risk' => $risk,176 'context_refs' => $refs,177 'next_step' => $task->nextStep,178 ]);179180 return $this->emitTask($output, $task, 'task_started', $jsonMode);181 }182183 private function list(InputInterface $input, OutputInterface $output, bool $jsonMode): int184 {185 $epicId = $input->getOption('epic') !== null ? (string) $input->getOption('epic') : null;186 try {187 $statuses = TaskStatus::parseList((string) ($input->getOption('status') ?? ''));188 } catch (\InvalidArgumentException $e) {189 return $this->error($output, $e->getMessage(), $jsonMode);190 }191 $statusLabel = $statuses === [] ? null : implode(',', array_map(static fn(TaskStatus $s): string => $s->value, $statuses));192 if ($epicId !== null) {193 try {194 WorkId::assertValid($epicId, 'epic id');195 } catch (\InvalidArgumentException $e) {196 return $this->error($output, $e->getMessage(), $jsonMode);197 }198 }199200 $scopeRaw = (string) ($input->getOption('scope') ?? BacklogScope::ACTIVE->value);201 try {202 $scope = BacklogScope::parse($scopeRaw);203 } catch (\InvalidArgumentException $e) {204 return $this->error($output, $e->getMessage(), $jsonMode);205 }206207 $all = array_values(array_filter(208 $this->taskStore->list($epicId),209 static fn(Task $task): bool => $statuses === [] || in_array($task->status, $statuses, true),210 ));211 $rows = [];212 $hiddenByScope = 0;213 foreach ($all as $task) {214 $assessment = $this->hygiene->assessTask($task);215 if (!$this->hygiene->taskInScope($task, $assessment, $scope)) {216 $hiddenByScope++;217 continue;218 }219 $rows[] = $task->toArray() + [220 'quality' => $assessment->quality->value,221 'issues' => $assessment->issues,222 'suggested_status' => $assessment->suggestedStatus,223 'suggested_action' => $assessment->suggestedAction,224 ];225 }226227 if ($jsonMode) {228 $output->writeln(json_encode([229 'artifact' => 'semitexa.ai-work.task-list/v1',230 'epic' => $epicId,231 'status' => $statusLabel,232 'scope' => $scope->value,233 'tasks' => $rows,234 'task_count' => count($rows),235 'hidden_by_scope' => $hiddenByScope,236 'total_in_store' => count($all),237 ], JSON_UNESCAPED_SLASHES));238 return self::SUCCESS;239 }240241 $output->writeln(json_encode([242 'kind' => 'summary',243 'epic' => $epicId,244 'status' => $statusLabel,245 'scope' => $scope->value,246 'task_count' => count($rows),247 'hidden_by_scope' => $hiddenByScope,248 'total_in_store' => count($all),249 ], JSON_UNESCAPED_SLASHES));250 foreach ($rows as $row) {251 $output->writeln(json_encode(['kind' => 'task'] + $row, JSON_UNESCAPED_SLASHES));252 }253 return self::SUCCESS;254 }255256 private function show(InputInterface $input, OutputInterface $output, bool $jsonMode): int257 {258 $id = $this->requireId($input, $output, $jsonMode);259 if ($id === null) {260 return self::FAILURE;261 }262 try {263 $task = $this->taskStore->get($id);264 } catch (\RuntimeException $e) {265 return $this->error($output, $e->getMessage(), $jsonMode);266 }267 return $this->emitTask($output, $task, 'task', $jsonMode);268 }269270 private function update(InputInterface $input, OutputInterface $output, bool $jsonMode): int271 {272 $id = $this->requireId($input, $output, $jsonMode);273 if ($id === null) {274 return self::FAILURE;275 }276 try {277 $task = $this->taskStore->get($id);278 } catch (\RuntimeException $e) {279 return $this->error($output, $e->getMessage(), $jsonMode);280 }281282 $statusRaw = $input->getOption('status');283 $status = null;284 if ($statusRaw !== null && $statusRaw !== '') {285 try {286 $status = TaskStatus::parse((string) $statusRaw);287 } catch (\InvalidArgumentException $e) {288 return $this->error($output, $e->getMessage(), $jsonMode);289 }290 }291292 $title = $this->optionalString($input, 'title');293 $recipe = $this->optionalString($input, 'recipe');294 $riskRaw = $this->optionalString($input, 'risk');295 $risk = null;296 if ($riskRaw !== null) {297 $risk = $this->normaliseRisk($riskRaw, $output, $jsonMode);298 if ($risk === null) {299 return self::FAILURE;300 }301 }302 $nextStep = $this->optionalString($input, 'next-step');303 $refs = $this->contextRefs($input);304 $refsOpt = $refs === [] ? null : $refs;305306 // Re-parenting. Epic membership is derived from the task, so a move is307 // one field — and without this it was one field edited in the JSON by308 // hand, which leaves no trace event and no record of why. Validated309 // against the store because an unknown epic id is the orphan case310 // BacklogHygiene already reports, and creating one here is worse than311 // refusing: the task disappears from every listing that starts at an312 // epic.313 $epicId = $this->optionalString($input, 'epic');314 if ($epicId !== null) {315 try {316 WorkId::assertValid($epicId, 'epic id');317 } catch (\InvalidArgumentException $e) {318 return $this->error($output, $e->getMessage(), $jsonMode);319 }320 if (!$this->epicStore->exists($epicId)) {321 return $this->error($output, "epic '{$epicId}' does not exist — run ai:epic start first", $jsonMode);322 }323 if ($epicId === $task->epicId) {324 $epicId = null; // already there; not a change to report325 }326 }327 $movedFrom = $epicId === null ? null : $task->epicId;328329 // Read here, not only in the note action. --note is declared on the330 // command and its description promises the trace; update() ignored it,331 // so closing a task WITH its reasoning changed the status, printed the332 // task as updated, exited 0 and threw the reasoning away. Eighteen333 // closures in one session were recorded that way and kept none of it.334 $note = $this->optionalString($input, 'note');335336 if ($title === null && $recipe === null && $risk === null && $status === null337 && $nextStep === null && $refsOpt === null && $note === null && $epicId === null) {338 return $this->error($output, 'update requires at least one of --title, --recipe, --risk, --status, --next-step, --context-ref, --note, --epic', $jsonMode);339 }340341 if (($refused = TaskClaim::claim(ProjectRoot::get(), $id, $status, (bool) $input->getOption('take-over'))) !== null) {342 return $this->error($output, $refused, $jsonMode);343 }344 $updated = $task->with(345 title: $title,346 status: $status,347 recipe: $recipe,348 risk: $risk,349 contextRefs: $refsOpt,350 nextStep: $nextStep,351 updatedAt: date('c'),352 epicId: $epicId,353 );354 $this->taskStore->save($updated);355356 $kind = $status !== null ? TraceEventKind::NEXT_STEP : TraceEventKind::NOTE;357 $summary = match (true) {358 $status !== null => "task '{$id}' status → {$status->value}",359 // The move is named in the summary rather than left in the payload:360 // a reader scanning a trace for where a task went reads summaries.361 $epicId !== null => "task '{$id}' moved {$movedFrom} → {$epicId}",362 default => "task '{$id}' updated",363 };364 $changes = array_filter([365 'title' => $title,366 'recipe' => $recipe,367 'risk' => $risk,368 'status' => $status?->value,369 'next_step' => $nextStep,370 'context_refs' => $refsOpt,371 'epic_id' => $epicId,372 'moved_from' => $movedFrom,373 ], static fn($v) => $v !== null);374375 if ($changes !== []) {376 $this->safeAppend($updated->traceId, $kind, $summary, [377 'artifact' => 'semitexa.ai-work.task-update/v1',378 'task_id' => $id,379 'changes' => $changes,380 ]);381 }382383 // Its own event, and the same shape the note action writes: a reader of384 // the trace should not have to know which command spelling produced a385 // note, and the two must not drift into two shapes.386 if ($note !== null) {387 try {388 $this->appendNote($updated->traceId, $id, $note);389 } catch (\InvalidArgumentException|\RuntimeException $e) {390 return $this->noteError($output, $updated, true, $e->getMessage(), $jsonMode);391 }392 }393394 return $this->emitTask($output, $updated, 'task_updated', $jsonMode);395 }396397 private function note(InputInterface $input, OutputInterface $output, bool $jsonMode): int398 {399 $id = $this->requireId($input, $output, $jsonMode);400 if ($id === null) {401 return self::FAILURE;402 }403 try {404 $task = $this->taskStore->get($id);405 } catch (\RuntimeException $e) {406 return $this->error($output, $e->getMessage(), $jsonMode);407 }408409 $note = $this->optionalString($input, 'note');410 $nextStep = $this->optionalString($input, 'next-step');411 if ($note === null && $nextStep === null) {412 return $this->error($output, 'note requires --note and/or --next-step', $jsonMode);413 }414415 if ($nextStep !== null) {416 $task = $task->with(nextStep: $nextStep, updatedAt: date('c'));417 $this->taskStore->save($task);418 }419420 if ($note !== null) {421 try {422 $this->appendNote($task->traceId, $id, $note, $nextStep);423 } catch (\InvalidArgumentException|\RuntimeException $e) {424 return $this->noteError($output, $task, $nextStep !== null, $e->getMessage(), $jsonMode);425 }426 } else {427 $this->safeAppend($task->traceId, TraceEventKind::NOTE, "task '{$id}' next step updated", array_filter([428 'artifact' => 'semitexa.ai-work.task-note/v1',429 'task_id' => $id,430 'next_step' => $nextStep,431 ], static fn($v) => $v !== null));432 }433434 return $this->emitTask($output, $task, 'task_noted', $jsonMode);435 }436437 /**438 * One place that writes a note to a trace, so `ai:work note` and439 * `ai:work update --note` cannot end up writing two different shapes of the440 * same thing.441 */442 private function appendNote(string $traceId, string $id, string $note, ?string $nextStep = null): void443 {444 // An explicitly requested note is the primary action, not best-effort445 // trace hygiene. Missing traces and failed writes must reach the caller.446 $this->traceStore->append(447 $traceId,448 TraceEventKind::NOTE,449 "note on task '{$id}': " . $this->truncate($note, 80),450 array_filter([451 'artifact' => 'semitexa.ai-work.task-note/v1',452 'task_id' => $id,453 'note' => $note,454 'next_step' => $nextStep,455 ], static fn($v) => $v !== null),456 );457 }458459 private function resume(InputInterface $input, OutputInterface $output, bool $jsonMode): int460 {461 $id = $this->requireId($input, $output, $jsonMode);462 if ($id === null) {463 return self::FAILURE;464 }465 $tail = (int) ($input->getOption('tail') ?? 5);466467 try {468 $snapshot = $this->resumeService->resume($id, $tail);469 } catch (\RuntimeException $e) {470 return $this->error($output, $e->getMessage(), $jsonMode);471 }472473 $this->safeAppend(474 $snapshot->task->traceId,475 TraceEventKind::NEXT_STEP,476 "resuming task '{$id}' (status={$snapshot->task->status->value})",477 [478 'artifact' => 'semitexa.ai-work.task-resume/v1',479 'task_id' => $id,480 'status' => $snapshot->task->status->value,481 'next_step' => $snapshot->task->nextStep,482 ],483 );484485 return $this->emitResume($output, $snapshot, $jsonMode);486 }487488 private function emitResume(OutputInterface $output, ResumeSnapshot $snapshot, bool $jsonMode): int489 {490 $envelope = [491 'artifact' => 'semitexa.ai-work.task-resume/v1',492 'task' => $snapshot->task->toArray(),493 'trace_header' => $snapshot->traceHeader?->toArray(),494 'tail_events' => array_map(static fn(TraceEvent $e) => $e->toArray(), $snapshot->tailEvents),495 'trace_warning' => $snapshot->traceWarning,496 ];497498 if ($jsonMode) {499 $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES));500 return self::SUCCESS;501 }502503 $output->writeln(json_encode([504 'kind' => 'summary',505 'task_id' => $snapshot->task->id,506 'status' => $snapshot->task->status->value,507 'epic_id' => $snapshot->task->epicId,508 'trace_id' => $snapshot->task->traceId,509 'tail_events' => count($snapshot->tailEvents),510 'next_step' => $snapshot->task->nextStep,511 ], JSON_UNESCAPED_SLASHES));512 $output->writeln(json_encode(['kind' => 'task'] + $snapshot->task->toArray(), JSON_UNESCAPED_SLASHES));513 if ($snapshot->traceHeader !== null) {514 $output->writeln(json_encode(['kind' => 'trace_header'] + $snapshot->traceHeader->toArray(), JSON_UNESCAPED_SLASHES));515 }516 foreach ($snapshot->tailEvents as $event) {517 $output->writeln(json_encode(['kind' => 'trace_event'] + $event->toArray(), JSON_UNESCAPED_SLASHES));518 }519 if ($snapshot->traceWarning !== null) {520 $output->writeln(json_encode([521 'kind' => 'warning',522 'warning' => $snapshot->traceWarning,523 ], JSON_UNESCAPED_SLASHES));524 }525 $output->writeln(json_encode([526 'kind' => 'next',527 'action' => 'proceed',528 'hint' => $snapshot->task->nextStep ?? 'no next_step recorded — inspect trace with ai:trace show --id=' . $snapshot->task->traceId,529 ], JSON_UNESCAPED_SLASHES));530 return self::SUCCESS;531 }532533 private function emitTask(OutputInterface $output, Task $task, string $kind, bool $jsonMode): int534 {535 if ($jsonMode) {536 $output->writeln(json_encode([537 'artifact' => 'semitexa.ai-work.task/v1',538 'task' => $task->toArray(),539 'next_command' => $this->buildTaskNextCommands($task),540 ], JSON_UNESCAPED_SLASHES));541 return self::SUCCESS;542 }543 $output->writeln(json_encode(['kind' => $kind] + $task->toArray(), JSON_UNESCAPED_SLASHES));544 return self::SUCCESS;545 }546547 /**548 * @return list<array{cmd: string, args: list<string>, why: string}>549 */550 private function buildTaskNextCommands(Task $task): array551 {552 $out = [];553 $status = $task->status->value;554555 if ($status === 'new') {556 $out[] = [557 'cmd' => 'ai:work',558 'args' => ['update', '--id=' . $task->id, '--status=in_progress', '--json'],559 'why' => 'mark the task started before editing',560 ];561 }562563 if ($status === 'in_progress' || $status === 'blocked') {564 if ($task->contextRefs !== []) {565 $out[] = [566 'cmd' => 'ai:verify',567 'args' => ['--files=' . implode(',', $task->contextRefs), '--json'],568 'why' => 'verify the current state of the files this task anchors to',569 ];570 }571 if ($task->recipe !== '' && $task->recipe !== 'unknown_task') {572 $out[] = [573 'cmd' => 'ai:context',574 'args' => [$task->recipe, '--json'],575 'why' => 'prior art for recipe ' . $task->recipe,576 ];577 }578 }579580 if ($status === 'done') {581 $out[] = [582 'cmd' => 'ai:orient',583 'args' => ['--json'],584 'why' => 'pick up the next task in the epic',585 ];586 }587588 $out[] = [589 'cmd' => 'ai:trace',590 'args' => ['show', '--id=' . $task->traceId, '--json'],591 'why' => 'full trace history for this task',592 ];593594 return $out;595 }596597 /**598 * @param array<string, mixed> $payload599 */600 private function safeAppend(string $traceId, string $kind, string $summary, array $payload): void601 {602 if ($traceId === '') {603 return;604 }605 if (!$this->traceStore->exists($traceId)) {606 return;607 }608 try {609 $this->traceStore->append($traceId, $kind, $summary, $payload);610 } catch (\RuntimeException) {611 // Trace hygiene is best-effort; never let it break the primary action.612 }613 }614615 private function requireId(InputInterface $input, OutputInterface $output, bool $jsonMode): ?string616 {617 $id = (string) ($input->getOption('id') ?? '');618 if ($id === '') {619 $this->error($output, '--id is required', $jsonMode);620 return null;621 }622 try {623 WorkId::assertValid($id, 'task id');624 } catch (\InvalidArgumentException $e) {625 $this->error($output, $e->getMessage(), $jsonMode);626 return null;627 }628 return $id;629 }630631 private function normaliseRisk(string $value, OutputInterface $output, bool $jsonMode): ?string632 {633 $lower = strtolower(trim($value));634 if (!in_array($lower, self::ALLOWED_RISK, true)) {635 $this->error(636 $output,637 "invalid risk '{$value}': expected one of " . implode(', ', self::ALLOWED_RISK),638 $jsonMode,639 );640 return null;641 }642 return $lower;643 }644645 /**646 * @return list<string>647 */648 private function contextRefs(InputInterface $input): array649 {650 $raw = $input->getOption('context-ref');651 if (!is_array($raw)) {652 return [];653 }654 $out = [];655 foreach ($raw as $ref) {656 $trimmed = trim((string) $ref);657 if ($trimmed !== '') {658 $out[] = $trimmed;659 }660 }661 return $out;662 }663664 private function optionalString(InputInterface $input, string $name): ?string665 {666 if (!$input->hasOption($name)) {667 return null;668 }669 $value = $input->getOption($name);670 if ($value === null) {671 return null;672 }673 $value = (string) $value;674 return $value === '' ? null : $value;675 }676677 private function truncate(string $value, int $max): string678 {679 // The limit is in Unicode characters; the full note stays in payload.680 return mb_strlen($value, 'UTF-8') > $max ? mb_substr($value, 0, $max - 1, 'UTF-8') . '…' : $value;681 }682683 private function noteError(OutputInterface $output, Task $task, bool $taskSaved, string $reason, bool $jsonMode): int684 {685 $prefix = $taskSaved ? 'Task changes were saved, but the note' : 'The note';686687 return $this->error($output, $prefix . ' could not be saved: ' . $reason, $jsonMode, [688 'task_saved' => $taskSaved,689 'note_saved' => false,690 'task' => $task->toArray(),691 ]);692 }693694 /** @param array<string, mixed> $details */695 private function error(OutputInterface $output, string $message, bool $jsonMode, array $details = []): int696 {697 $record = $jsonMode698 ? ['artifact' => 'semitexa.ai-work.task/v1', 'status' => 'error']699 : ['kind' => 'error'];700 $output->writeln(json_encode($record + ['error' => $message] + $details, JSON_UNESCAPED_SLASHES));701 return self::FAILURE;702 }703}704
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\DirtyWorkspaceScanner;14use Semitexa\Dev\Application\Service\Ai\Verify\ChangedFileClassifier;15use Semitexa\Dev\Application\Service\Ai\Verify\VerificationExecutor;16use Semitexa\Dev\Application\Service\Ai\Verify\VerificationPlan;17use Semitexa\Dev\Application\Service\Ai\Verify\VerificationPlanner;18use Semitexa\Dev\Application\Service\Ai\Verify\VerificationResult;19use Semitexa\Dev\Application\Service\Ai\Verify\VerificationTarget;20use Semitexa\Dev\Application\Service\Ai\Verify\VerifyReportSerializer;21use Semitexa\Dev\Application\Service\Ai\Verify\Impact\ImpactProbe;22use Semitexa\Dev\Application\Service\Ai\Verify\Impact\ImpactReport;23use Semitexa\Orm\Application\Service\Connection\ConnectionRegistry;24use Symfony\Component\Console\Input\InputInterface;25use Symfony\Component\Console\Input\InputOption;26use Symfony\Component\Console\Output\OutputInterface;27use Symfony\Component\Console\Output\BufferedOutput;2829/**30 * Agent-facing verifier: takes a diff or file list, plans the precise lint /31 * syntax / phpunit / module-structure subset to run, executes it, and emits32 * an NDJSON envelope.33 *34 * bin/semitexa ai:verify --files=src/modules/Foo/src/Application/Handler/PayloadHandler/Bar.php35 * bin/semitexa ai:verify --git-ref=HEAD~1 --scope=standard36 * git diff --name-only HEAD~1 | bin/semitexa ai:verify --diff-stdin37 *38 * Output (NDJSON, one JSON object per line):39 * {"kind":"summary", recipe-style header}40 * {"kind":"expansion", note-of-why-scope-bumped} (zero or more)41 * {"kind":"target", pre-execution per-target metadata}42 * {"kind":"result", post-execution per-target outcome}43 * {"kind":"violation", per-target structured diagnostic} (zero or more)44 * {"kind":"verdict", pass/fail rollup}45 *46 * `--json` mode flips the output into a single envelope (artifact:47 * `semitexa-dev.verify-report/v1`) for callers that prefer one blob over a48 * stream — both modes carry exactly the same data, including the49 * `violations` aggregate from the `module_structure` check (rules and50 * remediation guidance live in51 * `packages/semitexa-docs/docs/MODULE_STRUCTURE.md`).52 */53#[AsCommand(name: 'ai:verify', description: 'Run the precise lint+test+module-structure subset for a diff/file list (NDJSON, agent-facing)')]54final class AiVerifyCommand extends BaseCommand55{56 #[InjectAsReadonly]57 protected TraceAutoAppender $traceAppender;5859 // Optional (nullable) so ai:verify still runs where no DB is bound60 // (e.g. the command's own test harness); only --impact needs it.61 #[InjectAsReadonly]62 protected ?ConnectionRegistry $connections = null;6364 public function __construct()65 {66 parent::__construct('ai:verify');67 }6869 protected function configure(): void70 {71 $this72 ->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.')73 ->addOption('git-ref', null, InputOption::VALUE_REQUIRED, 'Compare working tree against this git ref (e.g. HEAD~1, origin/main)')74 ->addOption('diff-stdin', null, InputOption::VALUE_NONE, 'Read newline-separated paths from stdin (output of `git diff --name-only`)')75 ->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.')76 ->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)')77 ->addOption('scope', null, InputOption::VALUE_REQUIRED, 'Verification scope: minimal, standard, broad', VerificationPlan::SCOPE_STANDARD)78 ->addOption('trace', null, InputOption::VALUE_REQUIRED, 'Append a verify_result event to this ai:trace id (falls back to $SEMITEXA_AI_TRACE_ID)')79 ->addOption('json', null, InputOption::VALUE_NONE, 'Emit a single JSON envelope instead of NDJSON')80 ->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.');81 }8283 protected function execute(InputInterface $input, OutputInterface $output): int84 {85 $jsonMode = (bool) $input->getOption('json');86 $scope = (string) $input->getOption('scope');8788 try {89 $paths = $this->collectPaths($input);90 } catch (\RuntimeException $e) {91 $this->emitError($output, $e->getMessage(), $jsonMode);92 return self::FAILURE;93 }9495 if ($paths === []) {96 // `--dirty` finding nothing is an ANSWER, not a misuse: the tree is97 // clean. Telling that caller to "pass --dirty" is advice they just98 // took, and failing the run would make `ai:verify --dirty` red on99 // every clean checkout. It is not a pass either — nothing was100 // verified — so it gets a verdict of its own, with the scan's reach101 // attached so the reader can see what was asked.102 if ((bool) $input->getOption('dirty')) {103 $scan = (new DirtyWorkspaceScanner($this->getProjectRoot()))->report();104105 // In the SAME shape the mode promises. Default mode is NDJSON106 // whose records are dispatched by `kind`, so an envelope107 // without one is a record such a consumer cannot place — and108 // every ordinary run ends with a `verdict` record.109 // An empty plan, so this answer reaches `--trace` the way every110 // other one does. Returning before maybeAppendToTrace() left a111 // traced workflow with no `verify_result` and no trace status112 // for the run at all — a gap in an audit trail reads as a step113 // that was never taken. Raised in review of dev#84.114 //115 // AND the report keeps its CONTRACT. This branch used to write116 // its own envelope, so a clean run was the one answer missing117 // `completed`, `counts` and `restart` — a consumer reading the118 // stable v1 schema had to special-case it, or fail on the119 // absent keys. The fields come off VerifyReportSerializer, the120 // same source the other two paths use, so there is one121 // definition of what an empty result looks like. Raised in122 // review of dev#84 by both reviewers.123 $emptyPlan = new VerificationPlan($scope, $scope, [], []);124 $report = new VerifyReportSerializer();125 $envelope = [126 'artifact' => 'semitexa-dev.verify-report/v1',127 'generated_at' => date('c'),128 'verdict' => 'nothing_to_verify',129 'completed' => $report->completed([]),130 'counts' => $report->countByStatus([]),131 'changed_files' => [],132 'dirty_scan' => $scan,133 'restart' => $report->restartAdvice([]),134 ];135136 if ($jsonMode) {137 $traceOutput = new BufferedOutput();138 $this->maybeAppendToTrace($input, $traceOutput, $emptyPlan, [], 'nothing_to_verify', $envelope);139 $envelope['trace'] = array_map(140 static fn(string $line): mixed => json_decode($line, true, 512, JSON_THROW_ON_ERROR),141 array_values(array_filter(explode("\n", trim($traceOutput->fetch())))),142 );143 $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES));144145 return self::SUCCESS;146 }147148 // The same two records a run WITH changes emits, so a consumer149 // reads the scan out of one place regardless of the answer.150 $output->writeln(json_encode(['kind' => 'dirty_scan'] + $scan, JSON_UNESCAPED_SLASHES));151 $output->writeln((string) json_encode(152 ['kind' => 'restart'] + $report->restartAdvice([]),153 JSON_UNESCAPED_SLASHES,154 ));155 $output->writeln(json_encode([156 'kind' => 'verdict',157 'verdict' => 'nothing_to_verify',158 // FALSE, and deliberately so. Nothing ran, and `completed`159 // is what a consumer reads to decide whether the required160 // checks happened — saying true here offered an empty run161 // as verification evidence, and contradicted162 // VerifyReportSerializer::completed([]) besides. Raised in163 // review of dev#84.164 'completed' => $report->completed([]),165 'counts' => $report->countByStatus([]),166 'dirty_scan' => $scan,167 ], JSON_UNESCAPED_SLASHES));168169 $this->maybeAppendToTrace($input, $output, $emptyPlan, [], 'nothing_to_verify', $envelope);170171 return self::SUCCESS;172 }173174 $this->emitError($output, 'no changed files supplied — pass --files, --git-ref, --diff-stdin or --dirty', $jsonMode);175 return self::FAILURE;176 }177178 $projectRoot = $this->getProjectRoot();179 $classifier = new ChangedFileClassifier();180 $changed = array_map(181 static fn(array $entry): ChangedFile => $classifier->classify(182 $entry['path'],183 $entry['status'],184 ($entry['originalPath'] ?? '') !== '' ? $entry['originalPath'] : null,185 ),186 $paths,187 );188 /** @var list<ChangedFile> $changed */189190 $planner = new VerificationPlanner($projectRoot, $classifier);191 $plan = $planner->plan($changed, $scope, (bool) $input->getOption('all'));192193 $app = $this->getApplication();194 if ($app === null) {195 $this->emitError($output, 'Application not available — cannot dispatch lint commands', $jsonMode);196 return self::FAILURE;197 }198 $executor = new VerificationExecutor($app, $projectRoot);199 $results = $executor->execute($plan);200201 $verdict = $this->verdict($results);202 $exit = in_array($verdict, [VerificationResult::STATUS_PASS, VerificationResult::STATUS_SKIPPED], true) ? self::SUCCESS : self::FAILURE;203204 $impact = null;205 if ((bool) $input->getOption('impact')) {206 $impact = $this->probeImpact($plan);207 }208209 $envelope = $this->buildEnvelope($plan, $results, $verdict, $impact);210 $dirtyScan = null;211 if ((bool) $input->getOption('dirty')) {212 // The reach of the answer, beside the answer. A scan that could not213 // ask half the tree must not read as "half the tree is clean".214 $dirtyScan = (new DirtyWorkspaceScanner($this->getProjectRoot()))->report();215 $envelope['dirty_scan'] = $dirtyScan;216 }217 if ($jsonMode) {218 $traceOutput = new BufferedOutput();219 $this->maybeAppendToTrace($input, $traceOutput, $plan, $results, $verdict, $envelope);220 $envelope['trace'] = array_map(221 static fn(string $line): mixed => json_decode($line, true, 512, JSON_THROW_ON_ERROR),222 array_values(array_filter(explode("\n", trim($traceOutput->fetch())))),223 );224 $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES));225 } else {226 $this->emitNdjson($output, $plan, $results, $verdict, $impact, $dirtyScan);227 $this->maybeAppendToTrace($input, $output, $plan, $results, $verdict, $envelope);228 }229230 return $exit;231 }232233 /**234 * @param list<VerificationResult> $results235 * @param array<string, mixed> $envelope236 */237 private function maybeAppendToTrace(238 InputInterface $input,239 OutputInterface $output,240 VerificationPlan $plan,241 array $results,242 string $verdict,243 array $envelope,244 ): void {245 $report = new VerifyReportSerializer();246 $counts = $report->countByStatus($results);247 $summary = sprintf(248 'verify %s — scope=%s targets=%d pass=%d fail=%d skipped=%d incomplete=%d',249 $verdict,250 $plan->effectiveScope,251 count($plan->targets),252 $counts['pass'] ?? 0,253 $counts['fail'] ?? 0,254 $counts['skipped'] ?? 0,255 $counts['incomplete'] ?? 0,256 );257 $this->traceAppender->appendIfActive($input, $output, TraceEventKind::VERIFY_RESULT, $summary, $envelope);258 }259260 /**261 * @return list<array{path: string, status: string, originalPath?: string}>262 */263 private function collectPaths(InputInterface $input): array264 {265 $sources = [];266 // --files is VALUE_IS_ARRAY: repeated flags arrive as a list, and each267 // element may itself be a comma-separated batch. Both forms combine, so268 // no path is ever silently dropped (the repeated-flag form previously269 // kept only the last value and returned a false-green partial verify).270 foreach ((array) $input->getOption('files') as $files) {271 foreach (explode(',', (string) $files) as $p) {272 $p = trim($p);273 if ($p !== '') {274 $sources[] = ['path' => $p, 'status' => ChangedFile::STATUS_MODIFIED];275 }276 }277 }278 if (($ref = $input->getOption('git-ref')) !== null && $ref !== '') {279 foreach ($this->gitDiffNameStatus((string) $ref) as $entry) {280 $sources[] = $entry;281 }282 }283 if ((bool) $input->getOption('diff-stdin')) {284 foreach ($this->readStdinPaths() as $entry) {285 $sources[] = $entry;286 }287 }288 if ((bool) $input->getOption('dirty')) {289 foreach ((new DirtyWorkspaceScanner($this->getProjectRoot()))->changedFiles() as $entry) {290 $sources[] = $entry;291 }292 }293 if ((bool) $input->getOption('all')) {294 foreach ($this->repoWidePaths() as $entry) {295 $sources[] = $entry;296 }297 }298 return $this->dedupe($sources);299 }300301 /**302 * Repo-wide module / package roots: every `packages/semitexa-X` whose303 * package root contains `composer.json`, plus every `src/modules/X`.304 * Used by `--all` for deterministic AI-facing repo-wide structure305 * verification. Output is sorted lexicographically so NDJSON is stable.306 *307 * @return list<array{path: string, status: string}>308 */309 private function repoWidePaths(): array310 {311 $root = rtrim($this->getProjectRoot(), '/');312 $paths = [];313314 $packagesDir = $root . '/packages';315 if (is_dir($packagesDir)) {316 foreach (glob($packagesDir . '/semitexa-*', GLOB_ONLYDIR) ?: [] as $abs) {317 if (!is_file($abs . '/composer.json')) {318 continue;319 }320 $rel = ltrim(substr($abs, strlen($root)), '/');321 $paths[] = $rel;322 }323 }324325 $modulesDir = $root . '/src/modules';326 if (is_dir($modulesDir)) {327 foreach (glob($modulesDir . '/*', GLOB_ONLYDIR) ?: [] as $abs) {328 $rel = ltrim(substr($abs, strlen($root)), '/');329 $paths[] = $rel;330 }331 }332333 sort($paths);334 return array_map(335 static fn(string $p) => ['path' => $p, 'status' => ChangedFile::STATUS_MODIFIED],336 $paths,337 );338 }339340 /**341 * @return list<array{path: string, status: string, originalPath?: string}>342 */343 private function gitDiffNameStatus(string $ref): array344 {345 $cmd = sprintf(346 'git -C %s diff --name-status %s 2>&1',347 escapeshellarg($this->getProjectRoot()),348 escapeshellarg($ref),349 );350 exec($cmd, $lines, $code);351 if ($code !== 0) {352 throw new \RuntimeException("git diff against '{$ref}' failed: " . implode(' / ', $lines));353 }354 return $this->parseNameStatus($lines);355 }356357 /**358 * @return list<array{path: string, status: string, originalPath?: string}>359 */360 private function readStdinPaths(): array361 {362 $raw = (string) stream_get_contents(STDIN);363 $lines = preg_split('/\R/', trim($raw)) ?: [];364 return $this->parseNameStatus($lines);365 }366367 /**368 * @param list<string> $lines git --name-status output OR plain `git diff --name-only` lines369 * @return list<array{path: string, status: string, originalPath?: string}>370 */371 private function parseNameStatus(array $lines): array372 {373 $out = [];374 foreach ($lines as $line) {375 $line = trim($line);376 if ($line === '') {377 continue;378 }379 $parts = preg_split('/\s+/', $line, 3) ?: [];380 if (count($parts) >= 2 && preg_match('/^[AMDR][0-9]*$/', $parts[0])) {381 $status = match ($parts[0][0]) {382 'A' => ChangedFile::STATUS_ADDED,383 'M' => ChangedFile::STATUS_MODIFIED,384 'D' => ChangedFile::STATUS_DELETED,385 'R' => ChangedFile::STATUS_RENAMED,386 default => ChangedFile::STATUS_MODIFIED,387 };388 // For R entries, `git diff --name-status` emits OLD<TAB>NEW; take the389 // new path AND carry the old path so the broken-FQCN guard can query390 // the renamed symbol's previous FQCN too.391 $path = $parts[count($parts) - 1];392 $entry = ['path' => $path, 'status' => $status];393 if ($status === ChangedFile::STATUS_RENAMED && count($parts) >= 3) {394 $entry['originalPath'] = $parts[1];395 }396 $out[] = $entry;397 continue;398 }399 $out[] = ['path' => $line, 'status' => ChangedFile::STATUS_MODIFIED];400 }401 return $out;402 }403404 /**405 * @param list<array{path: string, status: string, originalPath?: string}> $entries406 * @return list<array{path: string, status: string, originalPath?: string}>407 */408 private function dedupe(array $entries): array409 {410 $seen = [];411 $out = [];412 foreach ($entries as $entry) {413 $key = $entry['path'];414 if (isset($seen[$key])) {415 // First-wins, EXCEPT where the first entry knows less. Two416 // ways that happens, and both produced a false green.417 //418 // A RENAME arriving second: `--files=<renamed destination>419 // --dirty` supplies the path by hand as a plain modification420 // and the scanner then finds the same path as a rename.421 // Dropping the second entry discarded `originalPath`, so422 // ContractMoveResolver never expanded consumers of the old423 // contract.424 //425 // A RECREATED file arriving second: staged for deletion and426 // then written again at the same path, git reports `D path`427 // followed by `?? path`. Keeping only the deletion made the428 // planner skip a file that is sitting right there, so a syntax429 // error in its new contents verified clean. If ANY source says430 // the path exists, it exists — a deletion never wins over a431 // record of a live file. Both raised in review of dev#84.432 $at = $seen[$key];433 $original = $entry['originalPath'] ?? '';434 $existingOriginal = $out[$at]['originalPath'] ?? '';435 $learnsOrigin = $original !== '' && $existingOriginal === '';436 $wasDeleted = $out[$at]['status'] === ChangedFile::STATUS_DELETED;437 $isAlive = $entry['status'] !== ChangedFile::STATUS_DELETED;438439 if ($learnsOrigin || ($wasDeleted && $isAlive)) {440 $merged = ['path' => $key, 'status' => $entry['status']];441 $keptOriginal = $original !== '' ? $original : $existingOriginal;442 if ($keptOriginal !== '') {443 $merged['originalPath'] = $keptOriginal;444 }445 $out[$at] = $merged;446 }447 continue;448 }449 $seen[$key] = count($out);450 $out[] = $entry;451 }452 return $out;453 }454455 /**456 * @param list<VerificationResult> $results457 */458 private function verdict(array $results): string459 {460 $report = new VerifyReportSerializer();461 if ($results === []) {462 return VerificationResult::STATUS_INCOMPLETE;463 }464 $hasFail = false;465 $allSkipped = true;466 foreach ($results as $r) {467 if ($r->status === VerificationResult::STATUS_FAIL) {468 $hasFail = true;469 }470 if ($r->status !== VerificationResult::STATUS_SKIPPED) {471 $allSkipped = false;472 }473 }474 if ($hasFail) {475 return VerificationResult::STATUS_FAIL;476 }477 if (!$report->completed($results)) {478 return VerificationResult::STATUS_INCOMPLETE;479 }480 return $allSkipped ? VerificationResult::STATUS_SKIPPED : VerificationResult::STATUS_PASS;481 }482483 /**484 * Read-only blast-radius probe. Never throws into the verify flow — a485 * graph problem must degrade to an "unknown" band, not fail verification.486 */487 private function probeImpact(VerificationPlan $plan): ImpactReport488 {489 $paths = [];490 foreach ($plan->changedFiles as $file) {491 if (str_ends_with($file->path, '.php')) {492 $paths[] = $file->path;493 }494 }495 if ($paths === []) {496 return ImpactReport::empty();497 }498499 if ($this->connections === null) {500 return ImpactReport::stale($paths, 'impact unavailable: no database connection bound in this context');501 }502503 try {504 return (new ImpactProbe($this->connections))->probe(array_values($paths));505 } catch (\Throwable $e) {506 return ImpactReport::stale($paths, 'impact probe failed: ' . $e->getMessage());507 }508 }509510 /**511 * @return array<string, string> path → band, for merging into changed_files512 */513 private function bandsByPath(?ImpactReport $impact): array514 {515 if ($impact === null) {516 return [];517 }518 $bands = [];519 foreach ($impact->files as $file) {520 $bands[$file->path] = $file->band;521 }522 return $bands;523 }524525 private function buildEnvelope(VerificationPlan $plan, array $results, string $verdict, ?ImpactReport $impact = null): array526 {527 $report = new VerifyReportSerializer();528 $violations = $this->collectViolations($results);529 $bands = $this->bandsByPath($impact);530 return [531 'artifact' => 'semitexa-dev.verify-report/v1',532 'generated_at' => date('c'),533 'requested_scope' => $plan->scope,534 'effective_scope' => $plan->effectiveScope,535 'expansions' => $plan->expansions,536 'changed_files' => array_map(537 static function (ChangedFile $f) use ($bands): array {538 $entry = ['path' => $f->path, 'kind' => $f->kind, 'status' => $f->status];539 if (isset($bands[$f->path])) {540 $entry['impact'] = $bands[$f->path];541 }542 return $entry;543 },544 $plan->changedFiles,545 ),546 'targets' => array_map(fn($t) => $report->serializeTarget($t), $plan->targets),547 'results' => array_map(fn($r) => $report->serializeResult($r), $results),548 'violations' => $violations,549 'verdict' => $verdict,550 'completed' => $report->completed($results),551 'counts' => $report->countByStatus($results),552 'impact' => $impact?->toSummary(),553 'next_command' => $this->buildNextCommands($verdict, $results),554 'restart' => $report->restartAdvice($plan->changedFiles),555 ];556 }557558 /**559 * @param list<VerificationResult> $results560 * @return list<array<string, mixed>>561 */562 private function collectViolations(array $results): array563 {564 $out = [];565 foreach ($results as $r) {566 foreach ($r->diagnostics as $diag) {567 $out[] = $diag;568 }569 }570 return $out;571 }572573 /**574 * @param list<VerificationResult> $results575 * @return list<array{cmd: string, args: list<string>, why: string}>576 */577 private function buildNextCommands(string $verdict, array $results): array578 {579 if ($verdict === VerificationResult::STATUS_PASS) {580 return [581 ['cmd' => 'ai:work', 'args' => ['update', '--id=<task-id>', '--status=done', '--json'], 'why' => 'close the active task — verify passed'],582 ['cmd' => 'ai:orient', 'args' => ['--json'], 'why' => 'pick up the next task'],583 ];584 }585 if (in_array($verdict, [VerificationResult::STATUS_FAIL, VerificationResult::STATUS_INCOMPLETE], true)) {586 $failingFile = null;587 $hasStructureFailure = false;588 $hasDiFailure = false;589 foreach ($results as $r) {590 if (in_array($r->status, [VerificationResult::STATUS_FAIL, VerificationResult::STATUS_INCOMPLETE], true)) {591 if ($r->target->type === VerificationTarget::TYPE_MODULE_STRUCTURE) {592 $hasStructureFailure = true;593 }594 if ($r->target->type === VerificationTarget::TYPE_PHPSTAN_DI) {595 $hasDiFailure = true;596 }597 if ($failingFile === null && $r->target->filePath !== null) {598 $failingFile = $r->target->filePath;599 }600 }601 }602 $out = [603 ['cmd' => 'ai:work', 'args' => ['update', '--id=<task-id>', '--status=blocked', '--note="verify failed"'], 'why' => 'record the block before iterating (§9.3)'],604 ];605 if ($hasStructureFailure) {606 $out[] = [607 'cmd' => 'cat',608 'args' => ['packages/semitexa-docs/docs/MODULE_STRUCTURE.md'],609 'why' => 'module structure violations — re-read the rule table before moving files',610 ];611 }612 if ($hasDiFailure) {613 $out[] = [614 'cmd' => 'cat',615 'args' => ['packages/semitexa-docs/docs/AI_BEST_PRACTICES.md'],616 'why' => 'DI violations — Semitexa is attribute-only DI; re-read before reshaping a class',617 ];618 }619 if ($failingFile !== null) {620 $out[] = ['cmd' => 'ai:ask', 'args' => ['path', '--path=' . $failingFile, '--json'], 'why' => 'inspect the failing file before querying symbol impact'];621 }622 $out[] = ['cmd' => 'logs:app', 'args' => ['--grep=error', '--lines=200', '--level=ERROR', '--json'], 'why' => 'runtime errors that might explain the failure'];623 return $out;624 }625 return [626 ['cmd' => 'ai:verify', 'args' => ['--files=<paths>', '--scope=standard', '--json'], 'why' => 'no actionable verification ran — select files and try standard scope'],627 ];628 }629630 /**631 * @param list<VerificationResult> $results632 * @param array{scanned: list<string>, unscannable: list<string>}|null $dirtyScan633 */634 private function emitNdjson(635 OutputInterface $output,636 VerificationPlan $plan,637 array $results,638 string $verdict,639 ?ImpactReport $impact = null,640 ?array $dirtyScan = null,641 ): void {642 $report = new VerifyReportSerializer();643 // NDJSON is the DEFAULT mode, and the reach of a `--dirty` scan existed644 // only in the `--json` envelope -- so a run that could not ask half the645 // tree said so exclusively to the readers who had not asked for this646 // mode. A record of its own, dispatchable by `kind` like every other.647 // Raised in review of dev#84.648 if ($dirtyScan !== null) {649 $output->writeln(json_encode(650 ['kind' => 'dirty_scan'] + $dirtyScan,651 JSON_UNESCAPED_SLASHES,652 ));653 }654655 $output->writeln(json_encode([656 'kind' => 'summary',657 'requested_scope' => $plan->scope,658 'effective_scope' => $plan->effectiveScope,659 'changed_files' => count($plan->changedFiles),660 'targets' => count($plan->targets),661 ], JSON_UNESCAPED_SLASHES));662663 if ($impact !== null) {664 $output->writeln(json_encode([665 'kind' => 'impact',666 'impact' => $impact->toSummary(),667 'files' => array_map(static fn ($f) => $f->toArray(), $impact->files),668 ], JSON_UNESCAPED_SLASHES));669 }670671 foreach ($plan->expansions as $note) {672 $output->writeln(json_encode([673 'kind' => 'expansion',674 'note' => $note,675 ], JSON_UNESCAPED_SLASHES));676 }677678 foreach ($plan->targets as $target) {679 $output->writeln(json_encode([680 'kind' => 'target',681 'target' => $report->serializeTarget($target),682 ], JSON_UNESCAPED_SLASHES));683 }684685 foreach ($results as $result) {686 $output->writeln(json_encode([687 'kind' => 'result',688 'result' => $report->serializeResult($result),689 ], JSON_UNESCAPED_SLASHES));690 foreach ($result->diagnostics as $diagnostic) {691 $output->writeln(json_encode([692 'kind' => 'violation',693 'target_id' => $result->target->id,694 ...$diagnostic,695 ], JSON_UNESCAPED_SLASHES));696 }697 }698699 // Before the verdict, so it is read rather than scrolled past.700 //701 // The advice existed only in the `--json` envelope, and NDJSON is the702 // default — so the habit this was written to correct (wrapping every703 // verify in a 16s server:restart) never met the sentence that corrects704 // it. An unstated assumption is not fixed by being wrong somewhere the705 // reader does not look.706 $output->writeln((string) json_encode(707 ['kind' => 'restart'] + $report->restartAdvice($plan->changedFiles),708 JSON_UNESCAPED_SLASHES,709 ));710711 $verdictLine = [712 'kind' => 'verdict',713 'verdict' => $verdict,714 'completed' => $report->completed($results),715 'counts' => $report->countByStatus($results),716 ];717 if ($impact !== null) {718 $verdictLine['impact'] = $impact->toSummary();719 }720 $output->writeln(json_encode($verdictLine, JSON_UNESCAPED_SLASHES));721 }722723 private function emitError(OutputInterface $output, string $message, bool $jsonMode): void724 {725 if ($jsonMode) {726 $output->writeln(json_encode([727 'artifact' => 'semitexa-dev.verify-report/v1',728 'generated_at' => date('c'),729 'verdict' => 'fail',730 'error' => $message,731 ], JSON_UNESCAPED_SLASHES));732 return;733 }734 $output->writeln(json_encode([735 'kind' => 'error',736 'error' => $message,737 ], JSON_UNESCAPED_SLASHES));738 }739}740
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.