agentic-orchestrator 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +5 -8
  2. package/agentic/orchestrator/prompts/planner.system.md +7 -1
  3. package/apps/control-plane/src/cli/help-command-handler.ts +6 -2
  4. package/apps/control-plane/src/cli/init-command-handler.ts +1 -1
  5. package/apps/control-plane/src/cli/stop-command-handler.ts +166 -2
  6. package/apps/control-plane/src/interfaces/cli/bootstrap.ts +11 -7
  7. package/apps/control-plane/test/bootstrap-edge-cases.spec.ts +35 -0
  8. package/apps/control-plane/test/bootstrap.spec.ts +34 -2
  9. package/apps/control-plane/test/cli-helpers.spec.ts +5 -4
  10. package/apps/control-plane/test/cli.unit.spec.ts +7 -2
  11. package/apps/control-plane/test/dashboard-client.spec.ts +51 -0
  12. package/apps/control-plane/test/stop-command.spec.ts +395 -0
  13. package/config/agentic/orchestrator/agents.yaml +1 -1
  14. package/config/agentic/orchestrator/gates.yaml +2 -2
  15. package/config/agentic/orchestrator/prompts/builder.system.md +47 -0
  16. package/config/agentic/orchestrator/prompts/planner.system.md +52 -1
  17. package/config/agentic/orchestrator/prompts/qa.system.md +46 -0
  18. package/dist/apps/control-plane/cli/help-command-handler.js +6 -2
  19. package/dist/apps/control-plane/cli/help-command-handler.js.map +1 -1
  20. package/dist/apps/control-plane/cli/init-command-handler.js +1 -1
  21. package/dist/apps/control-plane/cli/stop-command-handler.d.ts +5 -1
  22. package/dist/apps/control-plane/cli/stop-command-handler.js +145 -2
  23. package/dist/apps/control-plane/cli/stop-command-handler.js.map +1 -1
  24. package/dist/apps/control-plane/interfaces/cli/bootstrap.js +9 -6
  25. package/dist/apps/control-plane/interfaces/cli/bootstrap.js.map +1 -1
  26. package/package.json +1 -1
  27. package/packages/web-dashboard/src/app/api/events/route.ts +37 -22
  28. package/packages/web-dashboard/src/app/page.tsx +45 -6
  29. package/packages/web-dashboard/src/lib/aop-client.ts +94 -4
  30. package/spec-files/outstanding/agentic_orchestrator_worker_runtime_watchdog_resilience_spec.md +1 -1
  31. package/spec-files/progress.md +89 -1
package/README.md CHANGED
@@ -329,18 +329,15 @@ node apps/control-plane/scripts/validate-docker-mcp-contract.mjs --smoke
329
329
 
330
330
  ### `stop`
331
331
 
332
- Current behavior is a placeholder only:
333
-
334
- ```json
335
- { "ok": true, "data": { "status": "stop_not_implemented_yet" } }
336
- ```
337
-
338
- It does not currently terminate active sessions or revoke leases.
332
+ Stops the dashboard server by killing the process listening on a port.
339
333
 
340
334
  Example:
341
335
 
342
336
  ```bash
343
337
  aop stop
338
+
339
+ # Stop a custom dashboard port
340
+ aop stop --port 8080
344
341
  ```
345
342
 
346
343
  ### `delete`
@@ -511,7 +508,7 @@ Supported options:
511
508
  | `--all` | Aggregate across all projects (`status`); delete all features (`delete`) |
512
509
  | `--auto` | Non-interactive init with all defaults (`init`) |
513
510
  | `--force` | Overwrite existing config (`init`); force retry regardless of state (`retry`) |
514
- | `--port <number>` | Dashboard listen port (default `3000`) |
511
+ | `--port <number>` | Dashboard listen port (`aop dashboard`) or stop target port (`aop stop`) |
515
512
  | `--dev` | Start dashboard in Next.js dev mode |
516
513
  | `--foreground` | Keep dashboard server in the foreground |
517
514
  | `--message <text>` | Message to deliver to agent session (`send`) |
@@ -21,7 +21,13 @@ Every `PLAN_SUBMISSION` must include ALL of the following fields in `plan_json`:
21
21
  | `acceptance_criteria` | string[] (min 1) | Conditions that must all be met before merge |
22
22
  | `gate_profile` | string | Gate profile name (e.g. `"fast"` or `"full"`) |
23
23
 
24
- Optional fields: `gate_targets`, `risk`, `revision_of`, `revision_reason`, `verification_overrides`.
24
+ Optional fields (with types):
25
+
26
+ - `gate_targets`: `string[]` — explicit gate mode names to run
27
+ - `risk`: **`string[]`** — list of risk statements (e.g. `["Schema migration may require downtime"]`). **Must be an array, never a plain string.**
28
+ - `revision_of`: `integer` — plan_version this revises
29
+ - `revision_reason`: `string` — why the plan was revised
30
+ - `verification_overrides`: `object`
25
31
 
26
32
  ### Minimal example
27
33
 
@@ -59,8 +59,12 @@ const COMMAND_HELP: Record<CliCommand, CommandHelp> = {
59
59
  ],
60
60
  },
61
61
  [CliCommand.Stop]: {
62
- usage: 'aop stop',
63
- description: 'Signal the running supervisor to stop gracefully.',
62
+ usage: 'aop stop [flags]',
63
+ description: 'Stop the dashboard server by terminating listeners on a target port.',
64
+ flags: [
65
+ { flag: '--port <number>', description: 'Port to stop (default: 3000)' },
66
+ { flag: '--project <name>', description: 'Use the project default dashboard port' },
67
+ ],
64
68
  },
65
69
  [CliCommand.Delete]: {
66
70
  usage: 'aop delete [flags]',
@@ -424,7 +424,7 @@ runtime:
424
424
  default_provider: ${wizard.defaultProvider}
425
425
  default_model: ${wizard.defaultModel}
426
426
  worker_provider_mode: live
427
- worker_response_timeout_ms: 120000
427
+ worker_response_timeout_ms: 600000
428
428
  max_consecutive_no_progress_iterations: 2
429
429
  ${providerConfigEnvLine} role_provider_overrides: {}
430
430
  `;
@@ -1,5 +1,169 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+ const DEFAULT_DASHBOARD_PORT = 3000;
6
+
7
+ interface StopCommandOptions {
8
+ port?: number;
9
+ }
10
+
11
+ function normalizePort(port: number | undefined): number {
12
+ if (typeof port === 'number' && Number.isInteger(port) && port >= 1 && port <= 65535) {
13
+ return port;
14
+ }
15
+ return DEFAULT_DASHBOARD_PORT;
16
+ }
17
+
18
+ function parsePids(raw: string): number[] {
19
+ const values = raw
20
+ .split(/\r?\n/)
21
+ .map((line) => Number.parseInt(line.trim(), 10))
22
+ .filter((value) => Number.isInteger(value) && value > 0);
23
+ return [...new Set(values)];
24
+ }
25
+
26
+ function extractStdout(result: unknown): string {
27
+ if (typeof result === 'string') {
28
+ return result;
29
+ }
30
+ if (!result || typeof result !== 'object') {
31
+ return '';
32
+ }
33
+ const record = result as Record<string, unknown>;
34
+ const stdout = record.stdout;
35
+ if (typeof stdout === 'string') {
36
+ return stdout;
37
+ }
38
+ if (stdout instanceof Uint8Array) {
39
+ return Buffer.from(stdout).toString('utf8');
40
+ }
41
+ return '';
42
+ }
43
+
44
+ function readErrorCode(error: unknown): string | number | undefined {
45
+ if (!error || typeof error !== 'object') {
46
+ return undefined;
47
+ }
48
+ const record = error as Record<string, unknown>;
49
+ const { code } = record;
50
+ return typeof code === 'string' || typeof code === 'number' ? code : undefined;
51
+ }
52
+
53
+ function isNotFoundError(error: unknown): boolean {
54
+ return readErrorCode(error) === 'ENOENT';
55
+ }
56
+
57
+ function isNoMatchesError(error: unknown): boolean {
58
+ return readErrorCode(error) === 1;
59
+ }
60
+
61
+ async function findPidsOnWindows(port: number): Promise<number[]> {
62
+ const stdout = extractStdout(await execFileAsync('netstat', ['-ano', '-p', 'tcp']));
63
+ const pids: number[] = [];
64
+ const lines = stdout.split(/\r?\n/);
65
+ for (const line of lines) {
66
+ if (!line.includes('LISTENING')) {
67
+ continue;
68
+ }
69
+ const parts = line.trim().split(/\s+/);
70
+ if (parts.length < 5) {
71
+ continue;
72
+ }
73
+ const localAddress = parts[1];
74
+ const pid = Number.parseInt(parts[4], 10);
75
+ const localPortRaw = localAddress.includes(':') ? localAddress.split(':').pop() : null;
76
+ const localPort = localPortRaw ? Number.parseInt(localPortRaw, 10) : Number.NaN;
77
+ if (localPort === port && Number.isInteger(pid) && pid > 0) {
78
+ pids.push(pid);
79
+ }
80
+ }
81
+ return [...new Set(pids)];
82
+ }
83
+
84
+ async function findPidsOnUnix(port: number): Promise<number[]> {
85
+ try {
86
+ const stdout = extractStdout(
87
+ await execFileAsync('lsof', ['-nP', '-iTCP:' + String(port), '-sTCP:LISTEN', '-t']),
88
+ );
89
+ return parsePids(stdout);
90
+ } catch (error) {
91
+ if (isNoMatchesError(error)) {
92
+ return [];
93
+ }
94
+ if (isNotFoundError(error)) {
95
+ // Minimal fallback when lsof is unavailable.
96
+ const stdout = extractStdout(
97
+ await execFileAsync('ss', ['-ltnp', 'sport = :' + String(port)]),
98
+ );
99
+ const pidMatches = [...stdout.matchAll(/pid=(\d+)/g)];
100
+ const pids = pidMatches
101
+ .map((match) => Number.parseInt(match[1], 10))
102
+ .filter((value) => Number.isInteger(value) && value > 0);
103
+ return [...new Set(pids)];
104
+ }
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ async function findListeningPids(port: number): Promise<number[]> {
110
+ if (process.platform === 'win32') {
111
+ return await findPidsOnWindows(port);
112
+ }
113
+ return await findPidsOnUnix(port);
114
+ }
115
+
116
+ async function killPid(pid: number): Promise<void> {
117
+ if (process.platform === 'win32') {
118
+ await execFileAsync('taskkill', ['/PID', String(pid), '/F']);
119
+ return;
120
+ }
121
+ await execFileAsync('kill', ['-TERM', String(pid)]);
122
+ }
123
+
124
+ function isAlreadyExitedError(error: unknown): boolean {
125
+ const code = readErrorCode(error);
126
+ return code === 'ESRCH' || code === 1;
127
+ }
128
+
1
129
  export class StopCommandHandler {
2
- execute(): Promise<unknown> {
3
- return Promise.resolve({ ok: true, data: { status: 'stop_not_implemented_yet' } });
130
+ async execute(options: StopCommandOptions = {}): Promise<unknown> {
131
+ const port = normalizePort(options.port);
132
+ const pids = await findListeningPids(port);
133
+ if (pids.length === 0) {
134
+ return {
135
+ ok: true,
136
+ data: {
137
+ command: 'stop',
138
+ target: 'dashboard',
139
+ port,
140
+ stopped: false,
141
+ message: `No process is listening on port ${port}`,
142
+ },
143
+ };
144
+ }
145
+
146
+ for (const pid of pids) {
147
+ try {
148
+ await killPid(pid);
149
+ } catch (error) {
150
+ if (isAlreadyExitedError(error)) {
151
+ continue;
152
+ }
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ return {
158
+ ok: true,
159
+ data: {
160
+ command: 'stop',
161
+ target: 'dashboard',
162
+ port,
163
+ stopped: true,
164
+ pids,
165
+ message: `Stopped listener(s) on port ${port}`,
166
+ },
167
+ };
4
168
  }
5
169
  }
@@ -253,6 +253,17 @@ export async function runCli(
253
253
  return 0;
254
254
  }
255
255
 
256
+ if (options.command === CliCommand.Stop) {
257
+ const handler = new StopCommandHandler();
258
+ const defaultDashboardPort =
259
+ selectedProject?.dashboard_port ?? multiConfig?.defaults?.dashboard_port;
260
+ const payload = await handler.execute({
261
+ port: options.port ?? defaultDashboardPort,
262
+ });
263
+ printPayload(payload);
264
+ return 0;
265
+ }
266
+
256
267
  const transport = parser.resolveTransport(options.transport);
257
268
  const runId = `run:${crypto.randomUUID()}`;
258
269
 
@@ -380,13 +391,6 @@ export async function runCli(
380
391
  return 0;
381
392
  }
382
393
 
383
- if (options.command === CliCommand.Stop) {
384
- const handler = new StopCommandHandler();
385
- const payload = await handler.execute();
386
- printPayload(payload);
387
- return 0;
388
- }
389
-
390
394
  if (options.command === CliCommand.Delete) {
391
395
  const handler = new DeleteCommandHandler(toolClient, runId);
392
396
  const payload = await handler.execute(options);
@@ -13,6 +13,17 @@ const ensureLoadedMock = vi.hoisted(() => vi.fn(async () => undefined));
13
13
  const createToolingRuntimeMock = vi.hoisted(() => vi.fn(async () => ({ runtime: 'ok' })));
14
14
  const resolveToolClientMock = vi.hoisted(() => vi.fn());
15
15
  const toolCallMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, data: {} })));
16
+ const stopExecuteMock = vi.hoisted(() =>
17
+ vi.fn(async (options?: { port?: number }) => ({
18
+ ok: true,
19
+ data: {
20
+ command: 'stop',
21
+ target: 'dashboard',
22
+ port: options?.port ?? 3000,
23
+ stopped: false,
24
+ },
25
+ })),
26
+ );
16
27
 
17
28
  /** Allows per-test control of getPolicySnapshot return value */
18
29
  const policySnapshotOverride = vi.hoisted(() => ({
@@ -98,6 +109,14 @@ vi.mock('../src/cli/dashboard-command-handler.js', () => ({
98
109
  },
99
110
  }));
100
111
 
112
+ vi.mock('../src/cli/stop-command-handler.js', () => ({
113
+ StopCommandHandler: class StopCommandHandler {
114
+ async execute(options?: { port?: number }) {
115
+ return await stopExecuteMock(options);
116
+ }
117
+ },
118
+ }));
119
+
101
120
  vi.mock('../src/cli/run-command-handler.js', () => ({
102
121
  RunCommandHandler: class RunCommandHandler {
103
122
  async execute() {
@@ -156,6 +175,7 @@ describe('bootstrap edge-case branch coverage', () => {
156
175
  toolCallMock.mockResolvedValue({ ok: true, data: { features: [] } });
157
176
  ensureLoadedMock.mockClear();
158
177
  createToolingRuntimeMock.mockClear();
178
+ stopExecuteMock.mockClear();
159
179
  stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
160
180
  policySnapshotOverride.value = {};
161
181
  kernelThrowOverride.value = null;
@@ -288,6 +308,21 @@ describe('bootstrap edge-case branch coverage', () => {
288
308
  expect(code).toBe(0);
289
309
  });
290
310
 
311
+ it('GIVEN_stop_command_without_port_option_WHEN_executed_THEN_uses_project_default_port', async () => {
312
+ const proj1 = path.join(tmpDir, 'proj1');
313
+ await fs.mkdir(proj1, { recursive: true });
314
+ await writeMultiProjectYaml(
315
+ tmpDir,
316
+ `version: "1.0"\nprojects:\n - name: proj1\n path: ${proj1}\n dashboard_port: 4111\n`,
317
+ );
318
+
319
+ const { runCli } = await import('../src/interfaces/cli/bootstrap.js');
320
+ const code = await runCli(['stop', '--project', 'proj1'], { cwd: tmpDir, env: process.env });
321
+
322
+ expect(code).toBe(0);
323
+ expect(stopExecuteMock).toHaveBeenCalledWith({ port: 4111 });
324
+ });
325
+
291
326
  it('GIVEN_status_all_with_one_project_throwing_error_without_code_WHEN_executed_THEN_uses_fallback_code', async () => {
292
327
  // Covers line 205: typed.code ?? ERROR_CODES.INTERNAL_ERROR when typed.code is undefined
293
328
  const proj1 = path.join(tmpDir, 'proj1');
@@ -11,6 +11,17 @@ const resolveToolClientMock = vi.hoisted(() => vi.fn());
11
11
  const toolCallMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, data: {} })));
12
12
  const readlineQuestionMock = vi.hoisted(() => vi.fn(async () => ''));
13
13
  const readlineCloseMock = vi.hoisted(() => vi.fn());
14
+ const stopExecuteMock = vi.hoisted(() =>
15
+ vi.fn(async (options?: { port?: number }) => ({
16
+ ok: true,
17
+ data: {
18
+ command: 'stop',
19
+ target: 'dashboard',
20
+ port: options?.port ?? 3000,
21
+ stopped: false,
22
+ },
23
+ })),
24
+ );
14
25
 
15
26
  vi.mock('node:readline/promises', () => ({
16
27
  default: {
@@ -88,6 +99,14 @@ vi.mock('../src/cli/dashboard-command-handler.js', () => ({
88
99
  },
89
100
  }));
90
101
 
102
+ vi.mock('../src/cli/stop-command-handler.js', () => ({
103
+ StopCommandHandler: class StopCommandHandler {
104
+ async execute(options?: { port?: number }) {
105
+ return await stopExecuteMock(options);
106
+ }
107
+ },
108
+ }));
109
+
91
110
  vi.mock('../src/cli/run-command-handler.js', () => ({
92
111
  RunCommandHandler: class RunCommandHandler {
93
112
  async execute() {
@@ -151,6 +170,7 @@ describe('bootstrap runCli', () => {
151
170
  toolCallMock.mockClear();
152
171
  ensureLoadedMock.mockClear();
153
172
  createToolingRuntimeMock.mockClear();
173
+ stopExecuteMock.mockClear();
154
174
  stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
155
175
  });
156
176
 
@@ -159,13 +179,25 @@ describe('bootstrap runCli', () => {
159
179
  vi.clearAllMocks();
160
180
  });
161
181
 
162
- it('GIVEN_stop_command_WHEN_executed_THEN_returns_stop_not_implemented_yet', async () => {
182
+ it('GIVEN_stop_command_WHEN_executed_THEN_returns_stop_dashboard_payload', async () => {
163
183
  const { runCli } = await import('../src/interfaces/cli/bootstrap.js');
164
184
  const code = await runCli(['stop'], { cwd: '/tmp', env: process.env });
165
185
 
166
186
  const writes = asJsonWrites(stdoutSpy);
167
187
  expect(code).toBe(0);
168
- expect(writes[0]).toMatchObject({ ok: true, data: { status: 'stop_not_implemented_yet' } });
188
+ expect(stopExecuteMock).toHaveBeenCalledWith({ port: undefined });
189
+ expect(writes[0]).toMatchObject({
190
+ ok: true,
191
+ data: { command: 'stop', target: 'dashboard', port: 3000, stopped: false },
192
+ });
193
+ });
194
+
195
+ it('GIVEN_stop_command_with_port_WHEN_executed_THEN_passes_port_to_handler', async () => {
196
+ const { runCli } = await import('../src/interfaces/cli/bootstrap.js');
197
+ const code = await runCli(['stop', '--port', '8080'], { cwd: '/tmp', env: process.env });
198
+
199
+ expect(code).toBe(0);
200
+ expect(stopExecuteMock).toHaveBeenCalledWith({ port: 8080 });
169
201
  });
170
202
 
171
203
  it('GIVEN_delete_command_with_feature_id_and_yes_WHEN_executed_THEN_calls_toolClient', async () => {
@@ -486,14 +486,15 @@ describe('HelpCommandHandler', () => {
486
486
  expect(result.data.help).toContain('Commands:');
487
487
  });
488
488
 
489
- it('GIVEN_command_without_flags_WHEN_execute_called_THEN_renders_without_flags_section', async () => {
489
+ it('GIVEN_stop_help_WHEN_execute_called_THEN_renders_stop_flags_section', async () => {
490
490
  const { HelpCommandHandler } = await import('../src/cli/help-command-handler.js');
491
491
  const handler = new HelpCommandHandler();
492
492
  const result = handler.execute('stop');
493
493
  expect(result.ok).toBe(true);
494
- expect(result.data.help).toContain('Usage: aop stop');
495
- // stop has no flags
496
- expect(result.data.help).not.toContain('Flags:');
494
+ expect(result.data.help).toContain('Usage: aop stop [flags]');
495
+ expect(result.data.help).toContain('Flags:');
496
+ expect(result.data.help).toContain('--port <number>');
497
+ expect(result.data.help).toContain('--project <name>');
497
498
  });
498
499
 
499
500
  it('GIVEN_delete_help_WHEN_execute_called_THEN_documents_yes_for_destructive_delete', async () => {
@@ -348,14 +348,19 @@ describe('aop CLI unit', () => {
348
348
  });
349
349
  });
350
350
 
351
- it('GIVEN_stop_command_WHEN_main_runs_THEN_returns_not_implemented_status', async () => {
351
+ it('GIVEN_stop_command_WHEN_main_runs_THEN_returns_stop_dashboard_payload', async () => {
352
352
  const code = await main(['stop'], { cwd, env: {} as NodeJS.ProcessEnv });
353
353
  const writes = asJsonWrites(stdoutSpy);
354
354
 
355
355
  expect(code).toBe(0);
356
356
  expect(writes[0]).toMatchObject({
357
357
  ok: true,
358
- data: { status: 'stop_not_implemented_yet' },
358
+ data: expect.objectContaining({
359
+ command: 'stop',
360
+ target: 'dashboard',
361
+ port: 3000,
362
+ stopped: expect.any(Boolean),
363
+ }),
359
364
  });
360
365
  });
361
366
 
@@ -118,6 +118,57 @@ describe('dashboard aop client mapping', () => {
118
118
  expect(payload.features[0].gates).toEqual({ fast: 'pass', full: 'fail', merge: 'na' });
119
119
  });
120
120
 
121
+ it('GIVEN_building_feature_without_activity_state_but_running_role_WHEN_readDashboardStatus_THEN_infers_active_activity', async () => {
122
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
123
+ tempRoots.push(repoRoot);
124
+ await fs.mkdir(path.join(repoRoot, '.aop', 'features'), { recursive: true });
125
+ await fs.writeFile(
126
+ path.join(repoRoot, '.aop', 'features', 'index.json'),
127
+ JSON.stringify({ active: ['feature_build'], blocked: [], merged: [], blocked_queue: [] }),
128
+ 'utf8',
129
+ );
130
+ await writeState(
131
+ repoRoot,
132
+ 'feature_build',
133
+ [
134
+ 'feature_id: feature_build',
135
+ 'version: 1',
136
+ 'status: building',
137
+ 'role_status:',
138
+ ' planner: done',
139
+ ' builder: running',
140
+ ' qa: ready',
141
+ ].join('\n'),
142
+ );
143
+
144
+ const payload = await readDashboardStatus(repoRoot);
145
+ expect(payload.features[0].activity_state).toBe('active');
146
+ });
147
+
148
+ it('GIVEN_building_feature_without_activity_state_and_stale_timestamp_WHEN_readDashboardStatus_THEN_infers_idle_activity', async () => {
149
+ const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
150
+ tempRoots.push(repoRoot);
151
+ await fs.mkdir(path.join(repoRoot, '.aop', 'features'), { recursive: true });
152
+ await fs.writeFile(
153
+ path.join(repoRoot, '.aop', 'features', 'index.json'),
154
+ JSON.stringify({ active: ['feature_idle'], blocked: [], merged: [], blocked_queue: [] }),
155
+ 'utf8',
156
+ );
157
+ await writeState(
158
+ repoRoot,
159
+ 'feature_idle',
160
+ [
161
+ 'feature_id: feature_idle',
162
+ 'version: 1',
163
+ 'status: building',
164
+ 'last_updated: 2026-01-01T00:00:00Z',
165
+ ].join('\n'),
166
+ );
167
+
168
+ const payload = await readDashboardStatus(repoRoot);
169
+ expect(payload.features[0].activity_state).toBe('idle');
170
+ });
171
+
121
172
  it('GIVEN_blocked_queue_feature_without_state_WHEN_readDashboardStatus_THEN_phase_is_blocked', async () => {
122
173
  const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'aop-dash-client-'));
123
174
  tempRoots.push(repoRoot);