← 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.

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_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.

© Harold Abelson: "Programs must be written for people to read, and only incidentally for machines to execute."

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\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\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;    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);        $workingNow = $this->workingNow();        if ($workingNow['you'] === null) {            // 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.            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,            // 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,            '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) ?: '{}');        }        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>>, 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());        $activity = $radar->dirtyRepos($live, $now);        return [            'you' => $you?->toArray(),            'agents' => array_map(static fn (AgentSession $a): array => $a->toArray() + ['silent_s' => $a->secondsSilent($now)], $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),            '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>} $workingNow     */    private function holderOf(string $taskId, array $workingNow): ?string    {        foreach ([...$workingNow['agents'], ...($workingNow['you'] !== null ? [$workingNow['you']] : [])] as $agent) {            if (($agent['task'] ?? null) === $taskId) {                return (string) $agent['id'];            }        }        return null;    }    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']}");        }        $wn = $envelope['working_now'];        $io->section('Working now');        $io->writeln('  you: ' . ($wn['you'] !== null ? $wn['you']['id'] . ' — ' . $wn['you']['intent'] : 'not joined — run ai:agent join so the others can see you'));        foreach ($wn['agents'] as $a) {            $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'] . ']' : ''));        }        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