@ludi-uni/ludi-agent-kit 0.1.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/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Phase 2 — minimal executable path (report)
|
|
2
|
+
|
|
3
|
+
Goal: `task -> scout -> Context Pack -> coder -> validation -> success | escalation candidate`,
|
|
4
|
+
running on pi with capability-routed models.
|
|
5
|
+
|
|
6
|
+
## What runs for real (verified on this machine)
|
|
7
|
+
|
|
8
|
+
| Step | Evidence |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| Registry merge | `adapters/pi/models.local.json` (gitignored) overrides the `TODO-*` template; `scripts/resolve-capabilities.mjs` prints `registrySources.local`. |
|
|
11
|
+
| Model resolution | scout -> cheap-code -> `openai-codex/gpt-5.6-luna:low` -> fallback `freetoken/Qwen3.6-35B-A3B-NVFP4:off` -> `openai-codex/gpt-5.6-sol:medium`; coder -> strong-code -> `openai-codex/gpt-5.6-sol:medium` -> `openai-codex/gpt-5.5:high`. (Names come from `models.local.json`, not code.) |
|
|
12
|
+
| Settings proposal | `adapters/pi/out/settings.proposal.json` = `{"subagents":{"agentOverrides":{scout,coder,visual,reviewer:{model,thinking}}}}`, diffed against live `~/.pi/agent/settings.json` (all `add`). Live settings never written. |
|
|
13
|
+
| Real invocation | `adapters/pi/lib/invoke.mjs` runs `node <pi cli.js> -p --model ... --no-tools --no-session --no-approve`. Both `openai-codex` (OAuth) and `freetoken` (local endpoint) returned text with system prompt honored. |
|
|
14
|
+
| Real E2E | Fixture copied to `%TEMP%`; baseline red. scout (`gpt-5.6-luna:low`, 38 s) produced a pack that normalized cleanly (4 files, 2 snippets, in-repo relative paths; external `~/.pi/agent/AGENTS.md` reported, not rewritten). coder (`gpt-5.6-sol:medium`, 13 s) returned one FILE block; applied `src/math.js`; `npm test` 3/3. `outcome: success`. |
|
|
15
|
+
| Real escalation | With `sol` bound to a non-existent id: attempt 1 failed (`Codex error: ... model is not supported`), runner moved to fallback `codex` (`gpt-5.5:high`), tests passed, `outcome: success, escalated: true, attempts: 2`; failure appended to `## previous_attempts`. |
|
|
16
|
+
| Loop/limit | Later a real `usage limit has been reached` on all openai-codex models: scout fell back to the local model successfully, coder exhausted both candidates after exactly 2 attempts, `outcome: exhausted`, no retry of the same modelId. |
|
|
17
|
+
|
|
18
|
+
## Still mock / proposal
|
|
19
|
+
|
|
20
|
+
- `settings.proposal.json` is not applied; pi-subagents launches still inherit the session model until the block is merged.
|
|
21
|
+
- pi-subagents cannot express fallback (removed `fallbackModels`); escalation exists only in the kit runner (`lib/pipeline.mjs withEscalation`), not inside `subagent(...)` launches.
|
|
22
|
+
- The runner uses one-shot `pi -p` with `--no-tools`; agents do not use tools. Coder returns whole-file blocks confined to `relevant_files`.
|
|
23
|
+
- No task classifier; the pipeline is fixed scout->coder. `--agent/--capability` only influence `--dry-run` reporting.
|
|
24
|
+
- `visual`/`reviewer` resolve and appear in the proposal but are not exercised.
|
|
25
|
+
|
|
26
|
+
## Context Pack schema adjustment (v1.1, backwards compatible)
|
|
27
|
+
|
|
28
|
+
Real greenfield run ("add src/greet.js + test") showed `relevant_files` legitimately containing only
|
|
29
|
+
*new* files or nothing. Added, all optional: `discovery: {status: found|partial|none, note?}` — `none`
|
|
30
|
+
is the only case that permits an empty `relevant_files`; `relevant_files[].create: true` (Markdown
|
|
31
|
+
reason prefix `(new)`) marks files the coder may create. Validator, JSON schema (`if/then/else`),
|
|
32
|
+
`toMarkdown`, prompts and tests updated. v1.0 packs validate unchanged; older validators would reject
|
|
33
|
+
the new keys, so producers should omit them when not needed.
|
|
34
|
+
|
|
35
|
+
## Test inventory (47 + 1 PowerShell + 1 opt-in real)
|
|
36
|
+
|
|
37
|
+
`node --test "tests/*.test.mjs"`: routing 9, context-pack 9, kit 7, loop-guard 6, pipeline 16
|
|
38
|
+
(registry merge ×3, resolution, proposal, normalizer ×2, escalation ×2, loop prevention, scripted
|
|
39
|
+
E2E ×5, parser). `tests/test-sync-pi.ps1` PASS. `tests/e2e-real-pi.mjs` (opt-in, spends quota).
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Roadmap — minimal next items
|
|
2
|
+
|
|
3
|
+
Phase 1 (structure/schema) and Phase 2 (executable path, see `phase2-report.md`) are done.
|
|
4
|
+
Ordered by dependency; each is small and independently testable.
|
|
5
|
+
|
|
6
|
+
1. **Apply settings proposal safely.** `sync-pi.ps1 -ApplySettings`: back up `settings.json`,
|
|
7
|
+
merge only `subagents.agentOverrides.{scout,coder,visual,reviewer}`, verify JSON round-trip,
|
|
8
|
+
refuse on concurrent change. Then `subagent({agent:"coder"})` in pi uses routed models.
|
|
9
|
+
2. **Tool-enabled agents.** Replace one-shot `pi -p --no-tools` for coder with a pi-subagents launch
|
|
10
|
+
(child gets `read/edit/powershell`), keeping the kit runner as escalation wrapper. Keep the
|
|
11
|
+
FILE-block path as fallback for tool-less/local models.
|
|
12
|
+
3. **Reviewer step.** After coder success, run `reviewer` (deep-review) read-only on the diff + pack;
|
|
13
|
+
block "success" on severity-high findings. Same escalation wrapper.
|
|
14
|
+
4. **Rule-based classifier.** `routing/classify.json` (keywords / file globs / has-image -> capability);
|
|
15
|
+
`--task` only entry; still no LLM classifier.
|
|
16
|
+
5. **Usage-limit awareness.** Recognise provider limit errors in the invoker and mark the backend
|
|
17
|
+
cooling for the run so both scout and coder skip it (currently each step discovers it separately).
|
|
18
|
+
6. **pi startup check.** Port `check-pi.mjs` (offline RPC `get_commands`) against a temp agent dir
|
|
19
|
+
populated by `sync-pi.ps1 -Apply`, asserting kit skills, agents and single loop guard.
|
|
20
|
+
7. **codex adapter sync.** `sync-codex.ps1` rendering `rules/` and skills (+ metadata overlay), dry-run first.
|
|
21
|
+
8. **local backend adapter.** `adapters/local/` documenting an OpenAI-compatible endpoint and binding it to `local`.
|
|
22
|
+
|
|
23
|
+
Orchestrator Phase 1 adds the plan/delegate/evaluate loop. Phase 2 (`docs/orchestrator.md`)
|
|
24
|
+
persists that loop in SQLite so a later process can resume it, including user decisions and
|
|
25
|
+
short-lived backend health. Phase 3 gaps (Asana) are listed there.
|
|
26
|
+
|
|
27
|
+
Explicit non-goals until the above exist: unbounded autonomous loops, agent-to-agent chat, cost optimization, GUI, pi-web changes.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# OSS and external dependencies
|
|
2
|
+
|
|
3
|
+
This repository contains its own source, documentation and scripts under the [MIT license](../LICENSE). Material reused from the predecessor `codex-setting` is enumerated in [the migration notes](migration-from-codex-setting.md); its `LICENSE` carries the same 2026 `ludi-uni` MIT notice. The repository does **not** bundle the executables, Python packages or npm packages below. Installing them separately does not change this repository's license; when redistributing their binaries, read the exact installed version's license, notices and bundled-component terms.
|
|
4
|
+
|
|
5
|
+
| Tool / project | Used for | Availability / license reference |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| [pi coding agent](https://github.com/badlogic/pi-mono) and optionally [pi-subagents](https://www.npmjs.com/package/pi-subagents) | Live agent/model calls; subagent integration | Separately installed; verify the license of the **actual pi distribution or fork** in use. `pi-subagents` npm metadata declares MIT. Neither is needed for source validation. |
|
|
8
|
+
| [TypeBox](https://github.com/sinclairzx81/sinclair-typebox) | `Type` import in optional pi extensions (`adapters/pi/{shell-gate,orchestrator-ext}/index.js`) | Provided by the compatible pi extension environment; upstream license: MIT. Not a dependency of core CLI scripts. |
|
|
9
|
+
| [agent-browser](https://github.com/vercel-labs/agent-browser) | Optional browser capability and opt-in browser E2E | Separately installed CLI; upstream license: Apache-2.0. |
|
|
10
|
+
| [Playwright](https://github.com/microsoft/playwright) | Browser UI verification guidance | External tool; upstream license: Apache-2.0. No bundled browser binaries. |
|
|
11
|
+
| [Microsoft WinApp CLI](https://github.com/microsoft/winappCli) | Optional Windows desktop inspection/capture | Separately installed preview CLI; upstream license: MIT. |
|
|
12
|
+
| [FFmpeg](https://ffmpeg.org/legal.html) / `ffprobe` | Optional media capture, frame/audio processing | Separately installed. FFmpeg's licensing is build-dependent (LGPL or GPL options); inspect the actual build before redistribution. |
|
|
13
|
+
| [WhisperX](https://github.com/m-bain/WhisperX) | Optional speech analysis via the local Python adapter | Separately installed Python environment/models; upstream license: BSD-2-Clause. Downloaded models and transitive dependencies may have separate terms. |
|
|
14
|
+
|
|
15
|
+
Core CLI validation uses Node.js built-ins (including `node:sqlite` for orchestration). The npm manifest declares `typebox` as a peer dependency for the Pi extensions, not a bundled runtime dependency. Provider/model API usage can incur charges and is subject to provider terms; the JSON model catalog is not a statement of licensing, entitlement or present availability. `scripts/check-environment.ps1` probes optional tools but does not install them. The precise licenses of an installed version should be checked at its upstream package/repository before including it in a release archive.
|
package/lib/agents.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Minimal agent definition loader: YAML-ish frontmatter (flat scalars + comma lists) + body.
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { validateAccess, EXECUTION_MODES } from './orchestrator/permissions.mjs';
|
|
5
|
+
import { withLanguagePolicy } from './language-policy.mjs';
|
|
6
|
+
|
|
7
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
8
|
+
|
|
9
|
+
function scalar(value) {
|
|
10
|
+
if (value === 'true') return true;
|
|
11
|
+
if (value === 'false') return false;
|
|
12
|
+
if (/^-?[0-9]+$/.test(value)) return Number(value);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseFrontmatter(text) {
|
|
17
|
+
const m = text.replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
18
|
+
if (!m) throw new Error('agent: missing frontmatter');
|
|
19
|
+
const lines = m[1].split('\n');
|
|
20
|
+
const meta = {};
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const raw = lines[i];
|
|
23
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
24
|
+
if (/^\s/.test(raw)) throw new Error(`agent: unexpected indent "${raw.trim()}"`);
|
|
25
|
+
const idx = raw.indexOf(':');
|
|
26
|
+
if (idx < 0) throw new Error(`agent: invalid frontmatter line "${raw.trim()}"`);
|
|
27
|
+
const key = raw.slice(0, idx).trim();
|
|
28
|
+
const value = raw.slice(idx + 1).trim();
|
|
29
|
+
if (value !== '') { meta[key] = scalar(value); continue; }
|
|
30
|
+
const block = {};
|
|
31
|
+
let nested = false;
|
|
32
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
|
|
33
|
+
nested = true;
|
|
34
|
+
const line = lines[++i].trim();
|
|
35
|
+
if (!line || line.startsWith('#')) continue;
|
|
36
|
+
const j = line.indexOf(':');
|
|
37
|
+
if (j < 0) throw new Error(`agent: invalid frontmatter line "${line}"`);
|
|
38
|
+
block[line.slice(0, j).trim()] = scalar(line.slice(j + 1).trim());
|
|
39
|
+
}
|
|
40
|
+
if (nested) meta[key] = block;
|
|
41
|
+
}
|
|
42
|
+
return { meta, body: m[2].trim() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateAgent(agent, routing) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
const { meta } = agent;
|
|
48
|
+
if (typeof meta.name !== 'string' || !NAME.test(meta.name)) errors.push('agent: name must be a lowercase identifier');
|
|
49
|
+
if (typeof meta.description !== 'string' || !meta.description.trim()) errors.push(`agent ${meta.name}: description required`);
|
|
50
|
+
if (typeof meta.capability !== 'string') errors.push(`agent ${meta.name}: capability required`);
|
|
51
|
+
else if (routing && !(meta.capability in routing.capabilities)) errors.push(`agent ${meta.name}: capability "${meta.capability}" not defined in routing`);
|
|
52
|
+
if ('model' in meta) errors.push(`agent ${meta.name}: must not pin a model; use capability`);
|
|
53
|
+
if ('provider' in meta) errors.push(`agent ${meta.name}: must not pin a provider; use capability`);
|
|
54
|
+
const execution = meta.execution?.preferred_mode ?? meta.execution;
|
|
55
|
+
if (execution !== undefined && !EXECUTION_MODES.includes(execution)) errors.push(`agent ${meta.name}: execution must be oneshot|subagent|pipeline`);
|
|
56
|
+
errors.push(...validateAccess(meta.access, meta.name));
|
|
57
|
+
if (!agent.body) errors.push(`agent ${meta.name}: empty system prompt`);
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadAgents(dir, routing) {
|
|
62
|
+
const agents = [];
|
|
63
|
+
const errors = [];
|
|
64
|
+
for (const file of readdirSync(dir).filter(f => f.endsWith('.md') && f !== 'README.md').sort()) {
|
|
65
|
+
const agent = parseFrontmatter(readFileSync(join(dir, file), 'utf8'));
|
|
66
|
+
agent.file = file;
|
|
67
|
+
// Shared language policy (Japanese default) appended once here — not duplicated
|
|
68
|
+
// into each agents/*.md. Provider-agnostic; schema keys/enums stay untranslated.
|
|
69
|
+
agent.body = withLanguagePolicy(agent.body);
|
|
70
|
+
// A locally disabled capability also disables its agents without changing
|
|
71
|
+
// their shared definitions. Do not surface it as a validation error.
|
|
72
|
+
if (routing && typeof agent.meta.capability === 'string' && !(agent.meta.capability in routing.capabilities)) continue;
|
|
73
|
+
const errs = validateAgent(agent, routing);
|
|
74
|
+
if (agent.meta.name !== file.replace(/\.md$/, '')) errs.push(`agent ${file}: name "${agent.meta.name}" must match filename`);
|
|
75
|
+
errors.push(...errs);
|
|
76
|
+
agents.push(agent);
|
|
77
|
+
}
|
|
78
|
+
return { agents, errors };
|
|
79
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Context Pack v1: Markdown parser + structural validator (see context-pack/SPEC.md).
|
|
2
|
+
// Dependency-free so it can run under plain Node in any adapter.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export const SCALAR_FIELDS = ['task', 'goal', 'expected_output'];
|
|
6
|
+
export const LIST_FIELDS = ['constraints', 'repo_rules', 'observed_errors', 'test_commands'];
|
|
7
|
+
export const STRUCT_FIELDS = ['relevant_files', 'relevant_snippets', 'previous_attempts'];
|
|
8
|
+
export const REQUIRED_FIELDS = ['task', 'goal', 'constraints', 'relevant_files', 'expected_output'];
|
|
9
|
+
export const ALL_FIELDS = [...SCALAR_FIELDS, ...LIST_FIELDS, ...STRUCT_FIELDS];
|
|
10
|
+
const META_FIELDS = ['version', 'capability', 'produced_by', 'budget', 'discovery'];
|
|
11
|
+
export const DISCOVERY_STATUS = ['found', 'partial', 'none'];
|
|
12
|
+
const LINES = /^[0-9]+-[0-9]+$/;
|
|
13
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
14
|
+
|
|
15
|
+
function splitSections(markdown, { lenient = false } = {}) {
|
|
16
|
+
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
17
|
+
const sections = new Map();
|
|
18
|
+
const order = [];
|
|
19
|
+
let current = null, fence = null, buffer = [];
|
|
20
|
+
let sawTitle = false;
|
|
21
|
+
const flush = () => { if (current !== null) sections.set(current, buffer.join('\n').trim()); buffer = []; };
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
|
24
|
+
if (fenceMatch) {
|
|
25
|
+
if (!fence) fence = fenceMatch[1];
|
|
26
|
+
else if (line.trim().startsWith(fence)) fence = null;
|
|
27
|
+
buffer.push(line);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!fence && /^# /.test(line)) { sawTitle = true; continue; }
|
|
31
|
+
const heading = !fence && line.match(/^## +(\S.*?)\s*$/);
|
|
32
|
+
if (heading) {
|
|
33
|
+
flush();
|
|
34
|
+
current = heading[1];
|
|
35
|
+
if (sections.has(current)) throw new Error(`context-pack: duplicate section "## ${current}"`);
|
|
36
|
+
order.push(current);
|
|
37
|
+
sections.set(current, '');
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (current !== null) buffer.push(line);
|
|
41
|
+
}
|
|
42
|
+
flush();
|
|
43
|
+
if (!sawTitle && !(lenient && sections.has('task'))) throw new Error('context-pack: missing "# Context Pack" title');
|
|
44
|
+
return { sections, order };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function listItems(body) {
|
|
48
|
+
// Top-level bullets become items; indented sub-bullets are folded into the preceding item.
|
|
49
|
+
const items = [];
|
|
50
|
+
for (const raw of body.split('\n')) {
|
|
51
|
+
if (/^[-*] /.test(raw)) items.push(raw.slice(2).trim());
|
|
52
|
+
else if (/^\s+[-*] /.test(raw) && items.length) items[items.length - 1] += ' ' + raw.trim().slice(2).trim();
|
|
53
|
+
}
|
|
54
|
+
return items;
|
|
55
|
+
}
|
|
56
|
+
function fencedBlocks(body) {
|
|
57
|
+
const blocks = [];
|
|
58
|
+
const re = /^ *(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n? *\1[ \t]*$/gm;
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = re.exec(body))) blocks.push({ language: m[2].trim() || undefined, content: m[3].replace(/\n$/, '') });
|
|
61
|
+
return blocks;
|
|
62
|
+
}
|
|
63
|
+
const strip = s => s.replace(/^`|`$/g, '');
|
|
64
|
+
|
|
65
|
+
// Accepts: `path` (lines a-b) — reason | `path:a-b` — reason | `path:a` | path — reason
|
|
66
|
+
function parseFileItem(item) {
|
|
67
|
+
const m = item.match(/^`([^`]+)`(?:\s*\(lines?\s+([0-9]+(?:-[0-9]+)?)\))?(?:\s*(?:—|--|-|:)\s*(.*))?$/);
|
|
68
|
+
let path, lines, reason;
|
|
69
|
+
if (m) { path = m[1]; lines = m[2]; reason = m[3]?.trim(); }
|
|
70
|
+
else {
|
|
71
|
+
const first = item.split(/\s+/)[0];
|
|
72
|
+
path = strip(first);
|
|
73
|
+
reason = item.slice(first.length).replace(/^\s*(?:—|--|-|:)\s*/, '').trim() || undefined;
|
|
74
|
+
}
|
|
75
|
+
let create = false;
|
|
76
|
+
if (reason && /^\(new\)/i.test(reason)) { create = true; reason = reason.replace(/^\(new\)\s*(?:—|--|-|:)?\s*/i, '').trim() || undefined; }
|
|
77
|
+
const suffix = path.match(/^(.*?):(\d+)(?:-(\d+))?$/);
|
|
78
|
+
if (suffix && !/^[A-Za-z]:$/.test(suffix[1])) { path = suffix[1]; lines = lines ?? (suffix[3] ? `${suffix[2]}-${suffix[3]}` : `${suffix[2]}-${suffix[2]}`); }
|
|
79
|
+
if (lines && !lines.includes('-')) lines = `${lines}-${lines}`;
|
|
80
|
+
const out = { path };
|
|
81
|
+
if (lines) out.lines = lines;
|
|
82
|
+
if (reason) out.reason = reason;
|
|
83
|
+
if (create) out.create = true;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function parseSnippets(body) {
|
|
87
|
+
const out = [];
|
|
88
|
+
let parts = body.split(/^### +/m).slice(1);
|
|
89
|
+
// Lenient: list-style headers "- `path:a-b`" followed by a fenced block.
|
|
90
|
+
if (!parts.length) parts = body.split(/^[-*] +(?=`)/m).slice(1);
|
|
91
|
+
for (const part of parts) {
|
|
92
|
+
const nl = part.indexOf('\n');
|
|
93
|
+
const head = (nl >= 0 ? part.slice(0, nl) : part).trim();
|
|
94
|
+
const rest = nl >= 0 ? part.slice(nl + 1) : '';
|
|
95
|
+
const hm = head.match(/^`([^`]+)`(?:\s*\(lines?\s+([0-9]+(?:-[0-9]+)?)\))?/);
|
|
96
|
+
const block = fencedBlocks(rest)[0];
|
|
97
|
+
const fileish = parseFileItem(hm ? `\`${hm[1]}\`${hm[2] ? ` (lines ${hm[2]})` : ''}` : head);
|
|
98
|
+
const snippet = { path: fileish.path, content: block ? block.content : rest.trim() };
|
|
99
|
+
if (fileish.lines) snippet.lines = fileish.lines;
|
|
100
|
+
if (block?.language) snippet.language = block.language;
|
|
101
|
+
out.push(snippet);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
function parseAttempts(body) {
|
|
106
|
+
return listItems(body).map(item => {
|
|
107
|
+
const m = item.match(/^(.*?)\s*(?:—|--|-)\s*outcome:\s*(.*)$/i);
|
|
108
|
+
return m ? { summary: m[1].trim(), outcome: m[2].trim() } : { summary: item };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse Markdown Context Pack into the JSON form. Throws on structural errors. `lenient` tolerates a missing title. */
|
|
113
|
+
export function parseContextPackMarkdown(markdown, { lenient = false } = {}) {
|
|
114
|
+
const { sections } = splitSections(markdown, { lenient });
|
|
115
|
+
const pack = { version: 1 };
|
|
116
|
+
for (const [name, body] of sections) {
|
|
117
|
+
if (SCALAR_FIELDS.includes(name)) pack[name] = body;
|
|
118
|
+
else if (LIST_FIELDS.includes(name)) {
|
|
119
|
+
const blocks = name === 'observed_errors' ? fencedBlocks(body) : [];
|
|
120
|
+
const items = blocks.length ? blocks.map(b => b.content) : listItems(body);
|
|
121
|
+
pack[name] = name === 'test_commands' ? items.map(i => i.replace(/^`(.*)`$/, '$1')) : items;
|
|
122
|
+
}
|
|
123
|
+
else if (name === 'relevant_files') pack[name] = listItems(body).map(parseFileItem);
|
|
124
|
+
else if (name === 'relevant_snippets') pack[name] = parseSnippets(body);
|
|
125
|
+
else if (name === 'previous_attempts') pack[name] = parseAttempts(body);
|
|
126
|
+
else if (name === 'capability' || name === 'produced_by') pack[name] = body;
|
|
127
|
+
else if (name === 'discovery') {
|
|
128
|
+
const m = body.match(/^\s*(found|partial|none)\b\s*(?:(?:—|--|-|:)\s*(.*))?/is);
|
|
129
|
+
pack.discovery = m ? { status: m[1].toLowerCase(), ...(m[2]?.trim() ? { note: m[2].trim() } : {}) } : { status: body.trim() };
|
|
130
|
+
}
|
|
131
|
+
else throw new Error(`context-pack: unknown section "## ${name}"`);
|
|
132
|
+
}
|
|
133
|
+
return pack;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Structural validation mirroring context-pack.schema.json. Returns an array of error strings. */
|
|
137
|
+
export function validateContextPack(pack) {
|
|
138
|
+
const errors = [];
|
|
139
|
+
const err = m => errors.push(m);
|
|
140
|
+
if (!pack || typeof pack !== 'object' || Array.isArray(pack)) return ['context-pack: root must be an object'];
|
|
141
|
+
for (const key of Object.keys(pack)) if (!ALL_FIELDS.includes(key) && !META_FIELDS.includes(key)) err(`context-pack: unknown field "${key}"`);
|
|
142
|
+
for (const f of REQUIRED_FIELDS) if (!(f in pack)) err(`context-pack: missing required field "${f}"`);
|
|
143
|
+
if (pack.version !== undefined && pack.version !== 1) err('context-pack: version must be 1');
|
|
144
|
+
if (pack.capability !== undefined && !(typeof pack.capability === 'string' && NAME.test(pack.capability))) err('context-pack: capability must be a lowercase capability name');
|
|
145
|
+
if (pack.budget !== undefined) {
|
|
146
|
+
if (typeof pack.budget !== 'object' || pack.budget === null) err('context-pack: budget must be an object');
|
|
147
|
+
else if (pack.budget.max_tokens !== undefined && !(Number.isInteger(pack.budget.max_tokens) && pack.budget.max_tokens > 0)) err('context-pack: budget.max_tokens must be a positive integer');
|
|
148
|
+
}
|
|
149
|
+
for (const f of SCALAR_FIELDS) if (f in pack && !(typeof pack[f] === 'string' && pack[f].trim())) err(`context-pack: "${f}" must be a non-empty string`);
|
|
150
|
+
for (const f of LIST_FIELDS) if (f in pack) {
|
|
151
|
+
if (!Array.isArray(pack[f])) err(`context-pack: "${f}" must be an array`);
|
|
152
|
+
else pack[f].forEach((v, i) => { if (!(typeof v === 'string' && v.trim())) err(`context-pack: "${f}[${i}]" must be a non-empty string`); });
|
|
153
|
+
}
|
|
154
|
+
if (pack.discovery !== undefined) {
|
|
155
|
+
if (!pack.discovery || typeof pack.discovery !== 'object') err('context-pack: discovery must be an object');
|
|
156
|
+
else {
|
|
157
|
+
if (!DISCOVERY_STATUS.includes(pack.discovery.status)) err(`context-pack: discovery.status must be one of ${DISCOVERY_STATUS.join('|')}`);
|
|
158
|
+
if (pack.discovery.note !== undefined && typeof pack.discovery.note !== 'string') err('context-pack: discovery.note must be a string');
|
|
159
|
+
for (const k of Object.keys(pack.discovery)) if (!['status', 'note'].includes(k)) err(`context-pack: discovery has unknown key "${k}"`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if ('relevant_files' in pack) {
|
|
163
|
+
const allowEmpty = pack.discovery?.status === 'none';
|
|
164
|
+
if (!Array.isArray(pack.relevant_files)) err('context-pack: "relevant_files" must be an array');
|
|
165
|
+
else if (pack.relevant_files.length === 0 && !allowEmpty) err('context-pack: "relevant_files" must contain at least one entry (or set discovery.status = none)');
|
|
166
|
+
else pack.relevant_files.forEach((v, i) => {
|
|
167
|
+
if (v?.create !== undefined && typeof v.create !== 'boolean') err(`context-pack: "relevant_files[${i}].create" must be boolean`);
|
|
168
|
+
for (const k of Object.keys(v ?? {})) if (!['path', 'reason', 'lines', 'create'].includes(k)) err(`context-pack: "relevant_files[${i}]" has unknown key "${k}"`);
|
|
169
|
+
if (!v || typeof v.path !== 'string' || !v.path.trim()) err(`context-pack: "relevant_files[${i}].path" is required`);
|
|
170
|
+
if (v?.lines !== undefined && !LINES.test(v.lines)) err(`context-pack: "relevant_files[${i}].lines" must be "start-end"`);
|
|
171
|
+
if (v?.path && /^[A-Za-z]:[\\/]|^\\\\/.test(v.path)) err(`context-pack: "relevant_files[${i}].path" must be repository-relative, not absolute`);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if ('relevant_snippets' in pack) {
|
|
175
|
+
if (!Array.isArray(pack.relevant_snippets)) err('context-pack: "relevant_snippets" must be an array');
|
|
176
|
+
else pack.relevant_snippets.forEach((v, i) => {
|
|
177
|
+
if (!v || typeof v.path !== 'string' || !v.path.trim()) err(`context-pack: "relevant_snippets[${i}].path" is required`);
|
|
178
|
+
if (!v || typeof v.content !== 'string') err(`context-pack: "relevant_snippets[${i}].content" is required`);
|
|
179
|
+
if (v?.lines !== undefined && !LINES.test(v.lines)) err(`context-pack: "relevant_snippets[${i}].lines" must be "start-end"`);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if ('previous_attempts' in pack) {
|
|
183
|
+
if (!Array.isArray(pack.previous_attempts)) err('context-pack: "previous_attempts" must be an array');
|
|
184
|
+
else pack.previous_attempts.forEach((v, i) => {
|
|
185
|
+
if (!v || typeof v.summary !== 'string' || !v.summary.trim()) err(`context-pack: "previous_attempts[${i}].summary" is required`);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return errors;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function loadContextPack(path) {
|
|
192
|
+
const text = readFileSync(path, 'utf8');
|
|
193
|
+
const pack = path.toLowerCase().endsWith('.json') ? JSON.parse(text) : parseContextPackMarkdown(text);
|
|
194
|
+
const errors = validateContextPack(pack);
|
|
195
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
196
|
+
return pack;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Serialize the JSON form back to canonical Markdown. */
|
|
200
|
+
export function toMarkdown(pack) {
|
|
201
|
+
const out = ['# Context Pack', ''];
|
|
202
|
+
const section = (name, body) => { out.push(`## ${name}`, body.trimEnd(), ''); };
|
|
203
|
+
section('task', pack.task);
|
|
204
|
+
section('goal', pack.goal);
|
|
205
|
+
section('constraints', pack.constraints.map(c => `- ${c}`).join('\n') || '-');
|
|
206
|
+
if (pack.discovery) section('discovery', `${pack.discovery.status}${pack.discovery.note ? ` — ${pack.discovery.note}` : ''}`);
|
|
207
|
+
section('relevant_files', pack.relevant_files.map(f => `- \`${f.path}\`${f.lines ? ` (lines ${f.lines})` : ''}${f.create || f.reason ? ` — ${f.create ? '(new) ' : ''}${f.reason ?? ''}`.trimEnd() : ''}`).join('\n') || '(none)');
|
|
208
|
+
if (pack.relevant_snippets?.length) section('relevant_snippets', pack.relevant_snippets.map(s => `### \`${s.path}\`${s.lines ? ` (lines ${s.lines})` : ''}\n\`\`\`${s.language ?? ''}\n${s.content}\n\`\`\``).join('\n\n'));
|
|
209
|
+
if (pack.repo_rules?.length) section('repo_rules', pack.repo_rules.map(c => `- ${c}`).join('\n'));
|
|
210
|
+
if (pack.observed_errors?.length) section('observed_errors', pack.observed_errors.map(e => `\`\`\`\n${e}\n\`\`\``).join('\n\n'));
|
|
211
|
+
if (pack.test_commands?.length) section('test_commands', pack.test_commands.map(c => `- \`${c}\``).join('\n'));
|
|
212
|
+
if (pack.previous_attempts?.length) section('previous_attempts', pack.previous_attempts.map(a => `- ${a.summary}${a.outcome ? ` — outcome: ${a.outcome}` : ''}`).join('\n'));
|
|
213
|
+
section('expected_output', pack.expected_output);
|
|
214
|
+
return out.join('\n');
|
|
215
|
+
}
|