@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.md +23 -18
- package/canon/skills/authoring-prd/SKILL.md +7 -0
- package/dist/agents/providers.js +10 -3
- package/dist/change/inbox.js +2 -0
- package/dist/cli.js +12 -5
- package/dist/dashboard/analytics.js +123 -0
- package/dist/dashboard/page.js +6 -4
- package/dist/dashboard/panels.js +19 -0
- package/dist/dashboard/server.js +20 -3
- package/dist/goals/command.js +31 -4
- package/dist/loop/dispatcher.js +5 -2
- package/dist/loop/git.js +1 -1
- package/dist/loop/loop.js +44 -3
- package/dist/loop/parallel-command.js +49 -8
- package/dist/loop/prd.js +2 -0
- package/dist/loop/reporter.js +29 -9
- package/dist/loop/run-command.js +58 -15
- package/dist/loop/runner.js +17 -10
- package/dist/loop/worker.js +28 -1
- package/dist/observability/events.js +6 -1
- package/dist/observability/history.js +80 -0
- package/dist/quality/candidate-comparison.js +1 -1
- package/dist/quality/command.js +27 -8
- package/dist/retrofit/config.js +6 -1
- package/dist/retrofit/gitignore.js +1 -0
- package/dist/routing/assessment.js +66 -0
- package/dist/routing/capability.js +79 -0
- package/dist/routing/router.js +80 -10
- package/dist/setup/command.js +24 -11
- package/docs/CAPABILITY-ROUTING.md +54 -0
- package/docs/PRODUCT-DIRECTION-2026-09-05.md +26 -0
- package/docs/VERIFIED-PROJECTS.md +20 -0
- package/gemini-extension.json +1 -1
- package/hooks/bounded-gemini.mjs +70 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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",
|
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.
|
|
6
|
-
<!-- yoke:tests:start -->
|
|
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)
|
|
18
18
|

|
|
19
19
|

|
|
20
|
-

|
|
21
21
|

|
|
22
22
|

|
|
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.
|
|
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
|
|
66
|
-
|
|
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;
|
|
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
|
|
566
|
+
### Adaptive model routing
|
|
567
567
|
|
|
568
|
-
`yoke setup`
|
|
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.
|
|
573
|
-
|
|
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
|
|
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`
|
|
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:
|
|
619
|
-
|
|
620
|
-
|
|
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
|
|
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 (
|
|
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
|
|
package/dist/agents/providers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/dist/change/inbox.js
CHANGED
|
@@ -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)) :
|
|
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
|
|
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
|
+
}
|
package/dist/dashboard/page.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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;
|