← CLI

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
semitexa-dev ai:orient ai:task ai:ask ai:context ai:plan ai:work ai:epic ai:trace ai:verify ai:observe ai:report project graph structured 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:

  1. What state is this project in?
  2. What kind of task is this?
  3. Where does the relevant behavior live?
  4. What is running right now?
  5. What should be verified after the change?
  6. 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.

Semitexa Dev operating loopSemitexa Dev operating loop. Orient: Read current project state. Classify: Choose recipe and risk. Inspect: Ask structure and runtime. Plan: Score the exact file set. Edit: Apply the focused change. Verify: Run relevant checks. Record: Preserve decisions and next stepintentscopeevidenceactprovepreservenext sessionOrientRead current project stateClassifyChoose recipe and riskInspectAsk structure and runtimePlanScore the exact file setEditApply the focused changeVerifyRun relevant checksRecordPreserve decisions and nextstep
Semitexa Dev operating loop

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:

TerminalRun in the project root
bin/semitexa ai:orient --json
Expected result
JSON envelope with artifact `semitexa-dev.ai-orient/v1`, current Git and work state, and contextual `next_command` suggestions.

The response combines repository state with active Semitexa work artifacts. Its next_command entries explain useful follow-ups based on the current state.

For a new executable request, classify the intent:

bin/semitexa ai:task "add tenant-aware invoice export" --json

The classifier returns a recipe and a confidence level. High confidence gives a reliable starting path. Low confidence is a signal to inspect the suggested alternatives or clarify the goal before running a generator.

Choose the narrowest inspection tool

Use ai:ask for project facts that Semitexa can answer directly:

bin/semitexa ai:ask project --json
bin/semitexa ai:ask module --name=Billing --json
bin/semitexa ai:ask route --path=/invoices --method=GET --json
bin/semitexa ai:ask event --name=InvoiceIssued --json
bin/semitexa ai:ask mechanisms --area=ssr --json
bin/semitexa ai:ask logs --grep=invoice --level=ERROR --lines=200 --json

These commands describe discovered routes, handlers, resources, templates, services, events, logs, and framework mechanisms. They are preferable to broad text searches when the question is about framework structure.

Use ai:context after ai:task chooses a recipe:

bin/semitexa ai:context add_html_page --module=Billing --json

It returns nearby prior art and repository conventions, so a new page follows the module that already works instead of inventing a parallel pattern.

Use Project Graph when the question is about relationships or blast radius:

bin/semitexa ai:review-graph:query \
  --usages='Semitexa\Billing\Application\Service\InvoiceExporter' \
  --json

bin/semitexa ai:review-graph:impact \
  'Semitexa\Billing\Application\Service\InvoiceExporter' \
  --json

Project Graph follows structural edges such as uses, implements, handles, and serves_route. Full-text search remains useful for literal strings; it should not substitute for a dependency query.

Plan against the real file set

Before a multi-file or uncertain edit, give ai:plan the recipe and the files you expect to touch:

bin/semitexa ai:plan add_html_page \
  --module=Billing \
  --files=packages/semitexa-billing/src/Application/Payload/Request/InvoicePayload.php,packages/semitexa-billing/src/Application/Handler/PayloadHandler/InvoiceHandler.php \
  --json

The result explains why the change is low, medium, or high risk. If the reported risk is higher than expected, reduce the file set, split the work, or inspect the newly exposed dependency before editing.

Debug the runtime through the Observatory

ai:observe is the first stop for runtime behavior. It reports what actually ran instead of asking you to infer behavior from source alone.

bin/semitexa ai:observe ps
bin/semitexa ai:observe tail --kind=http --follow --duration=15
bin/semitexa ai:observe show --id=p-123 --source

The three views serve different questions:

View Use it for
ps Live processes, recent completions, workers, and stale activity
tail A bounded stream of new HTTP, queue, scheduler, or SSE lifecycles
show One process with spans, queries, payload snapshot, timing, and optional source

A request made with ?__trace=1 records a full waterfall. The same trace is available to humans at /__trace and to agents through ai:observe show.

The Observatory also shows the code's structure: its graph mode browses the project graph, and links a class to the recent traces that ran it. See Browsing the Graph.

When a recorded request needs controlled reproduction, replay it in the development sandbox:

bin/semitexa ai:observe replay --id=p-123 --mutate status='"cancelled"'

Replay rolls writes back and records the differences from the original process. This makes it useful for testing a hypothesis without leaving mutated application state behind.

For quick handler feedback without HTTP, auth, or middleware, use ai:invoke:

bin/semitexa ai:invoke \
  --route=/invoices \
  --payload='{"page":1}' \
  --json

The response states which pipeline layers were omitted. Treat it as a handler probe, then use an HTTP or browser check when the complete request pipeline matters.

A page's live timeline

bin/semitexa ai:observe timeline                 # the pages seen lately
bin/semitexa ai:observe timeline --id=sse_…      # one page, in order

In development, every page with a live stream (its KISS session id) keeps a timeline:

  • event: a component's UI event, with its effects and how long it took;
  • frame: each frame its stream wrote, with id and size;
  • subscribe / unsubscribe: a feed attached or detached;
  • rerun: a feed that re-ran, why (a write or a view change), and what it sent (a keyed patch, a whole page, or nothing);
  • replay / reset: a reconnect resumed, or could not;
  • deferred: a deferred component rendered, and how long it took.

The same timeline is at /__observatory/timeline for a human, linked from the Observatory panel. It is written one short line at a time under var/observatory/timeline/, stops at 1 MB a page, and is removed after a day. Outside development nothing is recorded; a record is one null check.

Keep review evidence private

A screenshot, a recording, a request trace or a graph export made to prove a change shows whatever was on screen or in the database. Keep it in the evidence store, not in a pull request:

bin/semitexa ai:evidence add var/tmp/cart.png --kind=screenshot --data=synthetic --note="cart after the fix"
bin/semitexa ai:evidence list
bin/semitexa ai:evidence prune --dry-run --tmp

Each item lives in var/evidence/<id>/ with a passport — what it is, whether it was made from synthetic or real data, who recorded it, when it expires (14 days by default). var/evidence/ is in the project's .gitignore, and lint:var-artifacts fails ai:verify when anything else under var/ is about to be committed.

Publishing is the operator's step, in their own terminal: ai:evidence publish <id> --to="PR acme/shop#7" checks the item — a picture of real data never goes, text is scanned for tokens and workstation paths — then asks for the id to be typed. It refuses without a terminal, so an agent cannot approve its own upload by accident. The same check guards review-prep's PR description and pr-reply.sh.

The Observatory's evidence mode (V) lists the store: search, filters by kind, data, visibility and day, sortable columns, pages, and a passport with a preview for each item. It is dev-only, and files are served sandboxed.

Keep long work recoverable

Semitexa stores long-lived work in three layers:

Artifact Responsibility
ai:epic The outcome shared by several work items
ai:work One executable leaf task with recipe, risk, context, and next step
ai:trace Decisions, observations, failed hypotheses, and verification events

Create an epic when work spans several modules or will not fit safely in one sitting:

bin/semitexa ai:epic start \
  --id=ep-invoice-export \
  --title="Ship invoice export" \
  --goal="Let tenant administrators export auditable invoice data." \
  --json

bin/semitexa ai:work start \
  --id=tk-invoice-export-http \
  --epic=ep-invoice-export \
  --title="Add the export endpoint" \
  --recipe=add_html_page \
  --risk=medium \
  --context-ref=packages/semitexa-billing \
  --next-step="Inspect the invoice route and generate the smallest export page." \
  --json

Write notes for the next working session: record the decision that would otherwise be debated again, the hypothesis already disproved, and the exact next command that restarts the work.

Verify the change, not the whole universe

Run ai:verify after a coherent edit:

bin/semitexa ai:verify \
  --files=packages/semitexa-billing/src/Application/Handler/PayloadHandler/InvoiceHandler.php,packages/semitexa-billing/src/Application/Resource/Response/InvoiceResource.php \
  --json

Semitexa classifies the changed files and selects the relevant checks. Depending on the diff, that can include PHP syntax, handler and DI lint, template validation, module structure, static-analysis rules, generated reference drift, or focused tests.

Verification runs in a fresh CLI process and sees saved files immediately. Restart the server only before exercising the long-running HTTP worker through a browser, curl, or E2E test.

What a person sees

A passing test does not see the page. When a template or a stylesheet changes, bin/semitexa ai:verify also opens the pages it shows on:

  • Where: in the e2e-runner on the host, against the app that is already running. Verify never starts or restarts the app.
  • How: in light and in dark. A colour that fails only in dark is a real bug.
  • What it checks: axe checks contrast, labels, names, the title and the language. The page check adds what axe does not see:
    • a page that uses --ui-* tokens with no skin linked;
    • a form field with no edge;
    • text against the window;
    • console errors;
    • an HTTP status of 400 or more.

Which pages. Only public GET routes that need no parameter, produce HTML and do not act when opened (logout, a sign-in start, submit…):

  • a file in a module opens that module's pages;
  • a package's file opens that package's pages;
  • a file with no pages of its own, such as the theme or a layout, opens the project's pages.

For a template, the module is what chooses the page, not proof that the page renders that template. For a stylesheet, a page counts only when it loads the stylesheet in both modes. Make sure a page that shows your template is in the list.

The shortest paths come first, and at most 8 pages are opened. Pages past the cap are not checked. bin/semitexa ai:verify --files=… --list-pages prints the list. Open the rest yourself when the change reaches further.

When it is not a pass. A page counts as checked only when the change could show on it. A finding in either mode fails the page, even when the other mode could not be checked. Without a finding, these are reported as incomplete, never as a pass:

  • a page that could not be reached;
  • a page seen in one mode only;
  • a page that redirected elsewhere, such as to a sign-in page;
  • a page that does not load the stylesheet that changed.

What the browser saw stays under var/run/verify-e2e/<run>/pages/, a screenshot per page and mode included.

The page check needs a matching bin/semitexa. The launcher, not the app, opens the pages. bin/semitexa update replaces the launcher together with semitexa/dev. A launcher you edited is left alone: bin/semitexa update --write-scaffold-candidates writes the new one beside it as bin/semitexa.new to merge. Until you merge it, every page is reported as incomplete and the reason names what is missing.

Report framework defects with evidence

When a project must work around a Semitexa defect, capture the defect as part of the fix:

bin/semitexa ai:report \
  --title="Route inspection omits response template" \
  --summary="The route is valid, but ai:ask route does not report its discovered Twig template." \
  --evidence="bin/semitexa ai:ask route --path=/invoices --json → template is absent" \
  --workaround="Inspected the resource attribute directly." \
  --package=semitexa-dev \
  --json

ai:report searches for duplicates and can add another sighting instead of opening a parallel issue. Review the rendered report before publication, and keep consumer configuration, credentials, and private application code out of public evidence.

Machine-readable by design

The ai:* commands expose stable JSON envelopes for automation. A typical envelope contains:

  • an artifact and schema version;
  • the requested result;
  • confidence, omissions, or risk where relevant;
  • next_command suggestions with reasons;
  • verification or trace outcomes when the command produces them.

This contract lets a human read the same facts an agent consumes. It also gives integrations a stable surface without scraping colored terminal output.

The local assistant is a separate entrypoint

bin/semitexa ai starts the optional local assistant backed by registered #[AsAiSkill] commands:

bin/semitexa ai --dry-run
bin/semitexa ai --yes
bin/semitexa ai:skills --json

The assistant translates natural-language intent into declared skills. The deterministic ai:* commands remain available whether or not a local language model is configured.

Command reference

Use this page to choose the right workflow. Use the generated AI commands reference for every argument and option supported by the installed release:

bin/semitexa help ai:orient
bin/semitexa help ai:observe
bin/semitexa help ai:verify
bin/semitexa ai:ask capabilities --json

The capability manifest is the authoritative inventory for the running project because it reflects the packages and commands actually installed there.

© Jeff Sickel: "Deleted code is debugged code."

ai:orient Command Implementation slice
<?phpdeclare(strict_types=1);namespace Semitexa\Dev\Application\Console\Command;use Semitexa\Dev\Application\Service\Ai\Presence\StackEvents;use Semitexa\Dev\Application\Service\Ai\Presence\WorkspaceActivity;use Semitexa\Dev\Application\Service\Ai\Presence\AgentSession;use Semitexa\Dev\Application\Service\Ai\Presence\AgentDocsFreshness;use Semitexa\Dev\Application\Service\Ai\Presence\AgentRegistry;use Semitexa\Core\Support\ProjectRoot;use Semitexa\Core\Attribute\AsCommand;use Semitexa\Core\Attribute\InjectAsReadonly;use Semitexa\Core\Console\BaseCommand;use Semitexa\Dev\Application\Service\Quality\QualityAdvisor;use Semitexa\Dev\Application\Service\Ai\Charter\CharterStore;use Semitexa\Dev\Application\Service\Ai\Charter\OwnerQuestions;use Semitexa\Dev\Application\Service\Ai\Trace\TraceEventKind;use Semitexa\Dev\Application\Service\Ai\Trace\TraceHeader;use Semitexa\Dev\Application\Service\Ai\Trace\TraceStore;use Semitexa\Dev\Application\Service\Ai\Work\Epic;use Semitexa\Dev\Application\Service\Ai\Work\EpicStore;use Semitexa\Dev\Application\Service\Ai\Work\Task;use Semitexa\Dev\Application\Service\Ai\Work\TaskStatus;use Semitexa\Dev\Application\Service\Ai\Work\TaskStore;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Input\InputOption;use Symfony\Component\Console\Output\OutputInterface;use Symfony\Component\Console\Style\SymfonyStyle;/** * Single-call session dashboard for an agent that just opened the repo. * * Fuses: git (branch + last commit + dirty flag) + active epic + in-progress * tasks + recent traces + last verify result + a next-step suggestion. * * Designed as the **first command** a cold-start agent runs. One tool call * should replace 5–6 probes (ai:ask project, ai:epic list, ai:work list, * ai:trace list, git status, git log). * * Cheap by construction — reads file-backed stores and runs two short shell * commands for git. Does NOT execute linters, verification, or graph rebuild. */#[AsCommand(    name: 'ai:orient',    description: 'Session dashboard: git + active epic + in-progress tasks + recent traces + next step. Run this first on a cold start.',)]final class AiOrientCommand extends BaseCommand{    #[InjectAsReadonly]    protected EpicStore $epicStore;    #[InjectAsReadonly]    protected TaskStore $taskStore;    #[InjectAsReadonly]    protected TraceStore $traceStore;    #[InjectAsReadonly]    protected CharterStore $charterStore;    public function __construct()    {        parent::__construct('ai:orient');    }    protected function configure(): void    {        $this            ->addOption('json', null, InputOption::VALUE_NONE, 'Emit the full JSON envelope (default for non-interactive callers)')            ->addOption('human', null, InputOption::VALUE_NONE, 'Force human-readable output even in non-interactive mode')            ->addOption('traces', null, InputOption::VALUE_REQUIRED, 'How many recent traces to include', '5');    }    protected function execute(InputInterface $input, OutputInterface $output): int    {        $traceLimit = max(1, min(50, (int) $input->getOption('traces')));        $git           = $this->collectGit();        $epics         = $this->collectEpics();        $inProgress    = $this->collectTasksByStatus(TaskStatus::IN_PROGRESS);        $blocked       = $this->collectTasksByStatus(TaskStatus::BLOCKED);        $activeEpicId  = $this->deriveActiveEpicId($inProgress, $blocked, $epics);        $recentTraces  = $this->collectRecentTraces($traceLimit);        $lastVerify    = $this->findLastVerify($recentTraces);        try {            $qualityNext = (new QualityAdvisor(ProjectRoot::get()))->targets(3);            $qualityError = null;        } catch (\RuntimeException $e) {            // A broken ledger is said, not shown as "nothing to improve".            [$qualityNext, $qualityError] = [[], $e->getMessage()];        }        $hints         = $this->suggestNext($git, $activeEpicId, $inProgress, $blocked, $epics);        $ownerQuestions = OwnerQuestions::of($this->charterStore);        $hints         = OwnerQuestions::lead($hints, $ownerQuestions);        $workingNow = $this->workingNow();        if ($workingNow['you'] === null || !($workingNow['you']['joined'] ?? true)) {            // Joining is how the others see you; it is first because an agent            // that skips it is the one the others have to guess about. One that            // bin/semitexa recognised is listed already, but with no intent.            array_unshift($hints['commands'], [                'cmd'  => 'ai:agent',                'args' => ['join', '--name=<claude|codex|…>', '--intent="<what you are about to do>"', '--repo=<repo you will edit>', '--json'],                'why'  => 'other agents see who you are and what you touch; then export SEMITEXA_AGENT_SESSION',            ]);        }        $envelope = [            'artifact'     => 'semitexa.ai-orient/v1',            'working_now'  => $workingNow,            'generated_at' => gmdate('c'),            'cwd'          => getcwd() ?: '',            'git'          => $git,            'backlog'      => [                'total_epics'           => count($epics),                'active_epic_id'        => $activeEpicId,                'active_epic'           => $activeEpicId !== null ? $this->epicBrief($epics, $activeEpicId) : null,                // held_by: the live agent working it, or null — an in_progress                // task nobody live holds is one somebody walked away from.                'in_progress_tasks'     => array_map(fn(Task $t) => $this->taskBrief($t) + ['held_by' => $this->holderOf($t->id, $workingNow)], $inProgress),                'in_progress_count'     => count($inProgress),                'blocked_tasks'         => array_map(fn(Task $t) => $this->taskBrief($t), $blocked),                'blocked_count'         => count($blocked),            ],            'recent_traces'  => $recentTraces,            'last_verify'    => $lastVerify,            // Red runs of the last day nobody checked with ai:verify:receipt:            // what a subagent saw and its parent may never have.            'unread_failed_verify' => array_values(array_filter(                (new \Semitexa\Dev\Application\Service\Ai\Verify\Receipt\VerifyReceipts($this->getProjectRoot()))->unread(86400),                static fn (array $r): bool => $r['verdict'] !== 'pass',            )),            // The improvement loop's entry point: what the quality ledger says to            // make better next. Read from the recorded baseline, measured nothing.            'quality_next'   => $qualityNext,            // What Semitexa asks the owner next (ep-project-charter), and how.            'owner_questions' => $ownerQuestions,            'quality_error'  => $qualityError,            'suggest_next'   => $hints['summary'],            'next_command'   => $hints['commands'],        ];        $forceJson  = (bool) $input->getOption('json');        $forceHuman = (bool) $input->getOption('human');        $useHuman   = $forceHuman || (!$forceJson && $input->isInteractive());        if ($useHuman) {            $this->renderHuman(new SymfonyStyle($input, $output), $envelope);        } else {            $output->writeln(json_encode($envelope, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '{}', OutputInterface::OUTPUT_RAW);        }        return self::SUCCESS;    }    // ─── collectors ────────────────────────────────────────────────    /**     * @return array{is_repo: bool, branch: ?string, head: ?string, last_commit_summary: ?string, dirty: ?bool, ahead: ?int, behind: ?int}     */    private function collectGit(): array    {        $cwd = getcwd() ?: '';        $cdPrefix = 'cd ' . escapeshellarg($cwd) . ' && ';        $isRepo = trim((string) shell_exec($cdPrefix . 'git rev-parse --is-inside-work-tree 2>/dev/null')) === 'true';        if (!$isRepo) {            return [                'is_repo' => false,                'branch'  => null,                'head'    => null,                'last_commit_summary' => null,                'dirty'   => null,                'ahead'   => null,                'behind'  => null,            ];        }        $branch = trim((string) shell_exec($cdPrefix . 'git branch --show-current 2>/dev/null')) ?: null;        $head   = trim((string) shell_exec($cdPrefix . 'git rev-parse --short HEAD 2>/dev/null')) ?: null;        $last   = trim((string) shell_exec($cdPrefix . 'git log -1 --pretty=%s 2>/dev/null')) ?: null;        $status = (string) shell_exec($cdPrefix . 'git status --porcelain 2>/dev/null');        $dirty  = trim($status) !== '';        $ahead  = null;        $behind = null;        if ($branch !== null) {            $track = trim((string) shell_exec($cdPrefix . 'git rev-list --left-right --count @{u}...HEAD 2>/dev/null'));            if ($track !== '' && preg_match('/^(\d+)\s+(\d+)$/', $track, $m)) {                $behind = (int) $m[1];                $ahead  = (int) $m[2];            }        }        return [            'is_repo' => true,            'branch'  => $branch,            'head'    => $head,            'last_commit_summary' => $last,            'dirty'   => $dirty,            'ahead'   => $ahead,            'behind'  => $behind,        ];    }    /**     * @return list<Epic>     */    private function collectEpics(): array    {        try {            return $this->epicStore->list();        } catch (\Throwable) {            return [];        }    }    /**     * Collect tasks of a single status, most-recently-updated first.     *     * IN_PROGRESS and BLOCKED are kept apart on purpose: a blocked task is NOT     * resumable work, so conflating the two made `in_progress_count` lie and     * made suggest_next promote a blocked task as the next action.     *     * @return list<Task>     */    private function collectTasksByStatus(TaskStatus $status): array    {        $out = [];        try {            foreach ($this->taskStore->list(null, $status) as $task) {                $out[] = $task;            }        } catch (\Throwable) {            // store not initialised yet — that's fine, return what we have        }        usort($out, static fn(Task $a, Task $b) => strcmp($b->updatedAt, $a->updatedAt));        return $out;    }    /**     * Choose the epic the agent should focus on, in priority order:     *   1. epic of the most-recent in-progress task (real WIP),     *   2. an epic whose own status is in_progress,     *   3. epic of the most-recent blocked task (stalled WIP — last resort, so     *      the dashboard still points somewhere instead of going blank).     *     * Blocked work never outranks live in-progress work, which is what the old     * recency-only rule got wrong.     *     * @param list<Task> $inProgress     * @param list<Task> $blocked     * @param list<Epic> $epics     */    private function deriveActiveEpicId(array $inProgress, array $blocked, array $epics): ?string    {        if ($inProgress !== []) {            return $inProgress[0]->epicId;        }        foreach ($epics as $epic) {            if ($epic->status->value === 'in_progress') {                return $epic->id;            }        }        if ($blocked !== []) {            return $blocked[0]->epicId;        }        return null;    }    /**     * @return list<array{id: string, created_at: string, topic: ?string, recipe: ?string, last_event: ?array{kind: string, at: string, summary: string}}>     */    private function collectRecentTraces(int $limit): array    {        try {            $headers = $this->traceStore->list();        } catch (\Throwable) {            return [];        }        usort($headers, static fn(TraceHeader $a, TraceHeader $b) => strcmp($b->createdAt, $a->createdAt));        $headers = array_slice($headers, 0, $limit);        $out = [];        foreach ($headers as $h) {            $lastEvent = null;            try {                $trace = $this->traceStore->read($h->traceId);                $events = $trace->events;                if ($events !== []) {                    $tail = end($events);                    $lastEvent = [                        'kind'    => $tail->eventKind,                        'at'      => $tail->at,                        'summary' => $tail->summary,                    ];                }            } catch (\Throwable) {                // skip unreadable traces            }            $out[] = [                'id'         => $h->traceId,                'created_at' => $h->createdAt,                'topic'      => $h->topic,                'recipe'     => $h->recipe,                'last_event' => $lastEvent,            ];        }        return $out;    }    /**     * @param list<array{id: string, created_at: string, topic: ?string, recipe: ?string, last_event: ?array{kind: string, at: string, summary: string}}> $recentTraces     * @return array{trace_id: string, at: string, summary: string, verdict: ?string}|null     */    private function findLastVerify(array $recentTraces): ?array    {        foreach ($recentTraces as $row) {            $last = $row['last_event'];            if ($last !== null && $last['kind'] === TraceEventKind::VERIFY_RESULT) {                return [                    'trace_id' => $row['id'],                    'at'       => $last['at'],                    'summary'  => $last['summary'],                    'verdict'  => $this->parseVerdictFromSummary($last['summary']),                ];            }        }        // scan deeper — look through trace bodies        foreach ($recentTraces as $row) {            try {                $trace = $this->traceStore->read($row['id']);                foreach (array_reverse($trace->events) as $e) {                    if ($e->eventKind === TraceEventKind::VERIFY_RESULT) {                        return [                            'trace_id' => $row['id'],                            'at'       => $e->at,                            'summary'  => $e->summary,                            'verdict'  => $this->parseVerdictFromSummary($e->summary),                        ];                    }                }            } catch (\Throwable) {                continue;            }        }        return null;    }    private function parseVerdictFromSummary(string $summary): ?string    {        $lower = strtolower($summary);        if (str_contains($lower, 'pass')) {            return 'pass';        }        if (str_contains($lower, 'fail')) {            return 'fail';        }        return null;    }    // ─── next-step suggestion ──────────────────────────────────────    /**     * @param array{is_repo: bool, dirty: ?bool, ...} $git     * @param list<Task> $inProgress     * @param list<Task> $blocked     * @param list<Epic> $epics     * @return array{summary: string, commands: list<array{cmd: string, args: list<string>, why: string}>}     */    /**     * Every other live agent, and what is uncommitted in the workspace — the     * two things an agent otherwise finds out by colliding with them.     *     * @return array{you: ?array<string, mixed>, agents: list<array<string, mixed>>, activity: list<array<string, mixed>>, activity_known: bool, stack: list<array<string, mixed>>, agent_docs: string, project: string, unclaimed_fresh: list<string>, unreadable: list<string>}     */    private function workingNow(): array    {        $now = time();        $registry = new AgentRegistry(ProjectRoot::get());        $registry->beat();        $you = $registry->current();        $live = $registry->all(false, $now);        $others = array_values(array_filter($live, static fn (AgentSession $a): bool => $a->id !== $you?->id));        $radar = new WorkspaceActivity(ProjectRoot::get());        // Repo paths are matched as host paths: an agent that joined in another        // project and edits this one's packages claims them too.        $activity = $radar->dirtyRepos($live, $now, $registry->project());        return [            'you' => $you?->toArray(),            // Agents of other projects on this machine too: they edit the same packages.            'agents' => array_map(static fn (AgentSession $a): array => $a->toArray() + ['silent_s' => $a->secondsSilent($now), 'elsewhere' => $a->project !== $registry->project()], $others),            'activity' => $activity,            // false: git is not available here, so "no edits" means unknown, not clean.            'activity_known' => $radar->gitAvailable(),            // Fresh edits nobody declared: another session that never joined,            // or your own work you did not list. Either way, look before you commit.            // The shared stack's last lifecycle events: a restart took the server            // and everyone's one-off containers down, and this says whose it was.            'stack' => (new StackEvents(ProjectRoot::get()))->recent(3, $now),            // A stale manual is how a whole project's agents missed "Say you are            // here": nothing updated AGENTS.md before 2026-10-17.            'agent_docs' => (new AgentDocsFreshness(ProjectRoot::get()))->agentsMd(),            'project' => $registry->project(),            'unclaimed_fresh' => array_values(array_map(                static fn (array $r): string => $r['repo'],                array_filter($activity, static fn (array $r): bool => $r['fresh'] && $r['claimed_by'] === []),            )),            'unreadable' => array_values(array_map(                static fn (array $r): string => $r['repo'],                array_filter($activity, static fn (array $r): bool => !$r['readable']),            )),        ];    }    /**     * @param array{agents: list<array<string, mixed>>, you: ?array<string, mixed>, project: string} $workingNow     */    private function holderOf(string $taskId, array $workingNow): ?string    {        foreach ([...$workingNow['agents'], ...($workingNow['you'] !== null ? [$workingNow['you']] : [])] as $agent) {            // The rule TaskClaim enforces (AgentSession::holds): the task's backlog, not where the agent joined.            if (($agent['task'] ?? null) === $taskId && (($agent['task_project'] ?? null) ?: ($agent['project'] ?? '')) === $workingNow['project']) {                return (string) $agent['id'];            }        }        return null;    }    /** @return array{summary: string, commands: list<array{cmd: string, args: list<string>, why: string}>} */    private function suggestNext(array $git, ?string $activeEpicId, array $inProgress, array $blocked, array $epics): array    {        $cmds = [];        $parts = [];        if ($inProgress !== []) {            $top = $inProgress[0];            $parts[] = "Resume task `{$top->id}` ({$top->status->value}) under epic `{$top->epicId}`.";            $cmds[] = [                'cmd'  => 'ai:work',                'args' => ['resume', '--id=' . $top->id, '--json'],                'why'  => "resume the in-progress task directly",            ];            if ($top->nextStep !== null && $top->nextStep !== '') {                $parts[] = 'Next step on record: ' . $top->nextStep;            }        } elseif ($blocked !== []) {            // No live work — only blocked tasks. A blocked task is NOT resumable:            // surface it for unblocking, but steer the agent to pickable work.            $top = $blocked[0];            $n = count($blocked);            $parts[] = $n === 1                ? "No in-progress work. Task `{$top->id}` is blocked under epic `{$top->epicId}` — unblock it or pick new work."                : "No in-progress work. {$n} tasks are blocked (latest `{$top->id}`) — unblock one or pick new work.";            $cmds[] = [                'cmd'  => 'ai:work',                'args' => ['show', '--id=' . $top->id, '--json'],                'why'  => 'inspect why the blocked task is stuck',            ];            $cmds[] = [                'cmd'  => 'ai:work',                'args' => ['list', '--status=new', '--json'],                'why'  => 'find pickable (new) work instead',            ];        } elseif ($activeEpicId !== null) {            $parts[] = "Epic `{$activeEpicId}` has no in-progress tasks — pick one up.";            $cmds[] = [                'cmd'  => 'ai:work',                'args' => ['list', '--epic=' . $activeEpicId, '--status=new', '--json'],                'why'  => 'list new tasks in the active epic',            ];        } else {            $activeCount = 0;            foreach ($epics as $e) {                if (!in_array($e->status->value, ['discarded', 'archived'], true)) {                    $activeCount++;                }            }            if ($activeCount > 0) {                $parts[] = 'No in-progress tasks. Check the backlog.';                $cmds[] = [                    'cmd'  => 'ai:epic',                    'args' => ['list', '--json'],                    'why'  => 'show active epics',                ];            } else {                $parts[] = 'No active backlog. Classify the request first.';                $cmds[] = [                    'cmd'  => 'ai:task',                    'args' => ['"<the request>"', '--json'],                    'why'  => 'classify the user request into a recipe',                ];            }        }        if (($git['dirty'] ?? false) === true) {            $parts[] = 'Working tree is dirty — verify before committing.';            $cmds[] = [                'cmd'  => 'ai:verify',                'args' => ['--json'],                'why'  => 'lint/test the current diff',            ];        }        $cmds[] = [            'cmd'  => 'ai:ask',            'args' => ['project', '--json'],            'why'  => 'structural overview if you need it (27 modules)',        ];        return [            'summary'  => implode(' ', $parts),            'commands' => $cmds,        ];    }    // ─── formatters ────────────────────────────────────────────────    /**     * @param list<Epic> $epics     * @return array{id: string, title: string, status: string, task_count: int}|null     */    private function epicBrief(array $epics, string $id): ?array    {        foreach ($epics as $e) {            if ($e->id === $id) {                return [                    'id'         => $e->id,                    'title'      => $e->title,                    'status'     => $e->status->value,                    'task_count' => count($e->taskIds),                ];            }        }        return null;    }    /**     * @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}     */    private function taskBrief(Task $task): array    {        return [            'id'           => $task->id,            'epic_id'      => $task->epicId,            'title'        => $task->title,            'status'       => $task->status->value,            'recipe'       => $task->recipe,            'risk'         => $task->risk,            'next_step'    => $task->nextStep,            'context_refs' => $task->contextRefs,            'updated_at'   => $task->updatedAt,        ];    }    /**     * @param array<string, mixed> $envelope     */    private function renderHuman(SymfonyStyle $io, array $envelope): void    {        $io->title('Session dashboard (ai:orient)');        $git = $envelope['git'];        if ($git['is_repo']) {            $dirty = $git['dirty'] ? 'dirty' : 'clean';            $track = '';            if ($git['ahead'] !== null || $git['behind'] !== null) {                $track = " (ahead {$git['ahead']} / behind {$git['behind']})";            }            $io->section('Git');            $io->writeln("  branch: {$git['branch']}{$track} — {$dirty}");            $io->writeln("  head:   {$git['head']}  {$git['last_commit_summary']}");        } else {            $io->section('Git');            $io->writeln('  (not a git repository)');        }        $backlog = $envelope['backlog'];        $io->section('Backlog');        $blockedCount = $backlog['blocked_count'] ?? 0;        $io->writeln("  epics: {$backlog['total_epics']} total  in-progress tasks: {$backlog['in_progress_count']}  blocked: {$blockedCount}");        if ($backlog['active_epic'] !== null) {            $ae = $backlog['active_epic'];            $io->writeln("  active epic: {$ae['id']}  [{$ae['status']}]  — {$ae['title']}");        }        foreach ($backlog['in_progress_tasks'] as $t) {            $next = $t['next_step'] !== null && $t['next_step'] !== '' ? "  — next: {$t['next_step']}" : '';            $io->writeln("    • [{$t['status']}] {$t['id']}  ({$t['recipe']}/{$t['risk']})  {$t['title']}{$next}");        }        foreach ($backlog['blocked_tasks'] ?? [] as $t) {            $io->writeln("    ⊘ [blocked] {$t['id']}  ({$t['recipe']}/{$t['risk']})  {$t['title']}");        }        if ($envelope['recent_traces'] !== []) {            $io->section('Recent traces');            foreach ($envelope['recent_traces'] as $tr) {                $tail = $tr['last_event'];                $line = "  • {$tr['id']}  ({$tr['created_at']})";                if ($tail !== null) {                    $line .= " — last: [{$tail['kind']}] {$tail['summary']}";                }                $io->writeln($line);            }        }        if ($envelope['last_verify'] !== null) {            $lv = $envelope['last_verify'];            $io->section('Last verify');            $io->writeln("  trace: {$lv['trace_id']}  at: {$lv['at']}");            $io->writeln("  verdict: " . ($lv['verdict'] ?? 'unknown') . "  — {$lv['summary']}");        }        $unreadRuns = $envelope['unread_failed_verify'] ?? [];        if (is_array($unreadRuns) && $unreadRuns !== []) {            $io->section('Red verify runs nobody checked (24h)');            foreach (array_slice($unreadRuns, 0, 3) as $run) {                if (!is_array($run)) {                    continue;                }                $runBy = is_array($run['run_by'] ?? null) ? $run['run_by'] : [];                $io->writeln(sprintf(                    '  %s  %s  %s  by %s',                    is_string($run['id'] ?? null) ? $run['id'] : '?',                    is_string($run['verdict'] ?? null) ? $run['verdict'] : '?',                    is_string($run['generated_at'] ?? null) ? substr($run['generated_at'], 0, 16) : '?',                    is_string($runBy['agent_session'] ?? null) ? $runBy['agent_session'] : 'unknown session',                ));            }            $io->writeln('  → bin/semitexa ai:verify:receipt --unread   # a run reported as green needs a receipt that holds');        }        $wn = $envelope['working_now'];        $io->section('Working now');        $io->writeln('  you: ' . match (true) {            $wn['you'] === null => 'not joined — run ai:agent join so the others can see you',            !($wn['you']['joined'] ?? true) => $wn['you']['id'] . ' — listed, but not joined: run ai:agent join so the others know what you are doing',            default => $wn['you']['id'] . ' — ' . $wn['you']['intent'],        });        $docsWarning = match ($wn['agent_docs'] ?? AgentDocsFreshness::UNKNOWN) {            AgentDocsFreshness::BEHIND => 'AGENTS.md is an older framework version: run bin/semitexa update — the rules you just read may be stale',            AgentDocsFreshness::EDITED => 'AGENTS.md matches no framework version (edited here): bin/semitexa update leaves AGENTS.md.new to merge',            AgentDocsFreshness::MISSING => 'AGENTS.md is missing: run bin/semitexa update',            default => null,        };        if ($docsWarning !== null) {            $io->writeln('  ⚠ ' . $docsWarning);        }        foreach ($wn['agents'] as $a) {            $io->writeln(sprintf(                '  %s (%s%s, %d min ago): %s%s',                $a['id'],                $a['agent'],                $a['elsewhere'] ? ', in ' . basename((string) $a['project']) : '',                intdiv((int) $a['silent_s'], 60),                $a['intent'],                $a['task'] !== null ? '  [task ' . $a['task'] . ']' : '',            ));        }        if ($wn['agents'] === []) {            $io->writeln('  no other agent has joined');        }        if ($wn['stack'] !== []) {            $io->writeln('  dev stack: last ' . StackEvents::describe($wn['stack'][0]));        }        if (!$wn['activity_known']) {            $io->writeln('  ⚠ uncommitted edits unknown: git is not available here');        }        foreach ($wn['unreadable'] as $repo) {            $io->writeln("  ⚠ {$repo}: git status failed — its uncommitted edits are unknown, not clean");        }        foreach ($wn['unclaimed_fresh'] as $repo) {            $io->writeln("  ⚠ {$repo}: edited in the last 30 min, claimed by no agent — someone may be working there");        }        if ($envelope['quality_error'] !== null) {            $io->writeln('  ⚠ ' . $envelope['quality_error']);        }        if ($envelope['quality_next'] !== []) {            $io->section('Improve next (ai:quality next)');            foreach ($envelope['quality_next'] as $t) {                $io->writeln("  {$t['metric']} — {$t['key']}: {$t['count']}");            }        }        $io->section('Next');        $io->writeln('  ' . $envelope['suggest_next']);        foreach ($envelope['next_command'] as $nc) {            $io->writeln("  → bin/semitexa {$nc['cmd']} " . implode(' ', $nc['args']) . "   # {$nc['why']}");        }    }}

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.

Support Semitexa
Built for developers who prefer control over magic. Your support helps keep it fast, open, and evolving.

Donate via PayPal