@hecer/yoke 1.7.0 → 1.9.0

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "yoke",
4
4
  "displayName": "Yoke",
5
- "version": "1.7.0",
5
+ "version": "1.9.0",
6
6
  "description": "Cross-agent coding harness: one curated skill canon (TDD, brainstorming, plans, reviews, shipping, design verification) plus mechanical safety gates and an autonomous loop via the yoke CLI.",
7
7
  "author": { "name": "HECer", "url": "https://github.com/HECer" },
8
8
  "homepage": "https://github.com/HECer/yoke#readme",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yoke",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Cross-agent coding discipline, mechanical gates, and release workflows",
5
5
  "skills": "./canon/skills/",
6
6
  "hooks": "./hooks/hooks.json"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.9.0 — 2026-09-06
6
+
7
+ ### Added
8
+ - Add capability-based routing with persisted task assessments, explicit model/effort tiers, role eligibility and conservative use of independent task-class outcomes.
9
+ - Keep planning on the start model; reuse assessments across attempts/worktrees and invalidate them when task requirements change.
10
+ - Add bounded repair and tier escalation after mechanical gate failures, retaining the patch and forwarding failure evidence. Infrastructure failures do not trigger capability escalation.
11
+ - Apply task-based profiles to reviews, quality critics/repairs and goal execution; display implementation selection reasons and next escalation in the dashboard.
12
+ - Add setup options `--routing-strategy=capability` and `--routing-preset` for explicit migration. Preserve existing strategies and custom profiles by default.
13
+
14
+ ### Validation limits
15
+ - Initial profile tiers are configurable hypotheses, not authenticated model benchmarks, price estimates or calibrated success probabilities. See [capability routing](docs/CAPABILITY-ROUTING.md) for defaults and bounds.
16
+
17
+ ## 1.8.0 — 2026-09-06
18
+
19
+ ### Added
20
+ - Add persistent local measurement history and dashboard views for current work, usage/time and results, including UTC day/week/month filters, model history, project comparisons and consumption charts.
21
+ - Display current tasks, worker phases, integration progress and status age, with automatic refresh of the current-work view.
22
+ - Record explicit acceptances and show measured tokens and time per acceptance. Attribute available reviewer, critic and repair usage; distinguish actual reported models, unknown calls and partial costs.
23
+
24
+ ### Changed
25
+ - Enable routing in new setups and automatically select up to three parallel workers when all pending tasks declare write scopes. Dependencies and overlapping scopes still constrain dispatch. Preserve explicit opt-outs and use isolated worktrees by default.
26
+ - Share routing decisions between synchronous and asynchronous runners; support routed parallel workers, stable recovery history and explicit provider affinity.
27
+ - Reserve execution capacity through integration and disable native delegation for loop providers; preserve Gemini system policy in a temporary bounded-execution configuration.
28
+ - Require a dated changelog entry, synchronized version metadata and verified release checks for every new version in the project instructions.
29
+
30
+ ### Fixed
31
+ - Avoid conflicting Codex sandbox arguments and prevent Codex-only options from leaking into Gemini workers.
32
+ - Keep compact measurement history after recent activity expires, deduplicate archived events, and report incomplete history instead of treating missing usage as zero.
33
+ - Preserve reviewer telemetry and worker/model attribution across parallel execution and recovery.
34
+
35
+ ### Migration and validation limits
36
+ - Use `--parallel=N` to choose a worker limit, `--parallel=auto` for automatic selection, `--no-routing` to opt out of routing, and `--no-isolate` to opt out of default isolation. Explicit existing configuration remains authoritative. Unknown write scopes, tool actions and worktree recovery select serial execution in auto mode.
37
+ - Automatic routing needs configured profiles; otherwise it keeps the selected parent provider. Explicit `--routing` without profiles reports a configuration error.
38
+ - Missing historical usage cannot be reconstructed. Tokens per minute describe interval or summed call consumption, not measured generation speed. Live authenticated provider benchmarks, resource-adaptive concurrency and calibrated time/cost predictions are not established by this release.
39
+
5
40
  ## 1.7.0 — 2026-09-05
6
41
 
7
42
  ### Added
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  # 🐂 Yoke
4
4
 
5
- <!-- yoke:version:start -->1.7.0<!-- yoke:version:end -->
6
- <!-- yoke:tests:start -->1100<!-- yoke:tests:end -->
5
+ <!-- yoke:version:start -->1.9.0<!-- yoke:version:end -->
6
+ <!-- yoke:tests:start -->1134<!-- yoke:tests:end -->
7
7
  <!-- yoke:skills:start -->34<!-- yoke:skills:end -->
8
8
  <!-- yoke:agents:start -->Claude | Codex | Gemini<!-- yoke:agents:end -->
9
9
 
@@ -17,7 +17,7 @@
17
17
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#-license)
18
18
  ![Node](https://img.shields.io/badge/node-%E2%89%A520-339933?logo=node.js&logoColor=white)
19
19
  ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
20
- ![Tests](https://img.shields.io/badge/tests-1100%20defined-blue.svg)
20
+ ![Tests](https://img.shields.io/badge/tests-1134%20defined-blue.svg)
21
21
  ![Agents](https://img.shields.io/badge/agents-Claude%20%7C%20Codex%20%7C%20Gemini-8A2BE2)
22
22
  ![Built with TDD](https://img.shields.io/badge/built%20with-TDD%20%2B%20review-ff69b4.svg)
23
23
 
@@ -27,7 +27,7 @@
27
27
 
28
28
  > **TL;DR** — `yoke setup .` asks six questions and installs the native harness for your agent. `yoke new my-app --idea="..."` bootstraps a project and drafts its story backlog. `yoke loop run my-app --isolate --review` then implements it behind hard gates: **clean tree → acceptance criteria → your real tests green → an independent model approves → commit**. Add `--parallel=N` for dependency-aware workers, or declare a reference and add `--quality` for a bounded critic/repair gauntlet. If any blocking gate is red, nothing is committed. Proof lives in `.yoke/proof/<story>/`.
29
29
 
30
- **New in 1.7.0:** [verified project goals, recovery and one local dashboard for all your registered projects](docs/VERIFIED-PROJECTS.md). Check an existing project, continue a bounded goal with Codex, Claude or Gemini, and inspect tasks, acceptance evidence, recorded consumption and estimated timing in one place.
30
+ **New in 1.9.0:** [routing by task requirements](docs/CAPABILITY-ROUTING.md). Keep planning on the start model, select execution models and effort from saved task assessments, and use bounded repair and escalation with independent checks. The dashboard explains model selection; existing routing settings remain authoritative.
31
31
 
32
32
  ### One dashboard, multiple projects
33
33
 
@@ -62,8 +62,8 @@ retain actionable failures and final summaries, while large complete stdout/stde
62
62
  in private, content-addressed local artifacts. Existing projects keep their serial behavior and use
63
63
  safe 2 KiB preview / 8 KiB artifact defaults unless configured otherwise.
64
64
 
65
- Yoke 1.4 adds opt-in parallel workers and a bounded, reference-driven quality gauntlet without
66
- changing existing serial loop defaults. See [the 1.4 migration guide](docs/MIGRATING-TO-1.4.md)
65
+ Yoke 1.4 introduced opt-in parallel workers and a bounded, reference-driven quality gauntlet.
66
+ Yoke 1.8.0 uses automatic parallelism for tasks with declared write scopes. See [the 1.4 migration guide](docs/MIGRATING-TO-1.4.md)
67
67
  for the new flags, configuration, cleanup behavior, and review-verdict contract.
68
68
 
69
69
  Yoke 1.1 is safe-by-default: provider CLIs use autonomous sandbox profiles unless `--unsafe`
@@ -191,7 +191,7 @@ Yoke's CLI is deterministic and chainable by design: an agent (or a shell `&&`)
191
191
  | `yoke projects add\|list\|remove` | Register a project, list registrations or remove a reference by ID | `0` · `2` invalid/unavailable |
192
192
  | `yoke check [dir] [--json] [--requirement=] [--protect [--refresh]]` | Execute acceptance checks or explicitly pin their infrastructure | `0` passed/pinned · `1` failed · `2` unverified/unavailable |
193
193
  | `yoke goal set\|run\|resume\|pause\|status\|handoff\|budget [dir]` | Durable objectives, provider handoff, protected checks and checkpoint budgets | run/resume: `0` complete · `1` unfinished · `2` unavailable |
194
- | `yoke setup [dir] [--yes] [--host=] [--agent=] [--runner=] [--code-graph=] [--decision-policy=] [--loop\|--no-loop] [--routing\|--no-routing]` | Shared six-question setup for Claude, Codex, and Gemini; adaptive routing is always an explicit opt-in | `0` · `1` invalid setup |
194
+ | `yoke setup [dir] [--yes] [--host=] [--agent=] [--runner=] [--code-graph=] [--decision-policy=] [--loop\|--no-loop] [--routing\|--no-routing]` | Shared six-question setup for Claude, Codex, and Gemini; routing defaults on for new setups and preserves explicit opt-outs | `0` · `1` invalid setup |
195
195
  | `yoke validate [canonDir]` | Validate the canon (schema, frontmatter, templates) | `0` valid · `1` errors |
196
196
  | `yoke new <dir> [--idea=] [--agent=] [--runner=] [--loop]` | Greenfield bootstrap: git init → scaffold → retrofit → context → PRD (drafted from `--idea`) → committed | `0` · `1` usage / non-empty dir / draft failed (scaffold survives) · `2` draft agent unavailable |
197
197
  | `yoke retrofit [dir] [--agent=claude,codex,gemini\|all] [--code-graph=graphify\|serena] [--loop]` | Install/update the harness, non-destructively | `0` |
@@ -563,14 +563,16 @@ use `yoke loop resume . --discard`; pending decisions are never deleted by that
563
563
  `loop.onAmbiguity: resolve|abort` and `--on-ambiguity=` remain supported as compatibility aliases;
564
564
  new projects should use `decisionPolicy: auto|critical`.
565
565
 
566
- ### Adaptive model routing (explicit opt-in)
566
+ ### Adaptive model routing
567
567
 
568
- `yoke setup` asks before enabling routing; the default is **off**. When enabled, the selected
568
+ `yoke setup` enables routing by default and preserves explicit opt-outs. Without configured
569
+ worker profiles, automatic execution keeps the selected parent. When enabled, the selected
569
570
  parent remains the strong planner/controller. Before each bounded story it receives only the
570
571
  story, acceptance criteria, and at most three eligible worker profiles, then returns one
571
572
  machine-readable choice. The worker can be a cheaper/faster Claude, Codex, or Gemini profile;
572
- `SELF` keeps difficult work on the parent. Provider-native subagents are disabled for these
573
- runs so Yoke does not pay for two orchestration layers.
573
+ `SELF` keeps difficult work on the parent. Explicit project rules skip the controller.
574
+ Loop runners disable native delegation in Codex, Claude and Gemini so it cannot multiply
575
+ the Yoke worker budget. Integration retains its execution slot until the candidate lands.
574
576
 
575
577
  **Provider support:** adaptive routing uses Yoke's shared provider adapter and works with Claude
576
578
  Code, Codex CLI, and Gemini CLI, including mixed-provider worker lists. Internal contract tests
@@ -584,7 +586,7 @@ runner:
584
586
  model: gpt-5.6-sol # optional; provider model strings stay opaque to Yoke
585
587
  reasoningEffort: high
586
588
  routing:
587
- enabled: true # setup defaults false; setup --routing opts in
589
+ enabled: true # new setup default; false preserves an explicit opt-out
588
590
  strategy: balanced # balanced | cost | speed | quality
589
591
  maxCandidates: 3
590
592
  workers:
@@ -605,7 +607,7 @@ routing:
605
607
  capabilities: [large-context, implementation]
606
608
  ```
607
609
 
608
- Use `yoke loop run . --routing` for a one-run opt-in or `--no-routing` for a controlled
610
+ Use `yoke loop run . --routing` to explicitly require configured routing or `--no-routing` for a controlled
609
611
  baseline. Routing control calls are read-only and deliberately tiny; malformed output or no
610
612
  eligible worker falls back to `SELF`. Yoke does not ship a universal, fast-aging
611
613
  "intelligence score". Candidate model IDs come from project configuration while setup defaults
@@ -615,9 +617,12 @@ after 30 days. It stores no prompts, source, or project paths—only a project h
615
617
  time/token/outcome evidence. Writes are immutable one-event files, so concurrent Yoke instances
616
618
  cannot overwrite a shared registry file.
617
619
 
618
- Routing is not free: it adds one controller call per story. It is most promising when a bounded
619
- worker saves more than that call costs; tiny stories may be slower. Keep it opt-in and measure it
620
- on your own backlog rather than assuming a win.
620
+ Routing is not free: stories without a matching rule can add a controller call. Measure it
621
+ on your own backlog rather than assuming a win. Routing now also runs within asynchronous
622
+ parallel workers. Automatic parallelism starts at up to three workers when pending tasks declare
623
+ write scopes; unknown scopes and configured tool actions keep execution serial. Isolation is
624
+ on by default. Explicit `--parallel=N`, `--no-routing` and `--no-isolate` remain available.
625
+ See [execution defaults and dashboard measurement details](docs/VERIFIED-PROJECTS.md#execution-defaults-in-180).
621
626
 
622
627
  ### Performance budgets: efficiency as a gate, not a style
623
628
 
@@ -841,7 +846,7 @@ the routed median used **33.8% less wall time, 11.0% less fresh input, 49.5% few
841
846
  and 78.2% fewer reasoning tokens**. All three pairs improved wall time and fresh input.
842
847
 
843
848
  The boundary matters: an earlier architecture/privacy task correctly stayed on `SELF` and paid
844
- controller overhead, so routing is an explicit opt-in rather than a universal win. Codex did not
849
+ controller overhead, so the routing default is not evidence of universal savings. Codex did not
845
850
  emit dollar cost for these plan-backed runs; Yoke reports the measured token breakdown instead of
846
851
  inventing a price. Method, ranges, controller cost, caveats, analyzer, and six raw JSON rows are in
847
852
  [`bench/RESULTS.md`](bench/RESULTS.md#codex-only-full-repository-routing-study-2026-08-02).
@@ -890,7 +895,7 @@ release provenance.
890
895
  ## 🧪 Development
891
896
 
892
897
  ```bash
893
- npm test # vitest (1100 tests)
898
+ npm test # vitest (1134 tests)
894
899
  npm run build # tsc, no emit errors
895
900
  npm run yoke -- validate canon
896
901
  ```
@@ -22,6 +22,13 @@ new stories; they do not require a release object.
22
22
  placeholders; critical irreversible choices use the structured decision channel.
23
23
  8. Use `needs` only for hard prerequisites, `area` for collision domains, and `agent` only as
24
24
  a Claude/Codex/Gemini affinity hint.
25
+ 9. Keep planning on the start model. Add an `assessment` to each story: `taskClass`
26
+ (`mechanical`, `implementation`, `debugging`, `architecture`), `difficulty`, `uncertainty`,
27
+ `risk`, `scope`, `testability` (each `low`, `medium`, `high`), a concise `reason`, and
28
+ an actionable `approach` including checks. High testability means executable checks
29
+ reliably detect mistakes. Small security-sensitive changes can still be high-risk.
30
+ These are planning judgments, never invented success probabilities; Yoke selects the
31
+ execution model from configured profiles and independent outcomes.
25
32
 
26
33
  ## Format (`.yoke/prd.yaml`)
27
34
 
@@ -1,4 +1,5 @@
1
1
  import { ModelSelectionSchema } from './contracts.js';
2
+ import { fileURLToPath } from 'node:url';
2
3
  export { providerSpawnOptions, startProviderProcess, } from './process.js';
3
4
  const argsFor = (agent, permissions) => {
4
5
  if (agent === 'claude') {
@@ -13,7 +14,8 @@ const argsFor = (agent, permissions) => {
13
14
  return ['exec', '--dangerously-bypass-approvals-and-sandbox', '--json'];
14
15
  if (permissions === 'read-only')
15
16
  return ['exec', '--sandbox', 'read-only', '--json'];
16
- return ['exec', '--sandbox', 'workspace-write', '--approve-for-me', '--json'];
17
+ // Automatic review already selects workspace-write and conflicts with --sandbox.
18
+ return ['exec', '--approve-for-me', '--json'];
17
19
  }
18
20
  if (permissions === 'unsafe')
19
21
  return ['--yolo', '--output-format', 'stream-json'];
@@ -26,8 +28,8 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
26
28
  throw new Error('Gemini does not support the bare startup selection');
27
29
  if (agent === 'gemini' && parsedSelection.reasoningEffort)
28
30
  throw new Error('Gemini does not support the reasoningEffort selection');
29
- if (agent === 'gemini' && parsedSelection.nativeMultiAgent !== undefined)
30
- throw new Error('Gemini does not support the nativeMultiAgent selection');
31
+ if (agent === 'gemini' && parsedSelection.nativeMultiAgent === true)
32
+ throw new Error('Gemini does not support enabling the nativeMultiAgent selection');
31
33
  const args = argsFor(agent, permissions);
32
34
  if (output.schemaFile !== undefined || output.jsonSchema !== undefined) {
33
35
  if (agent === 'codex' && output.schemaFile && output.jsonSchema === undefined) {
@@ -54,11 +56,16 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
54
56
  }
55
57
  if (agent === 'codex' && parsedSelection.nativeMultiAgent === false)
56
58
  args.push('--disable', 'multi_agent');
59
+ if (agent === 'claude' && parsedSelection.nativeMultiAgent === false)
60
+ args.push('--disallowedTools', 'Agent', 'Task', 'TeamCreate', 'SendMessage');
57
61
  if (parsedSelection.bare) {
58
62
  if (agent === 'codex')
59
63
  args.push('--ignore-user-config');
60
64
  else if (agent === 'claude')
61
65
  args.push('--bare');
62
66
  }
67
+ if (agent === 'gemini' && parsedSelection.nativeMultiAgent === false) {
68
+ return { command: process.execPath, args: [fileURLToPath(new URL('../../hooks/bounded-gemini.mjs', import.meta.url)), ...args], input: prompt, cwd };
69
+ }
63
70
  return { command: agent, args, input: prompt, cwd };
64
71
  }
@@ -1,3 +1,4 @@
1
+ import { assessmentInstructions } from "../routing/assessment.js";
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { execFileSync } from 'node:child_process';
3
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
@@ -93,6 +94,7 @@ export function buildChangePrompt(request, proposalPath, stories) {
93
94
  `Change request ${request.id}: ${request.request}`,
94
95
  '',
95
96
  'Create an append-only proposal: add small new stories; never rewrite or delete existing stories.',
97
+ assessmentInstructions,
96
98
  `Existing story IDs: ${stories.map(story => story.id).join(', ') || '(none)'}`,
97
99
  'Every proposed story must have passes: false and 2-5 structured acceptance criteria.',
98
100
  'Every criterion must have a stable id, behavioral text, and one or more executable verify commands.',
package/dist/cli.js CHANGED
@@ -134,10 +134,17 @@ export function main(argv) {
134
134
  }
135
135
  const loop = rest.includes('--loop') ? true : rest.includes('--no-loop') ? false : undefined;
136
136
  const routing = rest.includes('--routing') ? true : rest.includes('--no-routing') ? false : undefined;
137
+ const routingStrategy = rest.find(a => a.startsWith('--routing-strategy='))?.slice('--routing-strategy='.length);
138
+ if (routingStrategy && !['capability', 'balanced', 'cost', 'speed', 'quality'].includes(routingStrategy)) {
139
+ console.error('Invalid routing strategy');
140
+ return 1;
141
+ }
137
142
  return runSetup(targetDir, {
138
143
  host: hostArg, agents, runner: runnerArg,
139
144
  codeGraph: graphArg,
140
145
  loop, routing, decisionPolicy: policyArg,
146
+ routingStrategy: routingStrategy,
147
+ routingPreset: rest.includes('--routing-preset'),
141
148
  interactive: rest.includes('--yes') ? false : undefined,
142
149
  });
143
150
  }
@@ -466,7 +473,7 @@ export function main(argv) {
466
473
  console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
467
474
  return 1;
468
475
  }
469
- const isolate = rest.includes('--isolate');
476
+ const isolate = rest.includes('--isolate') ? true : rest.includes('--no-isolate') ? false : undefined;
470
477
  const reviewerArg = rest.find(a => a.startsWith('--reviewer='))?.slice('--reviewer='.length);
471
478
  let reviewer;
472
479
  if (reviewerArg) {
@@ -480,8 +487,8 @@ export function main(argv) {
480
487
  const allowSelfReview = rest.includes('--allow-self-review');
481
488
  const permissions = rest.includes('--unsafe') ? 'unsafe' : undefined;
482
489
  const parallelArg = rest.find(a => a.startsWith('--parallel='));
483
- const parallel = parallelArg ? Number(parallelArg.slice('--parallel='.length)) : 1;
484
- if (!Number.isInteger(parallel) || parallel < 1) {
490
+ const parallel = parallelArg && parallelArg !== '--parallel=auto' ? Number(parallelArg.slice('--parallel='.length)) : undefined;
491
+ if (parallel !== undefined && (!Number.isInteger(parallel) || parallel < 1)) {
485
492
  console.error(`Invalid --parallel value: ${parallelArg}`);
486
493
  return 1;
487
494
  }
@@ -512,9 +519,9 @@ export function main(argv) {
512
519
  console.error(qualityFlags.error);
513
520
  return 1;
514
521
  }
515
- return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, resumeWorktree: rest.includes('--resume-worktree'), parallel, reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...qualityFlags.options });
522
+ return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, resumeWorktree: rest.includes('--resume-worktree'), parallel, parallelAuto: parallelArg === '--parallel=auto', reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...qualityFlags.options });
516
523
  }
517
- console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]');
524
+ console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=<auto|N>] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate|--no-isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]');
518
525
  return 1;
519
526
  }
520
527
  case 'new': {
@@ -0,0 +1,123 @@
1
+ import { readMeasurements } from '../observability/history.js';
2
+ import { readEvents } from '../observability/events.js';
3
+ export function parsePeriod(params, now = Date.now()) {
4
+ const to = params.has('to') ? Date.parse(params.get('to')) : now;
5
+ const from = params.has('from') ? Date.parse(params.get('from')) : to - 30 * 86400000;
6
+ const bucket = params.get('bucket') ?? 'day';
7
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from >= to || to - from > 366 * 86400000 || !['day', 'week', 'month'].includes(bucket))
8
+ throw Error('Choose a valid period of at most 366 days and day, week or month grouping');
9
+ return { from, to, bucket: bucket };
10
+ }
11
+ const finite = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
12
+ const numeric = (value) => finite(value) ? value : 0;
13
+ const name = (value) => typeof value === 'string' ? value.slice(0, 200) : 'unknown';
14
+ function bucketOf(timestamp, bucket) {
15
+ const date = new Date(timestamp);
16
+ if (bucket === 'month')
17
+ return date.toISOString().slice(0, 7);
18
+ if (bucket === 'week')
19
+ date.setUTCDate(date.getUTCDate() - (date.getUTCDay() + 6) % 7);
20
+ return date.toISOString().slice(0, 10);
21
+ }
22
+ function empty() {
23
+ return { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, cacheWriteInputTokens: 0, reportedCostUsd: 0,
24
+ measuredCalls: 0, unknownCalls: 0, costReportedCalls: 0, incompleteCosts: 0, callDurationMs: 0, attemptDurationMs: 0,
25
+ attempts: 0, successfulAttempts: 0, accepted: 0, repairs: 0, escalations: 0, unmeasuredAttempts: 0 };
26
+ }
27
+ export function aggregateMeasurements(events, period) {
28
+ const total = empty(), buckets = new Map(), models = new Map(), modelBuckets = new Map(), tasks = new Map(), phases = new Map();
29
+ const modelDetails = new Map();
30
+ const seen = new Set(), accepted = new Set();
31
+ let earliest, latest;
32
+ const get = (map, key) => { if (!map.has(key))
33
+ map.set(key, empty()); return map.get(key); };
34
+ for (const event of events) {
35
+ const time = Date.parse(event.timestamp);
36
+ if (seen.has(event.id) || !Number.isFinite(time) || time < period.from || time >= period.to || event.type === 'status')
37
+ continue;
38
+ seen.add(event.id);
39
+ if (!earliest || event.timestamp < earliest)
40
+ earliest = event.timestamp;
41
+ if (!latest || event.timestamp > latest)
42
+ latest = event.timestamp;
43
+ const data = event.data ?? {}, bucket = get(buckets, bucketOf(event.timestamp, period.bucket)), task = get(tasks, event.storyId ?? 'unattributed');
44
+ const targets = [total, bucket, task];
45
+ if (event.type === 'tokens') {
46
+ const calls = Array.isArray(data.calls) && data.calls.length ? data.calls : [{ ...data, actualModel: data.model, durationMs: event.durationMs }];
47
+ for (const item of calls) {
48
+ if (!item || typeof item !== 'object')
49
+ continue;
50
+ const call = item;
51
+ const provider = name(call.provider), model = name(call.actualModel), role = name(call.role);
52
+ const key = JSON.stringify([provider, model, role]);
53
+ modelDetails.set(key, { provider, model, role });
54
+ for (const target of [...targets, get(models, key), get(modelBuckets, JSON.stringify([bucketOf(event.timestamp, period.bucket), provider, model, role]))]) {
55
+ target.inputTokens += numeric(call.inputTokens);
56
+ target.outputTokens += numeric(call.outputTokens);
57
+ target.cachedInputTokens += numeric(call.cachedInputTokens);
58
+ target.cacheWriteInputTokens += numeric(call.cacheWriteInputTokens);
59
+ target.reportedCostUsd += numeric(call.totalCostUsd);
60
+ target.callDurationMs += numeric(call.durationMs);
61
+ const measured = finite(call.inputTokens) && finite(call.outputTokens) && call.usageAvailable !== false && call.measurementComplete !== false;
62
+ if (measured)
63
+ target.measuredCalls++;
64
+ else
65
+ target.unknownCalls++;
66
+ if (finite(call.totalCostUsd))
67
+ target.costReportedCalls++;
68
+ if (call.costMeasurementComplete === false || data.costMeasurementComplete === false)
69
+ target.incompleteCosts++;
70
+ }
71
+ }
72
+ if (data.escalated === true)
73
+ for (const target of targets)
74
+ target.escalations++;
75
+ }
76
+ else if (event.type === 'phase-ended') {
77
+ const phase = name(event.phase);
78
+ phases.set(phase, (phases.get(phase) ?? 0) + numeric(event.durationMs));
79
+ if (phase === 'repairing')
80
+ for (const target of targets)
81
+ target.repairs++;
82
+ }
83
+ else if (event.type === 'attempt-ended') {
84
+ for (const target of targets) {
85
+ target.attempts++;
86
+ target.attemptDurationMs += numeric(event.durationMs);
87
+ if (['completed', 'passed'].includes(event.outcome ?? ''))
88
+ target.successfulAttempts++;
89
+ if (data.usageAvailable !== true)
90
+ target.unmeasuredAttempts++;
91
+ }
92
+ }
93
+ else if (event.type === 'accepted') {
94
+ const key = event.runId + ':' + (event.storyId ?? event.attemptId ?? event.id);
95
+ if (!accepted.has(key)) {
96
+ accepted.add(key);
97
+ for (const target of targets)
98
+ target.accepted++;
99
+ }
100
+ }
101
+ }
102
+ const decorate = (value) => ({ ...value,
103
+ tokensPerCallMinute: value.callDurationMs > 0 ? (value.inputTokens + value.outputTokens) / (value.callDurationMs / 60000) : null,
104
+ tokensPerAccepted: value.accepted ? (value.inputTokens + value.outputTokens) / value.accepted : null,
105
+ timePerAcceptedMs: value.accepted ? value.attemptDurationMs / value.accepted : null,
106
+ costState: value.costReportedCalls === 0 ? 'unknown' : value.costReportedCalls === value.measuredCalls && value.unknownCalls === 0 && value.unmeasuredAttempts === 0 && value.incompleteCosts === 0 ? 'measured' : 'partial',
107
+ });
108
+ return { total: { ...decorate(total), tokensPerElapsedMinute: (total.inputTokens + total.outputTokens) / ((period.to - period.from) / 60000) },
109
+ buckets: [...buckets].sort(([a], [b]) => a.localeCompare(b)).map(([label, value]) => ({ label, ...decorate(value) })),
110
+ models: [...models].map(([key, value]) => ({ ...modelDetails.get(key), ...decorate(value) })),
111
+ modelBuckets: [...modelBuckets].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => { const [label, provider, model, role] = JSON.parse(key); return { label, provider, model, role, ...decorate(value) }; }),
112
+ tasks: [...tasks].map(([storyId, value]) => ({ storyId, ...decorate(value) })),
113
+ phases: [...phases].map(([phase, durationMs]) => ({ phase, durationMs })), earliest, latest, };
114
+ }
115
+ export function projectAnalytics(root, period) {
116
+ const history = readMeasurements(root, period.from, period.to);
117
+ const recent = readEvents(root, 1000);
118
+ return { ...aggregateMeasurements([...history.events, ...recent], period),
119
+ from: new Date(period.from).toISOString(), to: new Date(period.to).toISOString(), bucket: period.bucket, timezone: 'UTC',
120
+ errors: history.errors,
121
+ coverage: 'Recorded measurements only. Earlier unrecorded or expired activity cannot be reconstructed. Usage is assigned to its reporting time; durations to their end time.',
122
+ };
123
+ }
@@ -1,3 +1,4 @@
1
+ import { dashboardPanels } from './panels.js';
1
2
  export function dashboardPage(token, nonce) {
2
3
  if (!/^[a-f0-9]{64}$/u.test(token) || !/^[A-Za-z0-9+/=]+$/u.test(nonce))
3
4
  throw new Error('Invalid dashboard session');
@@ -5,7 +6,7 @@ export function dashboardPage(token, nonce) {
5
6
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Yoke · Project workspace</title>
6
7
  <style nonce="${nonce}">
7
8
  :root{color-scheme:light;--ink:#15242d;--muted:#647077;--line:#dce1df;--paper:#f4f5f0;--accent:#c34c27;--green:#167452}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}button{font:inherit;cursor:pointer}button:focus-visible,a:focus-visible{outline:3px solid #dc734a;outline-offset:3px}.layout{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}aside{background:#142b32;color:#eef5f1;padding:34px 22px;display:flex;flex-direction:column;gap:30px}.brand{font-size:31px;font-weight:780;letter-spacing:-1.5px}.brand span{color:#ffb185}aside p{color:#afc3c6;font-size:13px}.label{text-transform:uppercase;font-size:11px;letter-spacing:1.7px;font-weight:750;color:var(--muted)}aside .label{color:#91a9ad}nav{display:grid;gap:7px}.nav-button{border:0;background:transparent;color:#cad8d9;text-align:left;padding:11px 12px;border-radius:8px;overflow-wrap:anywhere}.nav-button[aria-current=true]{background:#29444b;color:#fff}.aside-foot{margin-top:auto;border-top:1px solid #355057;padding-top:20px}main{max-width:1500px;width:100%;padding:40px 5vw 70px}.top{display:flex;justify-content:space-between;align-items:center;gap:20px}.local{font-size:12px;color:var(--green);background:#e2ece1;border-radius:30px;padding:6px 12px}.button{border:1px solid #bcc7c5;background:#fff;border-radius:7px;padding:9px 15px;color:var(--ink)}.button.primary{background:var(--ink);color:#fff;border-color:var(--ink)}h1{font-size:clamp(27px,3vw,40px);line-height:1.2;margin:17px 0 8px;letter-spacing:-1.4px}h2{font-size:18px;margin:0 0 18px}h3{font-size:16px;margin:0 0 8px}p{margin:6px 0}.muted{color:var(--muted)}.summary{max-width:850px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin:28px 0}.metric,.panel,.project-card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:22px}.metric strong{display:block;font-size:28px;letter-spacing:-.8px;margin:8px 0 4px}.metric p{font-size:12px;color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px;margin:28px 0}.project-card{display:flex;flex-direction:column;align-items:flex-start;gap:12px}.project-card p{color:var(--muted);overflow-wrap:anywhere}.project-card button{margin-top:auto}.columns{display:grid;grid-template-columns:1.2fr 1fr;gap:22px}.panel{margin-bottom:22px}.row{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;padding:13px 0;border-top:1px solid #edf0eb}.row:first-child{border-top:0}.row-main{min-width:0;overflow-wrap:anywhere}.row small{display:block;color:var(--muted);margin-top:4px}.badge{font-size:11px;font-weight:650;background:#edf0ed;padding:4px 9px;border-radius:20px;white-space:nowrap}.badge.good{color:#166343;background:#e4f0e7}.badge.attention{color:#a43e22;background:#fff0e7}.notice{padding:13px 16px;margin:18px 0;border-left:3px solid var(--accent);background:#fff2e9;overflow-wrap:anywhere}.actions{display:flex;align-items:center;gap:12px;margin:22px 0}.path{font:12px/1.5 ui-monospace,monospace;word-break:break-all}.empty{padding:26px 0;color:var(--muted)}details{font-size:12px;margin-top:8px}details p{white-space:pre-wrap;max-height:220px;overflow:auto}.timeline .row{font-size:13px}.loading{padding:50px;color:var(--muted)}@media(max-width:900px){.layout{grid-template-columns:1fr}aside{padding:16px 24px;gap:12px}.brand{font-size:25px}aside>p,.aside-foot,aside>.label{display:none}nav{display:flex;overflow:auto}.nav-button{white-space:nowrap}.metrics{gap:9px}.metric{padding:15px}.columns{grid-template-columns:1fr}main{padding:25px 22px}.metric strong{font-size:23px}}@media(max-width:520px){.metrics{grid-template-columns:1fr}.top{align-items:flex-start}.local{white-space:nowrap}.metric strong{font-size:26px}.metric p{font-size:13px}}
8
- </style></head><body><div class="layout"><aside><div class="brand">yoke<span>.</span></div><p>Work you can verify.<br>Projects you can pick up again.</p><div class="label">Your workspace</div><nav id="navigation" aria-label="Projects"></nav><div class="aside-foot"><div class="label">Local workspace</div><p>Project data stays on this machine.</p></div></aside><main><div class="top"><div class="label">Project workspace</div><div><span class="local">● Local session</span> <button id="refresh" class="button">Refresh</button></div></div><div id="content" aria-live="polite"><p class="loading">Loading your projects…</p></div></main></div>
9
+ main,aside,.panel{min-width:0}.actions{flex-wrap:wrap}.table-scroll{overflow-x:auto;max-width:100%}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:10px;border-bottom:1px solid var(--line)}select,input{font:inherit;padding:8px;margin:4px;border:1px solid var(--line);border-radius:6px;max-width:100%}</style></head><body><div class="layout"><aside><div class="brand">yoke<span>.</span></div><p>Work you can verify.<br>Projects you can pick up again.</p><div class="label">Your workspace</div><nav id="navigation" aria-label="Projects"></nav><div class="aside-foot"><div class="label">Local workspace</div><p>Project data stays on this machine.</p></div></aside><main><div class="top"><div class="label">Project workspace</div><div><span class="local">● Local session</span> <button id="refresh" class="button">Refresh</button></div></div><div id="content" aria-live="polite"><p class="loading">Loading your projects…</p></div></main></div>
9
10
  <script nonce="${nonce}">
10
11
  const sessionToken = ${JSON.stringify(token)};
11
12
  const content=document.getElementById('content'), navigation=document.getElementById('navigation');
@@ -13,7 +14,7 @@ let selected=null, projects=[];
13
14
  function el(tag,text,cls){const node=document.createElement(tag);if(text!==undefined)node.textContent=String(text);if(cls)node.className=cls;return node}
14
15
  function append(parent,...children){for(const child of children)parent.append(child);return parent}
15
16
  function badge(state){return el('span',state||'unknown','badge '+(['complete','passed'].includes(state)?'good':['blocked','failed','paused','unverified'].includes(state)?'attention':''))}
16
- function button(label,action,primary=false){const node=el('button',label,'button'+(primary?' primary':''));node.addEventListener('click',action);return node}
17
+ function button(label,action,primary=false){const node=el('button',label,'button'+(primary?' primary':''));node.addEventListener('click',()=>Promise.resolve().then(action).catch(error=>content.append(el('p',error.message,'notice'))));return node}
17
18
  function duration(ms){if(!Number.isFinite(ms))return 'Unknown';if(ms<60000)return Math.round(ms/1000)+'s';if(ms<3600000)return Math.round(ms/60000)+'m';return (ms/3600000).toFixed(1)+'h'}
18
19
  function tokenText(value){return Number.isFinite(value)?value.toLocaleString('en-US'):'Unknown'}
19
20
  function taskTiming(task,estimate){const planned=estimate?.available?estimate.tasks.find(item=>item.storyId===task.id):undefined;return planned?'Predicted '+duration(planned.endMs-planned.startMs)+' · planned start +'+duration(planned.startMs):task.passes?'Completed · timing unknown':'Duration and planned start unknown'}
@@ -23,9 +24,10 @@ function metric(title,value,note){return append(el('div',undefined,'metric'),el(
23
24
  async function api(path,options){const response=await fetch(path,options);const data=await response.json();if(!response.ok)throw Error(data.error||'Request failed');return data}
24
25
  function nav(){navigation.replaceChildren();const all=el('button','All projects','nav-button');all.setAttribute('aria-current',String(selected===null));all.onclick=()=>{selected=null;renderOverview()};navigation.append(all);for(const project of projects){const item=el('button',project.name,'nav-button');item.setAttribute('aria-current',String(selected===project.id));item.onclick=()=>showProject(project.id);navigation.append(item)}}
25
26
  function heading(title,subtitle){content.replaceChildren(el('h1',title),el('p',subtitle,'summary muted'))}
26
- function renderOverview(){nav();heading('A clear view of the work.','Goals, independent checks and the next thing that needs your attention.');const blocked=projects.filter(p=>p.errors.length||['blocked','paused'].includes(p.goal?.status||p.status?.state)).length;const active=projects.filter(p=>['active','running'].includes(p.goal?.status||p.status?.state)).length;content.append(append(el('div',undefined,'metrics'),metric('Projects',projects.length,'Registered on this machine'),metric('In progress',active,'Active goals and story loops'),metric('Needs attention',blocked,'Blocked, paused or unavailable')));const cards=el('div',undefined,'cards');for(const p of projects){const card=append(el('article',undefined,'project-card'),badge(p.goal?.status||p.status?.state||(p.errors.length?'unavailable':'ready')),el('h2',p.name),el('p',p.goal?.objective||'No active objective yet.'),el('p',p.root,'path'));if(p.errors.length)card.append(el('p',p.errors.join('; '),'notice'));card.append(button('Open project →',()=>showProject(p.id)));cards.append(card)}if(!projects.length)cards.append(el('p','No registered projects. Run yoke dashboard from a project directory.','empty'));content.append(cards)}
27
+ function renderOverview(){nav();heading('A clear view of the work.','Goals, independent checks and the next thing that needs your attention.');const blocked=projects.filter(p=>p.errors.length||['blocked','paused'].includes(p.goal?.status||p.status?.state)).length;const active=projects.filter(p=>['active','running'].includes(p.goal?.status||p.status?.state)).length;content.append(append(el('div',undefined,'metrics'),metric('Projects',projects.length,'Registered on this machine'),metric('In progress',active,'Active goals and story loops'),metric('Needs attention',blocked,'Blocked, paused or unavailable')));content.append(button('Compare consumption',showWorkspaceUsage));const cards=el('div',undefined,'cards');for(const p of projects){const card=append(el('article',undefined,'project-card'),badge(p.goal?.status||p.status?.state||(p.errors.length?'unavailable':'ready')),el('h2',p.name),el('p',p.goal?.objective||'No active objective yet.'),el('p',p.root,'path'));if(p.errors.length)card.append(el('p',p.errors.join('; '),'notice'));card.append(button('Open project →',()=>showProject(p.id)));cards.append(card)}if(!projects.length)cards.append(el('p','No registered projects. Run yoke dashboard from a project directory.','empty'));content.append(cards)}
27
28
  function row(title,note,state){return append(el('div',undefined,'row'),append(el('div',undefined,'row-main'),el('div',title),el('small',note)),badge(state))}
28
- async function showProject(id){selected=id;nav();heading('Loading project…','');try{const p=await api('/api/projects/'+id);if(selected!==id)return;heading(p.name,p.goal?.objective||'No active goal. Explore the saved work and acceptance evidence below.');content.append(el('p',p.root,'path'));for(const error of p.errors)content.append(el('div',error,'notice'));if(p.goal?.reason||p.status?.reason)content.append(el('div',p.goal?.reason||p.status?.reason,'notice'));const tasks=p.stories||[],passed=tasks.filter(t=>t.passes).length, estimate=p.estimate;const tokens=p.status?.tokens,cost=tokens?.totalCostUsd;const costState=p.status?.measurement?.costAvailable||'unknown';const measuredCost=costState==='measured'&&Number.isFinite(cost);const costLabel=Number.isFinite(cost)&&costState==='partial'?'$'+cost.toFixed(3)+' known':measuredCost?'$'+cost.toFixed(3):'Unknown';const range=estimate?.available?duration(estimate.lowerMs)+' – '+duration(estimate.upperMs):'Unknown';content.append(append(el('div',undefined,'metrics'),metric('Accepted tasks',passed+' / '+tasks.length,'Saved task acceptance state'),metric('Remaining time',range,estimate?.available?'Empirical range · '+estimate.sampleCount+' samples · '+estimate.confidence+' confidence':estimate?.reason||'No duration evidence yet'),metric('Reported cost',costLabel,costState==='partial'?'Partial measurement; total cost unknown':measuredCost?'For measured calls; other work may be unreported':'Provider cost is not fully reported')));renderUsage(p);const actions=el('div',undefined,'actions');actions.append(badge(p.goal?.status||p.status?.state||'ready'));if(p.goal&&!['complete','paused'].includes(p.goal.status)){const pause=button('Request pause',async()=>{pause.disabled=true;try{await api('/api/projects/'+id+'/pause',{method:'POST',headers:{'x-yoke-token':sessionToken}});pause.textContent='Pause requested';actions.append(el('small','Takes effect at a safe boundary.','muted'))}catch(error){pause.disabled=false;actions.append(el('span',error.message,'notice'))}});actions.append(pause)}content.append(actions);const columns=el('div',undefined,'columns'),left=el('div'),right=el('div');const taskPanel=append(el('section',undefined,'panel'),el('h2','Tasks'));for(const task of tasks)taskPanel.append(row(task.title,task.id+(task.area?' · '+task.area:'')+(task.needs?.length?' · after '+task.needs.join(', '):'')+' · '+taskTiming(task,estimate),task.passes?'passed':'open'));if(!tasks.length)taskPanel.append(el('p','No story plan saved yet.','empty'));left.append(taskPanel);const checks=append(el('section',undefined,'panel'),el('h2','Acceptance evidence'));if(p.check){checks.append(el('p',p.check.summary,'muted'));for(const criterion of p.check.criteria){const item=row(criterion.text,criterion.id,criterion.status);if(criterion.summary){const detail=append(el('details'),el('summary','View evidence'),el('p',criterion.summary));item.firstChild.append(detail)}checks.append(item)}checks.append(el('p','Checked '+p.check.generatedAt+' · historical evidence, not a fresh verification.','muted'))}else checks.append(el('p','No saved independent check. Run yoke check to create evidence.','empty'));left.append(checks);const attempts=append(el('section',undefined,'panel'),el('h2','Goal attempts'));for(const attempt of p.goal?.attempts||[])attempts.append(row(attempt.provider||'Agent',(attempt.summary||duration(attempt.durationMs))+' · input '+tokenText(attempt.inputTokens)+' / output '+tokenText(attempt.outputTokens)+' tokens',attempt.success?'finished':'failed'));if(!p.goal?.attempts?.length)attempts.append(el('p','No goal attempts recorded.','empty'));right.append(attempts);const timeline=append(el('section',undefined,'panel timeline'),el('h2','Recent activity'));for(const event of (p.events||[]).slice(-16).reverse())timeline.append(row(event.storyId||event.type,event.phase?event.phase+' · '+duration(event.durationMs):event.timestamp,event.outcome||event.type));if(!p.events?.length)timeline.append(el('p','Activity will appear as Yoke records transitions.','empty'));right.append(timeline);append(columns,left,right);content.append(columns)}catch(error){heading('Project unavailable',error.message)}}
29
+
30
+ ${dashboardPanels()}
29
31
  async function refresh(){try{projects=await api('/api/projects');if(selected)await showProject(selected);else renderOverview()}catch(error){heading('Workspace unavailable',error.message)}}
30
32
  document.getElementById('refresh').onclick=refresh;refresh();
31
33
  </script></body></html>`;
@@ -0,0 +1,19 @@
1
+ /** Static script; all project-controlled values are inserted through textContent. */
2
+ export function dashboardPanels() {
3
+ return String.raw `
4
+ let projectView='now', periodDays=30, bucket='day', customFrom='', customTo='', requestVersion=0;
5
+ function panel(title){return append(el('section',undefined,'panel'),el('h2',title))}
6
+ function table(headers,rows){const wrap=el('div',undefined,'table-scroll'),t=el('table'),head=el('thead'),hr=el('tr');for(const title of headers)hr.append(el('th',title));head.append(hr);t.append(head);const body=el('tbody');for(const values of rows){const r=el('tr');for(const value of values)r.append(el('td',String(value)));body.append(r)}t.append(body);wrap.append(t);return wrap}
7
+ function costText(t){return t.costState==='unknown'?'Unknown':'$'+t.reportedCostUsd.toFixed(3)+(t.costState==='partial'?' reported (partial)':' reported')}
8
+ function rate(value){return Number.isFinite(value)?value.toLocaleString('en-US',{maximumFractionDigits:1}):'Unknown'}
9
+ function usageChart(buckets){const data=buckets.slice(-31);const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 800 220');svg.setAttribute('role','img');svg.setAttribute('aria-label','Recorded input and output tokens over the latest '+data.length+' periods; exact values in the following table');const max=Math.max(1,...data.map(d=>d.inputTokens+d.outputTokens));const step=760/Math.max(1,data.length);data.forEach((d,i)=>{let y=180;for(const [value,color] of [[d.inputTokens,'#29444b'],[d.outputTokens,'#c34c27']]){const h=value/max*160;y-=h;const r=document.createElementNS(svg.namespaceURI,'rect');for(const [k,v] of Object.entries({x:20+i*step,y,width:Math.max(1,step-4),height:h,fill:color}))r.setAttribute(k,String(v));const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.label+': input '+d.inputTokens+', output '+d.outputTokens;r.append(title);svg.append(r)}if(i===0||i===data.length-1){const label=document.createElementNS(svg.namespaceURI,'text');label.setAttribute('x',String(i===0?20:780));label.setAttribute('y','205');label.setAttribute('text-anchor',i===0?'start':'end');label.setAttribute('font-size','12');label.textContent=d.label;svg.append(label)}});return svg}
10
+ async function showWorkspaceUsage(){selected=null;const version=++requestVersion;nav();heading('Compare project consumption','Recorded usage in the selected period; missing measurements remain unknown.');periodControls(null);const p=panel('Projects');p.append(el('p','Loading…','muted'));content.append(p);const rows=[];const query=queryPeriod();for(const project of projects){try{const a=await api('/api/projects/'+project.id+'/analytics?'+query);rows.push([project.name,tokenText(a.total.inputTokens),tokenText(a.total.outputTokens),costText(a.total),a.total.accepted,a.total.unknownCalls,a.errors.length?'Partial history':'Recorded portion'])}catch(error){rows.push([project.name,'Unknown','Unknown','Unknown','Unknown','Unknown',error.message])}if(version!==requestVersion||selected!==null)return}p.replaceChildren(el('h2','Projects'),table(['Project','Input','Output','Cost','Accepted','Unknown calls','Coverage'],rows))}
11
+ function queryPeriod(){const end=customTo?new Date(customTo+'T00:00:00Z').getTime()+86400000:Date.now();const start=customFrom?Date.parse(customFrom+'T00:00:00Z'):end-periodDays*86400000;return new URLSearchParams({from:new Date(start).toISOString(),to:new Date(end).toISOString(),bucket}).toString()}
12
+ function periodControls(id){const p=panel('Period · UTC');const select=el('select');select.setAttribute('aria-label','Period');for(const [value,label] of [[1,'Last 24 hours'],[7,'Last 7 days'],[30,'Last 30 days'],[90,'Last 90 days'],[365,'Last 365 days']]){const o=el('option',label);o.value=String(value);o.selected=value===periodDays;select.append(o)}select.onchange=()=>{periodDays=Number(select.value);customFrom='';customTo='';(id?showProject(id):showWorkspaceUsage())};p.append(select);const grouping=el('select');grouping.setAttribute('aria-label','Group by');for(const value of ['day','week','month']){const o=el('option',value);o.value=value;o.selected=value===bucket;grouping.append(o)}grouping.onchange=()=>{bucket=grouping.value;(id?showProject(id):showWorkspaceUsage())};p.append(grouping);const from=el('input'),to=el('input');from.type=to.type='date';from.value=customFrom;to.value=customTo;from.setAttribute('aria-label','From date UTC');to.setAttribute('aria-label','Through date UTC');p.append(from,to,button('Apply dates',()=>{if(!from.value||!to.value||from.value>to.value){p.append(el('p','Choose both dates in chronological order.','notice'));return}customFrom=from.value;customTo=to.value;(id?showProject(id):showWorkspaceUsage())}));p.append(el('p','Calendar dates include the entire end date. Maximum range: 366 days.','muted'));content.append(p)}
13
+ function renderNow(p){const status=p.status||{},goal=p.goal||{},workers=status.parallel?.workers||[];const age=status.updatedAt?Date.now()-Date.parse(status.updatedAt):undefined;const stale=Number.isFinite(age)&&age>20*60000;const summary=panel('Now');summary.append(badge(goal.status||status.state||'unknown'));summary.append(el('p',goal.reason||status.reason||'No reported blocker.'));summary.append(el('p','Last status: '+(status.updatedAt||'Unknown')+(stale?' · stale; activity is not confirmed':''),'muted'));summary.append(el('p','Reported workers: '+workers.length+' / '+(status.parallel?.maxConcurrency||1)+' · queue: '+tokenText(status.parallel?.queuedCandidates),'muted'));if(goal.pendingAttempt)summary.append(row('Goal attempt',goal.pendingAttempt.provider+' · requested model '+(goal.pendingAttempt.model||'provider default')+' · started '+goal.pendingAttempt.startedAt,'running'));if(status.execution&&status.state==='running')summary.append(el('p',status.execution.provider+' · requested model '+(status.execution.requestedModel||'provider default')+' · elapsed '+duration(Date.now()-Date.parse(status.execution.startedAt))));if(status.story&&!workers.length)summary.append(row(status.storyTitle||status.story,status.phase||'Phase unknown',status.state));for(const w of workers)summary.append(row(w.storyTitle||w.story,[w.selectedProvider||w.provider,'requested model '+(w.selectedModel||w.model||'provider default'),w.phase||w.lifecycle||'working',w.startedAt?'elapsed '+duration(Date.now()-Date.parse(w.startedAt)):null,w.quality?'repair '+w.quality.usedRepairs:null].filter(Boolean).join(' · '),w.lifecycle||'running'));if(status.parallel?.integrator){const w=status.parallel.integrator;summary.append(row('Integration: '+(w.storyTitle||w.story),w.phase||'checking','integrating'))}if(!workers.length&&!status.story&&!goal.pendingAttempt)summary.append(el('p','No task currently reported.','empty'));summary.append(el('p','Status refreshes every 5 seconds. Requested models are not proof of the model actually used; reported model identities appear under Usage & time.','muted'));content.append(summary);const tasks=panel('Tasks');for(const task of p.stories||[])tasks.append(row(task.title,task.id+(task.needs?.length?' · after '+task.needs.join(', '):'')+' · '+taskTiming(task,p.estimate),task.passes?'passed':workers.some(w=>w.story===task.id)?'running':'open'));content.append(tasks);const routing=panel('Model selection');for(const [id,d] of Object.entries(status.routingDecisions||{})){routing.append(row(id,d.provider+' / '+(d.model||'provider default')+(d.reasoningEffort?' · '+d.reasoningEffort:''),d.profile));routing.append(el('p',d.reason));routing.append(el('p','Next escalation: '+d.next,'muted'))}if(!Object.keys(status.routingDecisions||{}).length)routing.append(el('p','No recorded model selection for this run.','empty'));content.append(routing);const activity=panel('Recent activity');for(const e of (p.events||[]).slice(-16).reverse())activity.append(row(e.storyId||e.type,e.timestamp+(e.phase?' · '+e.phase:'')+(Number.isFinite(e.durationMs)?' · '+duration(e.durationMs):''),e.outcome||e.type));content.append(activity)}
14
+ function renderUsageHistory(a){const t=a.total;content.append(append(el('div',undefined,'metrics'),metric('Recorded input',tokenText(t.inputTokens),'Output: '+tokenText(t.outputTokens)),metric('Reported cost',costText(t),'Missing charges are not estimated'),metric('Tokens / elapsed minute',rate(t.tokensPerElapsedMinute),'Input + output / entire selected period')));const notes=panel('Measurement coverage');notes.append(el('p',a.coverage));notes.append(el('p','First record in period: '+(a.earliest||'none')+' · latest: '+(a.latest||'none'),'muted'));notes.append(el('p','Measured calls: '+t.measuredCalls+' · calls with unknown usage: '+t.unknownCalls+' · unmeasured attempts: '+t.unmeasuredAttempts));notes.append(el('p','Tokens / recorded call minute: '+rate(t.tokensPerCallMinute)+' · summed call time: '+duration(t.callDurationMs)+'. Parallel calls overlap; this is consumption intensity, not generation speed.'));notes.append(el('p','Cache reads: '+tokenText(t.cachedInputTokens)+' · cache writes: '+tokenText(t.cacheWriteInputTokens)+'. These are reported categories and are not added again to input totals.','muted'));for(const error of a.errors)notes.append(el('p',error,'notice'));content.append(notes);const series=panel('Consumption over time');series.append(usageChart(a.buckets));series.append(el('p','Dark: input · orange: output. Chart shows up to 31 latest periods; table includes all measured periods.','muted'));series.append(table(['UTC '+a.bucket,'Input','Output','Cache reads','Reported cost'],a.buckets.map(b=>[b.label,tokenText(b.inputTokens),tokenText(b.outputTokens),tokenText(b.cachedInputTokens),costText(b)])));if(!a.buckets.length)series.append(el('p','No measurements in this period.','empty'));content.append(series);const models=panel('Reported models in this period');models.append(table(['Provider','Actual model','Role','Input','Output','Call time','Cost'],a.models.map(m=>[m.provider,m.model,m.role,tokenText(m.inputTokens),tokenText(m.outputTokens),duration(m.callDurationMs),costText(m)])));content.append(models);const modelTimeline=panel('Models over time');modelTimeline.append(table(['UTC '+a.bucket,'Provider','Actual model','Role','Input','Output'],a.modelBuckets.map(m=>[m.label,m.provider,m.model,m.role,tokenText(m.inputTokens),tokenText(m.outputTokens)])));content.append(modelTimeline);const tasks=panel('Consumption by task');tasks.append(table(['Task','Input','Output','Attempt time','Cost'],a.tasks.map(t=>[t.storyId,tokenText(t.inputTokens),tokenText(t.outputTokens),duration(t.attemptDurationMs),costText(t)])));content.append(tasks);const phases=panel('Recorded phase time');phases.append(table(['Phase','Summed duration'],a.phases.map(p=>[p.phase,duration(p.durationMs)])));phases.append(el('p','Overlapping worker time is summed. Unrecorded queue time and human waiting time remain unknown.','muted'));content.append(phases)}
15
+ function renderResults(p,a){const t=a.total;content.append(append(el('div',undefined,'metrics'),metric('Accepted in period',t.accepted,'Recorded acceptance events'),metric('Repair phases',t.repairs,'Routing escalations: '+t.escalations),metric('Tokens / acceptance',rate(t.tokensPerAccepted),'Reported input + output, including failed work')));const attempts=panel('Measured outcomes');attempts.append(el('p','Ended attempts: '+t.attempts+' · explicitly successful attempts: '+t.successfulAttempts+' · average summed attempt time per acceptance: '+duration(t.timePerAcceptedMs)));attempts.append(el('p','Worker termination alone is not counted as successful acceptance. Rates only describe recorded activity in the selected period.','muted'));attempts.append(table(['Task','Attempts','Accepted','Repairs','Escalations','Tokens / acceptance'],a.tasks.map(v=>[v.storyId,v.attempts,v.accepted,v.repairs,v.escalations,rate(v.tokensPerAccepted)])));content.append(attempts);const checks=panel('Latest saved acceptance evidence');if(p.check){checks.append(el('p',p.check.summary));for(const c of p.check.criteria){const item=row(c.text,c.id,c.status);item.firstChild.append(append(el('details'),el('summary','View evidence'),el('p',c.summary)));checks.append(item)}checks.append(el('p','Checked '+p.check.generatedAt+' · historical evidence; independent of the selected statistics period.','muted'))}else checks.append(el('p','No saved independent check.','empty'));content.append(checks);const goals=panel('Saved goal attempts');for(const attempt of p.goal?.attempts||[])goals.append(row(attempt.provider||'Agent',(attempt.summary||'')+' · '+duration(attempt.durationMs)+' · input '+tokenText(attempt.inputTokens)+' / output '+tokenText(attempt.outputTokens),attempt.success?'finished':'failed'));content.append(goals)}
16
+ async function showProject(id){const version=++requestVersion;selected=id;nav();try{const p=await api('/api/projects/'+id);const a=projectView==='now'?null:await api('/api/projects/'+id+'/analytics?'+queryPeriod());if(version!==requestVersion||selected!==id)return;heading(p.name,p.goal?.objective||'Saved project work');content.append(el('p',p.root,'path'));for(const error of p.errors)content.append(el('p',error,'notice'));const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results']]){const b=button(label,()=>{projectView=view;showProject(id)},projectView===view);b.setAttribute('aria-pressed',String(projectView===view));tabs.append(b)}if(p.goal&&!['complete','paused'].includes(p.goal.status))tabs.append(button('Request pause',async()=>{await api('/api/projects/'+id+'/pause',{method:'POST',headers:{'x-yoke-token':sessionToken}});showProject(id)}));content.append(tabs);if(projectView==='now'){const tasks=p.stories||[];content.append(append(el('div',undefined,'metrics'),metric('Accepted tasks',tasks.filter(t=>t.passes).length+' / '+tasks.length,'Saved acceptance state'),metric('Remaining time',p.estimate?.available?duration(p.estimate.lowerMs)+' – '+duration(p.estimate.upperMs):'Unknown',p.estimate?.available?p.estimate.sampleCount+' samples · empirical range':'No reliable duration history'),metric('Project status',p.goal?.status||p.status?.state||'unknown','Latest reported state')));renderNow(p)}else{periodControls(id);if(projectView==='usage')renderUsageHistory(a);else renderResults(p,a)}}catch(error){if(version===requestVersion&&selected===id)heading('Project unavailable',error.message)}}
17
+ if(typeof setInterval==='function')setInterval(()=>{if(selected&&projectView==='now'&&typeof document.hidden!=='undefined'&&!document.hidden&&!['INPUT','SELECT','TEXTAREA'].includes(document.activeElement?.tagName)&&!document.activeElement?.matches?.(':focus-visible'))showProject(selected)},5000);
18
+ `;
19
+ }
@@ -9,9 +9,10 @@ import { dashboardPage } from './page.js';
9
9
  import { readEvents } from '../observability/events.js';
10
10
  import { estimateSchedule } from '../estimation/schedule.js';
11
11
  import { pauseProjectGoal } from '../goals/command.js';
12
+ import { parsePeriod, projectAnalytics } from './analytics.js';
12
13
  const text = z.string().max(16000);
13
14
  const number = z.number().finite().nonnegative();
14
- const Goal = z.object({ objective: text, status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), reason: text.optional(), attempts: z.array(z.object({ durationMs: number.optional(), provider: text.optional(), success: z.boolean().optional(), summary: text.optional(), inputTokens: number.optional(), outputTokens: number.optional() })).max(200).default([]) });
15
+ const Goal = z.object({ objective: text, status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), reason: text.optional(), pendingAttempt: z.object({ provider: text, model: text.optional(), startedAt: text }).optional(), attempts: z.array(z.object({ durationMs: number.optional(), provider: text.optional(), success: z.boolean().optional(), summary: text.optional(), inputTokens: number.optional(), outputTokens: number.optional() })).max(200).default([]) });
15
16
  const Status = z.object({ state: text, phase: text.optional(), reason: text.optional(), progress: z.object({ passed: number, total: number }).optional(), tokens: z.object({ inputTokens: number, outputTokens: number, totalCostUsd: number.optional(), measurementComplete: z.boolean().optional(), model: text.optional(), calls: z.array(z.object({ usageAvailable: z.boolean().optional() })).max(10000).optional() }).optional(), measurement: z.object({ costAvailable: z.enum(['unknown', 'partial', 'measured']), measuredCalls: number.optional(), unknownCalls: number.optional(), unmeasuredAttempts: number.optional() }).passthrough().optional(), parallel: z.object({ maxConcurrency: number }).passthrough().optional() }).passthrough();
16
17
  const Stories = z.array(z.object({ id: text, title: text, passes: z.boolean(), priority: number.optional(), area: text.optional(), writes: z.array(text).optional(), needs: z.array(text).optional() })).max(2000);
17
18
  const Check = z.object({ id: text, status: z.enum(['passed', 'failed', 'unverified']), generatedAt: text, summary: text, criteria: z.array(z.object({ id: text, text, status: z.enum(['passed', 'failed', 'unverified']), summary: text })).max(500) });
@@ -120,18 +121,34 @@ export async function startDashboard(options = {}) {
120
121
  send(200, listProjects().map(project => snapshot(project, false)));
121
122
  return;
122
123
  }
123
- const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause)?$/u.exec(path);
124
+ const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause|\/analytics)?$/u.exec(path);
124
125
  if (match) {
125
126
  const project = listProjects().find(project => project.id === match[1]);
126
127
  if (!project) {
127
128
  send(404, { error: 'Unknown project' });
128
129
  return;
129
130
  }
131
+ if (req.method === 'GET' && match[2] === '/analytics') {
132
+ if (project.error) {
133
+ send(409, { error: project.error });
134
+ return;
135
+ }
136
+ let period;
137
+ try {
138
+ period = parsePeriod(new URL(req.url, origin).searchParams);
139
+ }
140
+ catch (error) {
141
+ send(400, { error: error.message });
142
+ return;
143
+ }
144
+ send(200, projectAnalytics(project.root, period));
145
+ return;
146
+ }
130
147
  if (req.method === 'GET' && !match[2]) {
131
148
  send(200, snapshot(project, true));
132
149
  return;
133
150
  }
134
- if (req.method === 'POST' && match[2]) {
151
+ if (req.method === 'POST' && match[2] === '/pause') {
135
152
  if (project.error || !snapshot(project, false).goal) {
136
153
  send(409, { error: 'No readable project goal' });
137
154
  return;