From 0efc5e07466b6680d50c0d2f789a2b5ce4961e18 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 15:46:00 -0700 Subject: [PATCH 01/13] feat: add NodeKit launch factory --- AGENTS.md | 14 ++ CLAUDE.md | 7 + README.md | 47 +++- package-lock.json | 58 ++++- package.json | 9 +- plugins/nodekit/.claude-plugin/plugin.json | 9 + plugins/nodekit/.codex-plugin/plugin.json | 18 ++ .../nodekit/skills/nodekit-launch/SKILL.md | 39 ++++ .../skills/nodekit-launch/agents/openai.yaml | 4 + .../references/launch-contract.md | 25 ++ schemas/nodeagent.application.v1.schema.json | 26 +++ schemas/nodeagent.pack.v1.schema.json | 17 ++ schemas/nodekit.schema.json | 1 + src/cli.mjs | 124 +++++++++- src/lib/agent-definition.mjs | 218 ++++++++++++++++++ src/lib/repo-check.mjs | 12 +- src/lib/scaffold.mjs | 197 ++++++++++++++++ templates/research-loop/.dockerignore | 8 + templates/research-loop/.env.example | 3 + templates/research-loop/AGENTS.md | 11 + templates/research-loop/Dockerfile | 28 +++ templates/research-loop/README.md | 35 +++ .../research-loop/adw/workflows/launch.yaml | 14 ++ .../research-loop/agent/experiment-loop.mjs | 126 ++++++++++ templates/research-loop/agent/instructions.md | 3 + .../agent/planner/planning-policy.yaml | 10 + .../research-loop/agent/policies/budgets.yaml | 5 + .../agent/policies/permissions.yaml | 11 + .../agent/skills/autoresearch-live/SKILL.md | 15 ++ .../agent/subagents/reviewer/agent.yaml | 8 + .../agent/tools/measure-ngram.mjs | 49 ++++ .../research-loop/apps/web/public/app.js | 69 ++++++ .../research-loop/apps/web/public/index.html | 81 +++++++ .../research-loop/apps/web/public/styles.css | 81 +++++++ templates/research-loop/apps/web/server.mjs | 104 +++++++++ .../backend/filesystem/store.mjs | 23 ++ .../evals/deterministic-smoke.json | 10 + .../research-loop/fixtures/corpus/train.txt | 1 + .../fixtures/corpus/validation.txt | 1 + templates/research-loop/gitignore.template | 4 + templates/research-loop/hackathon.yaml | 18 ++ .../integrations/pi-ai/provider.mjs | 94 ++++++++ templates/research-loop/nodeagent.yaml | 38 +++ templates/research-loop/nodekit.yaml | 31 +++ templates/research-loop/package.json | 27 +++ .../research-loop/packs/primary/pack.yaml | 18 ++ templates/research-loop/render.yaml | 10 + .../schemas/experiment-receipt.schema.json | 12 + .../research-loop/scripts/browser-proof.mjs | 42 ++++ templates/research-loop/scripts/check.mjs | 21 ++ templates/research-loop/scripts/demo.mjs | 27 +++ templates/research-loop/scripts/eval.mjs | 34 +++ .../research-loop/scripts/lib/friction.mjs | 15 ++ .../research-loop/scripts/live-smoke.mjs | 17 ++ templates/research-loop/scripts/phase.mjs | 20 ++ templates/research-loop/scripts/proof.mjs | 45 ++++ templates/research-loop/scripts/timeline.mjs | 22 ++ .../test/experiment-loop.test.mjs | 23 ++ test/factory.test.mjs | 76 ++++++ 59 files changed, 2094 insertions(+), 21 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 plugins/nodekit/.claude-plugin/plugin.json create mode 100644 plugins/nodekit/.codex-plugin/plugin.json create mode 100644 plugins/nodekit/skills/nodekit-launch/SKILL.md create mode 100644 plugins/nodekit/skills/nodekit-launch/agents/openai.yaml create mode 100644 plugins/nodekit/skills/nodekit-launch/references/launch-contract.md create mode 100644 schemas/nodeagent.application.v1.schema.json create mode 100644 schemas/nodeagent.pack.v1.schema.json create mode 100644 src/lib/agent-definition.mjs create mode 100644 src/lib/scaffold.mjs create mode 100644 templates/research-loop/.dockerignore create mode 100644 templates/research-loop/.env.example create mode 100644 templates/research-loop/AGENTS.md create mode 100644 templates/research-loop/Dockerfile create mode 100644 templates/research-loop/README.md create mode 100644 templates/research-loop/adw/workflows/launch.yaml create mode 100644 templates/research-loop/agent/experiment-loop.mjs create mode 100644 templates/research-loop/agent/instructions.md create mode 100644 templates/research-loop/agent/planner/planning-policy.yaml create mode 100644 templates/research-loop/agent/policies/budgets.yaml create mode 100644 templates/research-loop/agent/policies/permissions.yaml create mode 100644 templates/research-loop/agent/skills/autoresearch-live/SKILL.md create mode 100644 templates/research-loop/agent/subagents/reviewer/agent.yaml create mode 100644 templates/research-loop/agent/tools/measure-ngram.mjs create mode 100644 templates/research-loop/apps/web/public/app.js create mode 100644 templates/research-loop/apps/web/public/index.html create mode 100644 templates/research-loop/apps/web/public/styles.css create mode 100644 templates/research-loop/apps/web/server.mjs create mode 100644 templates/research-loop/backend/filesystem/store.mjs create mode 100644 templates/research-loop/evals/deterministic-smoke.json create mode 100644 templates/research-loop/fixtures/corpus/train.txt create mode 100644 templates/research-loop/fixtures/corpus/validation.txt create mode 100644 templates/research-loop/gitignore.template create mode 100644 templates/research-loop/hackathon.yaml create mode 100644 templates/research-loop/integrations/pi-ai/provider.mjs create mode 100644 templates/research-loop/nodeagent.yaml create mode 100644 templates/research-loop/nodekit.yaml create mode 100644 templates/research-loop/package.json create mode 100644 templates/research-loop/packs/primary/pack.yaml create mode 100644 templates/research-loop/render.yaml create mode 100644 templates/research-loop/schemas/experiment-receipt.schema.json create mode 100644 templates/research-loop/scripts/browser-proof.mjs create mode 100644 templates/research-loop/scripts/check.mjs create mode 100644 templates/research-loop/scripts/demo.mjs create mode 100644 templates/research-loop/scripts/eval.mjs create mode 100644 templates/research-loop/scripts/lib/friction.mjs create mode 100644 templates/research-loop/scripts/live-smoke.mjs create mode 100644 templates/research-loop/scripts/phase.mjs create mode 100644 templates/research-loop/scripts/proof.mjs create mode 100644 templates/research-loop/scripts/timeline.mjs create mode 100644 templates/research-loop/test/experiment-loop.test.mjs create mode 100644 test/factory.test.mjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3abea7c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# NodeKit coding-agent entrypoint + +When a user supplies a business pain point, purpose, use case, hackathon brief, judging rubric, or sponsor tools, read `plugins/nodekit/skills/nodekit-launch/SKILL.md` and follow it as the launch workflow. + +The default outcome is not a plan. It is the smallest credible application that is researched, scaffolded, compiled, evaluated, live-provider tested, browser tested, deployable, and accompanied by sanitized proof and a phase-by-phase timeline. + +Safety boundaries: + +- Prefer official, current sponsor documentation and record sources and versions. +- Never copy a secret into files, YAML, browser code, logs, or receipts. Pass it only to the process that needs it. +- Ask before paid activation, production data migration, destructive writes, public posting, or production deployment unless the user already authorized that exact action. +- Never weaken evaluators or substitute a different implementation in benchmark mode. +- `nodekit create` is empty-directory only. Use `nodekit adopt` for an existing repository. +- Keep the launch clock honest. Reused research or fixtures must be labeled as reused rather than timed as new work. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9373e03 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# NodeKit launch routing + +If the user describes an idea, business problem, use case, hackathon, or required sponsor stack, immediately load and follow: + +`plugins/nodekit/skills/nodekit-launch/SKILL.md` + +Treat the user's prose as the intake brief. Research only the high-impact unknowns, choose one demonstrable vertical slice, and use NodeKit to produce a working application and proof. Preserve the approval and secret-handling rules in `AGENTS.md`. diff --git a/README.md b/README.md index ce9f30d..fdb2aaa 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,29 @@ -# Node Platform +# NodeKit / Node Platform -Node Platform is the machine-readable ownership and repository-contract layer for the Node ecosystem. It prevents new contract forks while the existing applications continue to ship independently. +NodeKit is the portable setup and conformance layer for proof-carrying agent applications. It turns an empty directory or an existing repository into a filesystem-authored agent harness with a compiled definition, capability packs, deterministic fixtures, live-provider gates, browser proof, and phase-by-phase receipts. -P0 deliberately does not move working product code between repositories. It records who owns each concept, classifies current adapters and migration sources, standardizes lifecycle commands, and fails CI when a new canonical-signature copy or clear layer violation appears. +Node Platform remains its ownership layer: it records which repository owns each shared contract and fails CI when a new fork or clear layer violation appears. + +## From a brief to a running app + +Clone this repository, open it in Codex or Claude Code, and describe the pain point, user, outcome, sponsor tools, and deadline. Root `AGENTS.md` and `CLAUDE.md` route that brief into the bundled NodeKit launch skill. + +Or use the CLI directly: + +```bash +node src/cli.mjs create ../my-agent-app \ + --name my-agent-app \ + --brief "A persistent research agent that users can steer mid-run" \ + --sponsors pi-ai,convex \ + --nodekit-specifier file:$(pwd) + +cd ../my-agent-app +npm run demo +npm run eval +npm run dev +``` + +The first certified preset is `research-loop`: a small reference runtime with an objective held-out metric, deterministic keep/revert decisions, versioned human intervention, interrupted-run recovery, a strict Pi smoke, and sanitized reproduction receipts. It is a reference adapter to the NodeAgent application contract; it is not presented as the still-unfinished extraction of NodeRoom's deeper production runtime. ## Commands @@ -16,6 +37,15 @@ npm run ecosystem:check npm run dashboard ``` +Factory commands: + +```bash +nodekit create --name --brief +nodekit adopt --name --brief +nodekit compile --repo-root +nodekit inspect --repo-root +``` + From any repository with `nodekit.yaml`: ```bash @@ -25,21 +55,24 @@ npx --yes @homenshum/nodekit check npx --yes @homenshum/nodekit proof ``` -For a source-pinned fallback, replace `@homenshum/nodekit` with `github:HomenShum/node-platform#v0.1.0`. The unscoped `nodekit` npm name belongs to an unrelated project; this project uses the `@homenshum/nodekit` package and exposes the `nodekit` binary. +`@homenshum/nodekit` 0.2.0 is not yet published. Until it is tagged and released, use a normalized local `file:` spec while dogfooding. The unscoped `nodekit` npm name belongs to an unrelated project. -## P0 Contract +## Contracts - [`ownership.yaml`](ownership.yaml) names one owner, current package, target package, status, version, and consumers for every governed concept. - [`repositories.yaml`](repositories.yaml) records lifecycle, support state, role, successor, and command profile. - [`architecture.yaml`](architecture.yaml) defines universal commands, allowed reuse modes, and source-layer rules. - [`schemas/nodekit.schema.json`](schemas/nodekit.schema.json) documents each consumer repository's `nodekit.yaml`. +- [`schemas/nodeagent.application.v1.schema.json`](schemas/nodeagent.application.v1.schema.json) and [`schemas/nodeagent.pack.v1.schema.json`](schemas/nodeagent.pack.v1.schema.json) are enforced during compilation. +- `nodekit compile` discovers authored files, validates pack references, rejects literal secrets, and hashes the runtime, backend, fixtures, schemas, integrations, and evals into `.nodeagent/`. +- `nodekit create` refuses non-empty targets. `nodekit adopt` writes missing files only, preserves host scripts, and emits a collision receipt. - `nodekit repo check` validates ownership declarations, command aliases, migration origins, signature classification, and source rules. - `nodekit ecosystem check` checks all active local clones together. - `nodekit dashboard` generates the cross-repository status table. -## Honest Boundary +## Honest boundary -`planned`, `migration-planned`, and `canonical-unpackaged` are intentionally distinct from a released shared package. P0 freezes new duplication; it does not claim the P1 package extraction, environment loader, templates, codemods, or release automation are complete. +`planned`, `migration-planned`, and `canonical-unpackaged` remain intentionally distinct from released shared packages. NodeKit now has one end-to-end reference preset; it does not claim that every runtime adapter, backend, template, codemod, or production deployment target is complete. In particular, NodeRoom still contains the deepest live runtime and its extraction into a published NodeAgent package remains separate work. See [`docs/DECISIONS.md`](docs/DECISIONS.md) for the ownership split and migration rules. The coordinated consumer commits, pull requests, hosted checks, and known limits are recorded in [`docs/P0_ROLLOUT.md`](docs/P0_ROLLOUT.md) and [`proof/p0-rollout.json`](proof/p0-rollout.json). diff --git a/package-lock.json b/package-lock.json index 1e03d21..d78f335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@homenshum/nodekit", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@homenshum/nodekit", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { + "ajv": "8.20.0", "yaml": "^2.9.0" }, "bin": { @@ -18,6 +19,59 @@ "node": ">=20" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index 1edb6ed..d4dc610 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@homenshum/nodekit", - "version": "0.1.0", - "description": "Node Platform repository contracts, ownership checks, and lifecycle CLI", + "version": "0.2.0", + "description": "Portable agent-application scaffolding, compilation, conformance, and proof tooling", "type": "module", "bin": { "nodekit": "src/cli.mjs" @@ -9,6 +9,8 @@ "files": [ "src", "schemas", + "templates", + "plugins", "ownership.yaml", "repositories.yaml", "architecture.yaml", @@ -18,7 +20,7 @@ "scripts": { "doctor": "node src/cli.mjs registry check --registry-root .", "audit:prod": "npm audit --omit=dev", - "test": "node --test", + "test": "node --test test/*.test.mjs", "registry:check": "node src/cli.mjs registry check --registry-root .", "ecosystem:check": "node src/cli.mjs ecosystem check --registry-root . --workspace ..", "dashboard": "node src/cli.mjs dashboard --registry-root . --workspace .. --write", @@ -31,6 +33,7 @@ "access": "public" }, "dependencies": { + "ajv": "8.20.0", "yaml": "^2.9.0" }, "license": "MIT" diff --git a/plugins/nodekit/.claude-plugin/plugin.json b/plugins/nodekit/.claude-plugin/plugin.json new file mode 100644 index 0000000..7c7bdd6 --- /dev/null +++ b/plugins/nodekit/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "nodekit", + "version": "0.2.0", + "description": "Timed business-brief-to-live-agent workflow with research, sponsor integration, evaluations, and proof.", + "author": { "name": "Homen Shum" }, + "repository": "https://github.com/HomenShum/node-platform", + "license": "MIT", + "keywords": ["agent", "hackathon", "scaffolding", "evaluation", "pi-ai"] +} diff --git a/plugins/nodekit/.codex-plugin/plugin.json b/plugins/nodekit/.codex-plugin/plugin.json new file mode 100644 index 0000000..00fbe9e --- /dev/null +++ b/plugins/nodekit/.codex-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "nodekit", + "version": "0.2.0", + "description": "Research, scaffold, evaluate, and prove a production-shaped agent application from a business brief.", + "author": { + "name": "Homen Shum" + }, + "skills": "./skills/", + "interface": { + "displayName": "NodeKit", + "shortDescription": "Turn a brief into a proof-carrying agent app.", + "longDescription": "NodeKit gives Codex a timed research, scaffolding, evaluation, live-provider, browser QA, and proof workflow for hackathon and founder agent applications.", + "developerName": "Homen Shum", + "category": "Developer Tools", + "capabilities": ["skills"], + "defaultPrompt": "Use NodeKit to turn my business pain point and required sponsor tools into the smallest live-tested, deployable, proof-carrying agent application." + } +} diff --git a/plugins/nodekit/skills/nodekit-launch/SKILL.md b/plugins/nodekit/skills/nodekit-launch/SKILL.md new file mode 100644 index 0000000..7090ef2 --- /dev/null +++ b/plugins/nodekit/skills/nodekit-launch/SKILL.md @@ -0,0 +1,39 @@ +--- +name: nodekit-launch +description: Turn a business pain point, product purpose, hackathon idea, judging rubric, or required sponsor stack into a researched, scaffolded, evaluated, live-tested, browser-proven agent application. Use for empty-directory builds or safely adopting NodeKit into an existing project. +--- + +# NodeKit Launch + +Build the smallest undeniable vertical slice. Keep an honest launch clock from intake through proof, aiming for 30 minutes and preserving remaining 2-hour and 4-hour hackathon runway. + +Read [the launch contract](references/launch-contract.md) before acting. + +## Workflow + +1. Start the launch timer. Capture the raw brief, deadline, judging rubric, sponsor requirements, and current directory state. +2. Research current official sources for the user problem and every sponsor. Record links, package versions, authentication, pricing/limits, and one visible contribution to the demo. +3. Select one workflow shaped as `input -> agent decision -> tool-backed action -> measurable artifact -> visible proof`. Prefer a real metric and a reversible experiment. +4. Compile the prose into `hackathon.yaml`. Ask only questions whose answers materially change the product, security model, or irreversible action. +5. For an empty target, run `nodekit create`. For an existing target, run `nodekit adopt` and inspect its collision receipt before accepting changes. +6. Run `nodekit compile` and `nodekit inspect`. Confirm the filesystem-discovered tools, skills, integrations, fixtures, evals, provider, secret references, and config hash. +7. Implement one end-to-end surface. Preserve one execution path for the no-key demo, live provider, browser, and evals. +8. Run deterministic demo, unit/contract tests, domain evals, failure cases, strict live provider smoke, and browser journeys. Test missing secrets, malformed input, reload/resume, repeated actions, narrow/mobile layout, and export/reopen. +9. Deploy only the exact tested revision and only with user authorization. Record URL, revision, environment identity, health, and a fresh-user journey. +10. Emit the release proof and launch timeline. Do not call the run production-proven if live, browser, deployment, or receipt evidence is absent. + +## Sponsor rule + +A dependency in `package.json` is not sponsor usage. Each sponsor needs an official-source research note, deterministic fixture, bounded live smoke, visible role in the main workflow, and sanitized receipt. + +## Time policy + +- Measure research, scaffold, install, compile, implementation, deterministic gates, live model, browser QA, deployment, and final proof independently. +- Treat 30 minutes as a target gate, not a reason to falsify evidence or skip safety. +- At 15 minutes, freeze the core workflow and defer side quests. +- At 22 minutes, stop aesthetic expansion and run the full proof ladder. +- If the run exceeds 30 minutes, preserve the actual duration and top friction causes; never rewrite timestamps. + +## Secret and approval policy + +Read credentials from environment references only. Never print values. Remove temporary process variables after the bounded call. Pause for paid activation, destructive changes, production migrations, public posting, or deployment unless explicitly authorized. diff --git a/plugins/nodekit/skills/nodekit-launch/agents/openai.yaml b/plugins/nodekit/skills/nodekit-launch/agents/openai.yaml new file mode 100644 index 0000000..b36da7b --- /dev/null +++ b/plugins/nodekit/skills/nodekit-launch/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "NodeKit Launch" + short_description: "Build and prove an agent app from a brief" + default_prompt: "Use NodeKit to research this brief, scaffold the smallest credible agent application, run deterministic and live evaluations, test it in the browser, and emit proof with a phase timeline." diff --git a/plugins/nodekit/skills/nodekit-launch/references/launch-contract.md b/plugins/nodekit/skills/nodekit-launch/references/launch-contract.md new file mode 100644 index 0000000..976f708 --- /dev/null +++ b/plugins/nodekit/skills/nodekit-launch/references/launch-contract.md @@ -0,0 +1,25 @@ +# Launch contract + +## Intake artifact + +Capture problem, affected user, desired outcome, current workaround, demo input, measurable result, sponsor list, time budget, spend budget, deployment requirement, and approval boundaries. + +## Required evidence ladder + +1. Scaffold: files generated into an empty directory or collision-safe adoption. +2. Compile: secret-free resolved definition and content hash. +3. Replay: no-key deterministic workflow. +4. Conformance: schemas, permissions, tools, persistence, and receipts. +5. Domain: fixed metric, happy path, invalid input, failure, and recovery. +6. Live: strict provider call with no fallback and sanitized usage. +7. Browser: fresh state, primary flow, edge cases, reload, mobile, console health, and exported artifact. +8. Deployment: exact tested revision with environment identity. +9. Proof: release receipt plus an honest phase timeline. + +## Completion language + +- Say `scaffolded` after generation only. +- Say `deterministically verified` after replay and evals. +- Say `live-provider verified` only after the strict no-fallback call. +- Say `browser proven` only after visible journeys and captured evidence. +- Say `production proven` only after the deployed first-user journey passes. diff --git a/schemas/nodeagent.application.v1.schema.json b/schemas/nodeagent.application.v1.schema.json new file mode 100644 index 0000000..15a9ac4 --- /dev/null +++ b/schemas/nodeagent.application.v1.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/HomenShum/node-platform/blob/main/schemas/nodeagent.application.v1.schema.json", + "title": "NodeAgent application manifest", + "type": "object", + "required": ["schemaVersion", "application", "runtime", "provider", "backend", "packs", "policies", "evaluations"], + "properties": { + "schemaVersion": { "const": "nodeagent.application/v1" }, + "application": { + "type": "object", + "required": ["id", "name", "purpose"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "name": { "type": "string", "minLength": 1 }, + "purpose": { "type": "string", "minLength": 1 } + } + }, + "runtime": { "type": "object", "required": ["engine", "profile"] }, + "provider": { "type": "object", "required": ["adapter", "model", "secretRef"] }, + "backend": { "type": "object", "required": ["adapter"] }, + "packs": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + "policies": { "type": "object" }, + "evaluations": { "type": "object", "required": ["required"] }, + "secrets": { "type": "array" } + } +} diff --git a/schemas/nodeagent.pack.v1.schema.json b/schemas/nodeagent.pack.v1.schema.json new file mode 100644 index 0000000..0ccea53 --- /dev/null +++ b/schemas/nodeagent.pack.v1.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/HomenShum/node-platform/blob/main/schemas/nodeagent.pack.v1.schema.json", + "title": "NodeAgent capability pack", + "type": "object", + "required": ["schemaVersion", "id", "version", "artifacts", "tools", "validators", "evals"], + "properties": { + "schemaVersion": { "const": "nodeagent.pack/v1" }, + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "version": { "type": "string" }, + "skill": { "type": "string" }, + "artifacts": { "type": "array", "items": { "type": "string" } }, + "tools": { "type": "array", "items": { "type": "string" } }, + "validators": { "type": "array", "items": { "type": "string" } }, + "evals": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/schemas/nodekit.schema.json b/schemas/nodekit.schema.json index 66041a4..949c6b0 100644 --- a/schemas/nodekit.schema.json +++ b/schemas/nodekit.schema.json @@ -22,6 +22,7 @@ "properties": { "schemaVersion": { "const": "nodekit.repo/v1" }, "repository": { "type": "string", "pattern": "^[^/]+/[^/]+$" }, + "registryMode": { "enum": ["ecosystem", "external"] }, "lifecycle": { "enum": ["production", "preview", "experimental", "reference", "archived"] }, "support": { "enum": ["active", "maintenance", "frozen"] }, "role": { "type": "string", "minLength": 1 }, diff --git a/src/cli.mjs b/src/cli.mjs index 82ea723..6bd8eab 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -4,9 +4,11 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { renderDashboard } from "./lib/dashboard.mjs"; +import { compileAgentDefinition, inspectAgentDefinition } from "./lib/agent-definition.mjs"; import { pathExists } from "./lib/files.mjs"; import { checkRepository, commandFor } from "./lib/repo-check.mjs"; import { loadRegistry, validateRegistry } from "./lib/registry.mjs"; +import { adoptProject, createProject, recordSetupEvent } from "./lib/scaffold.mjs"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -38,19 +40,23 @@ function parseArgs(argv) { } function printHelp() { - console.log(`NodeKit P0 + console.log(`NodeKit Usage: + nodekit create --name --brief [--preset research-loop] + [--provider openrouter] [--model openai/gpt-4o-mini] [--backend filesystem] + [--nodekit-specifier ] [--sponsors ] + [--launch-started-at ] [--research-ms ] [--no-install] [--no-git] + nodekit adopt [directory] --name --brief + nodekit compile [--repo-root ] [--check] [--json] + nodekit inspect [--repo-root ] [--json] nodekit doctor [--repo-root ] [--json] nodekit dev|demo|check|proof [--repo-root ] [-- ] nodekit repo check [--repo-root ] [--json] nodekit registry check [--registry-root ] [--json] nodekit ecosystem check [--workspace ] [--json] nodekit dashboard [--workspace ] [--write] [--out ] - nodekit certify [--repo-root ] [--json] - -P0 owns repository contracts and drift prevention. create/add/upgrade/migrate/release -remain planned commands and are not reported as implemented.`); + nodekit certify [--repo-root ] [--json]`); } function summarize(result) { @@ -204,11 +210,103 @@ async function runDoctor(parsed) { passed: nodeMajor >= 20, }); if (nodeMajor < 20) result.errors.unshift("Node.js 20 or newer is required"); + const repoRoot = path.resolve(String(parsed.options["repo-root"] ?? process.cwd())); + if (await pathExists(path.join(repoRoot, "nodeagent.yaml"))) { + const [major, minor] = process.versions.node.split(".").map(Number); + const compiled = await compileAgentDefinition(repoRoot, { write: false }); + if (compiled.definition.provider.package === "@earendil-works/pi-ai" && (major < 22 || (major === 22 && minor < 19))) { + result.errors.unshift("@earendil-works/pi-ai requires Node.js 22.19 or newer"); + } + } result.passed = result.errors.length === 0; printResult(result, parsed.options.json); if (!result.passed) process.exitCode = 1; } +function optionEnabled(options, name, defaultValue = true) { + if (options[`no-${name}`]) return false; + if (options[name] === false || options[name] === "false") return false; + return defaultValue; +} + +async function runCreate(parsed) { + const target = parsed.positional[1]; + if (!target) throw new Error("create requires a target directory"); + const nodekitSpecifier = parsed.options["nodekit-specifier"] ?? parsed.options["nodekit-source"]; + const result = await createProject({ + backend: parsed.options.backend, + brief: parsed.options.brief, + git: optionEnabled(parsed.options, "git"), + install: optionEnabled(parsed.options, "install"), + launchStartedAt: parsed.options["launch-started-at"], + model: parsed.options.model, + name: parsed.options.name ?? path.basename(path.resolve(target)), + nodekitSpecifier, + preset: parsed.options.preset, + provider: parsed.options.provider, + researchMs: parsed.options["research-ms"] === undefined ? undefined : Number(parsed.options["research-ms"]), + secretRef: parsed.options["secret-ref"], + sponsors: String(parsed.options.sponsors ?? "").split(",").filter(Boolean), + target, + }); + const compileStarted = Date.now(); + const compiled = await compileAgentDefinition(result.target); + await recordSetupEvent(result.target, "compile_completed", { configHash: compiled.definition.configHash }, Date.now() - compileStarted); + console.log(`CREATED ${result.name} at ${result.target}`); + console.log(`NEXT cd ${quoteArgument(result.target)} && npm run compile && npm run demo`); +} + +async function runAdopt(parsed) { + const target = path.resolve(String(parsed.positional[1] ?? parsed.options["repo-root"] ?? process.cwd())); + const result = await adoptProject({ + backend: parsed.options.backend, + brief: parsed.options.brief, + model: parsed.options.model, + name: parsed.options.name ?? path.basename(target), + nodekitSpecifier: parsed.options["nodekit-specifier"] ?? parsed.options["nodekit-source"], + provider: parsed.options.provider, + secretRef: parsed.options["secret-ref"], + target, + }); + console.log(`ADOPTED ${result.name} at ${result.target}`); + console.log("NodeKit only added missing harness files; existing auth, routes, CSS, and schemas were preserved."); + console.log(`COLLISIONS ${result.collisions.length}; inspect proof/adoption-receipt.json before installation.`); +} + +async function runCompile(parsed) { + const repoRoot = path.resolve(String(parsed.options["repo-root"] ?? process.cwd())); + const result = await compileAgentDefinition(repoRoot, { + check: Boolean(parsed.options.check), + write: !parsed.options.check, + }); + const output = { + application: result.definition.application.id, + configHash: result.definition.configHash, + fileCount: result.definition.fileCount, + passed: true, + schemaVersion: "nodekit.compile/v1", + }; + if (parsed.options.json) console.log(JSON.stringify(output, null, 2)); + else console.log(`${parsed.options.check ? "CURRENT" : "COMPILED"} ${output.application} ${output.configHash.slice(0, 12)} (${output.fileCount} authored files)`); +} + +async function runInspect(parsed) { + const repoRoot = path.resolve(String(parsed.options["repo-root"] ?? process.cwd())); + const output = inspectAgentDefinition(await compileAgentDefinition(repoRoot, { write: false })); + if (parsed.options.json) { + console.log(JSON.stringify(output, null, 2)); + return; + } + console.log(`${output.application.name} (${output.application.id})`); + console.log(` runtime ${output.runtime.engine}/${output.runtime.profile}`); + console.log(` provider ${output.provider.adapter}:${output.provider.model.provider}/${output.provider.model.id}`); + console.log(` backend ${output.backend.adapter}`); + console.log(` config ${output.configHash}`); + console.log(` files ${output.fileCount}`); + for (const [name, count] of Object.entries(output.discovered)) console.log(` ${name} ${count}`); + for (const secret of output.secrets) console.log(` secret ${secret.name}: ${secret.configured ? "configured" : "missing"}`); +} + async function runCertify(parsed) { const result = await checkOne(parsed.options); const criteria = { @@ -257,6 +355,22 @@ async function main() { await runLifecycle(first, parsed); return; } + if (first === "create") { + await runCreate(parsed); + return; + } + if (first === "adopt") { + await runAdopt(parsed); + return; + } + if (first === "compile") { + await runCompile(parsed); + return; + } + if (first === "inspect") { + await runInspect(parsed); + return; + } if (first === "doctor") { await runDoctor(parsed); return; diff --git a/src/lib/agent-definition.mjs b/src/lib/agent-definition.mjs new file mode 100644 index 0000000..d2ba48b --- /dev/null +++ b/src/lib/agent-definition.mjs @@ -0,0 +1,218 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import { normalizePath, pathExists, readYaml } from "./files.mjs"; + +const APPLICATION_SCHEMA = "nodeagent.application/v1"; +const DISCOVERY_ROOTS = ["agent", "packs", "integrations", "backend", "workers", "evals", "fixtures", "schemas"]; +const SECRET_FIELD = /(api.?key|password|secret|token)/i; +const SECRET_LITERAL = /(?:sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/; +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +async function schemaValidator(name) { + const schema = JSON.parse(await readFile(path.join(packageRoot, "schemas", name), "utf8")); + const ajv = new Ajv2020({ allErrors: true, strict: false }); + return ajv.compile(schema); +} + +function formatSchemaErrors(label, validator) { + return (validator.errors ?? []).map((entry) => `${label}${entry.instancePath || "/"} ${entry.message}`); +} + +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalize(entry)]), + ); + } + return value; +} + +function hash(value) { + return createHash("sha256").update(value).digest("hex"); +} + +async function discoverFiles(repoRoot) { + const files = []; + + async function visit(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if ([".git", ".nodeagent", "node_modules"].includes(entry.name)) continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(absolute); + else { + const content = await readFile(absolute); + files.push({ + bytes: content.byteLength, + digest: hash(content), + path: normalizePath(path.relative(repoRoot, absolute)), + }); + } + } + } + + for (const root of DISCOVERY_ROOTS) { + const absolute = path.join(repoRoot, root); + if (await pathExists(absolute)) await visit(absolute); + } + return files.sort((left, right) => left.path.localeCompare(right.path)); +} + +function validateSecrets(value, location = "nodeagent.yaml", errors = []) { + if (!value || typeof value !== "object") return errors; + for (const [key, entry] of Object.entries(value)) { + const current = `${location}.${key}`; + if (typeof entry === "string") { + const isReference = /ref$/i.test(key) || key === "env"; + if ((SECRET_FIELD.test(key) && !isReference) || SECRET_LITERAL.test(entry)) { + errors.push(`${current} appears to contain a literal secret; use an envRef/secretRef`); + } + } else validateSecrets(entry, current, errors); + } + return errors; +} + +export function validateAgentManifest(manifest) { + const errors = []; + if (manifest?.schemaVersion !== APPLICATION_SCHEMA) { + errors.push(`nodeagent.yaml must use ${APPLICATION_SCHEMA}`); + } + if (!manifest?.application?.id) errors.push("application.id is required"); + if (!manifest?.application?.purpose) errors.push("application.purpose is required"); + if (!manifest?.runtime?.engine) errors.push("runtime.engine is required"); + if (!manifest?.provider?.adapter) errors.push("provider.adapter is required"); + if (!manifest?.provider?.model?.provider || !manifest?.provider?.model?.id) { + errors.push("provider.model.provider and provider.model.id are required"); + } + if (!manifest?.backend?.adapter) errors.push("backend.adapter is required"); + if (!Array.isArray(manifest?.packs) || manifest.packs.length === 0) { + errors.push("at least one capability pack is required"); + } + return [...errors, ...validateSecrets(manifest)]; +} + +function classify(files) { + const matching = (fragment, suffix) => files + .filter((file) => file.path.includes(fragment) && (!suffix || file.path.endsWith(suffix))) + .map((file) => file.path); + return { + evals: matching("evals/"), + integrations: matching("integrations/"), + packs: matching("packs/", "pack.yaml"), + policies: matching("agent/policies/"), + skills: matching("/skills/", "SKILL.md"), + subagents: matching("agent/subagents/", "agent.yaml"), + tools: matching("/tools/"), + }; +} + +export async function compileAgentDefinition(repoRoot, { check = false, write = true } = {}) { + const manifestPath = path.join(repoRoot, "nodeagent.yaml"); + if (!(await pathExists(manifestPath))) throw new Error("nodeagent.yaml is missing"); + const manifestText = await readFile(manifestPath, "utf8"); + const manifest = await readYaml(manifestPath); + const errors = validateAgentManifest(manifest); + const validateApplication = await schemaValidator("nodeagent.application.v1.schema.json"); + if (!validateApplication(manifest)) errors.push(...formatSchemaErrors("nodeagent.yaml", validateApplication)); + const validatePack = await schemaValidator("nodeagent.pack.v1.schema.json"); + for (const pack of manifest.packs ?? []) { + const packPath = path.resolve(repoRoot, String(pack)); + if (!(await pathExists(packPath))) errors.push(`capability pack does not exist: ${pack}`); + else { + const packManifest = await readYaml(packPath); + if (!validatePack(packManifest)) errors.push(...formatSchemaErrors(String(pack), validatePack)); + for (const reference of [packManifest.skill, ...(packManifest.evals ?? [])].filter(Boolean)) { + const referencedPath = path.resolve(path.dirname(packPath), String(reference)); + if (!(await pathExists(referencedPath))) errors.push(`${pack} references missing file: ${reference}`); + } + } + } + const discoveredEvalIds = new Set(); + const evalRoot = path.join(repoRoot, "evals"); + if (await pathExists(evalRoot)) { + for (const entry of await readdir(evalRoot, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + try { + const value = JSON.parse(await readFile(path.join(evalRoot, entry.name), "utf8")); + if (value.id) discoveredEvalIds.add(value.id); + } catch { + errors.push(`evaluation file is invalid JSON: evals/${entry.name}`); + } + } + } + for (const evalId of manifest.evaluations?.required ?? []) { + if (evalId === "pi-live-smoke") continue; + if (!discoveredEvalIds.has(evalId) && !["keep-revert-invariant", "interrupted-run-recovery"].includes(evalId)) { + errors.push(`required evaluation is not declared by an eval file: ${evalId}`); + } + } + if (errors.length > 0) throw new Error(errors.join("\n")); + + const files = await discoverFiles(repoRoot); + const manifestDigest = hash(manifestText); + const hashInput = JSON.stringify(canonicalize({ files, manifest })); + const configHash = hash(hashInput); + const secretRefs = [...new Set([ + manifest.provider?.secretRef, + ...(manifest.secrets ?? []).map((entry) => entry.envRef), + ].filter(Boolean))].sort(); + const definition = { + application: manifest.application, + backend: manifest.backend, + configHash, + discovered: classify(files), + fileCount: files.length, + manifestDigest, + orchestration: manifest.orchestration ?? {}, + policies: manifest.policies ?? {}, + provider: { + adapter: manifest.provider.adapter, + model: manifest.provider.model, + package: manifest.provider.package ?? null, + }, + runtime: manifest.runtime, + schemaVersion: "nodeagent.resolved/v1", + secretRefs, + }; + + const outputRoot = path.join(repoRoot, ".nodeagent"); + const hashPath = path.join(outputRoot, "config-hash.txt"); + if (check) { + if (!(await pathExists(hashPath))) throw new Error("compiled definition is missing; run nodekit compile"); + const existing = (await readFile(hashPath, "utf8")).trim(); + if (existing !== configHash) throw new Error("compiled definition is stale; run nodekit compile"); + } + + if (write) { + await mkdir(outputRoot, { recursive: true }); + await writeFile(path.join(outputRoot, "discovery.json"), `${JSON.stringify({ files, schemaVersion: "nodeagent.discovery/v1" }, null, 2)}\n`); + await writeFile(path.join(outputRoot, "resolved-definition.json"), `${JSON.stringify(definition, null, 2)}\n`); + await writeFile(path.join(outputRoot, "evaluation-plan.json"), `${JSON.stringify({ required: manifest.evaluations?.required ?? [], schemaVersion: "nodeagent.evaluation-plan/v1" }, null, 2)}\n`); + await writeFile(path.join(outputRoot, "diagnostics.json"), `${JSON.stringify({ errors: [], schemaVersion: "nodeagent.diagnostics/v1", warnings: [] }, null, 2)}\n`); + await writeFile(hashPath, `${configHash}\n`); + } + return { definition, files, manifest }; +} + +export function inspectAgentDefinition(compiled) { + const { definition } = compiled; + return { + application: definition.application, + backend: definition.backend, + configHash: definition.configHash, + discovered: Object.fromEntries( + Object.entries(definition.discovered).map(([key, values]) => [key, values.length]), + ), + fileCount: definition.fileCount, + provider: definition.provider, + runtime: definition.runtime, + secrets: definition.secretRefs.map((name) => ({ configured: Boolean(process.env[name]), name })), + }; +} diff --git a/src/lib/repo-check.mjs b/src/lib/repo-check.mjs index 6115e32..f072acc 100644 --- a/src/lib/repo-check.mjs +++ b/src/lib/repo-check.mjs @@ -132,16 +132,20 @@ export async function checkRepository(repoRoot, registry) { const packageJson = (await pathExists(packagePath)) ? await readJson(packagePath) : null; const name = manifest?.repository ? repositoryName(manifest) : path.basename(repoRoot); const catalogEntry = repositoryByName(registry, name); + const external = manifest?.registryMode === "external"; addCheck(checks, "manifest-schema", manifest?.schemaVersion === REPO_SCHEMA, REPO_SCHEMA); - addCheck(checks, "catalog-entry", Boolean(catalogEntry), name); + addCheck(checks, "catalog-entry", external || Boolean(catalogEntry), external ? "external repository" : name); addCheck(checks, "lifecycle", LIFECYCLES.has(manifest?.lifecycle), manifest?.lifecycle ?? "missing"); addCheck(checks, "support", SUPPORT_STATES.has(manifest?.support), manifest?.support ?? "missing"); if (manifest?.schemaVersion !== REPO_SCHEMA) { errors.push(`nodekit.yaml must use ${REPO_SCHEMA}`); } - if (!catalogEntry) errors.push(`${name} is missing from repositories.yaml`); + if (!external && !catalogEntry) errors.push(`${name} is missing from repositories.yaml`); + if (manifest?.registryMode && !["ecosystem", "external"].includes(manifest.registryMode)) { + errors.push(`invalid registryMode ${manifest.registryMode}`); + } if (!LIFECYCLES.has(manifest?.lifecycle)) errors.push(`invalid lifecycle ${manifest?.lifecycle ?? "missing"}`); if (!SUPPORT_STATES.has(manifest?.support)) errors.push(`invalid support ${manifest?.support ?? "missing"}`); if (!Array.isArray(manifest?.canonicalFor)) errors.push("canonicalFor must be an array"); @@ -229,7 +233,7 @@ export async function checkRepository(repoRoot, registry) { } } for (const conceptId of declaredConsumedConcepts) { - if (!consumedConcepts.includes(conceptId)) { + if (!external && !consumedConcepts.includes(conceptId)) { errors.push(`consumes lists ${conceptId} but the ownership registry does not register ${name}`); } } @@ -250,7 +254,7 @@ export async function checkRepository(repoRoot, registry) { const concept = concepts[conceptId]; if (!concept) { errors.push(`consumes references unknown concept ${conceptId}`); - } else if (concept.owner !== name && !(concept.consumers ?? []).includes(name)) { + } else if (!external && concept.owner !== name && !(concept.consumers ?? []).includes(name)) { errors.push(`${conceptId} does not declare ${name} as a consumer`); } } diff --git a/src/lib/scaffold.mjs b/src/lib/scaffold.mjs new file mode 100644 index 0000000..8ee3ee1 --- /dev/null +++ b/src/lib/scaffold.mjs @@ -0,0 +1,197 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { pathExists } from "./files.mjs"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const templateRoot = path.join(packageRoot, "templates", "research-loop"); + +function titleCase(value) { + return value.split(/[-_\s]+/).filter(Boolean).map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join(" "); +} + +export function slugify(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 64); +} + +async function isEmpty(directory) { + if (!(await pathExists(directory))) return true; + return (await readdir(directory)).length === 0; +} + +function substitutions(options) { + const slug = slugify(options.name || path.basename(options.target)); + const nodekitSpecifier = String(options.nodekitSpecifier ?? "github:HomenShum/node-platform").replaceAll("\\", "/"); + const sponsors = [...new Set(["pi-ai", ...(options.sponsors ?? [])].map(slugify).filter(Boolean))]; + return { + "__APP_NAME__": slug, + "__APP_TITLE__": titleCase(slug), + "__BACKEND__": options.backend ?? "filesystem", + "__BRIEF_JSON__": JSON.stringify(options.brief ?? "Build a measurable, proof-carrying agent workflow."), + "__BRIEF_TEXT__": String(options.brief ?? "Build a measurable, proof-carrying agent workflow.").replaceAll(/\s+/g, " ").trim(), + "__NODEKIT_SPECIFIER_JSON__": JSON.stringify(nodekitSpecifier), + "__PI_PACKAGE__": options.piPackage ?? "0.80.10", + "__PROVIDER_ID__": options.provider ?? "openrouter", + "__MODEL_ID__": options.model ?? "openai/gpt-4o-mini", + "__SECRET_REF__": options.secretRef ?? "OPENROUTER_API_KEY", + "__SPONSORS_YAML__": sponsors.map((sponsor) => ` - id: ${sponsor}\n intendedUse: ${sponsor === "pi-ai" ? "Provider-neutral live hypothesis generation." : "Sponsor capability selected during launch research."}`).join("\n"), + }; +} + +function replaceTokens(value, values) { + let output = value; + for (const [token, replacement] of Object.entries(values)) output = output.replaceAll(token, replacement); + return output; +} + +async function copyTemplate(source, destination, values, { collisions = [], missingOnly = false } = {}) { + const entries = await readdir(source, { withFileTypes: true }); + for (const entry of entries) { + const sourcePath = path.join(source, entry.name); + const destinationName = entry.name === "gitignore.template" ? ".gitignore" : replaceTokens(entry.name, values); + const destinationPath = path.join(destination, destinationName); + if (entry.isDirectory()) { + await mkdir(destinationPath, { recursive: true }); + await copyTemplate(sourcePath, destinationPath, values, { collisions, missingOnly }); + continue; + } + if (missingOnly && await pathExists(destinationPath)) { + collisions.push(destinationPath); + continue; + } + const text = await readFile(sourcePath, "utf8"); + await mkdir(path.dirname(destinationPath), { recursive: true }); + await writeFile(destinationPath, replaceTokens(text, values), "utf8"); + } + return collisions; +} + +function run(command, args, cwd) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env: process.env, shell: process.platform === "win32", stdio: "inherit" }); + child.on("error", reject); + child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${command} exited ${code}`))); + }); +} + +export async function createProject(options) { + const target = path.resolve(options.target); + if (!(await isEmpty(target))) { + throw new Error(`target is not empty: ${target}`); + } + if (options.preset && options.preset !== "research-loop") { + throw new Error(`unknown preset ${options.preset}; available: research-loop`); + } + const startedAt = new Date().toISOString(); + const launchStartedAt = options.launchStartedAt && Number.isFinite(Date.parse(options.launchStartedAt)) ? options.launchStartedAt : startedAt; + const values = substitutions({ ...options, target }); + await mkdir(target, { recursive: true }); + await copyTemplate(templateRoot, target, values); + const sponsors = [...new Set(["pi-ai", ...(options.sponsors ?? [])].map(slugify).filter(Boolean))]; + for (const sponsor of sponsors.filter((entry) => entry !== "pi-ai")) { + const integrationRoot = path.join(target, "integrations", sponsor); + await mkdir(integrationRoot, { recursive: true }); + await writeFile(path.join(integrationRoot, "sponsor.yaml"), `schemaVersion: nodekit.sponsor-integration/v1\nid: ${sponsor}\nstatus: research-required\nproofRequired:\n - live-tool-receipt\n - visible-product-use\n`); + await writeFile(path.join(integrationRoot, "RESEARCH.md"), `# ${titleCase(sponsor)} integration\n\nResearch only official sources, pin versions, document authentication and cost, then implement a deterministic fixture and bounded live smoke.\n`); + } + const friction = { + application: values.__APP_NAME__, + events: [ + { at: launchStartedAt, name: "launch_started" }, + ...(Number.isFinite(options.researchMs) ? [{ at: startedAt, durationMs: options.researchMs, name: "research_completed" }] : []), + { at: startedAt, name: "scaffold_started" }, + { at: new Date().toISOString(), name: "files_generated" }, + { + at: new Date().toISOString(), + detail: { mode: "generated-vertical-slice" }, + durationMs: Date.now() - Date.parse(startedAt), + name: "implementation_completed", + }, + ], + nodekitVersion: "0.2.0", + preset: "research-loop", + repairLoops: 0, + schemaVersion: "nodekit.build-friction/v1", + }; + await mkdir(path.join(target, "proof"), { recursive: true }); + await writeFile(path.join(target, "proof", "build-friction.json"), `${JSON.stringify(friction, null, 2)}\n`); + if (options.install !== false) { + const installStarted = Date.now(); + try { + await run("npm", ["install"], target); + friction.events.push({ at: new Date().toISOString(), durationMs: Date.now() - installStarted, name: "install_completed" }); + } catch (error) { + friction.events.push({ at: new Date().toISOString(), durationMs: Date.now() - installStarted, name: "install_failed" }); + await writeFile(path.join(target, "proof", "build-friction.json"), `${JSON.stringify(friction, null, 2)}\n`); + throw error; + } + } + friction.events.push({ at: new Date().toISOString(), durationMs: Date.now() - Date.parse(startedAt), name: "scaffold_completed" }); + await writeFile(path.join(target, "proof", "build-friction.json"), `${JSON.stringify(friction, null, 2)}\n`); + if (options.git !== false && !(await pathExists(path.join(target, ".git")))) await run("git", ["init"], target); + return { name: values.__APP_NAME__, target }; +} + +export async function recordSetupEvent(target, name, detail = {}, durationMs) { + const file = path.join(path.resolve(target), "proof", "build-friction.json"); + const receipt = JSON.parse(await readFile(file, "utf8")); + receipt.events.push({ at: new Date().toISOString(), detail, ...(durationMs === undefined ? {} : { durationMs }), name }); + await writeFile(file, `${JSON.stringify(receipt, null, 2)}\n`); +} + +export async function adoptProject(options) { + const target = path.resolve(options.target); + if (!(await pathExists(target)) || !(await stat(target)).isDirectory()) { + throw new Error(`repository does not exist: ${target}`); + } + const values = substitutions({ ...options, target }); + const collisions = []; + const adoptRoots = ["nodekit.yaml", "nodeagent.yaml", "hackathon.yaml", "agent", "packs", "integrations", "backend", "fixtures", "evals", "schemas", "scripts", "adw", "apps"]; + for (const root of adoptRoots) { + const source = path.join(templateRoot, root); + if (!(await pathExists(source))) continue; + const destination = path.join(target, root); + if ((await stat(source)).isDirectory()) { + await mkdir(destination, { recursive: true }); + await copyTemplate(source, destination, values, { collisions, missingOnly: true }); + } else if (!(await pathExists(destination))) { + await writeFile(destination, replaceTokens(await readFile(source, "utf8"), values)); + } else { + collisions.push(destination); + } + } + const packagePath = path.join(target, "package.json"); + let packageJson; + if (await pathExists(packagePath)) packageJson = JSON.parse(await readFile(packagePath, "utf8")); + else packageJson = { name: values.__APP_NAME__, private: true, type: "module", version: "0.1.0" }; + packageJson.scripts ??= {}; + packageJson.dependencies ??= {}; + packageJson.devDependencies ??= {}; + const scripts = { + "nodekit:compile": "nodekit compile --repo-root .", + "nodekit:demo": "node scripts/demo.mjs", + "nodekit:eval": "node scripts/eval.mjs", + "nodekit:inspect": "nodekit inspect --repo-root .", + "nodekit:proof": "node scripts/proof.mjs", + "nodekit:smoke:pi": "node scripts/live-smoke.mjs", + }; + for (const [name, command] of Object.entries(scripts)) { + if (packageJson.scripts[name] && packageJson.scripts[name] !== command) collisions.push(`package.json#scripts.${name}`); + else packageJson.scripts[name] = command; + } + if (!packageJson.dependencies["@earendil-works/pi-ai"]) packageJson.dependencies["@earendil-works/pi-ai"] = "0.80.10"; + else if (packageJson.dependencies["@earendil-works/pi-ai"] !== "0.80.10") collisions.push("package.json#dependencies.@earendil-works/pi-ai"); + if (!packageJson.devDependencies["@homenshum/nodekit"]) packageJson.devDependencies["@homenshum/nodekit"] = JSON.parse(values.__NODEKIT_SPECIFIER_JSON__); + await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); + const receipt = { + addedRoots: adoptRoots, + collisions: collisions.map((entry) => path.isAbsolute(entry) ? path.relative(target, entry).replaceAll("\\", "/") : entry), + generatedAt: new Date().toISOString(), + installRequired: true, + schemaVersion: "nodekit.adoption-receipt/v1", + }; + await mkdir(path.join(target, "proof"), { recursive: true }); + await writeFile(path.join(target, "proof", "adoption-receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`); + return { collisions: receipt.collisions, name: values.__APP_NAME__, target }; +} diff --git a/templates/research-loop/.dockerignore b/templates/research-loop/.dockerignore new file mode 100644 index 0000000..5311d1e --- /dev/null +++ b/templates/research-loop/.dockerignore @@ -0,0 +1,8 @@ +.git +.env +.env.* +!.env.example +.data +node_modules +proof +npm-debug.log* diff --git a/templates/research-loop/.env.example b/templates/research-loop/.env.example new file mode 100644 index 0000000..e61b575 --- /dev/null +++ b/templates/research-loop/.env.example @@ -0,0 +1,3 @@ +# Pi reads this only in the server process. Never expose it to browser code. +__SECRET_REF__= +PORT=4173 diff --git a/templates/research-loop/AGENTS.md b/templates/research-loop/AGENTS.md new file mode 100644 index 0000000..3603a81 --- /dev/null +++ b/templates/research-loop/AGENTS.md @@ -0,0 +1,11 @@ +# __APP_TITLE__ agent instructions + +Read `nodeagent.yaml`, `hackathon.yaml`, and `.nodeagent/resolved-definition.json` before changing the harness. + +- Preserve one execution path: the browser, deterministic demo, live Pi run, and evals call `agent/experiment-loop.mjs`. +- Keep the evaluator deterministic. Model output may propose an experiment but cannot decide whether it passed. +- Never place provider secrets in source, YAML, browser bundles, logs, or receipts. +- Do not weaken a metric or fixture to make an experiment pass. +- Treat `.data/` as durable runtime state and `proof/` as sanitized evidence. +- Ask before deploying, creating paid resources, publishing, or making destructive changes. +- Run `npm run compile`, `npm run check`, `npm run eval`, and `npm run proof` after harness changes. diff --git a/templates/research-loop/Dockerfile b/templates/research-loop/Dockerfile new file mode 100644 index 0000000..94f3c07 --- /dev/null +++ b/templates/research-loop/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22.22-alpine + +WORKDIR /runtime +RUN npm init -y \ + && npm install --save-exact --omit=dev --no-audit --no-fund @earendil-works/pi-ai@__PI_PACKAGE__ + +WORKDIR /app +COPY package.json ./ +COPY .nodeagent ./.nodeagent +COPY apps ./apps +COPY backend ./backend +COPY integrations ./integrations +COPY fixtures ./fixtures +COPY agent ./agent +COPY packs ./packs +COPY nodeagent.yaml nodekit.yaml hackathon.yaml ./ + +RUN ln -s /runtime/node_modules /app/node_modules \ + && mkdir -p /app/.data \ + && chown -R node:node /app + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=10000 +EXPOSE 10000 +USER node + +CMD ["node", "apps/web/server.mjs"] diff --git a/templates/research-loop/README.md b/templates/research-loop/README.md new file mode 100644 index 0000000..26c3f4c --- /dev/null +++ b/templates/research-loop/README.md @@ -0,0 +1,35 @@ +# __APP_TITLE__ + +__BRIEF_TEXT__ + +This repository was generated by NodeKit's `research-loop` preset. It is a deliberately small proof-carrying agent application: a model may propose a bounded experiment, deterministic code measures the result, the harness keeps or reverts it, and a human can steer the next step without restarting. + +## Quickstart + +```bash +npm install +npm run compile +npm run demo +npm run check +npm run dev +``` + +Open `http://127.0.0.1:4173`. + +For one bounded live Pi conformance call, provide `__SECRET_REF__` only to the process and run: + +```bash +npm run smoke:pi +``` + +No-key mode uses the same experiment loop with a deterministic hypothesis source. Live mode changes only the proposal provider. + +## What is real + +- The metric is held-out character n-gram bits per character, computed from local fixtures. +- Session state is atomically persisted and reloadable. +- A lower measured metric is kept; a regression is reverted. +- Interventions are versioned events applied to the next proposal. +- Receipts contain hashes, configuration, metrics, decisions, usage, and replay instructions. + +This starter does not claim frontier model-training improvement. It proves the interactive, durable, measurable research-loop primitive. diff --git a/templates/research-loop/adw/workflows/launch.yaml b/templates/research-loop/adw/workflows/launch.yaml new file mode 100644 index 0000000..e3fdb2d --- /dev/null +++ b/templates/research-loop/adw/workflows/launch.yaml @@ -0,0 +1,14 @@ +schemaVersion: nodekit.development-workflow/v1 +id: launch +planningPolicy: proportional-to-reversibility +steps: + - research + - compile + - deterministic-demo + - conformance + - live-smoke + - browser-proof + - receipt +approvals: + productionDeployment: human + publicSubmission: human diff --git a/templates/research-loop/agent/experiment-loop.mjs b/templates/research-loop/agent/experiment-loop.mjs new file mode 100644 index 0000000..f4461b0 --- /dev/null +++ b/templates/research-loop/agent/experiment-loop.mjs @@ -0,0 +1,126 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { measureNgram } from "./tools/measure-ngram.mjs"; + +function digest(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function event(type, details = {}) { + return { at: new Date().toISOString(), details, id: randomUUID(), type }; +} + +export async function readConfigHash(repoRoot = process.cwd()) { + try { + return (await readFile(path.join(repoRoot, ".nodeagent", "config-hash.txt"), "utf8")).trim(); + } catch { + return "uncompiled"; + } +} + +export async function startSession(store, options = {}) { + const existing = await store.load(); + if (existing && !options.force) { + if (existing.status === "measuring") { + existing.status = "ready"; + existing.events.push(event("session.recovered", { previousStatus: "measuring" })); + existing.digest = digest({ ...existing, digest: undefined }); + await store.save(existing); + } + return existing; + } + const config = options.baseline ?? { alpha: 0.5, order: 1 }; + const measured = await measureNgram(config, options.fixtureRoot); + const session = { + baseline: measured, + best: measured, + configHash: await readConfigHash(options.repoRoot), + events: [event("session.started", { baseline: measured })], + experiments: [], + intervention: null, + interventionVersion: 0, + objective: "minimize held-out character bits per character", + schemaVersion: "nodekit.experiment-session/v1", + sessionId: randomUUID(), + status: "ready", + }; + session.digest = digest(session); + return store.save(session); +} + +export async function intervene(store, instruction) { + const session = await store.load(); + if (!session) throw new Error("start a session first"); + const text = String(instruction ?? "").trim(); + if (!text) throw new Error("intervention cannot be empty"); + session.interventionVersion += 1; + session.intervention = { at: new Date().toISOString(), instruction: text, version: session.interventionVersion }; + session.events.push(event("human.intervened", session.intervention)); + session.digest = digest({ ...session, digest: undefined }); + return store.save(session); +} + +export async function runExperiment(store, proposal, options = {}) { + const session = await store.load(); + if (!session) throw new Error("start a session first"); + const startedAt = new Date().toISOString(); + const candidate = { + alpha: Math.max(0.001, Math.min(10, Number(proposal.config?.alpha))), + order: Math.max(1, Math.min(6, Math.round(Number(proposal.config?.order)))), + }; + if (!Number.isFinite(candidate.alpha) || !Number.isFinite(candidate.order)) throw new Error("proposal contains invalid values"); + session.status = "measuring"; + session.events.push(event("experiment.started", { candidate, hypothesis: proposal.hypothesis })); + await store.save(session); + + const result = await measureNgram(candidate, options.fixtureRoot); + const improved = result.heldoutBitsPerCharacter < session.best.heldoutBitsPerCharacter; + const experiment = { + candidate, + completedAt: new Date().toISOString(), + decision: improved ? "keep" : "revert", + delta: Number((result.heldoutBitsPerCharacter - session.best.heldoutBitsPerCharacter).toFixed(6)), + hypothesis: String(proposal.hypothesis ?? "bounded configuration experiment"), + id: randomUUID(), + intervention: session.intervention, + model: proposal.model ?? null, + result, + startedAt, + usage: proposal.usage ?? null, + }; + session.experiments.push(experiment); + if (improved) session.best = result; + session.events.push(event(`experiment.${experiment.decision}`, { delta: experiment.delta, experimentId: experiment.id })); + session.status = "ready"; + session.digest = digest({ ...session, digest: undefined }); + await store.save(session); + return { experiment, session }; +} + +export function deterministicProposal(index = 0) { + const proposals = [ + { config: { alpha: 10, order: 1 }, hypothesis: "Heavy smoothing should test the revert path." }, + { config: { alpha: 0.12, order: 3 }, hypothesis: "A short character context should lower held-out uncertainty." }, + { config: { alpha: 0.08, order: 4 }, hypothesis: "One more context character may capture recurring local structure." }, + ]; + return { ...proposals[index % proposals.length], model: { mode: "replay", provider: "deterministic" } }; +} + +export async function createReceipt(session) { + const receipt = { + baseline: session.baseline, + best: session.best, + configHash: session.configHash, + events: session.events, + experiments: session.experiments, + generatedAt: new Date().toISOString(), + interventionVersion: session.interventionVersion, + replay: ["npm install", "npm run compile", "npm run demo", "npm run eval"], + schemaVersion: "nodekit.experiment-receipt/v1", + sessionDigest: session.digest, + sessionId: session.sessionId, + }; + receipt.receiptDigest = digest(receipt); + return receipt; +} diff --git a/templates/research-loop/agent/instructions.md b/templates/research-loop/agent/instructions.md new file mode 100644 index 0000000..00b59b8 --- /dev/null +++ b/templates/research-loop/agent/instructions.md @@ -0,0 +1,3 @@ +# Orchestrator + +Operate a persistent research loop. Propose exactly one bounded configuration change per step. Honor the latest human intervention. Never declare an improvement yourself: the deterministic evaluator owns keep/revert. Explain the hypothesis in one sentence and return only the requested JSON object. diff --git a/templates/research-loop/agent/planner/planning-policy.yaml b/templates/research-loop/agent/planner/planning-policy.yaml new file mode 100644 index 0000000..feb168b --- /dev/null +++ b/templates/research-loop/agent/planner/planning-policy.yaml @@ -0,0 +1,10 @@ +schemaVersion: nodeagent.planning-policy/v1 +strategy: one-variable-at-a-time +objective: minimize heldoutBitsPerCharacter +constraints: + order: { minimum: 1, maximum: 6 } + alpha: { minimum: 0.001, maximum: 10 } +approvalRequiredFor: + - changingMetric + - changingCorpus + - productionDeployment diff --git a/templates/research-loop/agent/policies/budgets.yaml b/templates/research-loop/agent/policies/budgets.yaml new file mode 100644 index 0000000..fdc5a87 --- /dev/null +++ b/templates/research-loop/agent/policies/budgets.yaml @@ -0,0 +1,5 @@ +schemaVersion: nodeagent.budgets/v1 +maxModelCallsPerStep: 1 +maxOutputTokensPerCall: 320 +maxExperimentSeconds: 30 +maxConcurrentExperiments: 1 diff --git a/templates/research-loop/agent/policies/permissions.yaml b/templates/research-loop/agent/policies/permissions.yaml new file mode 100644 index 0000000..f0812bf --- /dev/null +++ b/templates/research-loop/agent/policies/permissions.yaml @@ -0,0 +1,11 @@ +schemaVersion: nodeagent.permissions/v1 +default: deny +allow: + - experiment.read + - experiment.propose + - experiment.measure + - session.persist + - receipt.write +network: + liveOnly: + - generativelanguage.googleapis.com diff --git a/templates/research-loop/agent/skills/autoresearch-live/SKILL.md b/templates/research-loop/agent/skills/autoresearch-live/SKILL.md new file mode 100644 index 0000000..a96056b --- /dev/null +++ b/templates/research-loop/agent/skills/autoresearch-live/SKILL.md @@ -0,0 +1,15 @@ +--- +name: autoresearch-live +description: Run a measurable keep-or-revert research loop that remains observable, steerable, resumable, and reproducible. Use when an experiment has a fixed objective metric and a human needs to intervene without restarting the agent. +--- + +# Autoresearch Live + +1. Load the durable session and latest intervention. +2. Propose one bounded change with a falsifiable rationale. +3. Run the unchanged deterministic evaluator. +4. Keep only a strict metric improvement; otherwise revert. +5. Append the proposal, result, decision, hashes, usage, and intervention version. +6. Persist atomically before reporting completion. + +Never let a model change the evaluator, corpus, budget, or keep/revert rule during a run. diff --git a/templates/research-loop/agent/subagents/reviewer/agent.yaml b/templates/research-loop/agent/subagents/reviewer/agent.yaml new file mode 100644 index 0000000..367b277 --- /dev/null +++ b/templates/research-loop/agent/subagents/reviewer/agent.yaml @@ -0,0 +1,8 @@ +schemaVersion: nodeagent.subagent/v1 +id: independent-reviewer +mode: read-only +tools: + allow: + - experiment.read + - receipt.verify +outputSchema: experiment-review/v1 diff --git a/templates/research-loop/agent/tools/measure-ngram.mjs b/templates/research-loop/agent/tools/measure-ngram.mjs new file mode 100644 index 0000000..28dea9b --- /dev/null +++ b/templates/research-loop/agent/tools/measure-ngram.mjs @@ -0,0 +1,49 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +function contexts(text, order) { + const pairs = []; + const prefix = "~".repeat(order); + const padded = `${prefix}${text}`; + for (let index = order; index < padded.length; index += 1) { + pairs.push([padded.slice(index - order, index), padded[index]]); + } + return pairs; +} + +export async function measureNgram(config, fixtureRoot = path.resolve("fixtures", "corpus")) { + const order = Math.max(1, Math.min(6, Number(config.order))); + const alpha = Math.max(0.001, Math.min(10, Number(config.alpha))); + if (!Number.isFinite(order) || !Number.isFinite(alpha)) throw new Error("invalid n-gram configuration"); + + const [train, validation] = await Promise.all([ + readFile(path.join(fixtureRoot, "train.txt"), "utf8"), + readFile(path.join(fixtureRoot, "validation.txt"), "utf8"), + ]); + const vocabulary = new Set(train); + for (const character of validation) vocabulary.add(character); + const counts = new Map(); + const totals = new Map(); + for (const [context, character] of contexts(train, order)) { + const key = `${context}\0${character}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + totals.set(context, (totals.get(context) ?? 0) + 1); + } + + let negativeLogLikelihood = 0; + const pairs = contexts(validation, order); + for (const [context, character] of pairs) { + const count = counts.get(`${context}\0${character}`) ?? 0; + const total = totals.get(context) ?? 0; + const probability = (count + alpha) / (total + alpha * vocabulary.size); + negativeLogLikelihood += -Math.log2(probability); + } + return { + alpha, + heldoutBitsPerCharacter: Number((negativeLogLikelihood / pairs.length).toFixed(6)), + order, + trainCharacters: train.length, + validationCharacters: validation.length, + vocabularySize: vocabulary.size, + }; +} diff --git a/templates/research-loop/apps/web/public/app.js b/templates/research-loop/apps/web/public/app.js new file mode 100644 index 0000000..f701ff1 --- /dev/null +++ b/templates/research-loop/apps/web/public/app.js @@ -0,0 +1,69 @@ +const elements = Object.fromEntries([...document.querySelectorAll("[id]")].map((element) => [element.id, element])); +let session = null; +let busy = false; + +async function api(path, options = {}) { + const response = await fetch(path, { headers: { "content-type": "application/json" }, ...options }); + const payload = await response.json().catch(() => ({ error: `HTTP ${response.status}` })); + if (!response.ok) throw new Error(payload.error ?? `HTTP ${response.status}`); + return payload; +} + +function metric(value) { return Number.isFinite(value) ? value.toFixed(4) : "—"; } +function escapeText(value) { const span = document.createElement("span"); span.textContent = String(value ?? ""); return span.innerHTML; } +function setBusy(value, status = "") { + busy = value; + elements["runtime-status"].textContent = status || (session ? `${session.status} · ${session.experiments.length} experiments` : "local harness ready"); + render(); +} +function fail(error) { elements["error-banner"].textContent = error.message; elements["error-banner"].hidden = false; setBusy(false, "action failed"); } +function clearError() { elements["error-banner"].hidden = true; } + +function render() { + const exists = Boolean(session); + elements["start-button"].textContent = exists ? "Reset clean session" : "Start clean session"; + elements["export-button"].disabled = !exists || busy; + elements["replay-step"].disabled = !exists || busy; + elements["live-step"].disabled = !exists || busy; + elements.intervention.disabled = !exists || busy; + elements["intervention-form"].querySelector("button").disabled = !exists || busy || !elements.intervention.value.trim(); + if (!exists) return; + + elements["baseline-metric"].textContent = metric(session.baseline.heldoutBitsPerCharacter); + elements["best-metric"].textContent = metric(session.best.heldoutBitsPerCharacter); + const improvement = session.baseline.heldoutBitsPerCharacter - session.best.heldoutBitsPerCharacter; + elements["delta-metric"].textContent = improvement > 0 ? `−${improvement.toFixed(4)}` : "0.0000"; + elements["session-id"].textContent = session.sessionId; + elements["config-hash"].textContent = `config: ${session.configHash.slice(0, 16)}`; + elements["event-count"].textContent = `${session.events.length} events`; + elements["latest-intervention"].innerHTML = session.intervention + ? `Direction v${session.intervention.version}
${escapeText(session.intervention.instruction)}` + : "No intervention yet."; + + elements["experiment-rows"].innerHTML = session.experiments.length ? session.experiments.map((experiment, index) => ` + + ${String(index + 1).padStart(2, "0")} + ${escapeText(experiment.hypothesis)}${experiment.intervention ? `
↳ v${experiment.intervention.version}: ${escapeText(experiment.intervention.instruction)}` : ""} + order ${experiment.candidate.order}
α ${experiment.candidate.alpha} + ${metric(experiment.result.heldoutBitsPerCharacter)} + ${experiment.delta > 0 ? "+" : ""}${experiment.delta.toFixed(4)} + ${experiment.decision} + `).join("") : 'Baseline established. Run the first experiment.'; + elements["activity-list"].innerHTML = session.events.slice(-9).reverse().map((entry) => ` +
  • ${escapeText(entry.type)}

    ${escapeText(JSON.stringify(entry.details))}

  • `).join(""); +} + +async function load() { const payload = await api("/api/session"); session = payload.session; render(); } +async function start() { clearError(); setBusy(true, "establishing baseline"); try { session = (await api("/api/start", { method: "POST", body: JSON.stringify({ force: Boolean(session) }) })).session; setBusy(false); } catch (error) { fail(error); } } +async function step(mode) { clearError(); setBusy(true, mode === "live" ? "Pi is proposing one experiment" : "replaying bounded proposal"); try { session = (await api("/api/step", { method: "POST", body: JSON.stringify({ mode }) })).session; setBusy(false); } catch (error) { fail(error); } } + +elements["start-button"].addEventListener("click", start); +elements["replay-step"].addEventListener("click", () => step("replay")); +elements["live-step"].addEventListener("click", () => step("live")); +elements.intervention.addEventListener("input", () => { elements["character-count"].textContent = `${elements.intervention.value.length} / 280`; render(); }); +elements["intervention-form"].addEventListener("submit", async (event) => { + event.preventDefault(); clearError(); setBusy(true, "recording human direction"); + try { session = (await api("/api/intervene", { method: "POST", body: JSON.stringify({ instruction: elements.intervention.value }) })).session; elements.intervention.value = ""; elements["character-count"].textContent = "0 / 280"; setBusy(false); } catch (error) { fail(error); } +}); +elements["export-button"].addEventListener("click", () => { window.location.href = "/api/receipt"; }); +load().catch(fail); diff --git a/templates/research-loop/apps/web/public/index.html b/templates/research-loop/apps/web/public/index.html new file mode 100644 index 0000000..2ac5ce8 --- /dev/null +++ b/templates/research-loop/apps/web/public/index.html @@ -0,0 +1,81 @@ + + + + + + + __APP_TITLE__ · Research control room + + + + + + +
    +
    +
    +

    AUTORESEARCH, WITH A STEERING WHEEL

    +

    Watch the loop.
    Change its mind.

    +

    A persistent experiment agent that exposes every hypothesis, measures one fixed metric, keeps only improvements, and accepts human direction without restarting.

    +
    +
    + + +
    +
    + + + +
    +
    BASELINE BPC—
    +
    BEST BPC—
    +
    IMPROVEMENT—
    +
    SESSIONnot started
    +
    + +
    +
    +
    +

    EXPERIMENT LEDGER

    Measured, then kept or reverted.

    +
    + + +
    +
    +
    + + + +
    #HypothesisConfigurationHeld-out BPCΔDecision
    Start a session to establish the baseline.
    +
    +
    + + +
    + +
    +

    DURABLE ACTIVITY

    Nothing important disappears into chat.

    0 events
    +
    1. Events will appear here after the session starts.
    +
    +
    +
    Built from a NodeKit compiled definition.config: —
    + + + diff --git a/templates/research-loop/apps/web/public/styles.css b/templates/research-loop/apps/web/public/styles.css new file mode 100644 index 0000000..09b59a8 --- /dev/null +++ b/templates/research-loop/apps/web/public/styles.css @@ -0,0 +1,81 @@ +:root { + color-scheme: light; + --ink: #111313; + --muted: #68706d; + --paper: #f3f1eb; + --panel: #fbfaf6; + --line: #d5d2c8; + --acid: #c9ff37; + --violet: #5e4cff; + --red: #d7492e; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } +body { margin: 0; color: var(--ink); background: var(--paper); min-height: 100vh; } +button, textarea { font: inherit; } +button:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid var(--violet); outline-offset: 3px; } +.skip-link { position: fixed; left: 1rem; top: -4rem; z-index: 20; background: var(--ink); color: white; padding: .75rem 1rem; } +.skip-link:focus { top: 1rem; } +.site-header { height: 68px; padding: 0 clamp(1rem, 4vw, 4.5rem); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; background: rgba(243,241,235,.92); position: sticky; top: 0; backdrop-filter: blur(12px); z-index: 10; } +.brand { color: inherit; text-decoration: none; font-weight: 760; letter-spacing: -.03em; } +.brand-mark { display: inline-grid; place-items: center; width: 29px; height: 29px; margin-right: .55rem; background: var(--ink); color: var(--acid); font-family: ui-monospace, monospace; font-size: .8rem; } +.header-meta { display: flex; align-items: center; gap: .5rem; font: 600 .72rem/1 ui-monospace, monospace; text-transform: uppercase; color: var(--muted); } +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #44ad56; box-shadow: 0 0 0 4px #44ad5620; } +main { width: min(1500px, 100%); margin: auto; padding: 0 clamp(1rem, 4vw, 4.5rem) 5rem; } +.hero { min-height: 430px; display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(240px, .5fr); gap: 3rem; align-items: end; padding: clamp(4rem, 9vw, 8rem) 0 3rem; } +.eyebrow { margin: 0 0 1rem; color: var(--muted); font: 700 .68rem/1.2 ui-monospace, monospace; letter-spacing: .16em; } +h1 { margin: 0; max-width: 900px; font-size: clamp(3.2rem, 8vw, 7.6rem); line-height: .88; letter-spacing: -.075em; font-weight: 770; } +h1 em { font-family: Georgia, serif; font-weight: 400; color: var(--violet); } +.lede { max-width: 690px; margin: 2rem 0 0; color: #4f5754; font-size: clamp(1rem, 1.5vw, 1.25rem); line-height: 1.55; } +.hero-actions { display: flex; flex-direction: column; gap: .75rem; align-items: stretch; padding-bottom: .4rem; } +.button { border: 1px solid var(--ink); background: transparent; color: var(--ink); min-height: 44px; padding: .7rem 1rem; font-weight: 720; cursor: pointer; transition: transform 120ms ease, background 120ms ease; } +.button:hover:not(:disabled) { transform: translateY(-2px); } +.button:disabled { opacity: .38; cursor: not-allowed; } +.button-primary { background: var(--acid); box-shadow: 4px 4px 0 var(--ink); } +.button-dark { background: var(--ink); color: white; } +.button-live { border-color: var(--violet); color: var(--violet); } +.spark { margin-right: .35rem; } +.error-banner { border: 1px solid var(--red); color: #8f2c1a; background: #fff2ee; padding: 1rem; margin-bottom: 1rem; } +.metric-strip { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--ink); background: var(--ink); gap: 1px; } +.metric-strip article { min-height: 122px; padding: 1.25rem; background: var(--panel); display: flex; flex-direction: column; justify-content: space-between; } +.metric-strip span { color: var(--muted); font: 700 .65rem/1.2 ui-monospace, monospace; letter-spacing: .12em; } +.metric-strip strong { font-size: clamp(1.3rem, 3vw, 2.5rem); letter-spacing: -.05em; } +.metric-strip .compact { font-size: .78rem; letter-spacing: 0; overflow-wrap: anywhere; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .75rem; } +.workspace { margin-top: 1.5rem; display: grid; grid-template-columns: minmax(0, 1fr) 370px; border: 1px solid var(--ink); background: var(--panel); } +.workbench { min-width: 0; padding: clamp(1.25rem, 3vw, 2.25rem); } +.section-heading { display: flex; justify-content: space-between; gap: 1rem; align-items: end; margin-bottom: 1.5rem; } +h2 { margin: 0; font-size: clamp(1.4rem, 2.3vw, 2.2rem); line-height: 1.05; letter-spacing: -.045em; } +.run-actions { display: flex; gap: .6rem; flex-wrap: wrap; justify-content: end; } +.table-wrap { overflow-x: auto; border-top: 1px solid var(--line); } +table { width: 100%; border-collapse: collapse; min-width: 760px; } +th { padding: .9rem .7rem; color: var(--muted); font: 700 .62rem/1.2 ui-monospace, monospace; text-align: left; letter-spacing: .09em; } +td { border-top: 1px solid var(--line); padding: 1rem .7rem; font-size: .87rem; vertical-align: top; } +.empty-row td { color: var(--muted); padding: 3rem .7rem; text-align: center; } +.decision { display: inline-block; padding: .3rem .5rem; border: 1px solid; font: 800 .62rem/1 ui-monospace, monospace; text-transform: uppercase; } +.decision.keep { background: var(--acid); }.decision.revert { color: var(--red); } +.steering-panel { border-left: 1px solid var(--ink); padding: 2rem; background: #e8e4fa; } +.steering-panel > p:not(.eyebrow) { color: #555362; line-height: 1.55; font-size: .9rem; } +form { margin-top: 2rem; } +label { display: block; margin-bottom: .55rem; font-size: .78rem; font-weight: 750; } +textarea { width: 100%; min-height: 130px; resize: vertical; border: 1px solid var(--ink); background: rgba(255,255,255,.65); padding: .85rem; line-height: 1.45; } +.form-footer { margin-top: .6rem; display: flex; align-items: center; justify-content: space-between; color: var(--muted); font: .7rem ui-monospace, monospace; } +.constraint-card { display: flex; gap: .8rem; border-top: 1px solid #bab3df; margin-top: 2rem; padding-top: 1.2rem; } +.constraint-card p { margin: .3rem 0 0; color: #5d5a68; font-size: .78rem; line-height: 1.4; } +.lock { font-size: 1.4rem; }.latest-intervention { margin-top: 1rem; padding: .8rem; background: rgba(255,255,255,.55); font-size: .78rem; line-height: 1.45; } +.activity-section { margin-top: 1.5rem; border: 1px solid var(--ink); background: var(--panel); padding: clamp(1.25rem, 3vw, 2.25rem); } +.activity-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); } +.activity-list li { background: var(--panel); padding: 1rem; min-height: 110px; } +.activity-list time { display: block; margin-bottom: 1rem; color: var(--muted); font: .62rem ui-monospace, monospace; } +.activity-list strong { font-size: .82rem; }.activity-list p { color: var(--muted); font-size: .72rem; line-height: 1.4; overflow-wrap: anywhere; } +footer { display: flex; justify-content: space-between; gap: 1rem; border-top: 1px solid var(--line); padding: 1.3rem clamp(1rem, 4vw, 4.5rem); color: var(--muted); font-size: .73rem; } +@media (max-width: 900px) { + .hero { grid-template-columns: 1fr; min-height: auto; padding-top: 5rem; }.hero-actions { flex-direction: row; } + .metric-strip { grid-template-columns: repeat(2, 1fr); }.workspace { grid-template-columns: 1fr; }.steering-panel { border-left: 0; border-top: 1px solid var(--ink); }.activity-list { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 560px) { + .site-header { height: 58px; }.header-meta span:last-child { display: none; }main { padding-inline: .8rem; }.hero { padding-top: 3rem; gap: 2rem; }.hero-actions { flex-direction: column; } + h1 { font-size: 3.5rem; }.metric-strip { grid-template-columns: 1fr 1fr; }.metric-strip article { min-height: 100px; padding: .9rem; }.metric-strip strong { font-size: 1.6rem; } + .section-heading { align-items: start; flex-direction: column; }.run-actions { width: 100%; justify-content: stretch; }.run-actions .button { flex: 1; }.workbench, .steering-panel, .activity-section { padding: 1.1rem; }.activity-list { grid-template-columns: 1fr; }footer { flex-direction: column; } +} +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } } diff --git a/templates/research-loop/apps/web/server.mjs b/templates/research-loop/apps/web/server.mjs new file mode 100644 index 0000000..923f6cb --- /dev/null +++ b/templates/research-loop/apps/web/server.mjs @@ -0,0 +1,104 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { createFileStore } from "../../backend/filesystem/store.mjs"; +import { createReceipt, deterministicProposal, intervene, runExperiment, startSession } from "../../agent/experiment-loop.mjs"; +import { proposeWithPi } from "../../integrations/pi-ai/provider.mjs"; + +const port = Number(process.env.PORT ?? 4173); +const host = process.env.HOST ?? "127.0.0.1"; +const publicRoot = path.resolve("apps", "web", "public"); +const store = createFileStore(path.resolve(".data", "session.json")); +const types = { ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8" }; +let mutationQueue = Promise.resolve(); + +function serializeMutation(task) { + const result = mutationQueue.then(task); + mutationQueue = result.catch(() => undefined); + return result; +} + +function send(response, status, body, contentType = "application/json; charset=utf-8") { + response.writeHead(status, { "cache-control": "no-store", "content-type": contentType }); + response.end(contentType.startsWith("application/json") ? JSON.stringify(body) : body); +} + +async function body(request) { + const chunks = []; + let bytes = 0; + for await (const chunk of request) { + bytes += chunk.length; + if (bytes > 64_000) throw new Error("request body is too large"); + chunks.push(chunk); + } + return chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}; +} + +async function handleApi(request, response, url) { + if (request.method === "GET" && url.pathname === "/api/health") { + return send(response, 200, { application: "__APP_NAME__", status: "ok" }); + } + if (request.method === "GET" && url.pathname === "/api/session") { + return send(response, 200, { session: await store.load() }); + } + if (request.method === "POST" && url.pathname === "/api/start") { + const input = await body(request); + return send(response, 200, { session: await serializeMutation(() => startSession(store, { force: Boolean(input.force) })) }); + } + if (request.method === "POST" && url.pathname === "/api/intervene") { + const input = await body(request); + return send(response, 200, { session: await serializeMutation(() => intervene(store, input.instruction)) }); + } + if (request.method === "POST" && url.pathname === "/api/step") { + const input = await body(request); + if (!new Set(["live", "replay"]).has(input.mode)) throw new Error("mode must be live or replay"); + return send(response, 200, await serializeMutation(async () => { + const session = await store.load(); + if (!session) throw new Error("start a session first"); + let proposal; + if (input.mode === "live") { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 45_000); + try { + proposal = await proposeWithPi(session, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + } else proposal = deterministicProposal(session.experiments.length); + return runExperiment(store, proposal); + })); + } + if (request.method === "GET" && url.pathname === "/api/receipt") { + const session = await store.load(); + if (!session) throw new Error("start a session first"); + response.setHeader("content-disposition", `attachment; filename=intervene-${session.sessionId}.json`); + return send(response, 200, await createReceipt(session)); + } + return false; +} + +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url, `http://${request.headers.host ?? "127.0.0.1"}`); + if (url.pathname.startsWith("/api/")) { + const handled = await handleApi(request, response, url); + if (handled !== false) return; + return send(response, 404, { error: "not found" }); + } + const relative = url.pathname === "/" ? "index.html" : url.pathname.slice(1); + if (relative.includes("..") || path.isAbsolute(relative)) return send(response, 400, "bad path", "text/plain"); + const file = path.join(publicRoot, relative); + const content = await readFile(file); + send(response, 200, content, types[path.extname(file)] ?? "application/octet-stream"); + } catch (error) { + if (error.code === "ENOENT") return send(response, 404, "not found", "text/plain"); + send(response, 400, { error: error.message }); + } +}); + +server.on("error", (error) => { + if (error.code === "EADDRINUSE") console.error(`Port ${port} is already in use. Set PORT to another value and retry.`); + else console.error(error); + process.exitCode = 1; +}); +server.listen(port, host, () => console.log(`__APP_TITLE__ running at http://${host}:${port}`)); diff --git a/templates/research-loop/backend/filesystem/store.mjs b/templates/research-loop/backend/filesystem/store.mjs new file mode 100644 index 0000000..edf5a94 --- /dev/null +++ b/templates/research-loop/backend/filesystem/store.mjs @@ -0,0 +1,23 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export function createFileStore(file = path.resolve(".data", "session.json")) { + return { + file, + async load() { + try { + return JSON.parse(await readFile(file, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + }, + async save(value) { + await mkdir(path.dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.tmp`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporary, file); + return value; + }, + }; +} diff --git a/templates/research-loop/evals/deterministic-smoke.json b/templates/research-loop/evals/deterministic-smoke.json new file mode 100644 index 0000000..5358b8e --- /dev/null +++ b/templates/research-loop/evals/deterministic-smoke.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": "nodeagent.eval-suite/v1", + "id": "deterministic-smoke", + "assertions": [ + "a deliberately over-smoothed experiment is reverted", + "a higher-order bounded experiment is kept when it improves held-out BPC", + "a human intervention is attached to the next experiment", + "reload produces the same session digest" + ] +} diff --git a/templates/research-loop/fixtures/corpus/train.txt b/templates/research-loop/fixtures/corpus/train.txt new file mode 100644 index 0000000..dfc2ace --- /dev/null +++ b/templates/research-loop/fixtures/corpus/train.txt @@ -0,0 +1 @@ +small systems become trustworthy when every change is measured. an experiment changes one bounded variable, runs against a fixed held-out metric, and records enough evidence to replay the result. people should be able to watch the loop, add a constraint, and resume after interruption without losing the experiment ledger. simple code and honest measurements beat a large opaque demo. diff --git a/templates/research-loop/fixtures/corpus/validation.txt b/templates/research-loop/fixtures/corpus/validation.txt new file mode 100644 index 0000000..2c723dd --- /dev/null +++ b/templates/research-loop/fixtures/corpus/validation.txt @@ -0,0 +1 @@ +trustworthy research keeps a fixed metric, records every change, and lets a person steer the next experiment without restarting the loop. simple inspectable systems make failures useful. diff --git a/templates/research-loop/gitignore.template b/templates/research-loop/gitignore.template new file mode 100644 index 0000000..56564d9 --- /dev/null +++ b/templates/research-loop/gitignore.template @@ -0,0 +1,4 @@ +node_modules/ +.data/ +.env +.env.local diff --git a/templates/research-loop/hackathon.yaml b/templates/research-loop/hackathon.yaml new file mode 100644 index 0000000..a5ada71 --- /dev/null +++ b/templates/research-loop/hackathon.yaml @@ -0,0 +1,18 @@ +schemaVersion: nodekit.brief/v1 +application: __APP_NAME__ +problem: __BRIEF_JSON__ +outcome: + - Run a bounded experiment against an objective held-out metric. + - Keep improvements and revert regressions. + - Let a human steer the next experiment without restarting the session. + - Resume from durable state and export a reproducibility receipt. +sponsors: +__SPONSORS_YAML__ +constraints: + noKeyDemo: true + liveSecretRef: __SECRET_REF__ + productionWrites: false +approvals: + paidResourceActivation: human + productionDeployment: human + publicSubmission: human diff --git a/templates/research-loop/integrations/pi-ai/provider.mjs b/templates/research-loop/integrations/pi-ai/provider.mjs new file mode 100644 index 0000000..17cd758 --- /dev/null +++ b/templates/research-loop/integrations/pi-ai/provider.mjs @@ -0,0 +1,94 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +function parseJsonObject(raw) { + const text = String(raw ?? "").trim(); + const candidates = [text]; + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fenced?.[1]) candidates.push(fenced[1].trim()); + const first = text.indexOf("{"); + const last = text.lastIndexOf("}"); + if (first >= 0 && last > first) candidates.push(text.slice(first, last + 1)); + for (const candidate of [...new Set(candidates)]) { + try { + const value = JSON.parse(candidate); + if (value && typeof value === "object" && !Array.isArray(value)) return value; + } catch { + // Try only the bounded representations above. + } + } + throw new Error("Pi response did not contain a JSON object"); +} + +async function loadDefinition(repoRoot = process.cwd()) { + const definition = JSON.parse(await readFile(path.join(repoRoot, ".nodeagent", "resolved-definition.json"), "utf8")); + if (definition.provider.adapter !== "pi-ai") throw new Error("compiled provider adapter is not pi-ai"); + if (definition.provider.model.provider !== "openrouter") { + throw new Error(`this starter currently certifies the Pi OpenRouter provider, not ${definition.provider.model.provider}`); + } + return definition; +} + +async function createClient(definition) { + const [{ createModels }, { openrouterProvider }] = await Promise.all([ + import("@earendil-works/pi-ai"), + import("@earendil-works/pi-ai/providers/openrouter"), + ]); + const models = createModels(); + models.setProvider(openrouterProvider()); + const model = models.getModel("openrouter", definition.provider.model.id); + if (!model) throw new Error(`Pi catalog does not contain ${definition.provider.model.id}`); + const secretName = definition.secretRefs[0]; + const apiKey = process.env[secretName]?.trim(); + if (!apiKey) throw new Error(`${secretName} is required for live mode`); + return { apiKey, model, models, secretName }; +} + +function responseText(response) { + return response.content.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim(); +} + +function sanitizedUsage(response, model) { + return { + cacheReadTokens: response.usage?.cacheRead ?? 0, + costUsd: response.usage?.cost?.total ?? 0, + inputTokens: response.usage?.input ?? 0, + outputTokens: response.usage?.output ?? 0, + requestedModel: model.id, + responseModel: response.responseModel ?? response.model ?? model.id, + totalTokens: response.usage?.totalTokens ?? ((response.usage?.input ?? 0) + (response.usage?.output ?? 0)), + }; +} + +export async function proposeWithPi(session, { repoRoot = process.cwd(), signal } = {}) { + const definition = await loadDefinition(repoRoot); + const { apiKey, model, models } = await createClient(definition); + const response = await models.complete(model, { + systemPrompt: "Propose one bounded character n-gram experiment. Return only JSON: {hypothesis:string, config:{order:integer 1..6, alpha:number 0.001..10}}. Honor the human intervention. Never change the corpus or metric.", + messages: [{ + role: "user", + content: JSON.stringify({ best: session.best, experiments: session.experiments.slice(-5), intervention: session.intervention }), + timestamp: Date.now(), + }], + }, { apiKey, maxTokens: 320, signal, temperature: 0 }); + if (response.stopReason !== "stop") throw new Error(`Pi completion ended with ${response.stopReason}: ${response.errorMessage ?? "no provider detail"}`); + const parsed = parseJsonObject(responseText(response)); + if (typeof parsed.hypothesis !== "string" || !parsed.config || !Number.isFinite(Number(parsed.config.order)) || !Number.isFinite(Number(parsed.config.alpha))) { + throw new Error("Pi proposal failed the experiment proposal schema"); + } + return { config: parsed.config, hypothesis: parsed.hypothesis, model: { id: model.id, mode: "live", provider: "openrouter" }, usage: sanitizedUsage(response, model) }; +} + +export async function runPiSmoke({ repoRoot = process.cwd(), signal } = {}) { + const definition = await loadDefinition(repoRoot); + const { apiKey, model, models } = await createClient(definition); + const response = await models.complete(model, { + systemPrompt: "Follow the user instruction exactly. Return no explanation.", + messages: [{ role: "user", content: "Reply exactly NODEKIT_PI_LIVE_OK", timestamp: Date.now() }], + }, { apiKey, maxTokens: 32, signal, temperature: 0 }); + const text = responseText(response); + if (response.stopReason !== "stop" || text !== "NODEKIT_PI_LIVE_OK") { + throw new Error(`strict Pi smoke failed (${response.stopReason}): ${text.slice(0, 120)}`); + } + return { model: model.id, provider: "openrouter", schemaVersion: "nodekit.pi-smoke/v1", status: "pass", stopReason: response.stopReason, usage: sanitizedUsage(response, model) }; +} diff --git a/templates/research-loop/nodeagent.yaml b/templates/research-loop/nodeagent.yaml new file mode 100644 index 0000000..1dfb6ef --- /dev/null +++ b/templates/research-loop/nodeagent.yaml @@ -0,0 +1,38 @@ +schemaVersion: nodeagent.application/v1 +application: + id: __APP_NAME__ + name: __APP_TITLE__ + purpose: __BRIEF_JSON__ +authoring: + directory: ./agent +runtime: + engine: nodekit-reference-loop + profile: replay +provider: + adapter: pi-ai + package: "@earendil-works/pi-ai" + model: + provider: __PROVIDER_ID__ + id: __MODEL_ID__ + secretRef: __SECRET_REF__ +backend: + adapter: __BACKEND__ +orchestration: + mode: persistent-metric-loop + maxConcurrentExperiments: 1 +packs: + - ./packs/primary/pack.yaml +policies: + tools: deny-by-default + maxModelCallsPerStep: 1 + maxExperimentSeconds: 30 + requireMetricBeforeKeep: true + requireReceipt: true +evaluations: + required: + - deterministic-smoke + - keep-revert-invariant + - interrupted-run-recovery + - pi-live-smoke +secrets: + - envRef: __SECRET_REF__ diff --git a/templates/research-loop/nodekit.yaml b/templates/research-loop/nodekit.yaml new file mode 100644 index 0000000..88deb48 --- /dev/null +++ b/templates/research-loop/nodekit.yaml @@ -0,0 +1,31 @@ +schemaVersion: nodekit.repo/v1 +registryMode: external +repository: local/__APP_NAME__ +lifecycle: experimental +support: active +role: agent-application +commandProfile: application +canonicalFor: [] +consumes: + - nodeplatform.repo-contract + - nodeagent.agent-run + - proofloop.certification +commands: + dev: { script: dev, mode: service } + demo: { script: demo, mode: finite } + doctor: { script: doctor, mode: finite } + check: { script: check, mode: finite } + proof: { script: proof, mode: finite } +noKey: + status: certified + command: npm run demo + externalAccountsRequired: 0 + disclosure: Deterministic n-gram experiments use local fixtures and no network. +environment: + contractVersion: nodeplatform.env/v1 + status: aligned +proof: + command: npm run proof + receiptSchema: nodekit.experiment-receipt/v1 +contractDeclarations: [] +architectureExceptions: [] diff --git a/templates/research-loop/package.json b/templates/research-loop/package.json new file mode 100644 index 0000000..9339912 --- /dev/null +++ b/templates/research-loop/package.json @@ -0,0 +1,27 @@ +{ + "name": "__APP_NAME__", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=22.19.0" }, + "scripts": { + "compile": "nodekit compile --repo-root .", + "inspect": "nodekit inspect --repo-root .", + "doctor": "nodekit doctor --repo-root .", + "dev": "node apps/web/server.mjs", + "demo": "node scripts/demo.mjs", + "eval": "node scripts/eval.mjs", + "smoke:pi": "node scripts/live-smoke.mjs", + "proof:browser": "node scripts/browser-proof.mjs", + "check": "node scripts/check.mjs", + "proof": "node scripts/proof.mjs", + "phase": "node scripts/phase.mjs", + "timeline": "node scripts/timeline.mjs" + }, + "dependencies": { + "@earendil-works/pi-ai": "__PI_PACKAGE__" + }, + "devDependencies": { + "@homenshum/nodekit": __NODEKIT_SPECIFIER_JSON__ + } +} diff --git a/templates/research-loop/packs/primary/pack.yaml b/templates/research-loop/packs/primary/pack.yaml new file mode 100644 index 0000000..eb4103b --- /dev/null +++ b/templates/research-loop/packs/primary/pack.yaml @@ -0,0 +1,18 @@ +schemaVersion: nodeagent.pack/v1 +id: measurable-research-loop +version: 0.1.0 +skill: ../../agent/skills/autoresearch-live/SKILL.md +artifacts: + - experiment-session + - experiment-result + - reproduction-receipt +tools: + - experiment.propose + - experiment.measure + - experiment.keep-or-revert +validators: + - metric-is-finite + - decision-matches-metric + - receipt-is-secret-free +evals: + - ../../evals/deterministic-smoke.json diff --git a/templates/research-loop/render.yaml b/templates/research-loop/render.yaml new file mode 100644 index 0000000..51b941f --- /dev/null +++ b/templates/research-loop/render.yaml @@ -0,0 +1,10 @@ +services: + - type: web + name: __APP_NAME__ + runtime: docker + plan: free + autoDeploy: false + healthCheckPath: /api/health + envVars: + - key: __SECRET_REF__ + sync: false diff --git a/templates/research-loop/schemas/experiment-receipt.schema.json b/templates/research-loop/schemas/experiment-receipt.schema.json new file mode 100644 index 0000000..75562e6 --- /dev/null +++ b/templates/research-loop/schemas/experiment-receipt.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "nodekit.experiment-receipt/v1", + "type": "object", + "required": ["schemaVersion", "sessionId", "configHash", "baseline", "best", "experiments", "events", "replay"], + "properties": { + "schemaVersion": { "const": "nodekit.experiment-receipt/v1" }, + "sessionId": { "type": "string" }, + "configHash": { "type": "string" }, + "experiments": { "type": "array" } + } +} diff --git a/templates/research-loop/scripts/browser-proof.mjs b/templates/research-loop/scripts/browser-proof.mjs new file mode 100644 index 0000000..8d936fc --- /dev/null +++ b/templates/research-loop/scripts/browser-proof.mjs @@ -0,0 +1,42 @@ +import { createHash } from "node:crypto"; +import { readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { recordFriction } from "./lib/friction.mjs"; + +const observationsPath = path.resolve("proof", "browser", "qa-observations.json"); +const observations = JSON.parse(await readFile(observationsPath, "utf8")); +const edge = JSON.parse(await readFile(path.resolve("proof", "edge-qa.json"), "utf8")); +const screenshotPaths = observations.screenshots ?? []; +const screenshots = []; +for (const relative of screenshotPaths) { + const file = path.resolve(relative); + const bytes = await readFile(file); + const metadata = await stat(file); + screenshots.push({ bytes: metadata.size, path: relative.replaceAll("\\", "/"), sha256: createHash("sha256").update(bytes).digest("hex") }); +} + +const checks = { + accessibilitySemantics: observations.accessibility?.passed === true, + appOriginConsoleErrors: observations.console?.appOriginErrors === 0, + edgeCases: edge.passed === true, + mainFlow: observations.mainFlow?.passed === true, + reloadPersistence: observations.reload?.passed === true, + responsive: observations.responsive?.passed === true, + screenshotsPresent: screenshots.length >= 4 && screenshots.every((entry) => entry.bytes > 0), +}; +const passed = Object.values(checks).every(Boolean); +const completedAt = new Date().toISOString(); +const durationMs = Math.max(0, Date.parse(completedAt) - Date.parse(observations.startedAt)); +const receipt = { + checks, + completedAt, + durationMs, + observations, + passed, + schemaVersion: "nodekit.browser-proof/v1", + screenshots, +}; +await writeFile(path.resolve("proof", "browser-proof.json"), `${JSON.stringify(receipt, null, 2)}\n`); +await recordFriction(passed ? "browser_qa_completed" : "browser_qa_failed", { checks }, durationMs); +console.log(JSON.stringify({ checks, durationMs, passed }, null, 2)); +if (!passed) process.exitCode = 1; diff --git a/templates/research-loop/scripts/check.mjs b/templates/research-loop/scripts/check.mjs new file mode 100644 index 0000000..cd136f0 --- /dev/null +++ b/templates/research-loop/scripts/check.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { recordFriction } from "./lib/friction.mjs"; + +const started = Date.now(); +const nodekit = path.resolve("node_modules", "@homenshum", "nodekit", "src", "cli.mjs"); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} exited ${result.status}`); +} + +try { + run(process.execPath, [nodekit, "compile", "--repo-root", ".", "--check"]); + run(process.execPath, ["--test"]); + await recordFriction("tests_passed", { compileHashCurrent: true }, Date.now() - started); +} catch (error) { + await recordFriction("tests_failed", { error: error.message }, Date.now() - started); + throw error; +} diff --git a/templates/research-loop/scripts/demo.mjs b/templates/research-loop/scripts/demo.mjs new file mode 100644 index 0000000..8330de8 --- /dev/null +++ b/templates/research-loop/scripts/demo.mjs @@ -0,0 +1,27 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { createFileStore } from "../backend/filesystem/store.mjs"; +import { createReceipt, deterministicProposal, intervene, runExperiment, startSession } from "../agent/experiment-loop.mjs"; +import { recordFriction } from "./lib/friction.mjs"; + +const started = Date.now(); +const store = createFileStore(path.resolve(".data", "demo-session.json")); +const session = await startSession(store, { force: true }); +const regression = await runExperiment(store, deterministicProposal(0)); +await intervene(store, "Test context width next; leave the corpus and metric unchanged."); +const improvement = await runExperiment(store, deterministicProposal(1)); +const finalSession = await store.load(); +const receipt = await createReceipt(finalSession); +await mkdir("proof", { recursive: true }); +await writeFile(path.resolve("proof", "demo-receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`); +await recordFriction("deterministic_demo_passed", { experiments: 2 }, Date.now() - started); +console.log(JSON.stringify({ + baseline: session.baseline.heldoutBitsPerCharacter, + best: finalSession.best.heldoutBitsPerCharacter, + firstDecision: regression.experiment.decision, + interventionVersion: finalSession.interventionVersion, + receipt: "proof/demo-receipt.json", + secondDecision: improvement.experiment.decision, + status: regression.experiment.decision === "revert" && improvement.experiment.decision === "keep" ? "pass" : "fail", +}, null, 2)); +if (regression.experiment.decision !== "revert" || improvement.experiment.decision !== "keep") process.exitCode = 1; diff --git a/templates/research-loop/scripts/eval.mjs b/templates/research-loop/scripts/eval.mjs new file mode 100644 index 0000000..66e0378 --- /dev/null +++ b/templates/research-loop/scripts/eval.mjs @@ -0,0 +1,34 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createFileStore } from "../backend/filesystem/store.mjs"; +import { deterministicProposal, intervene, runExperiment, startSession } from "../agent/experiment-loop.mjs"; +import { recordFriction } from "./lib/friction.mjs"; + +const started = Date.now(); +const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-eval-")); +try { + const store = createFileStore(path.join(root, "session.json")); + const initial = await startSession(store); + const bad = await runExperiment(store, deterministicProposal(0)); + await intervene(store, "Only vary context width; preserve corpus and evaluator."); + const good = await runExperiment(store, deterministicProposal(1)); + const beforeReload = await store.load(); + beforeReload.status = "measuring"; + beforeReload.events.push({ at: new Date().toISOString(), details: { simulated: true }, id: "simulated-interruption", type: "experiment.interrupted" }); + await store.save(beforeReload); + const afterReload = await startSession(store); + const assertions = { + durableRecovery: afterReload.status === "ready" && afterReload.events.some((entry) => entry.type === "session.recovered"), + interventionAttached: good.experiment.intervention?.version === 1, + regressionReverted: bad.experiment.decision === "revert", + strictImprovementKept: good.experiment.decision === "keep" && good.session.best.heldoutBitsPerCharacter < initial.best.heldoutBitsPerCharacter, + }; + const receipt = { assertions, generatedAt: new Date().toISOString(), passed: Object.values(assertions).every(Boolean), schemaVersion: "nodekit.eval-receipt/v1" }; + await writeFile(path.resolve("proof", "eval-receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`); + await recordFriction(receipt.passed ? "eval_passed" : "eval_failed", assertions, Date.now() - started); + console.log(JSON.stringify(receipt, null, 2)); + if (!receipt.passed) process.exitCode = 1; +} finally { + await rm(root, { force: true, recursive: true }); +} diff --git a/templates/research-loop/scripts/lib/friction.mjs b/templates/research-loop/scripts/lib/friction.mjs new file mode 100644 index 0000000..15b7593 --- /dev/null +++ b/templates/research-loop/scripts/lib/friction.mjs @@ -0,0 +1,15 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export async function recordFriction(name, detail = {}, durationMs) { + const file = path.resolve("proof", "build-friction.json"); + let receipt; + try { + receipt = JSON.parse(await readFile(file, "utf8")); + } catch { + receipt = { events: [], repairLoops: 0, schemaVersion: "nodekit.build-friction/v1" }; + } + if (name.endsWith("_failed")) receipt.repairLoops = (receipt.repairLoops ?? 0) + 1; + receipt.events.push({ at: new Date().toISOString(), detail, ...(durationMs === undefined ? {} : { durationMs }), name }); + await writeFile(file, `${JSON.stringify(receipt, null, 2)}\n`); +} diff --git a/templates/research-loop/scripts/live-smoke.mjs b/templates/research-loop/scripts/live-smoke.mjs new file mode 100644 index 0000000..1b069b2 --- /dev/null +++ b/templates/research-loop/scripts/live-smoke.mjs @@ -0,0 +1,17 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { runPiSmoke } from "../integrations/pi-ai/provider.mjs"; +import { recordFriction } from "./lib/friction.mjs"; + +const controller = new AbortController(); +const timeout = setTimeout(() => controller.abort(), 45_000); +try { + const started = Date.now(); + const result = await runPiSmoke({ signal: controller.signal }); + await mkdir("proof", { recursive: true }); + await writeFile(path.resolve("proof", "pi-live-receipt.json"), `${JSON.stringify({ ...result, generatedAt: new Date().toISOString() }, null, 2)}\n`); + await recordFriction("pi_live_smoke_passed", { model: result.model, provider: result.provider, totalTokens: result.usage.totalTokens }, Date.now() - started); + console.log(JSON.stringify(result, null, 2)); +} finally { + clearTimeout(timeout); +} diff --git a/templates/research-loop/scripts/phase.mjs b/templates/research-loop/scripts/phase.mjs new file mode 100644 index 0000000..382e2d4 --- /dev/null +++ b/templates/research-loop/scripts/phase.mjs @@ -0,0 +1,20 @@ +import { recordFriction } from "./lib/friction.mjs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const [phase, state = "completed", ...detailParts] = process.argv.slice(2); +if (!phase || !/^[a-z][a-z0-9-]*$/.test(phase)) throw new Error("usage: npm run phase -- [started|completed|failed] [detail]"); +if (!new Set(["started", "completed", "failed"]).has(state)) throw new Error("phase state must be started, completed, or failed"); +const normalized = phase.replaceAll("-", "_"); +let durationMs; +if (state !== "started") { + try { + const receipt = JSON.parse(await readFile(path.resolve("proof", "build-friction.json"), "utf8")); + const start = [...receipt.events].reverse().find((entry) => entry.name === `${normalized}_started`); + if (start) durationMs = Math.max(0, Date.now() - Date.parse(start.at)); + } catch { + // The recorder will create the receipt if this is the first event. + } +} +await recordFriction(`${normalized}_${state}`, { note: detailParts.join(" ") || undefined }, durationMs); +console.log(`${phase} ${state}`); diff --git a/templates/research-loop/scripts/proof.mjs b/templates/research-loop/scripts/proof.mjs new file mode 100644 index 0000000..9930668 --- /dev/null +++ b/templates/research-loop/scripts/proof.mjs @@ -0,0 +1,45 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { recordFriction } from "./lib/friction.mjs"; + +const started = Date.now(); + +async function readJson(name, required = true) { + try { + return JSON.parse(await readFile(path.resolve("proof", name), "utf8")); + } catch (error) { + if (!required && error.code === "ENOENT") return null; + throw new Error(`missing or invalid proof/${name}; run the corresponding gate first`); + } +} + +const [demo, evaluation, live, browser, deployment, friction] = await Promise.all([ + readJson("demo-receipt.json"), + readJson("eval-receipt.json"), + readJson("pi-live-receipt.json"), + readJson("browser-proof.json"), + readJson("deployment-receipt.json"), + readJson("build-friction.json"), +]); +const secretPattern = /(?:sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/; +const secretFree = !secretPattern.test(JSON.stringify({ browser, demo, deployment, evaluation, live, friction })); +const deploymentPassed = deployment.passed === true || deployment.status === "pass"; +const receipt = { + checks: { + deterministicDemo: demo.schemaVersion === "nodekit.experiment-receipt/v1", + deterministicEvaluation: evaluation.passed === true, + livePi: live.status === "pass", + browserQa: browser.passed === true, + deployment: deploymentPassed, + secretFree, + }, + generatedAt: new Date().toISOString(), + passed: evaluation.passed === true && live.status === "pass" && browser.passed === true && deploymentPassed && secretFree, + schemaVersion: "nodekit.release-proof/v1", +}; +await mkdir("proof", { recursive: true }); +await writeFile(path.resolve("proof", "release-proof.json"), `${JSON.stringify(receipt, null, 2)}\n`); +await recordFriction(receipt.passed ? "proof_passed" : "proof_failed", receipt.checks, Date.now() - started); +console.log(JSON.stringify(receipt, null, 2)); +if (!receipt.passed) process.exitCode = 1; +else await import("./timeline.mjs"); diff --git a/templates/research-loop/scripts/timeline.mjs b/templates/research-loop/scripts/timeline.mjs new file mode 100644 index 0000000..de60417 --- /dev/null +++ b/templates/research-loop/scripts/timeline.mjs @@ -0,0 +1,22 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const friction = JSON.parse(await readFile(path.resolve("proof", "build-friction.json"), "utf8")); +const first = friction.events.find((entry) => entry.name === "launch_started") ?? friction.events[0]; +const last = friction.events.at(-1); +const elapsedMs = Math.max(0, Date.parse(last.at) - Date.parse(first.at)); +const durations = Object.fromEntries(friction.events.filter((entry) => Number.isFinite(entry.durationMs)).map((entry) => [entry.name, entry.durationMs])); +const required = ["research_completed", "scaffold_completed", "install_completed", "implementation_completed", "compile_completed", "deterministic_demo_passed", "tests_passed", "eval_passed", "pi_live_smoke_passed", "browser_qa_completed", "deployment_completed", "proof_passed"]; +const observed = new Set(friction.events.map((entry) => entry.name)); +const missing = required.filter((name) => !observed.has(name)); +const report = { + budget: { hackathonHours: [2, 4], targetMinutes: 30 }, + durations, + elapsedMinutes: Number((elapsedMs / 60_000).toFixed(2)), + missing, + passed: missing.length === 0 && elapsedMs <= 30 * 60_000, + schemaVersion: "nodekit.launch-timeline/v1", +}; +await writeFile(path.resolve("proof", "launch-timeline.json"), `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify(report, null, 2)); +if (!report.passed) process.exitCode = 1; diff --git a/templates/research-loop/test/experiment-loop.test.mjs b/templates/research-loop/test/experiment-loop.test.mjs new file mode 100644 index 0000000..2cf664b --- /dev/null +++ b/templates/research-loop/test/experiment-loop.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createFileStore } from "../backend/filesystem/store.mjs"; +import { deterministicProposal, intervene, runExperiment, startSession } from "../agent/experiment-loop.mjs"; + +test("metric loop reverts regressions, persists intervention, and resumes", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-loop-")); + t.after(() => rm(root, { force: true, recursive: true })); + const store = createFileStore(path.join(root, "session.json")); + const baseline = await startSession(store, { fixtureRoot: path.resolve("fixtures/corpus") }); + const bad = await runExperiment(store, deterministicProposal(0), { fixtureRoot: path.resolve("fixtures/corpus") }); + assert.equal(bad.experiment.decision, "revert"); + await intervene(store, "test context width; do not change the corpus"); + const good = await runExperiment(store, deterministicProposal(1), { fixtureRoot: path.resolve("fixtures/corpus") }); + assert.equal(good.experiment.decision, "keep"); + assert.match(good.experiment.intervention.instruction, /do not change the corpus/); + const resumed = await startSession(store, { fixtureRoot: path.resolve("fixtures/corpus") }); + assert.equal(resumed.sessionId, baseline.sessionId); + assert.equal(resumed.experiments.length, 2); +}); diff --git a/test/factory.test.mjs b/test/factory.test.mjs new file mode 100644 index 0000000..a7e1e0a --- /dev/null +++ b/test/factory.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { compileAgentDefinition, inspectAgentDefinition } from "../src/lib/agent-definition.mjs"; +import { adoptProject, createProject } from "../src/lib/scaffold.mjs"; + +test("create emits a parseable, reproducible application from multiline input", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-create-")); + const target = path.join(root, "fresh-app"); + t.after(() => rm(root, { force: true, recursive: true })); + await createProject({ + brief: "First line:\nsecond line with # and : characters", + git: false, + install: false, + name: "Fresh App", + nodekitSpecifier: "file:D:\\work\\node-platform", + sponsors: ["Convex", "Map Sponsor"], + target, + }); + const packageJson = JSON.parse(await readFile(path.join(target, "package.json"), "utf8")); + assert.equal(packageJson.devDependencies["@homenshum/nodekit"], "file:D:/work/node-platform"); + assert.equal(packageJson.dependencies["@earendil-works/pi-ai"], "0.80.10"); + assert.equal(await readFile(path.join(target, "integrations", "convex", "sponsor.yaml"), "utf8").then(Boolean), true); + const dockerfile = await readFile(path.join(target, "Dockerfile"), "utf8"); + const renderBlueprint = await readFile(path.join(target, "render.yaml"), "utf8"); + assert.match(dockerfile, /@earendil-works\/pi-ai@0\.80\.10/); + assert.match(renderBlueprint, /name: fresh-app/); + assert.match(renderBlueprint, /healthCheckPath: \/api\/health/); + assert.equal(`${dockerfile}\n${renderBlueprint}`.includes("__"), false); + + const first = await compileAgentDefinition(target); + const second = await compileAgentDefinition(target, { write: false }); + assert.equal(first.definition.configHash, second.definition.configHash); + assert.equal(first.manifest.application.purpose.includes("second line"), true); + assert.equal(inspectAgentDefinition(second).secrets[0].name, "OPENROUTER_API_KEY"); +}); + +test("compiled hash detects fixture drift and literal secrets fail closed", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-hash-")); + t.after(() => rm(root, { force: true, recursive: true })); + await createProject({ git: false, install: false, name: "hash-test", target: root }); + const compiled = await compileAgentDefinition(root); + await writeFile(path.join(root, "fixtures", "corpus", "validation.txt"), "changed heldout input\n"); + await assert.rejects(() => compileAgentDefinition(root, { check: true, write: false }), /compiled definition is stale/); + const changed = await compileAgentDefinition(root, { write: false }); + assert.notEqual(changed.definition.configHash, compiled.definition.configHash); + + const manifestPath = path.join(root, "nodeagent.yaml"); + await writeFile(manifestPath, `${await readFile(manifestPath, "utf8")}\napiKey: sk-abcdefghijklmnopqrstuv\n`); + await assert.rejects(() => compileAgentDefinition(root, { write: false }), /literal secret/); +}); + +test("create refuses nonempty targets", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-nonempty-")); + t.after(() => rm(root, { force: true, recursive: true })); + await writeFile(path.join(root, "user-file.txt"), "keep me"); + await assert.rejects(() => createProject({ force: true, git: false, install: false, name: "unsafe", target: root }), /target is not empty/); + assert.equal(await readFile(path.join(root, "user-file.txt"), "utf8"), "keep me"); +}); + +test("adopt is additive, runnable, and reports collisions", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-adopt-")); + t.after(() => rm(root, { force: true, recursive: true })); + await mkdir(path.join(root, "agent"), { recursive: true }); + await writeFile(path.join(root, "agent", "instructions.md"), "user-owned instructions\n"); + await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "host", scripts: { dev: "host-dev" }, type: "module" })); + const result = await adoptProject({ name: "host", nodekitSpecifier: "file:D:/node-platform", target: root }); + const packageJson = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); + assert.equal(packageJson.scripts.dev, "host-dev"); + assert.equal(packageJson.scripts["nodekit:demo"], "node scripts/demo.mjs"); + assert.equal(await readFile(path.join(root, "agent", "instructions.md"), "utf8"), "user-owned instructions\n"); + assert.equal(result.collisions.includes("agent/instructions.md"), true); + assert.equal(await readFile(path.join(root, "backend", "filesystem", "store.mjs"), "utf8").then(Boolean), true); +}); From f23a3a3c9bc5f9bbaaedefda583ce582653cb837 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 19:17:38 -0700 Subject: [PATCH 02/13] feat: freeze NodeKit application contracts --- README.md | 15 +- docs/DECISIONS.md | 8 ++ docs/ECOSYSTEM_STATUS.md | 2 + ownership.yaml | 28 +++- repositories.yaml | 10 +- schemas/nodeagent.application.v1.schema.json | 97 +++++++++++-- schemas/nodeagent.event.v1.schema.json | 43 ++++++ schemas/nodeagent.pack.v1.schema.json | 37 ++++- schemas/nodekit.schema.json | 1 + src/cli.mjs | 2 + src/lib/agent-definition.mjs | 38 +++--- src/lib/contracts.mjs | 43 ++++++ src/lib/repo-check.mjs | 12 +- src/lib/schema-validation.mjs | 24 ++++ templates/research-loop/nodeagent.yaml | 3 + test/contracts.test.mjs | 136 +++++++++++++++++++ 16 files changed, 455 insertions(+), 44 deletions(-) create mode 100644 schemas/nodeagent.event.v1.schema.json create mode 100644 src/lib/contracts.mjs create mode 100644 src/lib/schema-validation.mjs create mode 100644 test/contracts.test.mjs diff --git a/README.md b/README.md index fdb2aaa..4c45f5a 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,25 @@ npx --yes @homenshum/nodekit proof - [`ownership.yaml`](ownership.yaml) names one owner, current package, target package, status, version, and consumers for every governed concept. - [`repositories.yaml`](repositories.yaml) records lifecycle, support state, role, successor, and command profile. - [`architecture.yaml`](architecture.yaml) defines universal commands, allowed reuse modes, and source-layer rules. -- [`schemas/nodekit.schema.json`](schemas/nodekit.schema.json) documents each consumer repository's `nodekit.yaml`. -- [`schemas/nodeagent.application.v1.schema.json`](schemas/nodeagent.application.v1.schema.json) and [`schemas/nodeagent.pack.v1.schema.json`](schemas/nodeagent.pack.v1.schema.json) are enforced during compilation. +- [`schemas/nodekit.schema.json`](schemas/nodekit.schema.json) enforces `nodekit.repo/v1` for each consumer repository's `nodekit.yaml`. +- [`schemas/nodeagent.application.v1.schema.json`](schemas/nodeagent.application.v1.schema.json) and [`schemas/nodeagent.pack.v1.schema.json`](schemas/nodeagent.pack.v1.schema.json) enforce the application and capability-pack contracts during compilation. +- [`schemas/nodeagent.event.v1.schema.json`](schemas/nodeagent.event.v1.schema.json) defines the canonical portable event envelope. Applications resolve `nodeagent.event/v1` and `nodeagent.trace/v1` contract references even when an older v1 manifest omits the optional `contracts` block. - `nodekit compile` discovers authored files, validates pack references, rejects literal secrets, and hashes the runtime, backend, fixtures, schemas, integrations, and evals into `.nodeagent/`. - `nodekit create` refuses non-empty targets. `nodekit adopt` writes missing files only, preserves host scripts, and emits a collision receipt. - `nodekit repo check` validates ownership declarations, command aliases, migration origins, signature classification, and source rules. - `nodekit ecosystem check` checks all active local clones together. - `nodekit dashboard` generates the cross-repository status table. +## Frozen v1 manifest dialect + +The three public v1 manifests use one flat, fail-closed shape: + +```yaml +schemaVersion: nodekit.repo/v1 # or nodeagent.application/v1 / nodeagent.pack/v1 +``` + +The earlier planning-only `apiVersion` / `kind` / `metadata` / `spec` envelope is not another accepted v1 dialect. Repository checks and application compilation reject that shape with a migration-oriented error. Existing `nodeagent.application/v1` manifests that predate the optional `contracts` block remain compatible; the compiler resolves their event and trace references to the canonical v1 values. + ## Honest boundary `planned`, `migration-planned`, and `canonical-unpackaged` remain intentionally distinct from released shared packages. NodeKit now has one end-to-end reference preset; it does not claim that every runtime adapter, backend, template, codemod, or production deployment target is complete. In particular, NodeRoom still contains the deepest live runtime and its extraction into a published NodeAgent package remains separate work. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5cc887c..3fc6ff0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -17,6 +17,14 @@ Current standalone owners: NodeTrace owns the portable trace UI/store, not every runtime event emitted by NodeAgent. The richer `nodeagent.trace/v1` workpaper currently lives in NodeRoom and is recorded as a migration source. Its target owner is NodeAgent. This avoids pretending a package exists before the protocol is extracted and compatibility-tested. +NodeAgent owns the portable `nodeagent.event/v1` envelope. Its minimum fields are `eventId`, `runId`, monotonic `sequence`, namespaced `type`, ISO `occurredAt`, optional actor and references, and a payload. NodeKit owns the JSON Schema and NodeAgent owns the typed runtime implementation. Product-specific stream rows are projections, not alternate event protocols. + +## Manifest Dialect + +`nodekit.repo/v1`, `nodeagent.application/v1`, and `nodeagent.pack/v1` use a flat `schemaVersion` manifest. The earlier `apiVersion` / `kind` / `metadata` / `spec` proposal is rejected rather than silently reinterpreted. This prevents benchmark, generator, and production paths from compiling different definitions from visually similar YAML. + +The `contracts` block in `nodeagent.application/v1` is optional for compatibility with manifests authored before the freeze. The compiler always resolves it to `nodeagent.event/v1` and `nodeagent.trace/v1`; new templates write those values explicitly. + ## Proposal Split `nodeslide.deck-patch/v1` remains a NodeSlide domain protocol. The planned generic `nodeagent.proposal/v1` may later carry review decisions, candidate receipts, and authority boundaries, but it must not erase deck-specific operations or make NodeSlide import another application's internals. diff --git a/docs/ECOSYSTEM_STATUS.md b/docs/ECOSYSTEM_STATUS.md index 65d1b97..2b1f0cb 100644 --- a/docs/ECOSYSTEM_STATUS.md +++ b/docs/ECOSYSTEM_STATUS.md @@ -22,3 +22,5 @@ - `nodeagent.trace-workpaper` -> `@nodeagent/trace-protocol`; current source: `NodeRoom/src/nodeagent/traces/traceTypes.ts` A PASS here means the P0 repository contract is internally consistent. Commands are declared and statically resolvable; this table does not claim they were executed by the dashboard. It does not promote a preview repository to production or turn a planned package into a released package. + +`NodeBenchAI` is registered as an active production domain application. It remains `untracked` by ecosystem command execution until its planning-era `apiVersion/kind/spec` repository manifest is explicitly migrated to the canonical flat `nodekit.repo/v1` contract. diff --git a/ownership.yaml b/ownership.yaml index 1b91fbf..4896c57 100644 --- a/ownership.yaml +++ b/ownership.yaml @@ -33,7 +33,7 @@ concepts: nodeagent.agent-run: owner: NodeAgent - package: nodeagent + package: "@homenshum/nodeagent" targetPackage: "@nodeagent/runtime" status: canonical-unpackaged contractVersion: nodeagent.run/v1 @@ -49,7 +49,7 @@ concepts: nodeagent.policy-context: owner: NodeAgent - package: nodeagent + package: "@homenshum/nodeagent" targetPackage: "@nodeagent/runtime" status: canonical-unpackaged contractVersion: nodeagent.policy/v1 @@ -73,6 +73,30 @@ concepts: - NodeSlide - NodeVideo + nodeagent.event-protocol: + owner: NodeAgent + package: "@homenshum/nodeagent" + targetPackage: "@nodeagent/event-protocol" + status: canonical-unpackaged + contractVersion: nodeagent.event/v1 + consumers: + - NodeTrace + - NodeMem + signatures: + - id: nodeagent.event/v1 + regex: '^\s*export\s+const\s+NODEAGENT_EVENT_SCHEMA_VERSION\b' + + nodeagent.provider-pi: + owner: NodeAgent + package: "@homenshum/nodeagent" + targetPackage: "@nodeagent/provider-pi" + status: canonical-unpackaged + contractVersion: nodeagent.provider-pi/v1 + consumers: [] + signatures: + - id: agent-model-adapter + regex: '^\s*export\s+function\s+createPiAiAdapter\b' + nodeagent.trace-workpaper: owner: NodeAgent package: null diff --git a/repositories.yaml b/repositories.yaml index d274baa..7f8ea32 100644 --- a/repositories.yaml +++ b/repositories.yaml @@ -94,11 +94,13 @@ repositories: - name: NodeBenchAI github: HomenShum/NodeBenchAI - lifecycle: reference - support: frozen - role: legacy-product + lifecycle: production + support: active + role: domain-application + # The live repository still uses the superseded apiVersion/kind/spec + # contract draft. Keep it visible but untracked until its manifest is + # explicitly migrated to the canonical flat nodekit.repo/v1 shape. commandProfile: untracked - replacedBy: NodeRoom - name: NodeBenchBoilerplate github: HomenShum/NodeBenchBoilerplate diff --git a/schemas/nodeagent.application.v1.schema.json b/schemas/nodeagent.application.v1.schema.json index 15a9ac4..d947b94 100644 --- a/schemas/nodeagent.application.v1.schema.json +++ b/schemas/nodeagent.application.v1.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/HomenShum/node-platform/blob/main/schemas/nodeagent.application.v1.schema.json", "title": "NodeAgent application manifest", + "description": "Canonical flat schemaVersion dialect. apiVersion/kind/spec envelopes are not part of v1.", "type": "object", "required": ["schemaVersion", "application", "runtime", "provider", "backend", "packs", "policies", "evaluations"], "properties": { @@ -13,14 +14,94 @@ "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, "name": { "type": "string", "minLength": 1 }, "purpose": { "type": "string", "minLength": 1 } - } + }, + "additionalProperties": false + }, + "authoring": { + "type": "object", + "required": ["directory"], + "properties": { + "directory": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "runtime": { + "type": "object", + "required": ["engine", "profile"], + "properties": { + "engine": { "type": "string", "minLength": 1 }, + "profile": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "provider": { + "type": "object", + "required": ["adapter", "model", "secretRef"], + "properties": { + "adapter": { "type": "string", "minLength": 1 }, + "package": { "type": "string", "minLength": 1 }, + "model": { + "type": "object", + "required": ["provider", "id"], + "properties": { + "provider": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "secretRef": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" } + }, + "additionalProperties": false + }, + "backend": { + "type": "object", + "required": ["adapter"], + "properties": { + "adapter": { "type": "string", "minLength": 1 }, + "config": { "type": "object" } + }, + "additionalProperties": false + }, + "orchestration": { "type": "object" }, + "packs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } }, - "runtime": { "type": "object", "required": ["engine", "profile"] }, - "provider": { "type": "object", "required": ["adapter", "model", "secretRef"] }, - "backend": { "type": "object", "required": ["adapter"] }, - "packs": { "type": "array", "minItems": 1, "items": { "type": "string" } }, "policies": { "type": "object" }, - "evaluations": { "type": "object", "required": ["required"] }, - "secrets": { "type": "array" } - } + "evaluations": { + "type": "object", + "required": ["required"], + "properties": { + "required": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + }, + "additionalProperties": false + }, + "contracts": { + "type": "object", + "required": ["event", "trace"], + "properties": { + "event": { "const": "nodeagent.event/v1" }, + "trace": { "const": "nodeagent.trace/v1" } + }, + "additionalProperties": false + }, + "secrets": { + "type": "array", + "items": { + "type": "object", + "required": ["envRef"], + "properties": { + "envRef": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false } diff --git a/schemas/nodeagent.event.v1.schema.json b/schemas/nodeagent.event.v1.schema.json new file mode 100644 index 0000000..6639c6f --- /dev/null +++ b/schemas/nodeagent.event.v1.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/HomenShum/node-platform/blob/main/schemas/nodeagent.event.v1.schema.json", + "title": "NodeAgent event envelope", + "type": "object", + "required": ["schemaVersion", "eventId", "runId", "sequence", "type", "occurredAt", "payload"], + "properties": { + "schemaVersion": { "const": "nodeagent.event/v1" }, + "eventId": { "type": "string", "minLength": 1 }, + "runId": { "type": "string", "minLength": 1 }, + "sequence": { "type": "integer", "minimum": 0 }, + "type": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]*$" }, + "occurredAt": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$" + }, + "actor": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "enum": ["user", "agent", "tool", "policy", "system"] }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "refs": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "id": { "type": "string", "minLength": 1 }, + "uri": { "type": "string", "minLength": 1 }, + "hash": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "payload": true + }, + "additionalProperties": false +} diff --git a/schemas/nodeagent.pack.v1.schema.json b/schemas/nodeagent.pack.v1.schema.json index 0ccea53..03da822 100644 --- a/schemas/nodeagent.pack.v1.schema.json +++ b/schemas/nodeagent.pack.v1.schema.json @@ -2,16 +2,39 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/HomenShum/node-platform/blob/main/schemas/nodeagent.pack.v1.schema.json", "title": "NodeAgent capability pack", + "description": "Canonical flat schemaVersion dialect. apiVersion/kind/spec envelopes are not part of v1.", "type": "object", "required": ["schemaVersion", "id", "version", "artifacts", "tools", "validators", "evals"], "properties": { "schemaVersion": { "const": "nodeagent.pack/v1" }, "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, - "version": { "type": "string" }, - "skill": { "type": "string" }, - "artifacts": { "type": "array", "items": { "type": "string" } }, - "tools": { "type": "array", "items": { "type": "string" } }, - "validators": { "type": "array", "items": { "type": "string" } }, - "evals": { "type": "array", "items": { "type": "string" } } - } + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "skill": { "type": "string", "minLength": 1 }, + "prompts": { "$ref": "#/$defs/pathList" }, + "artifacts": { "$ref": "#/$defs/idList" }, + "tools": { "$ref": "#/$defs/idList" }, + "jobs": { "$ref": "#/$defs/idList" }, + "validators": { "$ref": "#/$defs/idList" }, + "policies": { "$ref": "#/$defs/pathList" }, + "renderers": { "$ref": "#/$defs/idList" }, + "fixtures": { "$ref": "#/$defs/pathList" }, + "examples": { "$ref": "#/$defs/pathList" }, + "evals": { "$ref": "#/$defs/pathList" }, + "permissions": { "type": "object" } + }, + "$defs": { + "idList": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "pathList": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + }, + "additionalProperties": false } diff --git a/schemas/nodekit.schema.json b/schemas/nodekit.schema.json index 949c6b0..2df22eb 100644 --- a/schemas/nodekit.schema.json +++ b/schemas/nodekit.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/HomenShum/node-platform/blob/main/schemas/nodekit.schema.json", "title": "NodeKit repository contract", + "description": "Canonical flat schemaVersion dialect. apiVersion/kind/spec envelopes are not part of v1.", "type": "object", "required": [ "schemaVersion", diff --git a/src/cli.mjs b/src/cli.mjs index 6bd8eab..105a762 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -282,6 +282,7 @@ async function runCompile(parsed) { const output = { application: result.definition.application.id, configHash: result.definition.configHash, + contracts: result.definition.contracts, fileCount: result.definition.fileCount, passed: true, schemaVersion: "nodekit.compile/v1", @@ -301,6 +302,7 @@ async function runInspect(parsed) { console.log(` runtime ${output.runtime.engine}/${output.runtime.profile}`); console.log(` provider ${output.provider.adapter}:${output.provider.model.provider}/${output.provider.model.id}`); console.log(` backend ${output.backend.adapter}`); + console.log(` contracts event=${output.contracts.event} trace=${output.contracts.trace}`); console.log(` config ${output.configHash}`); console.log(` files ${output.fileCount}`); for (const [name, count] of Object.entries(output.discovered)) console.log(` ${name} ${count}`); diff --git a/src/lib/agent-definition.mjs b/src/lib/agent-definition.mjs index d2ba48b..2a8ec5d 100644 --- a/src/lib/agent-definition.mjs +++ b/src/lib/agent-definition.mjs @@ -1,25 +1,19 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import Ajv2020 from "ajv/dist/2020.js"; +import { + alternateDialectErrors, + CONTRACT_SCHEMA_FILES, + CONTRACT_VERSIONS, + resolveRuntimeContracts, +} from "./contracts.mjs"; import { normalizePath, pathExists, readYaml } from "./files.mjs"; +import { validateSchema } from "./schema-validation.mjs"; -const APPLICATION_SCHEMA = "nodeagent.application/v1"; +const APPLICATION_SCHEMA = CONTRACT_VERSIONS.application; const DISCOVERY_ROOTS = ["agent", "packs", "integrations", "backend", "workers", "evals", "fixtures", "schemas"]; const SECRET_FIELD = /(api.?key|password|secret|token)/i; const SECRET_LITERAL = /(?:sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/; -const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); - -async function schemaValidator(name) { - const schema = JSON.parse(await readFile(path.join(packageRoot, "schemas", name), "utf8")); - const ajv = new Ajv2020({ allErrors: true, strict: false }); - return ajv.compile(schema); -} - -function formatSchemaErrors(label, validator) { - return (validator.errors ?? []).map((entry) => `${label}${entry.instancePath || "/"} ${entry.message}`); -} function canonicalize(value) { if (Array.isArray(value)) return value.map(canonicalize); @@ -80,7 +74,7 @@ function validateSecrets(value, location = "nodeagent.yaml", errors = []) { } export function validateAgentManifest(manifest) { - const errors = []; + const errors = alternateDialectErrors(manifest, "nodeagent.yaml", APPLICATION_SCHEMA); if (manifest?.schemaVersion !== APPLICATION_SCHEMA) { errors.push(`nodeagent.yaml must use ${APPLICATION_SCHEMA}`); } @@ -119,15 +113,16 @@ export async function compileAgentDefinition(repoRoot, { check = false, write = const manifestText = await readFile(manifestPath, "utf8"); const manifest = await readYaml(manifestPath); const errors = validateAgentManifest(manifest); - const validateApplication = await schemaValidator("nodeagent.application.v1.schema.json"); - if (!validateApplication(manifest)) errors.push(...formatSchemaErrors("nodeagent.yaml", validateApplication)); - const validatePack = await schemaValidator("nodeagent.pack.v1.schema.json"); + errors.push(...await validateSchema(CONTRACT_SCHEMA_FILES.application, manifest, "nodeagent.yaml")); for (const pack of manifest.packs ?? []) { const packPath = path.resolve(repoRoot, String(pack)); if (!(await pathExists(packPath))) errors.push(`capability pack does not exist: ${pack}`); else { const packManifest = await readYaml(packPath); - if (!validatePack(packManifest)) errors.push(...formatSchemaErrors(String(pack), validatePack)); + errors.push( + ...alternateDialectErrors(packManifest, String(pack), CONTRACT_VERSIONS.pack), + ...await validateSchema(CONTRACT_SCHEMA_FILES.pack, packManifest, String(pack)), + ); for (const reference of [packManifest.skill, ...(packManifest.evals ?? [])].filter(Boolean)) { const referencedPath = path.resolve(path.dirname(packPath), String(reference)); if (!(await pathExists(referencedPath))) errors.push(`${pack} references missing file: ${reference}`); @@ -157,7 +152,8 @@ export async function compileAgentDefinition(repoRoot, { check = false, write = const files = await discoverFiles(repoRoot); const manifestDigest = hash(manifestText); - const hashInput = JSON.stringify(canonicalize({ files, manifest })); + const contracts = resolveRuntimeContracts(manifest); + const hashInput = JSON.stringify(canonicalize({ files, manifest: { ...manifest, contracts } })); const configHash = hash(hashInput); const secretRefs = [...new Set([ manifest.provider?.secretRef, @@ -167,6 +163,7 @@ export async function compileAgentDefinition(repoRoot, { check = false, write = application: manifest.application, backend: manifest.backend, configHash, + contracts, discovered: classify(files), fileCount: files.length, manifestDigest, @@ -213,6 +210,7 @@ export function inspectAgentDefinition(compiled) { fileCount: definition.fileCount, provider: definition.provider, runtime: definition.runtime, + contracts: definition.contracts, secrets: definition.secretRefs.map((name) => ({ configured: Boolean(process.env[name]), name })), }; } diff --git a/src/lib/contracts.mjs b/src/lib/contracts.mjs new file mode 100644 index 0000000..961d972 --- /dev/null +++ b/src/lib/contracts.mjs @@ -0,0 +1,43 @@ +export const CONTRACT_VERSIONS = Object.freeze({ + application: "nodeagent.application/v1", + event: "nodeagent.event/v1", + pack: "nodeagent.pack/v1", + repository: "nodekit.repo/v1", + trace: "nodeagent.trace/v1", +}); + +export const CONTRACT_SCHEMA_FILES = Object.freeze({ + application: "nodeagent.application.v1.schema.json", + pack: "nodeagent.pack.v1.schema.json", + repository: "nodekit.schema.json", +}); + +export const DEFAULT_RUNTIME_CONTRACTS = Object.freeze({ + event: CONTRACT_VERSIONS.event, + trace: CONTRACT_VERSIONS.trace, +}); + +const ALTERNATE_ENVELOPE_FIELDS = ["apiVersion", "kind", "metadata", "spec"]; + +/** + * NodeKit v1 deliberately uses a flat `schemaVersion` manifest. Older planning + * documents used a Kubernetes-style apiVersion/kind/spec envelope. Detect that + * shape explicitly so callers receive a migration-oriented error instead of a + * collection of unrelated missing-field messages. + */ +export function alternateDialectErrors(manifest, label, expectedVersion) { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return []; + const fields = ALTERNATE_ENVELOPE_FIELDS.filter((field) => Object.hasOwn(manifest, field)); + if (fields.length === 0) return []; + return [ + `${label} uses the unsupported apiVersion/kind/spec manifest dialect (${fields.join(", ")}); ` + + `use the flat schemaVersion: ${expectedVersion} contract`, + ]; +} + +export function resolveRuntimeContracts(manifest) { + return { + event: manifest?.contracts?.event ?? DEFAULT_RUNTIME_CONTRACTS.event, + trace: manifest?.contracts?.trace ?? DEFAULT_RUNTIME_CONTRACTS.trace, + }; +} diff --git a/src/lib/repo-check.mjs b/src/lib/repo-check.mjs index f072acc..e7bc59e 100644 --- a/src/lib/repo-check.mjs +++ b/src/lib/repo-check.mjs @@ -1,5 +1,10 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; +import { + alternateDialectErrors, + CONTRACT_SCHEMA_FILES, + CONTRACT_VERSIONS, +} from "./contracts.mjs"; import { listSourceFiles, normalizePath, @@ -8,8 +13,9 @@ import { readYaml, } from "./files.mjs"; import { repositoryByName, repositoryName, validateRegistry } from "./registry.mjs"; +import { validateSchema } from "./schema-validation.mjs"; -const REPO_SCHEMA = "nodekit.repo/v1"; +const REPO_SCHEMA = CONTRACT_VERSIONS.repository; const LIFECYCLES = new Set(["production", "preview", "experimental", "reference", "archived"]); const SUPPORT_STATES = new Set(["active", "maintenance", "frozen"]); const NO_KEY_STATES = new Set(["certified", "partial", "missing", "not-applicable"]); @@ -128,6 +134,10 @@ export async function checkRepository(repoRoot, registry) { } const manifest = await readYaml(manifestPath); + errors.push( + ...alternateDialectErrors(manifest, "nodekit.yaml", REPO_SCHEMA), + ...await validateSchema(CONTRACT_SCHEMA_FILES.repository, manifest, "nodekit.yaml"), + ); const packagePath = path.join(repoRoot, "package.json"); const packageJson = (await pathExists(packagePath)) ? await readJson(packagePath) : null; const name = manifest?.repository ? repositoryName(manifest) : path.basename(repoRoot); diff --git a/src/lib/schema-validation.mjs b/src/lib/schema-validation.mjs new file mode 100644 index 0000000..780ccef --- /dev/null +++ b/src/lib/schema-validation.mjs @@ -0,0 +1,24 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const validators = new Map(); + +async function validatorFor(name) { + if (!validators.has(name)) { + const schema = JSON.parse(await readFile(path.join(packageRoot, "schemas", name), "utf8")); + const ajv = new Ajv2020({ allErrors: true, strict: false }); + validators.set(name, ajv.compile(schema)); + } + return validators.get(name); +} + +export async function validateSchema(name, value, label) { + const validator = await validatorFor(name); + if (validator(value)) return []; + return (validator.errors ?? []).map( + (entry) => `${label}${entry.instancePath || "/"} ${entry.message}`, + ); +} diff --git a/templates/research-loop/nodeagent.yaml b/templates/research-loop/nodeagent.yaml index 1dfb6ef..f49f503 100644 --- a/templates/research-loop/nodeagent.yaml +++ b/templates/research-loop/nodeagent.yaml @@ -17,6 +17,9 @@ provider: secretRef: __SECRET_REF__ backend: adapter: __BACKEND__ +contracts: + event: nodeagent.event/v1 + trace: nodeagent.trace/v1 orchestration: mode: persistent-metric-loop maxConcurrentExperiments: 1 diff --git a/test/contracts.test.mjs b/test/contracts.test.mjs new file mode 100644 index 0000000..007aed1 --- /dev/null +++ b/test/contracts.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { compileAgentDefinition } from "../src/lib/agent-definition.mjs"; +import { CONTRACT_SCHEMA_FILES, CONTRACT_VERSIONS } from "../src/lib/contracts.mjs"; +import { checkRepository } from "../src/lib/repo-check.mjs"; +import { loadRegistry } from "../src/lib/registry.mjs"; +import { createProject } from "../src/lib/scaffold.mjs"; +import { validateSchema } from "../src/lib/schema-validation.mjs"; + +const platformRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +async function freshProject(t, prefix) { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)); + t.after(() => rm(root, { force: true, recursive: true })); + await createProject({ git: false, install: false, name: "contract-fixture", target: root }); + return root; +} + +test("generated manifests use the canonical flat contracts and resolve event/trace versions", async (t) => { + const root = await freshProject(t, "nodekit-contract-"); + const compiled = await compileAgentDefinition(root, { write: false }); + assert.equal(compiled.manifest.schemaVersion, CONTRACT_VERSIONS.application); + assert.deepEqual(compiled.manifest.contracts, { + event: CONTRACT_VERSIONS.event, + trace: CONTRACT_VERSIONS.trace, + }); + assert.deepEqual(compiled.definition.contracts, compiled.manifest.contracts); +}); + +test("applications authored before contracts references were added remain v1 compatible", async (t) => { + const root = await freshProject(t, "nodekit-compatible-"); + const explicit = await compileAgentDefinition(root, { write: false }); + const manifestPath = path.join(root, "nodeagent.yaml"); + const manifest = (await readFile(manifestPath, "utf8")).replace( + /contracts:\r?\n event: nodeagent\.event\/v1\r?\n trace: nodeagent\.trace\/v1\r?\n/, + "", + ); + await writeFile(manifestPath, manifest); + const compiled = await compileAgentDefinition(root, { write: false }); + assert.deepEqual(compiled.definition.contracts, { + event: CONTRACT_VERSIONS.event, + trace: CONTRACT_VERSIONS.trace, + }); + assert.equal(compiled.definition.configHash, explicit.definition.configHash); +}); + +test("nodeagent application apiVersion/kind/spec envelopes fail closed", async (t) => { + const root = await freshProject(t, "nodekit-app-dialect-"); + await writeFile( + path.join(root, "nodeagent.yaml"), + "apiVersion: nodeagent.dev/v1\nkind: AgentApplication\nmetadata:\n name: legacy\nspec: {}\n", + ); + await assert.rejects( + () => compileAgentDefinition(root, { write: false }), + /unsupported apiVersion\/kind\/spec manifest dialect.*nodeagent\.application\/v1/s, + ); +}); + +test("nodeagent pack apiVersion/kind/spec envelopes fail closed", async (t) => { + const root = await freshProject(t, "nodekit-pack-dialect-"); + await writeFile( + path.join(root, "packs", "primary", "pack.yaml"), + "apiVersion: nodeagent.dev/v1\nkind: CapabilityPack\nmetadata:\n name: legacy\nspec: {}\n", + ); + await assert.rejects( + () => compileAgentDefinition(root, { write: false }), + /unsupported apiVersion\/kind\/spec manifest dialect.*nodeagent\.pack\/v1/s, + ); +}); + +test("nodekit repository apiVersion/kind/spec envelopes fail closed", async (t) => { + const root = await freshProject(t, "nodekit-repo-dialect-"); + await writeFile( + path.join(root, "nodekit.yaml"), + "apiVersion: nodekit.dev/v1\nkind: Repository\nmetadata:\n name: legacy\nspec: {}\n", + ); + const result = await checkRepository(root, await loadRegistry(platformRoot)); + assert.equal(result.passed, false); + assert.match( + result.errors.join("\n"), + /unsupported apiVersion\/kind\/spec manifest dialect.*nodekit\.repo\/v1/s, + ); +}); + +test("canonical event envelopes are strict and traceable", async () => { + const valid = { + schemaVersion: CONTRACT_VERSIONS.event, + eventId: "evt-1", + runId: "run-1", + sequence: 0, + type: "run.started", + occurredAt: "2026-07-19T12:00:00.000Z", + actor: { type: "agent", id: "orchestrator" }, + refs: [{ kind: "trace", id: "trace-1" }], + payload: { objective: "prove the contract" }, + }; + assert.deepEqual(await validateSchema("nodeagent.event.v1.schema.json", valid, "event"), []); + assert.match( + (await validateSchema("nodeagent.event.v1.schema.json", { ...valid, kind: "Run" }, "event")).join("\n"), + /must NOT have additional properties/, + ); + assert.match( + (await validateSchema( + "nodeagent.event.v1.schema.json", + { ...valid, occurredAt: "2026-07-19T05:00:00-07:00" }, + "event", + )).join("\n"), + /must match pattern/, + ); + assert.deepEqual(CONTRACT_SCHEMA_FILES.repository, "nodekit.schema.json"); +}); + +test("NodeBenchAI is tracked honestly as an active production application", async () => { + const registry = await loadRegistry(platformRoot); + const entry = registry.repositoryCatalog.repositories.find((repository) => repository.name === "NodeBenchAI"); + assert.deepEqual( + { + commandProfile: entry?.commandProfile, + lifecycle: entry?.lifecycle, + replacedBy: entry?.replacedBy, + role: entry?.role, + support: entry?.support, + }, + { + commandProfile: "untracked", + lifecycle: "production", + replacedBy: undefined, + role: "domain-application", + support: "active", + }, + ); +}); From 3b3e501ffeba9c1613581ff648171c56cf3161e1 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 19:26:05 -0700 Subject: [PATCH 03/13] fix: certify the no-key local proof path --- README.md | 2 ++ templates/research-loop/nodekit.yaml | 2 +- templates/research-loop/scripts/proof.mjs | 39 ++++++++++++++++------- test/factory.test.mjs | 21 ++++++++++++ 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4c45f5a..abee082 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ npm run dev The first certified preset is `research-loop`: a small reference runtime with an objective held-out metric, deterministic keep/revert decisions, versioned human intervention, interrupted-run recovery, a strict Pi smoke, and sanitized reproduction receipts. It is a reference adapter to the NodeAgent application contract; it is not presented as the still-unfinished extraction of NodeRoom's deeper production runtime. +`npm run proof` works before credentials exist: it emits a passing `local-ready` receipt after the deterministic demo and evaluation. If live Pi, browser, or deployment receipts are present, every attempted gate must pass; the receipt becomes `release-ready` only when all three are present and green. + ## Commands From this repository: diff --git a/templates/research-loop/nodekit.yaml b/templates/research-loop/nodekit.yaml index 88deb48..0b0ed0a 100644 --- a/templates/research-loop/nodekit.yaml +++ b/templates/research-loop/nodekit.yaml @@ -26,6 +26,6 @@ environment: status: aligned proof: command: npm run proof - receiptSchema: nodekit.experiment-receipt/v1 + receiptSchema: nodekit.proof-receipt/v1 contractDeclarations: [] architectureExceptions: [] diff --git a/templates/research-loop/scripts/proof.mjs b/templates/research-loop/scripts/proof.mjs index 9930668..34301cd 100644 --- a/templates/research-loop/scripts/proof.mjs +++ b/templates/research-loop/scripts/proof.mjs @@ -16,30 +16,47 @@ async function readJson(name, required = true) { const [demo, evaluation, live, browser, deployment, friction] = await Promise.all([ readJson("demo-receipt.json"), readJson("eval-receipt.json"), - readJson("pi-live-receipt.json"), - readJson("browser-proof.json"), - readJson("deployment-receipt.json"), + readJson("pi-live-receipt.json", false), + readJson("browser-proof.json", false), + readJson("deployment-receipt.json", false), readJson("build-friction.json"), ]); const secretPattern = /(?:sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/; const secretFree = !secretPattern.test(JSON.stringify({ browser, demo, deployment, evaluation, live, friction })); -const deploymentPassed = deployment.passed === true || deployment.status === "pass"; +const deterministicDemo = demo.schemaVersion === "nodekit.experiment-receipt/v1"; +const deterministicEvaluation = evaluation.passed === true; +const livePi = live === null ? null : live.status === "pass"; +const browserQa = browser === null ? null : browser.passed === true; +const deploymentPassed = deployment === null + ? null + : deployment.passed === true || deployment.status === "pass"; +const optionalChecksPassed = [livePi, browserQa, deploymentPassed] + .every((value) => value === null || value === true); +const localReady = deterministicDemo && deterministicEvaluation && secretFree && optionalChecksPassed; +const releaseReady = localReady && livePi === true && browserQa === true && deploymentPassed === true; const receipt = { checks: { - deterministicDemo: demo.schemaVersion === "nodekit.experiment-receipt/v1", - deterministicEvaluation: evaluation.passed === true, - livePi: live.status === "pass", - browserQa: browser.passed === true, + deterministicDemo, + deterministicEvaluation, + livePi, + browserQa, deployment: deploymentPassed, secretFree, }, generatedAt: new Date().toISOString(), - passed: evaluation.passed === true && live.status === "pass" && browser.passed === true && deploymentPassed && secretFree, - schemaVersion: "nodekit.release-proof/v1", + level: releaseReady ? "release-ready" : "local-ready", + missingReleaseGates: [ + ...(live === null ? ["livePi"] : []), + ...(browser === null ? ["browserQa"] : []), + ...(deployment === null ? ["deployment"] : []), + ], + passed: localReady, + releaseReady, + schemaVersion: "nodekit.proof-receipt/v1", }; await mkdir("proof", { recursive: true }); await writeFile(path.resolve("proof", "release-proof.json"), `${JSON.stringify(receipt, null, 2)}\n`); await recordFriction(receipt.passed ? "proof_passed" : "proof_failed", receipt.checks, Date.now() - started); console.log(JSON.stringify(receipt, null, 2)); if (!receipt.passed) process.exitCode = 1; -else await import("./timeline.mjs"); +else if (receipt.releaseReady) await import("./timeline.mjs"); diff --git a/test/factory.test.mjs b/test/factory.test.mjs index a7e1e0a..5fe1f4f 100644 --- a/test/factory.test.mjs +++ b/test/factory.test.mjs @@ -1,11 +1,15 @@ import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { promisify } from "node:util"; import { compileAgentDefinition, inspectAgentDefinition } from "../src/lib/agent-definition.mjs"; import { adoptProject, createProject } from "../src/lib/scaffold.mjs"; +const execFileAsync = promisify(execFile); + test("create emits a parseable, reproducible application from multiline input", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-create-")); const target = path.join(root, "fresh-app"); @@ -74,3 +78,20 @@ test("adopt is additive, runnable, and reports collisions", async (t) => { assert.equal(result.collisions.includes("agent/instructions.md"), true); assert.equal(await readFile(path.join(root, "backend", "filesystem", "store.mjs"), "utf8").then(Boolean), true); }); + +test("a fresh no-key project reaches an honest local-ready proof", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-local-proof-")); + t.after(() => rm(root, { force: true, recursive: true })); + await createProject({ git: false, install: false, name: "local-proof", target: root }); + + for (const script of ["demo.mjs", "eval.mjs", "proof.mjs"]) { + await execFileAsync(process.execPath, [path.join(root, "scripts", script)], { cwd: root }); + } + + const receipt = JSON.parse(await readFile(path.join(root, "proof", "release-proof.json"), "utf8")); + assert.equal(receipt.schemaVersion, "nodekit.proof-receipt/v1"); + assert.equal(receipt.level, "local-ready"); + assert.equal(receipt.passed, true); + assert.equal(receipt.releaseReady, false); + assert.deepEqual(receipt.missingReleaseGates, ["livePi", "browserQa", "deployment"]); +}); From d137baec9462e9cd0fd688ccd4e999e49bac2df2 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 19:33:17 -0700 Subject: [PATCH 04/13] fix: discover pack-root skills --- src/lib/agent-definition.mjs | 7 ++++++- test/contracts.test.mjs | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/agent-definition.mjs b/src/lib/agent-definition.mjs index 2a8ec5d..9b45589 100644 --- a/src/lib/agent-definition.mjs +++ b/src/lib/agent-definition.mjs @@ -96,12 +96,17 @@ function classify(files) { const matching = (fragment, suffix) => files .filter((file) => file.path.includes(fragment) && (!suffix || file.path.endsWith(suffix))) .map((file) => file.path); + const skills = files + .filter((file) => file.path.endsWith("SKILL.md") && ( + file.path.includes("/skills/") || file.path.startsWith("packs/") + )) + .map((file) => file.path); return { evals: matching("evals/"), integrations: matching("integrations/"), packs: matching("packs/", "pack.yaml"), policies: matching("agent/policies/"), - skills: matching("/skills/", "SKILL.md"), + skills, subagents: matching("agent/subagents/", "agent.yaml"), tools: matching("/tools/"), }; diff --git a/test/contracts.test.mjs b/test/contracts.test.mjs index 007aed1..89df275 100644 --- a/test/contracts.test.mjs +++ b/test/contracts.test.mjs @@ -48,6 +48,20 @@ test("applications authored before contracts references were added remain v1 com assert.equal(compiled.definition.configHash, explicit.definition.configHash); }); +test("pack-root SKILL.md files appear in compiled discovery", async (t) => { + const root = await freshProject(t, "nodekit-pack-skill-"); + const packPath = path.join(root, "packs", "primary", "pack.yaml"); + const pack = (await readFile(packPath, "utf8")).replace( + "skill: ../../agent/skills/autoresearch-live/SKILL.md", + "skill: SKILL.md", + ); + await writeFile(packPath, pack); + await writeFile(path.join(root, "packs", "primary", "SKILL.md"), "# Pack skill\n"); + + const compiled = await compileAgentDefinition(root, { write: false }); + assert.equal(compiled.definition.discovered.skills.includes("packs/primary/SKILL.md"), true); +}); + test("nodeagent application apiVersion/kind/spec envelopes fail closed", async (t) => { const root = await freshProject(t, "nodekit-app-dialect-"); await writeFile( From 047619727ce3b0fe39074535bc47e9cfac342162 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 19:39:47 -0700 Subject: [PATCH 05/13] feat: add evidence-bound presentation lane --- .../nodekit/skills/nodekit-present/SKILL.md | 36 +++++++++ .../skills/nodekit-present/agents/openai.yaml | 4 + .../references/change-story-contract.md | 74 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 plugins/nodekit/skills/nodekit-present/SKILL.md create mode 100644 plugins/nodekit/skills/nodekit-present/agents/openai.yaml create mode 100644 plugins/nodekit/skills/nodekit-present/references/change-story-contract.md diff --git a/plugins/nodekit/skills/nodekit-present/SKILL.md b/plugins/nodekit/skills/nodekit-present/SKILL.md new file mode 100644 index 0000000..12ce32c --- /dev/null +++ b/plugins/nodekit/skills/nodekit-present/SKILL.md @@ -0,0 +1,36 @@ +--- +name: nodekit-present +description: Capture a substantial code change, architecture migration, benchmark, release, or completed hackathon application as an editable, evidence-backed presentation. Use when Codex must explain what changed, assemble a judge or reviewer deck, keep presentation material current during development, or drive NodeSlide through its package, CLI, MCP, or host adapter. +--- + +# NodeKit Present + +Turn verified development artifacts into a living presentation. Treat the deck as a projection of the change record, never as an independent source of claims. + +Read [the change-story contract](references/change-story-contract.md) before creating or updating presentation artifacts. + +## Workflow + +1. Classify the change tier. Skip decks for trivial work; use a change card for a narrow fix, a 3–5 slide mini-deck for a major feature, and a full deck plus appendix for releases or hackathons. +2. Create or update `changes//change.yaml`. Record audience, problem, prior state, decision, alternatives, affected systems, user workflow, proof requirements, limitations, and presentation tier. +3. Capture evidence while work happens: baseline and after screenshots, exact commits, deployment identity, tests, benchmarks, traces, artifacts, exports, and known failures. Preserve raw receipts. +4. Build an evidence index. Give every material claim a status and evidence IDs. Label planned, inferred, user-asserted, and measured claims distinctly. +5. Plan each slide before rendering. State its job, audience question, takeaway, narrative role, dominant visual, evidence IDs, density budget, and speaker-note goal. +6. Use the installed NodeSlide transport in this order: repository-native adapter, NodeSlide CLI, NodeSlide MCP, or package API. Inspect capabilities first and never assume hosted writes, export, or approval are available. +7. Propose deck changes against the pinned deck version. Validate, compare, and apply through the host policy; do not bypass NodeSlide governance for convenience. +8. Verify every material claim against the current commit or deployment. Refresh stale screenshots and metrics. Keep limitations visible. +9. Export the requested editable format and reopen it. Verify the rendered deck, speaker notes, source bindings, and any PPTX round trip. +10. Derive the demo script, README section, release notes, and submission copy from the same Change Story and Evidence Index. + +## Parallel lane + +For a large implementation, run presentation work as a read-mostly lane beside building and QA. Draft the problem and architecture early; replace placeholders only with verified evidence from later gates. Block release only for unsupported claims, missing required proof, stale evidence, or a broken export. + +## Completion language + +- Say `drafted` when slide plans exist. +- Say `evidence-bound` when every material claim resolves to current evidence. +- Say `export-verified` only after the requested output reopens successfully. +- Say `release-ready` only when the application proof and presentation proof both pass. + +Do not turn a green unit test into a production claim, an HTTP 200 into browser proof, or an advisory model judgment into an authoritative verdict. diff --git a/plugins/nodekit/skills/nodekit-present/agents/openai.yaml b/plugins/nodekit/skills/nodekit-present/agents/openai.yaml new file mode 100644 index 0000000..6b916f8 --- /dev/null +++ b/plugins/nodekit/skills/nodekit-present/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "NodeKit Present" + short_description: "Turn verified changes into editable evidence-backed decks" + default_prompt: "Capture this change or built application as an evidence-bound story, then use NodeSlide to produce and verify an editable presentation." diff --git a/plugins/nodekit/skills/nodekit-present/references/change-story-contract.md b/plugins/nodekit/skills/nodekit-present/references/change-story-contract.md new file mode 100644 index 0000000..23a61f5 --- /dev/null +++ b/plugins/nodekit/skills/nodekit-present/references/change-story-contract.md @@ -0,0 +1,74 @@ +# Change story contract + +## Canonical directory + +```text +changes// +├── change.yaml +├── evidence/ +│ ├── baseline/ +│ ├── implementation/ +│ ├── tests/ +│ ├── browser/ +│ ├── traces/ +│ ├── benchmarks/ +│ └── deployment/ +├── story/ +│ ├── claims.json +│ ├── evidence-index.json +│ ├── architecture-diff.json +│ └── limitations.json +└── presentation/ + ├── deck-spec.json + ├── slide-design-plans/ + ├── speaker-notes.md + └── exports/ +``` + +## Required change fields + +- stable ID, title, change type, audience, and presentation tier +- previous state, user pain or risk, and affected users +- selected decision, alternatives, and tradeoffs +- affected systems and contracts +- required proof and approval boundaries +- honest limitations and next milestone + +## Claim record + +Each material claim must include: + +- stable ID and exact text +- status: `verified`, `measured`, `observed`, `user_asserted`, or `planned` +- evidence IDs +- commit, deployment, benchmark, or artifact scope when applicable +- limitations + +Invalidate a claim when its bound commit, deployment, source, or benchmark is stale. + +## Presentation tiers + +- Tier 0: no deck; PR or changelog text only +- Tier 1: one-page `Problem → Change → Proof` card +- Tier 2: 3–5 slides for a substantial feature or migration +- Tier 3: 6–10 slides plus technical appendix for a release +- Tier 4: judge/customer/investor deck, appendix, demo choreography, and Q&A map + +## Default narrative + +1. Why the change exists +2. Previous state and concrete limitation +3. Decision and tradeoffs +4. New architecture or workflow +5. User-visible result +6. Agent/tool execution path +7. Proof +8. Limitations +9. Reuse across the ecosystem +10. Next milestone + +Match visual form to meaning: before/after, architecture, workflow, trace, benchmark, screenshot, code contract, or timeline. Avoid repeating title-plus-bullets on every slide. + +## Verification gate + +Require current evidence, accurate architecture, scoped metrics, visible limitations, no overflow/collision, editable export, reopen success, and human-reviewable proposals. A missing production artifact may still yield a draft, but it cannot yield a production claim. From 82d6b9a0dfa53ddbda627808241d6a37774285d0 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 19:44:00 -0700 Subject: [PATCH 06/13] feat: project NodeKit skills into generated apps --- .../nodekit/skills/nodekit-launch/SKILL.md | 1 + src/lib/scaffold.mjs | 17 +++++++++++++++++ templates/research-loop/AGENTS.md | 1 + .../research-loop/adw/workflows/launch.yaml | 1 + test/factory.test.mjs | 19 +++++++++++++++++++ 5 files changed, 39 insertions(+) diff --git a/plugins/nodekit/skills/nodekit-launch/SKILL.md b/plugins/nodekit/skills/nodekit-launch/SKILL.md index 7090ef2..b8115d1 100644 --- a/plugins/nodekit/skills/nodekit-launch/SKILL.md +++ b/plugins/nodekit/skills/nodekit-launch/SKILL.md @@ -21,6 +21,7 @@ Read [the launch contract](references/launch-contract.md) before acting. 8. Run deterministic demo, unit/contract tests, domain evals, failure cases, strict live provider smoke, and browser journeys. Test missing secrets, malformed input, reload/resume, repeated actions, narrow/mobile layout, and export/reopen. 9. Deploy only the exact tested revision and only with user authorization. Record URL, revision, environment identity, health, and a fresh-user journey. 10. Emit the release proof and launch timeline. Do not call the run production-proven if live, browser, deployment, or receipt evidence is absent. +11. Read and run the sibling `nodekit-present` skill. Bind the problem, product workflow, sponsor use, architecture, screenshots, and proof to one Change Story; produce the presentation tier required by the audience without upgrading unsupported claims. ## Sponsor rule diff --git a/src/lib/scaffold.mjs b/src/lib/scaffold.mjs index 8ee3ee1..b3ad6af 100644 --- a/src/lib/scaffold.mjs +++ b/src/lib/scaffold.mjs @@ -6,6 +6,8 @@ import { pathExists } from "./files.mjs"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const templateRoot = path.join(packageRoot, "templates", "research-loop"); +const pluginSkillsRoot = path.join(packageRoot, "plugins", "nodekit", "skills"); +const projectedSkillNames = ["nodekit-launch", "nodekit-present"]; function titleCase(value) { return value.split(/[-_\s]+/).filter(Boolean).map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join(" "); @@ -67,6 +69,18 @@ async function copyTemplate(source, destination, values, { collisions = [], miss return collisions; } +async function projectCodingAgentSkills(target, values, { collisions = [], missingOnly = false } = {}) { + for (const agentRoot of [".claude", ".codex"]) { + for (const skillName of projectedSkillNames) { + const source = path.join(pluginSkillsRoot, skillName); + const destination = path.join(target, agentRoot, "skills", skillName); + await mkdir(destination, { recursive: true }); + await copyTemplate(source, destination, values, { collisions, missingOnly }); + } + } + return collisions; +} + function run(command, args, cwd) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, env: process.env, shell: process.platform === "win32", stdio: "inherit" }); @@ -88,6 +102,7 @@ export async function createProject(options) { const values = substitutions({ ...options, target }); await mkdir(target, { recursive: true }); await copyTemplate(templateRoot, target, values); + await projectCodingAgentSkills(target, values); const sponsors = [...new Set(["pi-ai", ...(options.sponsors ?? [])].map(slugify).filter(Boolean))]; for (const sponsor of sponsors.filter((entry) => entry !== "pi-ai")) { const integrationRoot = path.join(target, "integrations", sponsor); @@ -161,6 +176,7 @@ export async function adoptProject(options) { collisions.push(destination); } } + await projectCodingAgentSkills(target, values, { collisions, missingOnly: true }); const packagePath = path.join(target, "package.json"); let packageJson; if (await pathExists(packagePath)) packageJson = JSON.parse(await readFile(packagePath, "utf8")); @@ -186,6 +202,7 @@ export async function adoptProject(options) { await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); const receipt = { addedRoots: adoptRoots, + codingAgentSkills: projectedSkillNames, collisions: collisions.map((entry) => path.isAbsolute(entry) ? path.relative(target, entry).replaceAll("\\", "/") : entry), generatedAt: new Date().toISOString(), installRequired: true, diff --git a/templates/research-loop/AGENTS.md b/templates/research-loop/AGENTS.md index 3603a81..9785134 100644 --- a/templates/research-loop/AGENTS.md +++ b/templates/research-loop/AGENTS.md @@ -7,5 +7,6 @@ Read `nodeagent.yaml`, `hackathon.yaml`, and `.nodeagent/resolved-definition.jso - Never place provider secrets in source, YAML, browser bundles, logs, or receipts. - Do not weaken a metric or fixture to make an experiment pass. - Treat `.data/` as durable runtime state and `proof/` as sanitized evidence. +- Use the projected `nodekit-present` skill for major changes and the final app presentation. Derive claims from current receipts, commits, screenshots, and limitations. - Ask before deploying, creating paid resources, publishing, or making destructive changes. - Run `npm run compile`, `npm run check`, `npm run eval`, and `npm run proof` after harness changes. diff --git a/templates/research-loop/adw/workflows/launch.yaml b/templates/research-loop/adw/workflows/launch.yaml index e3fdb2d..2849ed7 100644 --- a/templates/research-loop/adw/workflows/launch.yaml +++ b/templates/research-loop/adw/workflows/launch.yaml @@ -9,6 +9,7 @@ steps: - live-smoke - browser-proof - receipt + - evidence-bound-presentation approvals: productionDeployment: human publicSubmission: human diff --git a/test/factory.test.mjs b/test/factory.test.mjs index 5fe1f4f..8ca5ec0 100644 --- a/test/factory.test.mjs +++ b/test/factory.test.mjs @@ -27,6 +27,14 @@ test("create emits a parseable, reproducible application from multiline input", assert.equal(packageJson.devDependencies["@homenshum/nodekit"], "file:D:/work/node-platform"); assert.equal(packageJson.dependencies["@earendil-works/pi-ai"], "0.80.10"); assert.equal(await readFile(path.join(target, "integrations", "convex", "sponsor.yaml"), "utf8").then(Boolean), true); + assert.match( + await readFile(path.join(target, ".claude", "skills", "nodekit-present", "SKILL.md"), "utf8"), + /evidence-backed presentation/, + ); + assert.match( + await readFile(path.join(target, ".codex", "skills", "nodekit-launch", "SKILL.md"), "utf8"), + /smallest undeniable vertical slice/, + ); const dockerfile = await readFile(path.join(target, "Dockerfile"), "utf8"); const renderBlueprint = await readFile(path.join(target, "render.yaml"), "utf8"); assert.match(dockerfile, /@earendil-works\/pi-ai@0\.80\.10/); @@ -68,14 +76,25 @@ test("adopt is additive, runnable, and reports collisions", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-adopt-")); t.after(() => rm(root, { force: true, recursive: true })); await mkdir(path.join(root, "agent"), { recursive: true }); + await mkdir(path.join(root, ".claude", "skills", "nodekit-present"), { recursive: true }); await writeFile(path.join(root, "agent", "instructions.md"), "user-owned instructions\n"); + await writeFile(path.join(root, ".claude", "skills", "nodekit-present", "SKILL.md"), "user-owned presentation skill\n"); await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "host", scripts: { dev: "host-dev" }, type: "module" })); const result = await adoptProject({ name: "host", nodekitSpecifier: "file:D:/node-platform", target: root }); const packageJson = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); assert.equal(packageJson.scripts.dev, "host-dev"); assert.equal(packageJson.scripts["nodekit:demo"], "node scripts/demo.mjs"); assert.equal(await readFile(path.join(root, "agent", "instructions.md"), "utf8"), "user-owned instructions\n"); + assert.equal( + await readFile(path.join(root, ".claude", "skills", "nodekit-present", "SKILL.md"), "utf8"), + "user-owned presentation skill\n", + ); + assert.equal( + await readFile(path.join(root, ".codex", "skills", "nodekit-present", "SKILL.md"), "utf8").then(Boolean), + true, + ); assert.equal(result.collisions.includes("agent/instructions.md"), true); + assert.equal(result.collisions.includes(".claude/skills/nodekit-present/SKILL.md"), true); assert.equal(await readFile(path.join(root, "backend", "filesystem", "store.mjs"), "utf8").then(Boolean), true); }); From 8c59b686779dc22ebc763369441545d148571f73 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 19:51:54 -0700 Subject: [PATCH 07/13] feat: support measured npm and pnpm setup --- src/cli.mjs | 4 +++- src/lib/scaffold.mjs | 12 ++++++++-- templates/research-loop/package.json | 1 + .../research-loop/scripts/audit-prod.mjs | 23 +++++++++++++++++++ test/factory.test.mjs | 17 +++++++++++++- 5 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 templates/research-loop/scripts/audit-prod.mjs diff --git a/src/cli.mjs b/src/cli.mjs index 105a762..8a263c1 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -46,6 +46,7 @@ Usage: nodekit create --name --brief [--preset research-loop] [--provider openrouter] [--model openai/gpt-4o-mini] [--backend filesystem] [--nodekit-specifier ] [--sponsors ] + [--package-manager npm|pnpm] [--launch-started-at ] [--research-ms ] [--no-install] [--no-git] nodekit adopt [directory] --name --brief nodekit compile [--repo-root ] [--check] [--json] @@ -242,6 +243,7 @@ async function runCreate(parsed) { model: parsed.options.model, name: parsed.options.name ?? path.basename(path.resolve(target)), nodekitSpecifier, + packageManager: parsed.options["package-manager"], preset: parsed.options.preset, provider: parsed.options.provider, researchMs: parsed.options["research-ms"] === undefined ? undefined : Number(parsed.options["research-ms"]), @@ -253,7 +255,7 @@ async function runCreate(parsed) { const compiled = await compileAgentDefinition(result.target); await recordSetupEvent(result.target, "compile_completed", { configHash: compiled.definition.configHash }, Date.now() - compileStarted); console.log(`CREATED ${result.name} at ${result.target}`); - console.log(`NEXT cd ${quoteArgument(result.target)} && npm run compile && npm run demo`); + console.log(`NEXT cd ${quoteArgument(result.target)} && ${result.packageManager} run compile && ${result.packageManager} run demo`); } async function runAdopt(parsed) { diff --git a/src/lib/scaffold.mjs b/src/lib/scaffold.mjs index b3ad6af..bd036ca 100644 --- a/src/lib/scaffold.mjs +++ b/src/lib/scaffold.mjs @@ -98,6 +98,10 @@ export async function createProject(options) { throw new Error(`unknown preset ${options.preset}; available: research-loop`); } const startedAt = new Date().toISOString(); + const packageManager = options.packageManager ?? "npm"; + if (!new Set(["npm", "pnpm"]).has(packageManager)) { + throw new Error(`unsupported package manager ${packageManager}; available: npm, pnpm`); + } const launchStartedAt = options.launchStartedAt && Number.isFinite(Date.parse(options.launchStartedAt)) ? options.launchStartedAt : startedAt; const values = substitutions({ ...options, target }); await mkdir(target, { recursive: true }); @@ -125,6 +129,7 @@ export async function createProject(options) { }, ], nodekitVersion: "0.2.0", + packageManager, preset: "research-loop", repairLoops: 0, schemaVersion: "nodekit.build-friction/v1", @@ -134,7 +139,10 @@ export async function createProject(options) { if (options.install !== false) { const installStarted = Date.now(); try { - await run("npm", ["install"], target); + const installArgs = packageManager === "pnpm" + ? ["install", "--prefer-offline"] + : ["install", "--prefer-offline", "--no-audit", "--no-fund"]; + await run(packageManager, installArgs, target); friction.events.push({ at: new Date().toISOString(), durationMs: Date.now() - installStarted, name: "install_completed" }); } catch (error) { friction.events.push({ at: new Date().toISOString(), durationMs: Date.now() - installStarted, name: "install_failed" }); @@ -145,7 +153,7 @@ export async function createProject(options) { friction.events.push({ at: new Date().toISOString(), durationMs: Date.now() - Date.parse(startedAt), name: "scaffold_completed" }); await writeFile(path.join(target, "proof", "build-friction.json"), `${JSON.stringify(friction, null, 2)}\n`); if (options.git !== false && !(await pathExists(path.join(target, ".git")))) await run("git", ["init"], target); - return { name: values.__APP_NAME__, target }; + return { name: values.__APP_NAME__, packageManager, target }; } export async function recordSetupEvent(target, name, detail = {}, durationMs) { diff --git a/templates/research-loop/package.json b/templates/research-loop/package.json index 9339912..cb7a122 100644 --- a/templates/research-loop/package.json +++ b/templates/research-loop/package.json @@ -15,6 +15,7 @@ "proof:browser": "node scripts/browser-proof.mjs", "check": "node scripts/check.mjs", "proof": "node scripts/proof.mjs", + "audit:prod": "node scripts/audit-prod.mjs", "phase": "node scripts/phase.mjs", "timeline": "node scripts/timeline.mjs" }, diff --git a/templates/research-loop/scripts/audit-prod.mjs b/templates/research-loop/scripts/audit-prod.mjs new file mode 100644 index 0000000..5a87ce3 --- /dev/null +++ b/templates/research-loop/scripts/audit-prod.mjs @@ -0,0 +1,23 @@ +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const friction = JSON.parse(await readFile(path.resolve("proof", "build-friction.json"), "utf8")); +const packageManager = friction.packageManager ?? "npm"; +if (!new Set(["npm", "pnpm"]).has(packageManager)) { + throw new Error(`unsupported package manager in build-friction receipt: ${packageManager}`); +} +const args = packageManager === "pnpm" ? ["audit", "--prod"] : ["audit", "--omit=dev"]; + +await new Promise((resolve, reject) => { + const child = spawn(packageManager, args, { + env: process.env, + shell: process.platform === "win32", + stdio: "inherit", + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${packageManager} ${args.join(" ")} exited ${code}`)); + }); +}); diff --git a/test/factory.test.mjs b/test/factory.test.mjs index 8ca5ec0..89a6781 100644 --- a/test/factory.test.mjs +++ b/test/factory.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -20,12 +20,17 @@ test("create emits a parseable, reproducible application from multiline input", install: false, name: "Fresh App", nodekitSpecifier: "file:D:\\work\\node-platform", + packageManager: "pnpm", sponsors: ["Convex", "Map Sponsor"], target, }); const packageJson = JSON.parse(await readFile(path.join(target, "package.json"), "utf8")); assert.equal(packageJson.devDependencies["@homenshum/nodekit"], "file:D:/work/node-platform"); assert.equal(packageJson.dependencies["@earendil-works/pi-ai"], "0.80.10"); + assert.equal( + JSON.parse(await readFile(path.join(target, "proof", "build-friction.json"), "utf8")).packageManager, + "pnpm", + ); assert.equal(await readFile(path.join(target, "integrations", "convex", "sponsor.yaml"), "utf8").then(Boolean), true); assert.match( await readFile(path.join(target, ".claude", "skills", "nodekit-present", "SKILL.md"), "utf8"), @@ -72,6 +77,16 @@ test("create refuses nonempty targets", async (t) => { assert.equal(await readFile(path.join(root, "user-file.txt"), "utf8"), "keep me"); }); +test("create rejects an unsupported package manager before writing files", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-package-manager-")); + t.after(() => rm(root, { force: true, recursive: true })); + await assert.rejects( + () => createProject({ git: false, install: false, name: "unsafe", packageManager: "yarn", target: root }), + /unsupported package manager yarn/, + ); + assert.deepEqual(await readdir(root), []); +}); + test("adopt is additive, runnable, and reports collisions", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-adopt-")); t.after(() => rm(root, { force: true, recursive: true })); From b79535f45c4c7acf0ba61607b12c0f36fedd39f8 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 20:00:33 -0700 Subject: [PATCH 08/13] feat: add one-command local proof --- README.md | 2 ++ .../nodekit/skills/nodekit-launch/SKILL.md | 2 +- src/cli.mjs | 19 +++++++++++++++++- test/factory.test.mjs | 20 +++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index abee082..00c95a2 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ node src/cli.mjs create ../my-agent-app \ --name my-agent-app \ --brief "A persistent research agent that users can steer mid-run" \ --sponsors pi-ai,convex \ + --package-manager pnpm \ + --local-proof \ --nodekit-specifier file:$(pwd) cd ../my-agent-app diff --git a/plugins/nodekit/skills/nodekit-launch/SKILL.md b/plugins/nodekit/skills/nodekit-launch/SKILL.md index b8115d1..69b88e5 100644 --- a/plugins/nodekit/skills/nodekit-launch/SKILL.md +++ b/plugins/nodekit/skills/nodekit-launch/SKILL.md @@ -15,7 +15,7 @@ Read [the launch contract](references/launch-contract.md) before acting. 2. Research current official sources for the user problem and every sponsor. Record links, package versions, authentication, pricing/limits, and one visible contribution to the demo. 3. Select one workflow shaped as `input -> agent decision -> tool-backed action -> measurable artifact -> visible proof`. Prefer a real metric and a reversible experiment. 4. Compile the prose into `hackathon.yaml`. Ask only questions whose answers materially change the product, security model, or irreversible action. -5. For an empty target, run `nodekit create`. For an existing target, run `nodekit adopt` and inspect its collision receipt before accepting changes. +5. For an empty target, run `nodekit create --local-proof`; add `--package-manager pnpm` when pnpm is available and appropriate. For an existing target, run `nodekit adopt` and inspect its collision receipt before accepting changes. 6. Run `nodekit compile` and `nodekit inspect`. Confirm the filesystem-discovered tools, skills, integrations, fixtures, evals, provider, secret references, and config hash. 7. Implement one end-to-end surface. Preserve one execution path for the no-key demo, live provider, browser, and evals. 8. Run deterministic demo, unit/contract tests, domain evals, failure cases, strict live provider smoke, and browser journeys. Test missing secrets, malformed input, reload/resume, repeated actions, narrow/mobile layout, and export/reopen. diff --git a/src/cli.mjs b/src/cli.mjs index 8a263c1..673671b 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -47,7 +47,8 @@ Usage: [--provider openrouter] [--model openai/gpt-4o-mini] [--backend filesystem] [--nodekit-specifier ] [--sponsors ] [--package-manager npm|pnpm] - [--launch-started-at ] [--research-ms ] [--no-install] [--no-git] + [--launch-started-at ] [--research-ms ] [--local-proof] + [--no-install] [--no-git] nodekit adopt [directory] --name --brief nodekit compile [--repo-root ] [--check] [--json] nodekit inspect [--repo-root ] [--json] @@ -254,6 +255,22 @@ async function runCreate(parsed) { const compileStarted = Date.now(); const compiled = await compileAgentDefinition(result.target); await recordSetupEvent(result.target, "compile_completed", { configHash: compiled.definition.configHash }, Date.now() - compileStarted); + if (parsed.options["local-proof"] === true || parsed.options["local-proof"] === "true") { + for (const script of ["demo.mjs", "eval.mjs", "proof.mjs"]) { + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(result.target, "scripts", script)], { + cwd: result.target, + env: process.env, + stdio: "inherit", + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${script} exited ${code}`)); + }); + }); + } + } console.log(`CREATED ${result.name} at ${result.target}`); console.log(`NEXT cd ${quoteArgument(result.target)} && ${result.packageManager} run compile && ${result.packageManager} run demo`); } diff --git a/test/factory.test.mjs b/test/factory.test.mjs index 89a6781..ea2da9a 100644 --- a/test/factory.test.mjs +++ b/test/factory.test.mjs @@ -87,6 +87,26 @@ test("create rejects an unsupported package manager before writing files", async assert.deepEqual(await readdir(root), []); }); +test("create --local-proof emits the deterministic receipt in one CLI workflow", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-cli-proof-")); + const target = path.join(root, "app"); + t.after(() => rm(root, { force: true, recursive: true })); + await execFileAsync(process.execPath, [ + path.resolve("src", "cli.mjs"), + "create", + target, + "--name", + "cli-proof", + "--no-install", + "--no-git", + "--local-proof", + ]); + const receipt = JSON.parse(await readFile(path.join(target, "proof", "release-proof.json"), "utf8")); + assert.equal(receipt.level, "local-ready"); + assert.equal(receipt.passed, true); + assert.equal(receipt.releaseReady, false); +}); + test("adopt is additive, runnable, and reports collisions", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-adopt-")); t.after(() => rm(root, { force: true, recursive: true })); From 05b4e0e52623e3be14475ded96a7b7095548675d Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 20:33:29 -0700 Subject: [PATCH 09/13] feat: bind brownfield agents and rollout evidence --- README.md | 11 ++ changes/nodekit-factory-p1/change.yaml | 64 +++++++++ .../presentation/change-card.patch.json | 28 ++++ .../presentation/change-card.proposal.json | 47 ++++++ .../presentation/change-card.v1.json | 74 ++++++++++ .../presentation/slide-design-plans.json | 95 ++++++++++++ .../presentation/speaker-notes.md | 7 + .../story/architecture-diff.json | 18 +++ changes/nodekit-factory-p1/story/claims.json | 41 ++++++ .../story/evidence-index.json | 76 ++++++++++ .../nodekit-factory-p1/story/limitations.json | 13 ++ docs/P1_FACTORY_ROLLOUT.md | 126 ++++++++++++++++ .../nodekit/skills/nodekit-launch/SKILL.md | 2 +- .../nodekit/skills/nodekit-present/SKILL.md | 2 +- .../references/change-story-contract.md | 53 +++---- plugins/nodekit/skills/nodekit-qa/SKILL.md | 34 +++++ .../skills/nodekit-qa/agents/openai.yaml | 4 + .../nodekit-qa/references/qa-contract.md | 19 +++ proof/p1-factory-rollout.json | 135 ++++++++++++++++++ src/lib/agent-definition.mjs | 103 ++++++++++--- src/lib/scaffold.mjs | 2 +- test/factory.test.mjs | 47 ++++++ 22 files changed, 953 insertions(+), 48 deletions(-) create mode 100644 changes/nodekit-factory-p1/change.yaml create mode 100644 changes/nodekit-factory-p1/presentation/change-card.patch.json create mode 100644 changes/nodekit-factory-p1/presentation/change-card.proposal.json create mode 100644 changes/nodekit-factory-p1/presentation/change-card.v1.json create mode 100644 changes/nodekit-factory-p1/presentation/slide-design-plans.json create mode 100644 changes/nodekit-factory-p1/presentation/speaker-notes.md create mode 100644 changes/nodekit-factory-p1/story/architecture-diff.json create mode 100644 changes/nodekit-factory-p1/story/claims.json create mode 100644 changes/nodekit-factory-p1/story/evidence-index.json create mode 100644 changes/nodekit-factory-p1/story/limitations.json create mode 100644 docs/P1_FACTORY_ROLLOUT.md create mode 100644 plugins/nodekit/skills/nodekit-qa/SKILL.md create mode 100644 plugins/nodekit/skills/nodekit-qa/agents/openai.yaml create mode 100644 plugins/nodekit/skills/nodekit-qa/references/qa-contract.md create mode 100644 proof/p1-factory-rollout.json diff --git a/README.md b/README.md index 00c95a2..d450054 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,17 @@ The first certified preset is `research-loop`: a small reference runtime with an `npm run proof` works before credentials exist: it emits a passing `local-ready` receipt after the deterministic demo and evaluation. If live Pi, browser, or deployment receipts are present, every attempted gate must pass; the receipt becomes `release-ready` only when all three are present and green. +Every created or adopted repository receives the same three coding-agent skills +under both `.claude/skills/` and `.codex/skills/`: + +- `nodekit-launch` turns the brief into the smallest proof-carrying vertical slice; +- `nodekit-qa` verifies the rendered journey, runtime, durable artifact, and receipt; +- `nodekit-present` turns the same revision-bound evidence into an editable change, + judge, or release presentation through an available NodeSlide transport. + +Adoption never overwrites a user-owned skill with the same path; the collision is +preserved in `proof/adoption-receipt.json` for explicit review. + ## Commands From this repository: diff --git a/changes/nodekit-factory-p1/change.yaml b/changes/nodekit-factory-p1/change.yaml new file mode 100644 index 0000000..fd12979 --- /dev/null +++ b/changes/nodekit-factory-p1/change.yaml @@ -0,0 +1,64 @@ +schemaVersion: nodekit.change-story/v1 +id: nodekit-factory-p1 +title: Portable agent-app factory and injectable presentation system +changeType: architecture +audience: + - technical-reviewer + - hackathon-builder + - open-source-developer +presentationTier: 3 +problem: + previousState: Agent applications, runtime adapters, proof formats, repository conventions, and presentation tooling were implemented independently across repositories. + painOrRisk: A coding agent had to rediscover setup, runtime, evaluation, deployment, and storytelling paths for every project; benchmark and production behavior could diverge. + affectedUsers: + - hackathon participants + - founders using Codex or Claude Code + - maintainers of Node ecosystem applications +decision: + selectedApproach: Freeze flat NodeKit and NodeAgent contracts, generate or additively adopt the harness, keep runtimes replaceable through adapters, make proof portable, and extract NodeSlide into governed packages and transports. + alternatives: + - build another monolithic agent framework + - force every repository into one physical tree immediately + - keep NodeSlide as a standalone-only application + tradeoffs: + - more explicit contracts and migration ledgers + - stacked pull requests must land in dependency order + - production deployment and package publication remain separate approval gates +implementation: + affectedSystems: + - NodeKit / node-platform + - NodeAgent + - NodeSlide + - NodeProof + - NodeTrace + - NodeMem + - NodeRoom + - NodeBenchAI + importantContracts: + - nodekit.repo/v1 + - nodeagent.application/v1 + - nodeagent.pack/v1 + - nodeagent.event/v1 + - nodeagent.trace/v1 + - proofloop.receipt/v1 + - NodeSlide repository and patch contracts +proofRequirements: + - generated empty-directory local-ready application + - additive brownfield adoption with collision receipt + - NodeAgent Pi adapter package proof + - NodeSlide package and second-consumer proof + - canonical proof envelope validation + - cross-repository registry validation +approvalBoundaries: + - merge to default branches + - npm publication + - production deployment + - paid resource activation + - destructive migration +limitations: + - under-30 setup has a fast observed pass but is not repeatably certified + - all implementation branches remain unmerged drafts + - no package was published and no production deployment was performed + - NodeSlide editable canvas and PPTX export/reopen certification remain open + - NodeBench native-runtime parity and honest no-key profile remain open +nextMilestone: Forward-test and review the dependency-ordered PR set, then mount NodeSlide in NodeRoom and certify one live hackathon application without changing the benchmark execution path. diff --git a/changes/nodekit-factory-p1/presentation/change-card.patch.json b/changes/nodekit-factory-p1/presentation/change-card.patch.json new file mode 100644 index 0000000..c69561d --- /dev/null +++ b/changes/nodekit-factory-p1/presentation/change-card.patch.json @@ -0,0 +1,28 @@ +{ + "id": "patch:nodekit-factory-p1:review-copy", + "deckId": "deck:nodekit-factory-p1", + "baseDeckVersion": 1, + "baseSlideVersions": { + "deck:nodekit-factory-p1:slide:1": 1 + }, + "baseElementVersions": { + "deck:nodekit-factory-p1:slide:1:title": 1 + }, + "scope": { + "kind": "elements", + "deckId": "deck:nodekit-factory-p1", + "slideIds": ["deck:nodekit-factory-p1:slide:1"], + "elementIds": ["deck:nodekit-factory-p1:slide:1:title"], + "operationMode": "copy" + }, + "operations": [ + { + "op": "replace_text", + "slideId": "deck:nodekit-factory-p1:slide:1", + "elementId": "deck:nodekit-factory-p1:slide:1:title", + "text": "P1 factory | Draft contracts, verified local seams" + } + ], + "source": "agent", + "summary": "Tighten the change-card title so draft and verified states remain distinct." +} diff --git a/changes/nodekit-factory-p1/presentation/change-card.proposal.json b/changes/nodekit-factory-p1/presentation/change-card.proposal.json new file mode 100644 index 0000000..156d339 --- /dev/null +++ b/changes/nodekit-factory-p1/presentation/change-card.proposal.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": "nodeslide.file-proposal/v1", + "id": "proposal:89b8c091db7beafa555b63bf5d9f064e", + "status": "ready", + "applied": false, + "createdAt": "2026-07-20T03:29:01.614Z", + "base": { + "deckId": "deck:nodekit-factory-p1", + "deckVersion": 1, + "snapshotDigest": "sha256:ae89ee8552ae034863d4ac267047e5ce4db9a0714da100a12635476c78efba95" + }, + "patch": { + "id": "patch:nodekit-factory-p1:review-copy", + "deckId": "deck:nodekit-factory-p1", + "baseDeckVersion": 1, + "baseSlideVersions": { + "deck:nodekit-factory-p1:slide:1": 1 + }, + "baseElementVersions": { + "deck:nodekit-factory-p1:slide:1:title": 1 + }, + "scope": { + "kind": "elements", + "deckId": "deck:nodekit-factory-p1", + "slideIds": ["deck:nodekit-factory-p1:slide:1"], + "elementIds": ["deck:nodekit-factory-p1:slide:1:title"], + "operationMode": "copy" + }, + "operations": [ + { + "op": "replace_text", + "slideId": "deck:nodekit-factory-p1:slide:1", + "elementId": "deck:nodekit-factory-p1:slide:1:title", + "text": "P1 factory | Draft contracts, verified local seams" + } + ], + "source": "agent", + "summary": "Tighten the change-card title so draft and verified states remain distinct." + }, + "candidate": { + "committedAt": 1784518141614, + "deckVersion": 2, + "snapshotDigest": "sha256:49d96ee78c0f600727a35ee845f30a179a42a600705b9972f4d6e5d0591638bd", + "affectedSlideIds": ["deck:nodekit-factory-p1:slide:1"], + "affectedElementIds": ["deck:nodekit-factory-p1:slide:1:title"] + } +} diff --git a/changes/nodekit-factory-p1/presentation/change-card.v1.json b/changes/nodekit-factory-p1/presentation/change-card.v1.json new file mode 100644 index 0000000..5d1e23a --- /dev/null +++ b/changes/nodekit-factory-p1/presentation/change-card.v1.json @@ -0,0 +1,74 @@ +{ + "deck": { + "schemaVersion": "nodeslide.slidelang/v1", + "toolchainVersion": "local-slidelang-adapter/1.1.0", + "id": "deck:nodekit-factory-p1", + "projectId": "project:nodekit", + "title": "NodeKit Factory P1", + "brief": { + "prompt": "Explain the NodeKit factory P1 rollout without upgrading draft evidence into release claims.", + "audience": "technical reviewers and hackathon builders", + "purpose": "Change review", + "successCriteria": [ + "The system boundary is understandable.", + "Verified evidence and open release gates remain distinct." + ] + }, + "theme": { + "id": "nodekit-review", + "name": "NodeKit review", + "mode": "light", + "colors": { + "canvas": "#f8f7f2", + "ink": "#17201b", + "muted": "#617068", + "accent": "#216e4e", + "accentSoft": "#dcefe5", + "insight": "#e9e3cf", + "insightInk": "#443d24", + "trace": "#132b24", + "border": "#c9cec9" + }, + "typography": { + "display": "Aptos Display", + "body": "Aptos", + "data": "Aptos Mono" + }, + "defaultRadius": 8, + "spacingUnit": 8 + }, + "slideOrder": ["deck:nodekit-factory-p1:slide:1"], + "version": 1, + "status": "ready", + "createdAt": 1784515200000, + "updatedAt": 1784515200000 + }, + "slides": [ + { + "id": "deck:nodekit-factory-p1:slide:1", + "deckId": "deck:nodekit-factory-p1", + "title": "Problem, contract, proof", + "background": "#f8f7f2", + "elementOrder": ["deck:nodekit-factory-p1:slide:1:title"], + "version": 1 + } + ], + "elements": [ + { + "id": "deck:nodekit-factory-p1:slide:1:title", + "slideId": "deck:nodekit-factory-p1:slide:1", + "name": "Title", + "kind": "text", + "role": "title", + "bbox": { "x": 0.08, "y": 0.12, "width": 0.82, "height": 0.24 }, + "rotation": 0, + "content": "NodeKit Factory P1 | Problem -> Contract -> Proof", + "style": { "color": "#17201b", "fontSize": 38, "fontWeight": 700 }, + "sourceIds": [], + "locked": false, + "exportCapabilities": ["web_native", "pptx_editable"], + "version": 1 + } + ], + "sources": [] +} diff --git a/changes/nodekit-factory-p1/presentation/slide-design-plans.json b/changes/nodekit-factory-p1/presentation/slide-design-plans.json new file mode 100644 index 0000000..0c261ea --- /dev/null +++ b/changes/nodekit-factory-p1/presentation/slide-design-plans.json @@ -0,0 +1,95 @@ +{ + "schemaVersion": "nodeslide.change-slide-plans/v1", + "changeId": "nodekit-factory-p1", + "status": "drafted", + "slides": [ + { + "slideId": "problem", + "job": "Establish why NodeKit exists.", + "audienceQuestion": "What repeated execution failure are we eliminating?", + "takeaway": "Good niche insight was repeatedly slowed by bespoke agent-app setup, proof, and presentation work.", + "narrativeRole": "problem", + "dominantVisual": "before_after", + "evidenceIds": [], + "maxVisibleWords": 42, + "speakerNoteGoal": "Separate the participant's valuable domain insight from reusable execution plumbing." + }, + { + "slideId": "contract", + "job": "Show the portable application contract.", + "audienceQuestion": "What is now standardized across repositories?", + "takeaway": "Filesystem-authored agents, flat manifests, compiled hashes, packs, evals, and proof form one inspectable contract.", + "narrativeRole": "architecture", + "dominantVisual": "architecture_diagram", + "evidenceIds": ["nodekit-pr-4", "nodeagent-pr-2"], + "maxVisibleWords": 48, + "speakerNoteGoal": "Explain that runtime engines are adapters, not the application identity." + }, + { + "slideId": "factory-loop", + "job": "Explain the brief-to-proof workflow.", + "audienceQuestion": "What does Codex or Claude Code actually do?", + "takeaway": "Brief, context, and sponsor requirements compile into a researched vertical slice, QA evidence, and presentation artifacts.", + "narrativeRole": "workflow", + "dominantVisual": "workflow", + "evidenceIds": ["nodekit-tests-26"], + "maxVisibleWords": 45, + "speakerNoteGoal": "Keep the 30-minute goal visible without presenting it as certified." + }, + { + "slideId": "nodeslide-multipurpose", + "job": "Show why NodeSlide is a platform capability.", + "audienceQuestion": "How can one presentation system serve builders, products, and external agents?", + "takeaway": "The same governed deck model can support change storytelling, embedded app editing, CLI/MCP automation, and NodeRoom collaboration.", + "narrativeRole": "architecture", + "dominantVisual": "architecture_diagram", + "evidenceIds": ["nodeslide-core-pr-5", "nodeslide-react-pr-6", "noderoom-pr-217"], + "maxVisibleWords": 50, + "speakerNoteGoal": "Distinguish core, React, host repository, and external transports." + }, + { + "slideId": "brownfield", + "job": "Explain migration without a rewrite.", + "audienceQuestion": "What happens to a halfway-working repository?", + "takeaway": "Map and wrap current paths first; shadow, compare, and promote one capability only after parity.", + "narrativeRole": "workflow", + "dominantVisual": "timeline", + "evidenceIds": ["nodebench-pr-591"], + "maxVisibleWords": 38, + "speakerNoteGoal": "Use NodeBench as the concrete shape-divergent but content-complete example." + }, + { + "slideId": "proof", + "job": "Show what actually passed.", + "audienceQuestion": "Which parts are verified rather than planned?", + "takeaway": "Local factory, package boundaries, CAS review, and registry contracts have executable evidence across draft branches.", + "narrativeRole": "proof", + "dominantVisual": "benchmark", + "evidenceIds": ["nodekit-tests-26", "nodekit-hosted-quality", "noderoom-pr-217"], + "maxVisibleWords": 48, + "speakerNoteGoal": "Name local and hosted evidence separately and avoid cumulative vanity counts." + }, + { + "slideId": "limits", + "job": "Make unfinished work legible.", + "audienceQuestion": "What cannot we claim yet?", + "takeaway": "No merge, publication, production deployment, repeatable under-30 guarantee, or PPTX certification occurred.", + "narrativeRole": "limitation", + "dominantVisual": "code_contract", + "evidenceIds": ["setup-timing-sample"], + "maxVisibleWords": 42, + "speakerNoteGoal": "Frame limits as release gates, not footnotes." + }, + { + "slideId": "next", + "job": "Define the next indisputable milestone.", + "audienceQuestion": "What closes the loop?", + "takeaway": "Land the dependency-ordered contracts, mount NodeSlide in NodeRoom, then certify one live hackathon app from brief through export and judge-ready deck.", + "narrativeRole": "next-step", + "dominantVisual": "timeline", + "evidenceIds": [], + "maxVisibleWords": 40, + "speakerNoteGoal": "End on one measurable integration milestone rather than another capability list." + } + ] +} diff --git a/changes/nodekit-factory-p1/presentation/speaker-notes.md b/changes/nodekit-factory-p1/presentation/speaker-notes.md new file mode 100644 index 0000000..e0930eb --- /dev/null +++ b/changes/nodekit-factory-p1/presentation/speaker-notes.md @@ -0,0 +1,7 @@ +# NodeKit Factory P1 speaker notes + +This is a technical-review deck, not a release announcement. Lead with the +execution problem, show the contract and dependency structure, prove the local +factory and package boundaries, then make the unmerged and unverified boundaries +explicit. Do not claim production deployment, package availability, or a +repeatable under-30 result. diff --git a/changes/nodekit-factory-p1/story/architecture-diff.json b/changes/nodekit-factory-p1/story/architecture-diff.json new file mode 100644 index 0000000..1762739 --- /dev/null +++ b/changes/nodekit-factory-p1/story/architecture-diff.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": "nodekit.architecture-diff/v1", + "changeId": "nodekit-factory-p1", + "before": [ + "Repository-specific runtime and setup conventions", + "Presentation assembled after implementation from prose and screenshots", + "Benchmark or consumer paths could use parallel implementations", + "Brownfield adoption implied physical reorganization" + ], + "after": [ + "Flat NodeKit and NodeAgent manifests compile to one hashed definition", + "NodeAgent runtime/provider seam supports Pi without domain forks", + "NodeSlide packages serve build-time presentation, runtime embedding, CLI, and MCP consumers", + "Change Story and evidence index feed presentation and distribution", + "Brownfield applications map and wrap existing paths before shadow migration", + "Proof receipts and QA skills preserve revision-bound evidence" + ] +} diff --git a/changes/nodekit-factory-p1/story/claims.json b/changes/nodekit-factory-p1/story/claims.json new file mode 100644 index 0000000..547855a --- /dev/null +++ b/changes/nodekit-factory-p1/story/claims.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": "nodekit.presentation-claims/v1", + "changeId": "nodekit-factory-p1", + "claims": [ + { + "id": "claim-empty-directory-factory", + "text": "NodeKit can create a deterministic, local-ready agent application from an empty directory and can additively adopt an existing repository.", + "status": "verified", + "evidenceIds": ["nodekit-tests-26", "nodekit-pr-4"], + "limitations": ["Live sponsor research, deployment, and production browser proof are separate gates."] + }, + { + "id": "claim-portable-pi-seam", + "text": "NodeAgent now has a proposed provider-neutral runtime contract and real Pi AI adapter rather than requiring a second domain runtime.", + "status": "observed", + "evidenceIds": ["nodeagent-pr-2"], + "limitations": ["The package is not published and production consumers have not been promoted."] + }, + { + "id": "claim-injectable-nodeslide", + "text": "NodeSlide is proposed as a governed embeddable system with core packages, controlled React surfaces, and a second consumer in NodeRoom.", + "status": "observed", + "evidenceIds": ["nodeslide-core-pr-5", "nodeslide-react-pr-6", "noderoom-pr-217"], + "limitations": ["The mounted production UI and PPTX export/reopen path are not certified."] + }, + { + "id": "claim-under-30", + "text": "Under 30 minutes remains a target, not a repeatably certified guarantee.", + "status": "measured", + "evidenceIds": ["setup-timing-sample"], + "limitations": ["Observed dependency installation variance crossed the target."] + }, + { + "id": "claim-brownfield", + "text": "A mature application can map existing paths and capability ownership before replacing its runtime.", + "status": "observed", + "evidenceIds": ["nodebench-pr-591"], + "limitations": ["Native runtime shadow parity has not run."] + } + ] +} diff --git a/changes/nodekit-factory-p1/story/evidence-index.json b/changes/nodekit-factory-p1/story/evidence-index.json new file mode 100644 index 0000000..d90b27b --- /dev/null +++ b/changes/nodekit-factory-p1/story/evidence-index.json @@ -0,0 +1,76 @@ +{ + "schemaVersion": "nodekit.evidence-index/v1", + "changeId": "nodekit-factory-p1", + "evidence": [ + { + "id": "nodekit-tests-26", + "kind": "test-run", + "status": "verified", + "location": "local://node-platform/npm-test", + "summary": "NodeKit contract, compiler, factory, adoption, no-key proof, arbitrary authoring-root binding, and registry suite passed 26 of 26 tests." + }, + { + "id": "nodekit-hosted-quality", + "kind": "ci-run", + "status": "verified", + "location": "https://github.com/HomenShum/node-platform/actions/runs/29713688834", + "summary": "Hosted NodeKit quality workflow passed before the final rollout-ledger update." + }, + { + "id": "nodekit-pr-4", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/node-platform/pull/4", + "summary": "Factory, compiler, launch/presentation/QA skills, deterministic evaluation, and local proof are proposed as a draft." + }, + { + "id": "nodeagent-pr-2", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeAgent/pull/2", + "summary": "Portable event contracts and real Pi AI adapter are proposed as a draft." + }, + { + "id": "nodeslide-core-pr-5", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeSlide/pull/5", + "summary": "Host-neutral NodeSlide contracts, engine, backend port, and conformance testkit are proposed as a draft." + }, + { + "id": "nodeslide-react-pr-6", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeSlide/pull/6", + "summary": "Controlled React/headless proposal review surfaces are proposed as a draft stacked on NodeSlide core." + }, + { + "id": "nodeproof-pr-22", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeProof/pull/22", + "summary": "The proofloop.receipt/v1 envelope and verifier CLI are proposed as a draft." + }, + { + "id": "noderoom-pr-217", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeRoom/pull/217", + "summary": "NodeRoom consumes NodeSlide packages and proves review, accept, stale CAS, versions, and receipts without modifying its production runtime." + }, + { + "id": "nodebench-pr-591", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeBenchAI/pull/591", + "summary": "NodeBench brownfield manifests, logical Entity Intelligence pack, eval bindings, and compiled definition are proposed without replacing its runtime." + }, + { + "id": "setup-timing-sample", + "kind": "benchmark-observation", + "status": "measured", + "location": "../../proof/p1-factory-rollout.json", + "summary": "Observed local-ready times ranged from 17.72 to 36.37 seconds; the latest pnpm local-proof run took 31.86 seconds, dominated by dependency installation." + } + ] +} diff --git a/changes/nodekit-factory-p1/story/limitations.json b/changes/nodekit-factory-p1/story/limitations.json new file mode 100644 index 0000000..01ede19 --- /dev/null +++ b/changes/nodekit-factory-p1/story/limitations.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": "nodekit.change-limitations/v1", + "changeId": "nodekit-factory-p1", + "limitations": [ + "Draft pull requests are not merged releases.", + "No npm package was published.", + "No production application was deployed in this rollout.", + "No paid resource was activated and no destructive migration ran.", + "Under-30 local setup was observed once but not repeated consistently.", + "The NodeSlide presentation produced by this change may be evidence-bound before it is PPTX export-verified.", + "NodeBench remains on its existing runtime until shadow parity proves promotion is safe." + ] +} diff --git a/docs/P1_FACTORY_ROLLOUT.md b/docs/P1_FACTORY_ROLLOUT.md new file mode 100644 index 0000000..77b3c63 --- /dev/null +++ b/docs/P1_FACTORY_ROLLOUT.md @@ -0,0 +1,126 @@ +# Node Platform P1 Factory Rollout + +P1 turns the P0 ownership map into executable seams. The work is intentionally +split into reviewable draft pull requests; nothing in this ledger implies that +the branches are merged, published to npm, or deployed to production. + +## Current pull-request set + +| Capability | Repository / pull request | Proven in this branch | Still open | +|---|---|---|---| +| Brief-to-app factory and frozen manifests | [node-platform #4](https://github.com/HomenShum/node-platform/pull/4) | Empty-directory create, additive adopt, canonical compiler, arbitrary brownfield authoring roots bound into the config hash, Pi seam, deterministic eval/proof, Launch/QA/Present skills for Codex and Claude, evidence-bound presentation lane | Repeatable under-30 setup, live sponsor research, browser/deploy certification, npm release | +| Portable runtime event and Pi adapter | [NodeAgent #2](https://github.com/HomenShum/NodeAgent/pull/2) | `nodeagent.event/v1`, provider-neutral adapter, real Pi streaming/tool/usage translation, package tarball proof | npm release and production consumer promotion | +| Injectable presentation core | [NodeSlide #5](https://github.com/HomenShum/NodeSlide/pull/5) | Host-neutral deck/repository contracts, proposal-before-apply, CAS, versions, receipts, memory adapter, conformance testkit | Production adapters and export/reopen certification | +| Controlled presentation UI | [NodeSlide #6](https://github.com/HomenShum/NodeSlide/pull/6) | Backend-neutral React package, accessible read-only deck viewer, deterministic proposal comparison, fail-closed review callbacks, scoped styling, tarball SSR proof | Editable canvas, presenter, host-mounted browser proof, media-egress authority | +| General proof envelope | [NodeProof #22](https://github.com/HomenShum/NodeProof/pull/22) | `proofloop.receipt/v1`, content hashes, authority classification, CLI verification | Consumer migration and signature policy | +| Trace consumer registration | [NodeTrace #2](https://github.com/HomenShum/NodeTrace/pull/2) | Truthful L1/L2 NodeKit adoption and deterministic trace path | Canonical event ingestion adapter | +| Memory consumer registration | [NodeMem #2](https://github.com/HomenShum/NodeMem/pull/2) | Truthful L1/L2 adoption, repaired demo command, deterministic proof | Canonical event ingestion and runtime memory adapter | +| NodeSlide second-consumer proof | [NodeRoom #217](https://github.com/HomenShum/NodeRoom/pull/217) | Package/tarball consumption, review, accept, stale CAS, versions, receipts, NodeRoom-auth normalization | Mounted UI, production repository adapter, ActorProof server binding, PPTX browser proof | +| Brownfield application alignment | [NodeBenchAI #591](https://github.com/HomenShum/NodeBenchAI/pull/591) | Canonical manifests, logical Entity Intelligence pack, existing eval bindings, compiled definition | No-key profile, native runtime shadow parity, Pi promotion, canonical receipts | + +NodeSlide #6 and the external-agent interface are stacked on NodeSlide #5 so the +core package boundary can be reviewed independently. They must not be retargeted +to `main` until their base dependency lands. + +## Dependency order + +```text +NodeKit contracts + NodeAgent event/Pi seam + | + +--> NodeBench brownfield mapping -> shadow parity later + +--> NodeTrace / NodeMem consumer adapters later + +NodeSlide core + +--> external CLI/MCP + +--> controlled React/headless UI + +--> NodeRoom consumer proof -> mounted production integration later + +ProofLoop receipt envelope + +--> application receipt migration and release certification later +``` + +## Timing evidence + +The fastest observed empty-directory deterministic local-ready run was 17.72 +seconds. Repeated Windows runs ranged up to 36.37 seconds. A pnpm +`create --local-proof` run completed in 31.86 seconds, with 29.5 seconds spent +installing dependencies. The deterministic demo/evaluation work itself took +milliseconds. + +Therefore P1 records the under-30 target as **not yet repeatably certified**. +Dependency extraction and process startup are the current bottleneck; the +factory must preserve every observed timing rather than publishing only the +fastest run. + +## Review-complete gate + +P1 is ready for coordinated merge review only when: + +1. every listed pull request remains mergeable against its documented base; +2. stacked NodeSlide changes name NodeSlide #5 as their base dependency; +3. local validation evidence and available hosted checks are green; +4. the central registry still passes with no undeclared protocol copy; +5. no branch claims npm publication, production deployment, or browser proof + that did not occur; +6. the NodeKit factory retains `local-ready` versus `release-ready` separation; +7. a failed production or package-consumer gate blocks promotion rather than + being relabeled advisory. + +## P1 boundary + +This rollout creates the reusable factory, runtime/provider seam, proof +envelope, presentation core, brownfield bridge, and first real consumer proof. +It does not yet finish production installation of NodeSlide inside NodeRoom, +replace NodeBench's mature runtime, migrate every repository to canonical +events/receipts, publish packages, or certify a fully live hackathon app from a +vague brief in under 30 minutes. + +Those remaining items are explicit P1 follow-ups or P2 work, not hidden inside +the word "done." + +## Remaining ecosystem map + +The GitHub-wide audit classifies repositories by the smallest honest adoption +step. A product-agent manifest is not required for every repository: protocols, +corpora, QA packs, and deterministic tools should not invent a runtime merely to +look uniform. + +### P1: close the factory loop + +| Repository | Current truth | P1 action | +|---|---|---| +| NodeVideo | Flat NodeKit registration; production-shaped Eve agent under `apps/eve-agent/agent`; no root application manifest | Map the existing Eve directory, packs, and evals through one root `nodeagent.yaml`; do not move the live harness first | +| NodeVoice | Flat NodeKit registration; runtime under `src/nodeagents`; proof receipt is not yet canonical | Brownfield-map the current runtime, logically extract one voice-room pack, and define only receipts supported by actual execution | +| NodeTasks | Unregistered 9,155-task corpus with useful fixtures plus vendored upstream snapshots | Register as a task corpus, emit a corpus validation receipt, and replace vendored runtime ownership with provenance references; no product-agent manifest | +| agentic-ui-qa | Valid NodeKit protocol with a self-check receipt | Install it from NodeKit as the default QA skill/pack; no product-agent manifest | +| BetterPRHandoff | Focused handoff protocol | Feed its structured handoff into NodeKit Present and the Change Story contract | +| FeatureClipStudio | Reproducible demo-video transport | Add a presentation evidence adapter for verified clips and screenshots | +| parity-studio | UI-kit staging surface that still embeds NodeSlide domain code | Keep it as a staging product, but freeze new embedded NodeSlide capability after standalone package parity | + +NodeBenchAI remains registered as `untracked` until it has an honest finite +no-key/demo profile. A canonical manifest alone is not evidence that the runtime +can satisfy the registry's executable lifecycle contract. + +### P2: reusable capability expansion + +- Register NodeGraph and expose its existing bridge as a capability pack. +- Register NodeSEO as a deterministic tool/protocol and expose an SEO proof pack. +- Package AgentRedteam and the agent-era maturity rubric as evaluation inputs. +- Distribute FreeAgentResources as NodeKit's research catalog, not a runtime. +- Extract NodeRL's unique reward, repair, and export contracts; freeze its copied + NodeTrace, NodeMem, and NodeEval implementations. +- Keep NodeSheet as a finance/spreadsheet pack until a second independent host + justifies a standalone repository. No NodeSheet GitHub repository was found. + +### Consolidate after unique material is preserved + +- Redirect NodeBenchBoilerplate to `nodekit create` or generate it as a fixture. +- Move authoritative NodeAgentSpec material into NodeAgent/NodeKit documentation. +- Extract unique skills from solo-founder-agent-builder and + agent-workspace-template, then archive or redirect them. +- Archive private NodeBenchClean after the public NodeBenchAI surface reaches + equivalent cleanliness. +- Fold VisualJudge's generic judging behavior into agentic-ui-qa or + FeatureClipStudio and retain only a narrow adapter if needed. +- Remove parity-studio's embedded NodeSlide domain only after standalone + NodeSlide reaches functional parity. diff --git a/plugins/nodekit/skills/nodekit-launch/SKILL.md b/plugins/nodekit/skills/nodekit-launch/SKILL.md index 69b88e5..0c06057 100644 --- a/plugins/nodekit/skills/nodekit-launch/SKILL.md +++ b/plugins/nodekit/skills/nodekit-launch/SKILL.md @@ -18,7 +18,7 @@ Read [the launch contract](references/launch-contract.md) before acting. 5. For an empty target, run `nodekit create --local-proof`; add `--package-manager pnpm` when pnpm is available and appropriate. For an existing target, run `nodekit adopt` and inspect its collision receipt before accepting changes. 6. Run `nodekit compile` and `nodekit inspect`. Confirm the filesystem-discovered tools, skills, integrations, fixtures, evals, provider, secret references, and config hash. 7. Implement one end-to-end surface. Preserve one execution path for the no-key demo, live provider, browser, and evals. -8. Run deterministic demo, unit/contract tests, domain evals, failure cases, strict live provider smoke, and browser journeys. Test missing secrets, malformed input, reload/resume, repeated actions, narrow/mobile layout, and export/reopen. +8. Read and run the sibling `nodekit-qa` skill. Establish the deterministic floor, strict live-provider smoke, and the critical browser journey; test missing secrets, malformed input, reload/resume, repeated actions, narrow/mobile layout, and export/reopen. 9. Deploy only the exact tested revision and only with user authorization. Record URL, revision, environment identity, health, and a fresh-user journey. 10. Emit the release proof and launch timeline. Do not call the run production-proven if live, browser, deployment, or receipt evidence is absent. 11. Read and run the sibling `nodekit-present` skill. Bind the problem, product workflow, sponsor use, architecture, screenshots, and proof to one Change Story; produce the presentation tier required by the audience without upgrading unsupported claims. diff --git a/plugins/nodekit/skills/nodekit-present/SKILL.md b/plugins/nodekit/skills/nodekit-present/SKILL.md index 12ce32c..e5b8176 100644 --- a/plugins/nodekit/skills/nodekit-present/SKILL.md +++ b/plugins/nodekit/skills/nodekit-present/SKILL.md @@ -11,7 +11,7 @@ Read [the change-story contract](references/change-story-contract.md) before cre ## Workflow -1. Classify the change tier. Skip decks for trivial work; use a change card for a narrow fix, a 3–5 slide mini-deck for a major feature, and a full deck plus appendix for releases or hackathons. +1. Classify the change tier. Skip decks for trivial work; use a change card for a narrow fix, a 3-5 slide mini-deck for a major feature, and a full deck plus appendix for releases or hackathons. 2. Create or update `changes//change.yaml`. Record audience, problem, prior state, decision, alternatives, affected systems, user workflow, proof requirements, limitations, and presentation tier. 3. Capture evidence while work happens: baseline and after screenshots, exact commits, deployment identity, tests, benchmarks, traces, artifacts, exports, and known failures. Preserve raw receipts. 4. Build an evidence index. Give every material claim a status and evidence IDs. Label planned, inferred, user-asserted, and measured claims distinctly. diff --git a/plugins/nodekit/skills/nodekit-present/references/change-story-contract.md b/plugins/nodekit/skills/nodekit-present/references/change-story-contract.md index 23a61f5..f90d4a4 100644 --- a/plugins/nodekit/skills/nodekit-present/references/change-story-contract.md +++ b/plugins/nodekit/skills/nodekit-present/references/change-story-contract.md @@ -4,25 +4,25 @@ ```text changes// -├── change.yaml -├── evidence/ -│ ├── baseline/ -│ ├── implementation/ -│ ├── tests/ -│ ├── browser/ -│ ├── traces/ -│ ├── benchmarks/ -│ └── deployment/ -├── story/ -│ ├── claims.json -│ ├── evidence-index.json -│ ├── architecture-diff.json -│ └── limitations.json -└── presentation/ - ├── deck-spec.json - ├── slide-design-plans/ - ├── speaker-notes.md - └── exports/ +|-- change.yaml +|-- evidence/ +| |-- baseline/ +| |-- implementation/ +| |-- tests/ +| |-- browser/ +| |-- traces/ +| |-- benchmarks/ +| `-- deployment/ +|-- story/ +| |-- claims.json +| |-- evidence-index.json +| |-- architecture-diff.json +| `-- limitations.json +`-- presentation/ + |-- deck-spec.json + |-- slide-design-plans/ + |-- speaker-notes.md + `-- exports/ ``` ## Required change fields @@ -49,9 +49,9 @@ Invalidate a claim when its bound commit, deployment, source, or benchmark is st ## Presentation tiers - Tier 0: no deck; PR or changelog text only -- Tier 1: one-page `Problem → Change → Proof` card -- Tier 2: 3–5 slides for a substantial feature or migration -- Tier 3: 6–10 slides plus technical appendix for a release +- Tier 1: one-page `Problem -> Change -> Proof` card +- Tier 2: 3-5 slides for a substantial feature or migration +- Tier 3: 6-10 slides plus technical appendix for a release - Tier 4: judge/customer/investor deck, appendix, demo choreography, and Q&A map ## Default narrative @@ -67,8 +67,13 @@ Invalidate a claim when its bound commit, deployment, source, or benchmark is st 9. Reuse across the ecosystem 10. Next milestone -Match visual form to meaning: before/after, architecture, workflow, trace, benchmark, screenshot, code contract, or timeline. Avoid repeating title-plus-bullets on every slide. +Match visual form to meaning: before/after, architecture, workflow, trace, +benchmark, screenshot, code contract, or timeline. Avoid repeating +title-plus-bullets on every slide. ## Verification gate -Require current evidence, accurate architecture, scoped metrics, visible limitations, no overflow/collision, editable export, reopen success, and human-reviewable proposals. A missing production artifact may still yield a draft, but it cannot yield a production claim. +Require current evidence, accurate architecture, scoped metrics, visible +limitations, no overflow/collision, editable export, reopen success, and +human-reviewable proposals. A missing production artifact may still yield a +draft, but it cannot yield a production claim. diff --git a/plugins/nodekit/skills/nodekit-qa/SKILL.md b/plugins/nodekit/skills/nodekit-qa/SKILL.md new file mode 100644 index 0000000..f497a68 --- /dev/null +++ b/plugins/nodekit/skills/nodekit-qa/SKILL.md @@ -0,0 +1,34 @@ +--- +name: nodekit-qa +description: Dogfood and regression-test an agentic application from the rendered user surface through its runtime, tools, durable state, artifacts, and proof. Use after NodeKit creates or adopts an application, before production or hackathon presentation claims, and whenever a major agentic UI workflow changes. +--- + +# NodeKit QA + +Test the product as a user and the agent as a system. A passing build is not a passing workflow. + +Read [the QA contract](references/qa-contract.md) before testing. If the full +`agentic-ui-qa` protocol is installed, use its relevant application profile and +preserve its evidence format; this skill is the portable NodeKit entrypoint. + +## Workflow + +1. Identify the one judge- or user-visible journey that carries the product thesis. Pin the tested commit, config hash, deployment identity, fixture, user identity class, and browser viewport. +2. Establish the deterministic floor: manifest compile, strict typecheck, focused tests, production build, no-key demo, domain evaluation, and proof validation. +3. Exercise the rendered surface with real interaction. Cover first load, meaningful input, visible planning/tool state, intermediate artifacts, review or approval, durable result, reload, and export/reopen when the product emits files. +4. Inspect browser console, failed requests, stale loading states, duplicate actions, cancellation, retry, malformed input, missing credentials, and a narrow/mobile viewport. +5. Verify runtime truth behind the UI: canonical run ID, tool calls, policy decisions, persisted artifact/version, receipt, and any provider or sponsor contribution claimed in the demo. +6. Capture screenshots, traces, receipts, and exported artifacts into a stable proof directory. Redact secrets and private user data. +7. Classify every issue by user impact and reproducibility. Repair only within the authorized scope, then rerun the smallest reproducing test and the complete critical journey. +8. Feed verified screenshots and receipts to `nodekit-present`. Do not let a polished deck substitute for a passing application path. + +## Completion language + +- `build-green`: deterministic code gates pass. +- `local-journey-proven`: the critical journey passes against local services. +- `production-journey-proven`: a fresh user completes the critical journey on the exact deployed revision. +- `artifact-certified`: the exported artifact reopens and passes its independent validator. + +Do not claim production proof from localhost, fixture fallback from a production +route, a healthy endpoint without the user journey, or a screenshot without the +underlying trace and revision identity. diff --git a/plugins/nodekit/skills/nodekit-qa/agents/openai.yaml b/plugins/nodekit/skills/nodekit-qa/agents/openai.yaml new file mode 100644 index 0000000..6e83a6f --- /dev/null +++ b/plugins/nodekit/skills/nodekit-qa/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "NodeKit QA" + short_description: "Prove the critical agent-app journey" + default_prompt: "Use NodeKit QA to test this application through its rendered UI, runtime events, durable artifacts, and proof receipts. Repair authorized failures and preserve exact revision-bound evidence." diff --git a/plugins/nodekit/skills/nodekit-qa/references/qa-contract.md b/plugins/nodekit/skills/nodekit-qa/references/qa-contract.md new file mode 100644 index 0000000..4ec5d96 --- /dev/null +++ b/plugins/nodekit/skills/nodekit-qa/references/qa-contract.md @@ -0,0 +1,19 @@ +# NodeKit QA contract + +Every QA run records: + +- repository, commit, config hash, environment, and deployment identity; +- the user journey and acceptance criteria; +- exact fixtures, account/workspace class, viewport, and browser; +- deterministic checks and their exit status; +- runtime run IDs, tool/agent events, policies, artifacts, versions, and receipts; +- console and network health; +- screenshots or video tied to the tested revision; +- export/reopen and independent validation results when applicable; +- discovered issues, repairs, reruns, and remaining limitations. + +The browser surface, runtime event stream, durable state, and exported artifact +must describe the same run. Fail closed when those identities diverge. + +Risky writes, production deployments, paid resource activation, publication, +and destructive cleanup still require the authority defined by the host project. diff --git a/proof/p1-factory-rollout.json b/proof/p1-factory-rollout.json new file mode 100644 index 0000000..cca485b --- /dev/null +++ b/proof/p1-factory-rollout.json @@ -0,0 +1,135 @@ +{ + "schemaVersion": "nodeplatform.p1-factory-rollout/v1", + "generatedAt": "2026-07-20T03:02:56Z", + "status": "draft-pull-requests-open", + "verification": { + "passed": false, + "reason": "The implementation branches are locally proven but remain unmerged drafts; the stacked NodeSlide external-agent PR and cross-repository release gates are not all complete.", + "nodekit": { + "tests": 26, + "registryCheck": "pass", + "productionAudit": "pass-zero-known-vulnerabilities", + "hostedQualityRun": "https://github.com/HomenShum/node-platform/actions/runs/29713688834", + "hostedQualityResult": "pass" + }, + "timing": { + "targetSeconds": 30, + "fastestObservedLocalReadySeconds": 17.72, + "slowestObservedLocalReadySeconds": 36.37, + "latestPnpmLocalReadySeconds": 31.86, + "latestPnpmInstallSeconds": 29.5, + "repeatablyUnderTarget": false + } + }, + "pullRequests": [ + { + "capability": "nodekit-factory-and-contracts", + "repository": "HomenShum/node-platform", + "pullRequest": "https://github.com/HomenShum/node-platform/pull/4", + "implementationHead": "b79535f45c4c7acf0ba61607b12c0f36fedd39f8", + "state": "draft-open" + }, + { + "capability": "nodeagent-event-and-pi-adapter", + "repository": "HomenShum/NodeAgent", + "pullRequest": "https://github.com/HomenShum/NodeAgent/pull/2", + "head": "c0ad523", + "state": "draft-open" + }, + { + "capability": "nodeslide-injectable-core", + "repository": "HomenShum/NodeSlide", + "pullRequest": "https://github.com/HomenShum/NodeSlide/pull/5", + "head": "0669be4", + "state": "draft-open" + }, + { + "capability": "nodeslide-controlled-react", + "repository": "HomenShum/NodeSlide", + "pullRequest": "https://github.com/HomenShum/NodeSlide/pull/6", + "head": "baa2d04", + "state": "draft-open", + "dependsOn": "https://github.com/HomenShum/NodeSlide/pull/5" + }, + { + "capability": "proofloop-receipt-envelope", + "repository": "HomenShum/NodeProof", + "pullRequest": "https://github.com/HomenShum/NodeProof/pull/22", + "head": "802f63c", + "state": "draft-open", + "hostedStatus": "vercel-preview-success" + }, + { + "capability": "nodetrace-nodekit-consumer", + "repository": "HomenShum/NodeTrace", + "pullRequest": "https://github.com/HomenShum/NodeTrace/pull/2", + "head": "09deaef", + "state": "draft-open" + }, + { + "capability": "nodemem-nodekit-consumer", + "repository": "HomenShum/NodeMem", + "pullRequest": "https://github.com/HomenShum/NodeMem/pull/2", + "head": "8d71920", + "state": "draft-open" + }, + { + "capability": "noderoom-nodeslide-consumer-proof", + "repository": "HomenShum/NodeRoom", + "pullRequest": "https://github.com/HomenShum/NodeRoom/pull/217", + "head": "b7962334", + "state": "draft-open", + "dependsOn": "https://github.com/HomenShum/NodeSlide/pull/5", + "hostedStatus": "vercel-preview-success" + }, + { + "capability": "nodebench-brownfield-contract-alignment", + "repository": "HomenShum/NodeBenchAI", + "pullRequest": "https://github.com/HomenShum/NodeBenchAI/pull/591", + "head": "795164fbd9f8c17925d411fc72de8033429ed1c5", + "state": "draft-open", + "hostedStatus": "vercel-preview-success" + } + ], + "ecosystemAudit": { + "p1": [ + "Map the existing NodeVideo Eve harness without relocating it.", + "Map the existing NodeVoice runtime and logically declare one voice-room pack.", + "Register NodeTasks as a task corpus rather than inventing a product-agent runtime.", + "Distribute agentic-ui-qa as the default NodeKit QA skill/pack.", + "Connect BetterPRHandoff and FeatureClipStudio to NodeKit Present.", + "Freeze new embedded NodeSlide domain capability in parity-studio after standalone parity." + ], + "p2": [ + "NodeGraph capability pack", + "NodeSEO proof pack", + "AgentRedteam and maturity-model evaluation inputs", + "FreeAgentResources research catalog", + "NodeRL unique reward/repair/export extraction" + ], + "consolidationCandidates": [ + "NodeBenchBoilerplate", + "NodeAgentSpec", + "solo-founder-agent-builder", + "agent-workspace-template", + "NodeBenchClean after public parity", + "VisualJudge as a standalone product", + "copied NodeTrace/NodeMem/NodeEval code in NodeRL", + "embedded NodeSlide domain code in parity-studio after parity" + ], + "nodesheet": "No standalone GitHub repository found; keep it as a finance/spreadsheet pack until a second host justifies extraction." + }, + "pendingBeforeReviewComplete": [ + "Add and verify the stacked NodeSlide external CLI/MCP pull request.", + "Record and verify the NodeVideo and NodeVoice brownfield-map pull requests.", + "Run central registry validation after all final branch heads are recorded.", + "Forward-test every branch against its documented base before coordinated merge review." + ], + "notPerformed": [ + "npm publication", + "production deployment", + "paid-resource activation", + "destructive data migration", + "merge to any default branch" + ] +} diff --git a/src/lib/agent-definition.mjs b/src/lib/agent-definition.mjs index 9b45589..53fa0e3 100644 --- a/src/lib/agent-definition.mjs +++ b/src/lib/agent-definition.mjs @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { alternateDialectErrors, @@ -31,8 +31,36 @@ function hash(value) { return createHash("sha256").update(value).digest("hex"); } -async function discoverFiles(repoRoot) { - const files = []; +function containedPath(repoRoot, candidate, label) { + const absoluteRoot = path.resolve(repoRoot); + const absolute = path.resolve(absoluteRoot, String(candidate)); + const relative = path.relative(absoluteRoot, absolute); + if (!relative || relative === ".") return { absolute, relative: "." }; + if (path.isAbsolute(String(candidate)) || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`${label} must stay inside the repository: ${candidate}`); + } + return { absolute, relative: normalizePath(relative) }; +} + +function discoveryRoots(repoRoot, manifest) { + const roots = new Map(); + for (const root of DISCOVERY_ROOTS) { + const resolved = containedPath(repoRoot, root, `discovery root ${root}`); + roots.set(resolved.relative, resolved.absolute); + } + if (manifest.authoring?.directory) { + const resolved = containedPath(repoRoot, manifest.authoring.directory, "authoring.directory"); + roots.set(resolved.relative, resolved.absolute); + } + for (const pack of manifest.packs ?? []) { + const resolved = containedPath(repoRoot, path.dirname(String(pack)), `capability pack ${pack}`); + roots.set(resolved.relative, resolved.absolute); + } + return [...roots.entries()].sort(([left], [right]) => left.localeCompare(right)); +} + +async function discoverFiles(repoRoot, manifest) { + const files = new Map(); async function visit(directory) { const entries = await readdir(directory, { withFileTypes: true }); @@ -43,20 +71,24 @@ async function discoverFiles(repoRoot) { if (entry.isDirectory()) await visit(absolute); else { const content = await readFile(absolute); - files.push({ + const relative = normalizePath(path.relative(repoRoot, absolute)); + files.set(relative, { bytes: content.byteLength, digest: hash(content), - path: normalizePath(path.relative(repoRoot, absolute)), + path: relative, }); } } } - for (const root of DISCOVERY_ROOTS) { - const absolute = path.join(repoRoot, root); - if (await pathExists(absolute)) await visit(absolute); + for (const [, absolute] of discoveryRoots(repoRoot, manifest)) { + if (!(await pathExists(absolute))) continue; + if (!(await stat(absolute)).isDirectory()) { + throw new Error(`discovery root is not a directory: ${normalizePath(path.relative(repoRoot, absolute))}`); + } + await visit(absolute); } - return files.sort((left, right) => left.path.localeCompare(right.path)); + return [...files.values()].sort((left, right) => left.path.localeCompare(right.path)); } function validateSecrets(value, location = "nodeagent.yaml", errors = []) { @@ -89,25 +121,31 @@ export function validateAgentManifest(manifest) { if (!Array.isArray(manifest?.packs) || manifest.packs.length === 0) { errors.push("at least one capability pack is required"); } + if (manifest?.authoring?.directory && ( + path.isAbsolute(manifest.authoring.directory) || + normalizePath(manifest.authoring.directory).split("/").includes("..") + )) { + errors.push("authoring.directory must be repository-relative and may not escape the repository"); + } return [...errors, ...validateSecrets(manifest)]; } -function classify(files) { +function classify(files, manifest) { const matching = (fragment, suffix) => files .filter((file) => file.path.includes(fragment) && (!suffix || file.path.endsWith(suffix))) .map((file) => file.path); - const skills = files - .filter((file) => file.path.endsWith("SKILL.md") && ( - file.path.includes("/skills/") || file.path.startsWith("packs/") - )) - .map((file) => file.path); + const authoringRoot = normalizePath(manifest.authoring?.directory ?? "agent").replace(/^\.\//, ""); + const skills = files.filter((file) => file.path.endsWith("SKILL.md")).map((file) => file.path); return { evals: matching("evals/"), integrations: matching("integrations/"), - packs: matching("packs/", "pack.yaml"), - policies: matching("agent/policies/"), + packs: [...new Set([ + ...matching("packs/", "pack.yaml"), + ...(manifest.packs ?? []).map((entry) => normalizePath(String(entry)).replace(/^\.\//, "")), + ])].sort(), + policies: matching(`${authoringRoot}/policies/`), skills, - subagents: matching("agent/subagents/", "agent.yaml"), + subagents: matching(`${authoringRoot}/subagents/`, "agent.yaml"), tools: matching("/tools/"), }; } @@ -119,8 +157,26 @@ export async function compileAgentDefinition(repoRoot, { check = false, write = const manifest = await readYaml(manifestPath); const errors = validateAgentManifest(manifest); errors.push(...await validateSchema(CONTRACT_SCHEMA_FILES.application, manifest, "nodeagent.yaml")); + if (manifest.authoring?.directory) { + try { + const authored = containedPath(repoRoot, manifest.authoring.directory, "authoring.directory"); + if (!(await pathExists(authored.absolute))) { + errors.push(`authoring directory does not exist: ${manifest.authoring.directory}`); + } else if (!(await stat(authored.absolute)).isDirectory()) { + errors.push(`authoring path is not a directory: ${manifest.authoring.directory}`); + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } for (const pack of manifest.packs ?? []) { - const packPath = path.resolve(repoRoot, String(pack)); + let packPath; + try { + packPath = containedPath(repoRoot, String(pack), `capability pack ${pack}`).absolute; + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + continue; + } if (!(await pathExists(packPath))) errors.push(`capability pack does not exist: ${pack}`); else { const packManifest = await readYaml(packPath); @@ -130,6 +186,11 @@ export async function compileAgentDefinition(repoRoot, { check = false, write = ); for (const reference of [packManifest.skill, ...(packManifest.evals ?? [])].filter(Boolean)) { const referencedPath = path.resolve(path.dirname(packPath), String(reference)); + const relative = path.relative(repoRoot, referencedPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + errors.push(`${pack} reference escapes the repository: ${reference}`); + continue; + } if (!(await pathExists(referencedPath))) errors.push(`${pack} references missing file: ${reference}`); } } @@ -155,7 +216,7 @@ export async function compileAgentDefinition(repoRoot, { check = false, write = } if (errors.length > 0) throw new Error(errors.join("\n")); - const files = await discoverFiles(repoRoot); + const files = await discoverFiles(repoRoot, manifest); const manifestDigest = hash(manifestText); const contracts = resolveRuntimeContracts(manifest); const hashInput = JSON.stringify(canonicalize({ files, manifest: { ...manifest, contracts } })); @@ -169,7 +230,7 @@ export async function compileAgentDefinition(repoRoot, { check = false, write = backend: manifest.backend, configHash, contracts, - discovered: classify(files), + discovered: classify(files, manifest), fileCount: files.length, manifestDigest, orchestration: manifest.orchestration ?? {}, diff --git a/src/lib/scaffold.mjs b/src/lib/scaffold.mjs index bd036ca..c7f29f9 100644 --- a/src/lib/scaffold.mjs +++ b/src/lib/scaffold.mjs @@ -7,7 +7,7 @@ import { pathExists } from "./files.mjs"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const templateRoot = path.join(packageRoot, "templates", "research-loop"); const pluginSkillsRoot = path.join(packageRoot, "plugins", "nodekit", "skills"); -const projectedSkillNames = ["nodekit-launch", "nodekit-present"]; +const projectedSkillNames = ["nodekit-launch", "nodekit-present", "nodekit-qa"]; function titleCase(value) { return value.split(/[-_\s]+/).filter(Boolean).map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join(" "); diff --git a/test/factory.test.mjs b/test/factory.test.mjs index ea2da9a..e432643 100644 --- a/test/factory.test.mjs +++ b/test/factory.test.mjs @@ -40,6 +40,14 @@ test("create emits a parseable, reproducible application from multiline input", await readFile(path.join(target, ".codex", "skills", "nodekit-launch", "SKILL.md"), "utf8"), /smallest undeniable vertical slice/, ); + assert.match( + await readFile(path.join(target, ".claude", "skills", "nodekit-qa", "SKILL.md"), "utf8"), + /rendered user surface/, + ); + assert.match( + await readFile(path.join(target, ".codex", "skills", "nodekit-qa", "references", "qa-contract.md"), "utf8"), + /must describe the same run/, + ); const dockerfile = await readFile(path.join(target, "Dockerfile"), "utf8"); const renderBlueprint = await readFile(path.join(target, "render.yaml"), "utf8"); assert.match(dockerfile, /@earendil-works\/pi-ai@0\.80\.10/); @@ -69,6 +77,45 @@ test("compiled hash detects fixture drift and literal secrets fail closed", asyn await assert.rejects(() => compileAgentDefinition(root, { write: false }), /literal secret/); }); +test("authoring.directory outside the conventional root is discovered and hash-bound", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-authoring-directory-")); + t.after(() => rm(root, { force: true, recursive: true })); + await createProject({ git: false, install: false, name: "eve-map", target: root }); + const authoredRoot = path.join(root, "apps", "eve-agent", "agent"); + await mkdir(path.join(authoredRoot, "tools"), { recursive: true }); + await writeFile(path.join(authoredRoot, "instructions.md"), "Run through the existing Eve adapter.\n"); + await writeFile(path.join(authoredRoot, "tools", "measure.ts"), "export const measure = true;\n"); + const manifestPath = path.join(root, "nodeagent.yaml"); + const original = await readFile(manifestPath, "utf8"); + await writeFile(manifestPath, original.replace("directory: ./agent", "directory: ./apps/eve-agent/agent")); + + const first = await compileAgentDefinition(root); + assert.equal( + first.files.some((file) => file.path === "apps/eve-agent/agent/tools/measure.ts"), + true, + ); + assert.equal( + first.definition.discovered.tools.includes("apps/eve-agent/agent/tools/measure.ts"), + true, + ); + await writeFile(path.join(authoredRoot, "tools", "measure.ts"), "export const measure = false;\n"); + const changed = await compileAgentDefinition(root, { write: false }); + assert.notEqual(changed.definition.configHash, first.definition.configHash); +}); + +test("authoring.directory cannot escape the repository", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-authoring-escape-")); + t.after(() => rm(root, { force: true, recursive: true })); + await createProject({ git: false, install: false, name: "unsafe-map", target: root }); + const manifestPath = path.join(root, "nodeagent.yaml"); + const original = await readFile(manifestPath, "utf8"); + await writeFile(manifestPath, original.replace("directory: ./agent", "directory: ../outside")); + await assert.rejects( + () => compileAgentDefinition(root, { write: false }), + /authoring\.directory must be repository-relative/, + ); +}); + test("create refuses nonempty targets", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-nonempty-")); t.after(() => rm(root, { force: true, recursive: true })); From f4ac7c6dfebea1a57c59ee65986eec26cb99ee8f Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 20:49:40 -0700 Subject: [PATCH 10/13] fix: classify adapter-authored subagents --- src/lib/agent-definition.mjs | 10 +++++++--- test/factory.test.mjs | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/lib/agent-definition.mjs b/src/lib/agent-definition.mjs index 53fa0e3..5f3eae4 100644 --- a/src/lib/agent-definition.mjs +++ b/src/lib/agent-definition.mjs @@ -122,8 +122,8 @@ export function validateAgentManifest(manifest) { errors.push("at least one capability pack is required"); } if (manifest?.authoring?.directory && ( - path.isAbsolute(manifest.authoring.directory) || - normalizePath(manifest.authoring.directory).split("/").includes("..") + path.isAbsolute(String(manifest.authoring.directory)) || + normalizePath(String(manifest.authoring.directory)).split("/").includes("..") )) { errors.push("authoring.directory must be repository-relative and may not escape the repository"); } @@ -136,6 +136,10 @@ function classify(files, manifest) { .map((file) => file.path); const authoringRoot = normalizePath(manifest.authoring?.directory ?? "agent").replace(/^\.\//, ""); const skills = files.filter((file) => file.path.endsWith("SKILL.md")).map((file) => file.path); + const subagentRoot = `${authoringRoot}/subagents/`; + const subagents = files + .filter((file) => file.path.startsWith(subagentRoot) && /\/agent\.(?:yaml|ts|js)$/.test(file.path)) + .map((file) => file.path); return { evals: matching("evals/"), integrations: matching("integrations/"), @@ -145,7 +149,7 @@ function classify(files, manifest) { ])].sort(), policies: matching(`${authoringRoot}/policies/`), skills, - subagents: matching(`${authoringRoot}/subagents/`, "agent.yaml"), + subagents, tools: matching("/tools/"), }; } diff --git a/test/factory.test.mjs b/test/factory.test.mjs index e432643..f3cc133 100644 --- a/test/factory.test.mjs +++ b/test/factory.test.mjs @@ -83,8 +83,13 @@ test("authoring.directory outside the conventional root is discovered and hash-b await createProject({ git: false, install: false, name: "eve-map", target: root }); const authoredRoot = path.join(root, "apps", "eve-agent", "agent"); await mkdir(path.join(authoredRoot, "tools"), { recursive: true }); + await mkdir(path.join(authoredRoot, "subagents", "reviewer"), { recursive: true }); await writeFile(path.join(authoredRoot, "instructions.md"), "Run through the existing Eve adapter.\n"); await writeFile(path.join(authoredRoot, "tools", "measure.ts"), "export const measure = true;\n"); + await writeFile( + path.join(authoredRoot, "subagents", "reviewer", "agent.ts"), + "export const reviewer = { runtime: 'eve' };\n", + ); const manifestPath = path.join(root, "nodeagent.yaml"); const original = await readFile(manifestPath, "utf8"); await writeFile(manifestPath, original.replace("directory: ./agent", "directory: ./apps/eve-agent/agent")); @@ -98,6 +103,10 @@ test("authoring.directory outside the conventional root is discovered and hash-b first.definition.discovered.tools.includes("apps/eve-agent/agent/tools/measure.ts"), true, ); + assert.deepEqual( + first.definition.discovered.subagents, + ["apps/eve-agent/agent/subagents/reviewer/agent.ts"], + ); await writeFile(path.join(authoredRoot, "tools", "measure.ts"), "export const measure = false;\n"); const changed = await compileAgentDefinition(root, { write: false }); assert.notEqual(changed.definition.configHash, first.definition.configHash); From c2e47f9c714f383b1b9c1075ed1cc8e01461164a Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 22:05:29 -0700 Subject: [PATCH 11/13] feat: complete NodeKit P1 ecosystem seams --- changes/nodekit-factory-p1/change.yaml | 17 ++- .../presentation/change-card.application.json | 104 ++++++++++++++ .../presentation/change-card.proposal.json | 10 +- .../change-card.transport-proof.json | 33 +++++ .../presentation/change-card.v1.json | 17 ++- .../presentation/change-card.v2.json | 87 ++++++++++++ .../presentation/slide-design-plans.json | 16 +-- .../presentation/speaker-notes.md | 7 +- .../story/architecture-diff.json | 5 +- changes/nodekit-factory-p1/story/claims.json | 35 ++++- .../story/evidence-index.json | 128 +++++++++++++++-- .../nodekit-factory-p1/story/limitations.json | 4 +- docs/GITHUB_ECOSYSTEM_AUDIT.md | 134 ++++++++++++++++++ docs/P1_FACTORY_ROLLOUT.md | 70 +++++---- nodekit.yaml | 10 +- ownership.yaml | 82 +++++++++-- repositories.yaml | 21 +++ src/lib/files.mjs | 2 +- src/lib/repo-check.mjs | 15 +- test/conformance.test.mjs | 25 ++++ 20 files changed, 735 insertions(+), 87 deletions(-) create mode 100644 changes/nodekit-factory-p1/presentation/change-card.application.json create mode 100644 changes/nodekit-factory-p1/presentation/change-card.transport-proof.json create mode 100644 changes/nodekit-factory-p1/presentation/change-card.v2.json create mode 100644 docs/GITHUB_ECOSYSTEM_AUDIT.md diff --git a/changes/nodekit-factory-p1/change.yaml b/changes/nodekit-factory-p1/change.yaml index fd12979..58d4ebd 100644 --- a/changes/nodekit-factory-p1/change.yaml +++ b/changes/nodekit-factory-p1/change.yaml @@ -34,6 +34,11 @@ implementation: - NodeMem - NodeRoom - NodeBenchAI + - NodeVideo + - NodeVoice + - NodeTasks + - BetterPRHandoff + - FeatureClipStudio importantContracts: - nodekit.repo/v1 - nodeagent.application/v1 @@ -41,12 +46,20 @@ implementation: - nodeagent.event/v1 - nodeagent.trace/v1 - proofloop.receipt/v1 + - nodetasks.corpus-receipt/v1 + - betterprhandoff.handoff/v1 + - featureclip.evidence-manifest/v1 + - featureclip.evidence-receipt/v1 - NodeSlide repository and patch contracts proofRequirements: - generated empty-directory local-ready application - additive brownfield adoption with collision receipt - NodeAgent Pi adapter package proof - NodeSlide package and second-consumer proof + - NodeSlide CLI/MCP external-agent proof + - NodeTasks corpus integrity proof + - BetterPRHandoff to Change Story compilation proof + - FeatureClipStudio media-to-Evidence-Index projection proof - canonical proof envelope validation - cross-repository registry validation approvalBoundaries: @@ -57,8 +70,8 @@ approvalBoundaries: - destructive migration limitations: - under-30 setup has a fast observed pass but is not repeatably certified - - all implementation branches remain unmerged drafts + - NodeSlide core, controlled React surfaces, and the NodeRoom consumer proof are merged; the remaining implementation branches are reviews - no package was published and no production deployment was performed - NodeSlide editable canvas and PPTX export/reopen certification remain open - NodeBench native-runtime parity and honest no-key profile remain open -nextMilestone: Forward-test and review the dependency-ordered PR set, then mount NodeSlide in NodeRoom and certify one live hackathon application without changing the benchmark execution path. +nextMilestone: Forward-test and review the dependency-ordered PR set, mount the NodeSlide React UI and production adapter in NodeRoom, then certify one live hackathon application without changing the benchmark execution path. diff --git a/changes/nodekit-factory-p1/presentation/change-card.application.json b/changes/nodekit-factory-p1/presentation/change-card.application.json new file mode 100644 index 0000000..045a54b --- /dev/null +++ b/changes/nodekit-factory-p1/presentation/change-card.application.json @@ -0,0 +1,104 @@ +{ + "schemaVersion": "nodeslide.file-application/v1", + "snapshot": { + "deck": { + "schemaVersion": "nodeslide.slidelang/v1", + "toolchainVersion": "local-slidelang-adapter/1.1.0", + "id": "deck:nodekit-factory-p1", + "projectId": "project:nodekit", + "title": "NodeKit Factory P1", + "brief": { + "prompt": "Explain the NodeKit factory P1 rollout without upgrading draft evidence into release claims.", + "audience": "technical reviewers and hackathon builders", + "purpose": "Change review", + "successCriteria": [ + "The system boundary is understandable.", + "Verified evidence and open release gates remain distinct." + ] + }, + "theme": { + "id": "nodekit-review", + "name": "NodeKit review", + "mode": "light", + "colors": { + "canvas": "#f8f7f2", + "ink": "#17201b", + "muted": "#617068", + "accent": "#216e4e", + "accentSoft": "#dcefe5", + "insight": "#e9e3cf", + "insightInk": "#443d24", + "trace": "#132b24", + "border": "#c9cec9" + }, + "typography": { + "display": "Aptos Display", + "body": "Aptos", + "data": "Aptos Mono" + }, + "defaultRadius": 8, + "spacingUnit": 8 + }, + "slideOrder": ["deck:nodekit-factory-p1:slide:1"], + "version": 2, + "status": "ready", + "createdAt": 1784515200000, + "updatedAt": 1784515200001 + }, + "slides": [ + { + "id": "deck:nodekit-factory-p1:slide:1", + "deckId": "deck:nodekit-factory-p1", + "title": "Problem, contract, proof", + "background": "#f8f7f2", + "elementOrder": ["deck:nodekit-factory-p1:slide:1:title"], + "version": 2 + } + ], + "elements": [ + { + "id": "deck:nodekit-factory-p1:slide:1:title", + "slideId": "deck:nodekit-factory-p1:slide:1", + "name": "Title", + "kind": "text", + "role": "title", + "bbox": { "x": 0.08, "y": 0.12, "width": 0.82, "height": 0.24 }, + "rotation": 0, + "content": "P1 factory | Draft contracts, verified local seams", + "style": { "color": "#17201b", "fontSize": 38, "fontWeight": 700 }, + "sourceIds": ["source:nodekit-factory-p1:evidence-index"], + "locked": false, + "exportCapabilities": ["web_native", "pptx_editable"], + "version": 2 + } + ], + "sources": [ + { + "id": "source:nodekit-factory-p1:evidence-index", + "deckId": "deck:nodekit-factory-p1", + "title": "NodeKit Factory P1 evidence index", + "sourceType": "document", + "retrievedAt": 1784515200000, + "citation": "../story/evidence-index.json", + "format": "json", + "provider": "nodekit-change-story", + "retention": "until_deleted", + "status": "ready" + } + ] + }, + "receipt": { + "id": "application:039db09490de59dce3d36d60b922b101", + "proposalId": "proposal:6a06b6872de43772d0db038fda160e5a", + "deckId": "deck:nodekit-factory-p1", + "baseDeckVersion": 1, + "resultingDeckVersion": 2, + "baseSnapshotDigest": "sha256:464cd9d5021cf2c1dcf556822ae58828ab8b3f6f54ec0ca09d293fca31b74031", + "resultingSnapshotDigest": "sha256:b742c7321effa324e0a99f6942ef49596d6eaecfb21666d5d49db7ae7e36abaf", + "patchDigest": "sha256:c1be9d504068abd4360a4eb37d2fafed75c640bb0e4ba661a37163154343695c", + "approval": "exact_proposal_id", + "appliedAt": "2026-07-20T04:41:43.531Z", + "affectedSlideIds": ["deck:nodekit-factory-p1:slide:1"], + "affectedElementIds": ["deck:nodekit-factory-p1:slide:1:title"] + } +} diff --git a/changes/nodekit-factory-p1/presentation/change-card.proposal.json b/changes/nodekit-factory-p1/presentation/change-card.proposal.json index 156d339..d84fe14 100644 --- a/changes/nodekit-factory-p1/presentation/change-card.proposal.json +++ b/changes/nodekit-factory-p1/presentation/change-card.proposal.json @@ -1,13 +1,13 @@ { "schemaVersion": "nodeslide.file-proposal/v1", - "id": "proposal:89b8c091db7beafa555b63bf5d9f064e", + "id": "proposal:6a06b6872de43772d0db038fda160e5a", "status": "ready", "applied": false, - "createdAt": "2026-07-20T03:29:01.614Z", + "createdAt": "2026-07-20T04:41:43.305Z", "base": { "deckId": "deck:nodekit-factory-p1", "deckVersion": 1, - "snapshotDigest": "sha256:ae89ee8552ae034863d4ac267047e5ce4db9a0714da100a12635476c78efba95" + "snapshotDigest": "sha256:464cd9d5021cf2c1dcf556822ae58828ab8b3f6f54ec0ca09d293fca31b74031" }, "patch": { "id": "patch:nodekit-factory-p1:review-copy", @@ -38,9 +38,9 @@ "summary": "Tighten the change-card title so draft and verified states remain distinct." }, "candidate": { - "committedAt": 1784518141614, + "committedAt": 1784515200001, "deckVersion": 2, - "snapshotDigest": "sha256:49d96ee78c0f600727a35ee845f30a179a42a600705b9972f4d6e5d0591638bd", + "snapshotDigest": "sha256:b742c7321effa324e0a99f6942ef49596d6eaecfb21666d5d49db7ae7e36abaf", "affectedSlideIds": ["deck:nodekit-factory-p1:slide:1"], "affectedElementIds": ["deck:nodekit-factory-p1:slide:1:title"] } diff --git a/changes/nodekit-factory-p1/presentation/change-card.transport-proof.json b/changes/nodekit-factory-p1/presentation/change-card.transport-proof.json new file mode 100644 index 0000000..ae0a7c8 --- /dev/null +++ b/changes/nodekit-factory-p1/presentation/change-card.transport-proof.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": "nodekit.nodeslide-external-proof/v1", + "nodeSlideRepository": "HomenShum/NodeSlide", + "nodeSlideHead": "7815b44fe24d6673745cb5d51af219ef4f6652c9", + "pullRequest": "https://github.com/HomenShum/NodeSlide/pull/10", + "commands": ["inspect", "validate", "propose", "apply"], + "validation": { + "passed": true, + "baseDeckVersion": 1, + "candidateDeckVersion": 2, + "baseSnapshotDigest": "sha256:464cd9d5021cf2c1dcf556822ae58828ab8b3f6f54ec0ca09d293fca31b74031", + "patchDigest": "sha256:c1be9d504068abd4360a4eb37d2fafed75c640bb0e4ba661a37163154343695c", + "validateCandidateDigest": "sha256:b742c7321effa324e0a99f6942ef49596d6eaecfb21666d5d49db7ae7e36abaf", + "proposalCandidateDigest": "sha256:b742c7321effa324e0a99f6942ef49596d6eaecfb21666d5d49db7ae7e36abaf", + "deterministicCandidateParity": true + }, + "application": { + "proposalId": "proposal:6a06b6872de43772d0db038fda160e5a", + "receiptId": "application:039db09490de59dce3d36d60b922b101", + "approval": "exact_proposal_id", + "resultingDeckVersion": 2, + "resultingSnapshotDigest": "sha256:b742c7321effa324e0a99f6942ef49596d6eaecfb21666d5d49db7ae7e36abaf" + }, + "boundary": { + "sourceBound": true, + "digestBound": true, + "exactApproval": true, + "claimsEvidenceCertified": false, + "pptxExportVerified": false, + "productionDeployed": false, + "packagePublished": false + } +} diff --git a/changes/nodekit-factory-p1/presentation/change-card.v1.json b/changes/nodekit-factory-p1/presentation/change-card.v1.json index 5d1e23a..0f249f1 100644 --- a/changes/nodekit-factory-p1/presentation/change-card.v1.json +++ b/changes/nodekit-factory-p1/presentation/change-card.v1.json @@ -64,11 +64,24 @@ "rotation": 0, "content": "NodeKit Factory P1 | Problem -> Contract -> Proof", "style": { "color": "#17201b", "fontSize": 38, "fontWeight": 700 }, - "sourceIds": [], + "sourceIds": ["source:nodekit-factory-p1:evidence-index"], "locked": false, "exportCapabilities": ["web_native", "pptx_editable"], "version": 1 } ], - "sources": [] + "sources": [ + { + "id": "source:nodekit-factory-p1:evidence-index", + "deckId": "deck:nodekit-factory-p1", + "title": "NodeKit Factory P1 evidence index", + "sourceType": "document", + "retrievedAt": 1784515200000, + "citation": "../story/evidence-index.json", + "format": "json", + "provider": "nodekit-change-story", + "retention": "until_deleted", + "status": "ready" + } + ] } diff --git a/changes/nodekit-factory-p1/presentation/change-card.v2.json b/changes/nodekit-factory-p1/presentation/change-card.v2.json new file mode 100644 index 0000000..2ca3067 --- /dev/null +++ b/changes/nodekit-factory-p1/presentation/change-card.v2.json @@ -0,0 +1,87 @@ +{ + "deck": { + "schemaVersion": "nodeslide.slidelang/v1", + "toolchainVersion": "local-slidelang-adapter/1.1.0", + "id": "deck:nodekit-factory-p1", + "projectId": "project:nodekit", + "title": "NodeKit Factory P1", + "brief": { + "prompt": "Explain the NodeKit factory P1 rollout without upgrading draft evidence into release claims.", + "audience": "technical reviewers and hackathon builders", + "purpose": "Change review", + "successCriteria": [ + "The system boundary is understandable.", + "Verified evidence and open release gates remain distinct." + ] + }, + "theme": { + "id": "nodekit-review", + "name": "NodeKit review", + "mode": "light", + "colors": { + "canvas": "#f8f7f2", + "ink": "#17201b", + "muted": "#617068", + "accent": "#216e4e", + "accentSoft": "#dcefe5", + "insight": "#e9e3cf", + "insightInk": "#443d24", + "trace": "#132b24", + "border": "#c9cec9" + }, + "typography": { + "display": "Aptos Display", + "body": "Aptos", + "data": "Aptos Mono" + }, + "defaultRadius": 8, + "spacingUnit": 8 + }, + "slideOrder": ["deck:nodekit-factory-p1:slide:1"], + "version": 2, + "status": "ready", + "createdAt": 1784515200000, + "updatedAt": 1784515200001 + }, + "slides": [ + { + "id": "deck:nodekit-factory-p1:slide:1", + "deckId": "deck:nodekit-factory-p1", + "title": "Problem, contract, proof", + "background": "#f8f7f2", + "elementOrder": ["deck:nodekit-factory-p1:slide:1:title"], + "version": 2 + } + ], + "elements": [ + { + "id": "deck:nodekit-factory-p1:slide:1:title", + "slideId": "deck:nodekit-factory-p1:slide:1", + "name": "Title", + "kind": "text", + "role": "title", + "bbox": { "x": 0.08, "y": 0.12, "width": 0.82, "height": 0.24 }, + "rotation": 0, + "content": "P1 factory | Draft contracts, verified local seams", + "style": { "color": "#17201b", "fontSize": 38, "fontWeight": 700 }, + "sourceIds": ["source:nodekit-factory-p1:evidence-index"], + "locked": false, + "exportCapabilities": ["web_native", "pptx_editable"], + "version": 2 + } + ], + "sources": [ + { + "id": "source:nodekit-factory-p1:evidence-index", + "deckId": "deck:nodekit-factory-p1", + "title": "NodeKit Factory P1 evidence index", + "sourceType": "document", + "retrievedAt": 1784515200000, + "citation": "../story/evidence-index.json", + "format": "json", + "provider": "nodekit-change-story", + "retention": "until_deleted", + "status": "ready" + } + ] +} diff --git a/changes/nodekit-factory-p1/presentation/slide-design-plans.json b/changes/nodekit-factory-p1/presentation/slide-design-plans.json index 0c261ea..44140f0 100644 --- a/changes/nodekit-factory-p1/presentation/slide-design-plans.json +++ b/changes/nodekit-factory-p1/presentation/slide-design-plans.json @@ -21,7 +21,7 @@ "takeaway": "Filesystem-authored agents, flat manifests, compiled hashes, packs, evals, and proof form one inspectable contract.", "narrativeRole": "architecture", "dominantVisual": "architecture_diagram", - "evidenceIds": ["nodekit-pr-4", "nodeagent-pr-2"], + "evidenceIds": ["nodekit-pr-4", "nodeagent-pr-2", "noderoom-pr-219"], "maxVisibleWords": 48, "speakerNoteGoal": "Explain that runtime engines are adapters, not the application identity." }, @@ -32,7 +32,7 @@ "takeaway": "Brief, context, and sponsor requirements compile into a researched vertical slice, QA evidence, and presentation artifacts.", "narrativeRole": "workflow", "dominantVisual": "workflow", - "evidenceIds": ["nodekit-tests-26"], + "evidenceIds": ["nodekit-tests-28"], "maxVisibleWords": 45, "speakerNoteGoal": "Keep the 30-minute goal visible without presenting it as certified." }, @@ -43,7 +43,7 @@ "takeaway": "The same governed deck model can support change storytelling, embedded app editing, CLI/MCP automation, and NodeRoom collaboration.", "narrativeRole": "architecture", "dominantVisual": "architecture_diagram", - "evidenceIds": ["nodeslide-core-pr-5", "nodeslide-react-pr-6", "noderoom-pr-217"], + "evidenceIds": ["nodeslide-core-pr-5", "nodeslide-react-pr-7", "nodeslide-external-pr-10", "nodeslide-external-hosted", "nodeslide-change-card-application", "noderoom-pr-218", "featureclip-pr-4"], "maxVisibleWords": 50, "speakerNoteGoal": "Distinguish core, React, host repository, and external transports." }, @@ -54,7 +54,7 @@ "takeaway": "Map and wrap current paths first; shadow, compare, and promote one capability only after parity.", "narrativeRole": "workflow", "dominantVisual": "timeline", - "evidenceIds": ["nodebench-pr-591"], + "evidenceIds": ["nodebench-pr-592", "nodevoice-pr-4", "nodevideo-pr-30", "nodetrace-pr-3", "nodemem-pr-3"], "maxVisibleWords": 38, "speakerNoteGoal": "Use NodeBench as the concrete shape-divergent but content-complete example." }, @@ -62,10 +62,10 @@ "slideId": "proof", "job": "Show what actually passed.", "audienceQuestion": "Which parts are verified rather than planned?", - "takeaway": "Local factory, package boundaries, CAS review, and registry contracts have executable evidence across draft branches.", + "takeaway": "Local factory, package boundaries, CAS review, and registry contracts have executable evidence across merged foundations and open review branches.", "narrativeRole": "proof", "dominantVisual": "benchmark", - "evidenceIds": ["nodekit-tests-26", "nodekit-hosted-quality", "noderoom-pr-217"], + "evidenceIds": ["nodekit-tests-28", "nodekit-hosted-quality", "nodeslide-change-card-application", "nodeslide-change-card-transport", "noderoom-pr-218", "nodetasks-pr-1", "betterprhandoff-pr-4", "featureclip-pr-4"], "maxVisibleWords": 48, "speakerNoteGoal": "Name local and hosted evidence separately and avoid cumulative vanity counts." }, @@ -73,7 +73,7 @@ "slideId": "limits", "job": "Make unfinished work legible.", "audienceQuestion": "What cannot we claim yet?", - "takeaway": "No merge, publication, production deployment, repeatable under-30 guarantee, or PPTX certification occurred.", + "takeaway": "Foundational merges occurred; package publication, production deployment, repeatable under-30 certification, and PPTX certification did not.", "narrativeRole": "limitation", "dominantVisual": "code_contract", "evidenceIds": ["setup-timing-sample"], @@ -84,7 +84,7 @@ "slideId": "next", "job": "Define the next indisputable milestone.", "audienceQuestion": "What closes the loop?", - "takeaway": "Land the dependency-ordered contracts, mount NodeSlide in NodeRoom, then certify one live hackathon app from brief through export and judge-ready deck.", + "takeaway": "Land the remaining contracts, mount NodeSlide's UI and production adapter in NodeRoom, then certify one live hackathon app through export and judge-ready deck.", "narrativeRole": "next-step", "dominantVisual": "timeline", "evidenceIds": [], diff --git a/changes/nodekit-factory-p1/presentation/speaker-notes.md b/changes/nodekit-factory-p1/presentation/speaker-notes.md index e0930eb..db7cb20 100644 --- a/changes/nodekit-factory-p1/presentation/speaker-notes.md +++ b/changes/nodekit-factory-p1/presentation/speaker-notes.md @@ -2,6 +2,7 @@ This is a technical-review deck, not a release announcement. Lead with the execution problem, show the contract and dependency structure, prove the local -factory and package boundaries, then make the unmerged and unverified boundaries -explicit. Do not claim production deployment, package availability, or a -repeatable under-30 result. +factory and package boundaries, then distinguish the merged NodeSlide core/React +surfaces and NodeRoom consumer proof from the remaining review branches and release gates. Do not claim +production deployment, package availability, PPTX certification, or a repeatable +under-30 result. diff --git a/changes/nodekit-factory-p1/story/architecture-diff.json b/changes/nodekit-factory-p1/story/architecture-diff.json index 1762739..8f4b21b 100644 --- a/changes/nodekit-factory-p1/story/architecture-diff.json +++ b/changes/nodekit-factory-p1/story/architecture-diff.json @@ -10,8 +10,11 @@ "after": [ "Flat NodeKit and NodeAgent manifests compile to one hashed definition", "NodeAgent runtime/provider seam supports Pi without domain forks", - "NodeSlide packages serve build-time presentation, runtime embedding, CLI, and MCP consumers", + "NodeSlide packages serve build-time presentation, runtime embedding, CLI, and MCP consumers; deterministic candidates flow through exact proposal approval", "Change Story and evidence index feed presentation and distribution", + "BetterPRHandoff compiles real handoff evidence into the same Change Story contract", + "FeatureClipStudio projects tracked media into the same Evidence Index while preserving artifact-versus-workflow proof boundaries", + "NodeVideo, NodeVoice, and NodeTasks map existing harness and corpus reality without invented runtimes", "Brownfield applications map and wrap existing paths before shadow migration", "Proof receipts and QA skills preserve revision-bound evidence" ] diff --git a/changes/nodekit-factory-p1/story/claims.json b/changes/nodekit-factory-p1/story/claims.json index 547855a..1dca246 100644 --- a/changes/nodekit-factory-p1/story/claims.json +++ b/changes/nodekit-factory-p1/story/claims.json @@ -6,21 +6,21 @@ "id": "claim-empty-directory-factory", "text": "NodeKit can create a deterministic, local-ready agent application from an empty directory and can additively adopt an existing repository.", "status": "verified", - "evidenceIds": ["nodekit-tests-26", "nodekit-pr-4"], + "evidenceIds": ["nodekit-tests-28", "nodekit-pr-4"], "limitations": ["Live sponsor research, deployment, and production browser proof are separate gates."] }, { "id": "claim-portable-pi-seam", "text": "NodeAgent now has a proposed provider-neutral runtime contract and real Pi AI adapter rather than requiring a second domain runtime.", "status": "observed", - "evidenceIds": ["nodeagent-pr-2"], + "evidenceIds": ["nodeagent-pr-2", "noderoom-pr-219"], "limitations": ["The package is not published and production consumers have not been promoted."] }, { "id": "claim-injectable-nodeslide", - "text": "NodeSlide is proposed as a governed embeddable system with core packages, controlled React surfaces, and a second consumer in NodeRoom.", - "status": "observed", - "evidenceIds": ["nodeslide-core-pr-5", "nodeslide-react-pr-6", "noderoom-pr-217"], + "text": "NodeSlide now has merged governed core and controlled React packages plus reviewable external-agent and NodeRoom consumer seams.", + "status": "verified", + "evidenceIds": ["nodeslide-core-pr-5", "nodeslide-react-pr-7", "nodeslide-external-pr-10", "nodeslide-external-hosted", "nodeslide-change-card-application", "nodeslide-change-card-transport", "noderoom-pr-218"], "limitations": ["The mounted production UI and PPTX export/reopen path are not certified."] }, { @@ -32,10 +32,31 @@ }, { "id": "claim-brownfield", - "text": "A mature application can map existing paths and capability ownership before replacing its runtime.", + "text": "Mature applications can content-bind existing paths, packs, subagents, and evaluations before replacing their runtimes.", "status": "observed", - "evidenceIds": ["nodebench-pr-591"], + "evidenceIds": ["github-ecosystem-audit", "ecosystem-conformance", "nodebench-pr-592", "nodevoice-pr-4", "nodevideo-pr-30", "nodetrace-pr-3", "nodemem-pr-3"], "limitations": ["Native runtime shadow parity has not run."] + }, + { + "id": "claim-corpus-boundary", + "text": "NodeTasks supplies a structurally validated 9,155-task evaluation corpus without upgrading proxy tasks into official benchmark scores.", + "status": "verified", + "evidenceIds": ["nodetasks-pr-1"], + "limitations": ["The corpus receipt is not an application quality score or leaderboard result."] + }, + { + "id": "claim-handoff-present", + "text": "BetterPRHandoff can deterministically transform a real archived handoff into NodeKit Present story artifacts and a content-hashed receipt.", + "status": "verified", + "evidenceIds": ["betterprhandoff-pr-4"], + "limitations": ["The transformation does not independently re-verify current implementation evidence; direct deck rendering and PPTX export remain separate NodeSlide steps."] + }, + { + "id": "claim-featureclip-present", + "text": "FeatureClipStudio can deterministically project tracked clips and screenshots into NodeKit's Evidence Index without conflating artifact integrity with product-workflow proof.", + "status": "observed", + "evidenceIds": ["featureclip-pr-4"], + "limitations": ["The current assets have no independent browser or judge receipts, so release readiness and product-workflow proof remain uncertified."] } ] } diff --git a/changes/nodekit-factory-p1/story/evidence-index.json b/changes/nodekit-factory-p1/story/evidence-index.json index d90b27b..1a867e5 100644 --- a/changes/nodekit-factory-p1/story/evidence-index.json +++ b/changes/nodekit-factory-p1/story/evidence-index.json @@ -3,18 +3,18 @@ "changeId": "nodekit-factory-p1", "evidence": [ { - "id": "nodekit-tests-26", + "id": "nodekit-tests-28", "kind": "test-run", "status": "verified", "location": "local://node-platform/npm-test", - "summary": "NodeKit contract, compiler, factory, adoption, no-key proof, arbitrary authoring-root binding, and registry suite passed 26 of 26 tests." + "summary": "NodeKit contract, compiler, factory, adoption, no-key proof, arbitrary authoring-root binding, and registry suite passed 28 of 28 tests." }, { "id": "nodekit-hosted-quality", "kind": "ci-run", "status": "verified", - "location": "https://github.com/HomenShum/node-platform/actions/runs/29713688834", - "summary": "Hosted NodeKit quality workflow passed before the final rollout-ledger update." + "location": "https://github.com/HomenShum/node-platform/actions/runs/29715497173", + "summary": "Hosted NodeKit quality workflow passed through the adapter-authored subagent compiler fix." }, { "id": "nodekit-pr-4", @@ -23,6 +23,20 @@ "location": "https://github.com/HomenShum/node-platform/pull/4", "summary": "Factory, compiler, launch/presentation/QA skills, deterministic evaluation, and local proof are proposed as a draft." }, + { + "id": "github-ecosystem-audit", + "kind": "repository-audit", + "status": "observed", + "location": "../../../docs/GITHUB_ECOSYSTEM_AUDIT.md", + "summary": "The authenticated GitHub inventory classified the first 100 returned repositories and bound the active nucleus, pack candidates, consolidation candidates, and exclusions to explicit governance rules." + }, + { + "id": "ecosystem-conformance", + "kind": "conformance-run", + "status": "verified", + "location": "../../../proof/p1-factory-rollout.json", + "summary": "The assembled workspace passed all 13 tracked repository checks, classified 14 discovered signatures, and required zero architecture exceptions. Observed runtimes ranged from 14.59 seconds to 69.8 seconds, so audit latency is not certified under 30 seconds." + }, { "id": "nodeagent-pr-2", "kind": "pull-request", @@ -35,14 +49,42 @@ "kind": "pull-request", "status": "observed", "location": "https://github.com/HomenShum/NodeSlide/pull/5", - "summary": "Host-neutral NodeSlide contracts, engine, backend port, and conformance testkit are proposed as a draft." + "summary": "Host-neutral NodeSlide contracts, engine, backend port, and conformance testkit passed their package proof and merged through pull request #5." }, { - "id": "nodeslide-react-pr-6", + "id": "nodeslide-react-pr-7", "kind": "pull-request", "status": "observed", - "location": "https://github.com/HomenShum/NodeSlide/pull/6", - "summary": "Controlled React/headless proposal review surfaces are proposed as a draft stacked on NodeSlide core." + "location": "https://github.com/HomenShum/NodeSlide/pull/7", + "summary": "Controlled React proposal-review surfaces passed 645 tests and merged directly on the injectable core." + }, + { + "id": "nodeslide-external-pr-10", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeSlide/pull/10", + "summary": "Safe CLI and MCP access inspected, validated, proposed, and exactly approved this change card; 655 tests and packed consumer proof pass." + }, + { + "id": "nodeslide-external-hosted", + "kind": "ci-run", + "status": "verified", + "location": "https://github.com/HomenShum/NodeSlide/actions/runs/29717653092", + "summary": "The restacked NodeSlide external-agent branch passed hosted app/packages, MCP, NodeKit conformance, and security checks at head 7815b44." + }, + { + "id": "nodeslide-change-card-application", + "kind": "application-receipt", + "status": "verified", + "location": "../presentation/change-card.application.json", + "summary": "NodeSlide compiled a source-bound change card deterministically and applied its exact approved proposal from deck version 1 to 2." + }, + { + "id": "nodeslide-change-card-transport", + "kind": "transport-proof", + "status": "verified", + "location": "../presentation/change-card.transport-proof.json", + "summary": "The packed external-agent transport proves digest parity, exact proposal approval, and confined no-clobber file application; it does not certify PPTX export or deployment." }, { "id": "nodeproof-pr-22", @@ -52,24 +94,80 @@ "summary": "The proofloop.receipt/v1 envelope and verifier CLI are proposed as a draft." }, { - "id": "noderoom-pr-217", + "id": "noderoom-pr-218", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeRoom/pull/218", + "summary": "Merged NodeRoom pull request #218 consumes NodeSlide packages and proves review, accept, stale CAS, versions, and receipts without modifying its production runtime." + }, + { + "id": "noderoom-pr-219", "kind": "pull-request", "status": "observed", - "location": "https://github.com/HomenShum/NodeRoom/pull/217", - "summary": "NodeRoom consumes NodeSlide packages and proves review, accept, stale CAS, versions, and receipts without modifying its production runtime." + "location": "https://github.com/HomenShum/NodeRoom/blob/65f8cfe4b2bd0c3665e7d111ce3e9e1b0df702d4/nodekit.yaml", + "summary": "Draft pull request #219 classifies NodeRoom's existing Pi adapter and merged NodeSlide contract consumption without changing runtime or deployment behavior." }, { - "id": "nodebench-pr-591", + "id": "nodebench-pr-592", "kind": "pull-request", "status": "observed", - "location": "https://github.com/HomenShum/NodeBenchAI/pull/591", - "summary": "NodeBench brownfield manifests, logical Entity Intelligence pack, eval bindings, and compiled definition are proposed without replacing its runtime." + "location": "https://github.com/HomenShum/NodeBenchAI/blob/8eb9872e094df1a240394fa1352ab644946d2406/nodekit.yaml", + "summary": "Draft pull request #592 maps NodeBench's brownfield manifests, Entity Intelligence pack, evaluations, and policy-context consumption while repairing Convex preflight to follow the configured functions directory; it does not replace the runtime." + }, + { + "id": "nodetrace-pr-3", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeTrace/pull/3", + "summary": "NodeTrace event-consumption mapping is restacked cleanly on its merged P0 manifest; happy path, smoke, and build pass." + }, + { + "id": "nodemem-pr-3", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeMem/pull/3", + "summary": "NodeMem event-consumption mapping, dev-command repair, and broader deterministic proof are restacked cleanly on P0." + }, + { + "id": "nodevoice-pr-4", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeVoice/pull/4", + "summary": "The existing voice runtime, logical pack, skill, and deterministic eval are content-bound without fabricating a durable receipt." + }, + { + "id": "nodevideo-pr-30", + "kind": "pull-request", + "status": "observed", + "location": "https://github.com/HomenShum/NodeVideo/pull/30", + "summary": "The existing Eve directory, three adapter-authored subagents, song-conditioned pack, and eval bindings are content-bound; the pre-existing default timeout remains disclosed." + }, + { + "id": "nodetasks-pr-1", + "kind": "corpus-receipt", + "status": "verified", + "location": "https://github.com/HomenShum/NodeTasks/blob/922d202568c64cc154c698eed961b6f6fc71d47f/proof/corpus-receipt.json", + "summary": "At immutable head 922d202, draft pull request #1 validates the 9,155-task corpus with zero source drift and zero official-score claims while preserving task ID/order, score-flag, and provenance semantics." + }, + { + "id": "betterprhandoff-pr-4", + "kind": "handoff-receipt", + "status": "verified", + "location": "https://github.com/HomenShum/BetterPRHandoff/blob/79a1e25c02df89ac0031cbfffa3cce7252f687fe/changes/nodebench-redesign-chat-sprints-1-4-v2/evidence/tests/nodekit-present-receipt.json", + "summary": "At immutable head 79a1e25, draft pull request #4 deterministically transforms an archived handoff into Change Story, claims, Evidence Index, architecture diff, limitations, and a content-hashed receipt; it does not independently re-verify the current implementation." + }, + { + "id": "featureclip-pr-4", + "kind": "media-evidence-receipt", + "status": "observed", + "location": "https://github.com/HomenShum/FeatureClipStudio/blob/36dd7741e53a6cc66e4ea0eac48d8717fa60ee69/proof/featureclip-evidence.receipt.json", + "summary": "At immutable head 36dd774, draft pull request #4 content-binds three real tracked media artifacts; all remain observed because no independent browser or judge receipt exists." }, { "id": "setup-timing-sample", "kind": "benchmark-observation", "status": "measured", - "location": "../../proof/p1-factory-rollout.json", + "location": "../../../proof/p1-factory-rollout.json", "summary": "Observed local-ready times ranged from 17.72 to 36.37 seconds; the latest pnpm local-proof run took 31.86 seconds, dominated by dependency installation." } ] diff --git a/changes/nodekit-factory-p1/story/limitations.json b/changes/nodekit-factory-p1/story/limitations.json index 01ede19..8a8081c 100644 --- a/changes/nodekit-factory-p1/story/limitations.json +++ b/changes/nodekit-factory-p1/story/limitations.json @@ -2,12 +2,12 @@ "schemaVersion": "nodekit.change-limitations/v1", "changeId": "nodekit-factory-p1", "limitations": [ - "Draft pull requests are not merged releases.", + "Merged NodeSlide core/React changes are not a complete NodeKit release; the remaining pull requests still require coordinated review.", "No npm package was published.", "No production application was deployed in this rollout.", "No paid resource was activated and no destructive migration ran.", "Under-30 local setup was observed once but not repeated consistently.", - "The NodeSlide presentation produced by this change may be evidence-bound before it is PPTX export-verified.", + "The NodeSlide change card is source-bound and transport-verified, but its full claims are not independently evidence-certified and its PPTX export/reopen path is not verified.", "NodeBench remains on its existing runtime until shadow parity proves promotion is safe." ] } diff --git a/docs/GITHUB_ECOSYSTEM_AUDIT.md b/docs/GITHUB_ECOSYSTEM_AUDIT.md new file mode 100644 index 0000000..026fece --- /dev/null +++ b/docs/GITHUB_ECOSYSTEM_AUDIT.md @@ -0,0 +1,134 @@ +# GitHub Ecosystem Audit + +Snapshot: 2026-07-19 (America/Los_Angeles) + +This audit inspected the first 100 repositories returned by the authenticated +`HomenShum` GitHub account listing. The result set was saturated at the query +limit, so `100` is a lower bound rather than a claim that the account contains +exactly 100 repositories. + +## Portfolio shape + +| Signal | Observed | +|---|---:| +| Repositories returned | 100 | +| Owner-authored rather than forks | 78 | +| Forks | 22 | +| Private repositories | 13 | +| GitHub-archived repositories | 0 | +| Pushed since 2026-06-19 | 36 | + +The central problem is not a lack of capability. It is duplicated ownership and +unclear promotion boundaries across many good experiments. NodeKit should track +the actively composed product system, not force every historical project, fork, +or case study to pretend it is a canonical agent runtime. + +## Active NodeKit nucleus + +These repositories form the current executable platform and are registered in +`repositories.yaml`: + +| Domain | Canonical owner or consumer | +|---|---| +| Repository contract, factory, adoption, Change Story | `node-platform` / NodeKit | +| Runtime events, policy, agent runs, Pi provider adapter | NodeAgent | +| Collaboration, artifact CAS, human-agent room | NodeRoom | +| Deck model, patching, React surfaces, CLI/MCP | NodeSlide | +| Portable certification receipt | NodeProof | +| Trace presentation | NodeTrace | +| Durable memory implementation | NodeMem | +| Media-agent harness | NodeVideo | +| Voice-room harness | NodeVoice | +| Brownfield entity-intelligence application | NodeBenchAI | +| Agentic browser/product QA protocol | agentic-ui-qa | +| Evaluation corpus and corpus receipt | NodeTasks | +| Handoff-to-Change-Story compiler | BetterPRHandoff | +| Clip/screenshot-to-Evidence-Index compiler | FeatureClipStudio | + +The assembled conformance workspace passed all 13 tracked executable/protocol +repositories, classified all discovered canonical signatures, and required zero +architecture exceptions. NodeBenchAI remains deliberately `untracked` for +executable ecosystem gates until it has an honest finite no-key lifecycle; its +brownfield map can still progress independently. + +## Promote as capability packs or adapters + +These repositories contain reusable material, but should enter NodeKit through a +narrow contract rather than becoming new platform runtimes: + +- `NodeGraph`: graph/bridge capability pack after its unique graph contract is + identified. +- `NodeSEO`: deterministic SEO research and proof pack. +- `AgentRedteam` and `agent-era-maturity-model`: evaluation/risk inputs for + NodeProof and NodeBench, not separate orchestration layers. +- `FreeAgentResources`: research catalog consumed by NodeKit launch research. +- `NodeRL`: reward, repair, and export contracts only; freeze copied trace, + memory, and evaluation implementations. +- `parity-studio`: UI-kit staging and comparison adapter; stop adding embedded + NodeSlide domain behavior once package parity exists. +- `GmailInbox`, `scratchnode-live`, `floorai`, `propertyai`, and `courseai`: + brownfield application fixtures that can prove NodeKit adoption in additional + domains without owning another runtime contract. +- `solo-founder-agent-builder` and `solo-founder-3d-proof-run`: source material + and acceptance fixtures for the brief-to-proof launch workflow. + +## Consolidate or redirect after preserving unique material + +No destructive archive action is part of this rollout. These are reviewed +consolidation candidates: + +- `NodeBenchBoilerplate` should redirect to `nodekit create` or become a generated + fixture. +- `NodeAgentSpec` should contribute authoritative material to NodeAgent/NodeKit + docs and cease acting like a parallel specification. +- `agent-workspace-template` should become a NodeKit preset after its unique + application-shell behavior is extracted. +- `VisualJudge` should become an adapter or fixture under agentic-ui-qa and + FeatureClipStudio. +- `NodeBenchClean` can archive only after the public NodeBenchAI surface reaches + functional and cleanliness parity. +- `proofloop-fork` should remain a clearly labeled upstream experiment; NodeProof + owns the Node ecosystem certification contract. + +## Keep as references, case studies, or independent products + +Forks such as `rocketride-benchmark`, `open-codesign`, and tool/MCP forks are +upstream references, not NodeKit conformance targets. Older domain projects such +as Parsely, healthcare prototypes, finance/research tools, and hackathon entries +remain valuable case studies and fixture sources. Migrating all of them now would +increase ceremony without improving the under-30-minute launch path. + +## Governance rule + +Register a repository as an active NodeKit ecosystem member only when at least +one of these is true: + +1. it owns a canonical contract; +2. it consumes a canonical contract in an actively tested product path; +3. it distributes a reusable protocol, pack, corpus, or proof tool; +4. it is a brownfield migration target with an explicit, honest conformance + level. + +Everything else stays visible in the audit but outside mandatory ecosystem +checks. This prevents uniform directory aesthetics from replacing actual +interoperability. + +## Immediate executed outcomes + +- NodeSlide core and controlled React packages merged. +- NodeRoom's package consumer proof merged. +- NodeSlide CLI/MCP transport was restacked on current main and passed hosted + package, MCP, NodeKit, security, and 655-test gates. +- NodeRoom's existing Pi adapter is explicitly classified against NodeAgent's + provider contract. +- NodeTasks and BetterPRHandoff now own their emitted receipt contracts. +- FeatureClipStudio emits an honest media evidence receipt and NodeKit Evidence + Index without claiming browser/judge certification. +- Central source scanning ignores generated JSON and temporary proof copies and + scans each repository once. One optimized assembled run completed in 14.59 + seconds, but the final cold rerun took 69.8 seconds; ecosystem-audit latency is + improved without being certified as repeatably under 30 seconds. + +The next portfolio-level proof is not another repository. It is one generated +hackathon application that uses these contracts from brief through interactive +prototype, production browser proof, editable deck, and presentation media. diff --git a/docs/P1_FACTORY_ROLLOUT.md b/docs/P1_FACTORY_ROLLOUT.md index 77b3c63..2ddcf6c 100644 --- a/docs/P1_FACTORY_ROLLOUT.md +++ b/docs/P1_FACTORY_ROLLOUT.md @@ -1,8 +1,13 @@ # Node Platform P1 Factory Rollout +Portfolio classification and consolidation decisions are recorded in +[`GITHUB_ECOSYSTEM_AUDIT.md`](GITHUB_ECOSYSTEM_AUDIT.md). + P1 turns the P0 ownership map into executable seams. The work is intentionally -split into reviewable draft pull requests; nothing in this ledger implies that -the branches are merged, published to npm, or deployed to production. +split into dependency-ordered pull requests. NodeSlide core, its controlled +React surface, and the NodeRoom consumer proof have merged; the remaining rows +are review branches unless explicitly labeled otherwise. Nothing here implies +npm publication or production deployment. ## Current pull-request set @@ -10,17 +15,26 @@ the branches are merged, published to npm, or deployed to production. |---|---|---|---| | Brief-to-app factory and frozen manifests | [node-platform #4](https://github.com/HomenShum/node-platform/pull/4) | Empty-directory create, additive adopt, canonical compiler, arbitrary brownfield authoring roots bound into the config hash, Pi seam, deterministic eval/proof, Launch/QA/Present skills for Codex and Claude, evidence-bound presentation lane | Repeatable under-30 setup, live sponsor research, browser/deploy certification, npm release | | Portable runtime event and Pi adapter | [NodeAgent #2](https://github.com/HomenShum/NodeAgent/pull/2) | `nodeagent.event/v1`, provider-neutral adapter, real Pi streaming/tool/usage translation, package tarball proof | npm release and production consumer promotion | -| Injectable presentation core | [NodeSlide #5](https://github.com/HomenShum/NodeSlide/pull/5) | Host-neutral deck/repository contracts, proposal-before-apply, CAS, versions, receipts, memory adapter, conformance testkit | Production adapters and export/reopen certification | -| Controlled presentation UI | [NodeSlide #6](https://github.com/HomenShum/NodeSlide/pull/6) | Backend-neutral React package, accessible read-only deck viewer, deterministic proposal comparison, fail-closed review callbacks, scoped styling, tarball SSR proof | Editable canvas, presenter, host-mounted browser proof, media-egress authority | +| Injectable presentation core | [NodeSlide #5](https://github.com/HomenShum/NodeSlide/pull/5) (merged) | Host-neutral deck/repository contracts, proposal-before-apply, CAS, versions, receipts, memory adapter, conformance testkit | Production adapters and export/reopen certification | +| Controlled presentation UI | [NodeSlide #7](https://github.com/HomenShum/NodeSlide/pull/7) (merged) | Backend-neutral React package, accessible read-only deck viewer, deterministic proposal comparison, fail-closed review callbacks, scoped styling, tarball SSR proof | Editable canvas, presenter, host-mounted browser proof, media-egress authority | +| External presentation agents | [NodeSlide #10](https://github.com/HomenShum/NodeSlide/pull/10) | Safe CLI and MCP inspect/validate/propose/apply, deterministic candidates, exact approval, path confinement, no-clobber writes, packed consumer proof | Publication, hosted transport promotion, and PPTX export/reopen certification | | General proof envelope | [NodeProof #22](https://github.com/HomenShum/NodeProof/pull/22) | `proofloop.receipt/v1`, content hashes, authority classification, CLI verification | Consumer migration and signature policy | -| Trace consumer registration | [NodeTrace #2](https://github.com/HomenShum/NodeTrace/pull/2) | Truthful L1/L2 NodeKit adoption and deterministic trace path | Canonical event ingestion adapter | -| Memory consumer registration | [NodeMem #2](https://github.com/HomenShum/NodeMem/pull/2) | Truthful L1/L2 adoption, repaired demo command, deterministic proof | Canonical event ingestion and runtime memory adapter | -| NodeSlide second-consumer proof | [NodeRoom #217](https://github.com/HomenShum/NodeRoom/pull/217) | Package/tarball consumption, review, accept, stale CAS, versions, receipts, NodeRoom-auth normalization | Mounted UI, production repository adapter, ActorProof server binding, PPTX browser proof | -| Brownfield application alignment | [NodeBenchAI #591](https://github.com/HomenShum/NodeBenchAI/pull/591) | Canonical manifests, logical Entity Intelligence pack, existing eval bindings, compiled definition | No-key profile, native runtime shadow parity, Pi promotion, canonical receipts | - -NodeSlide #6 and the external-agent interface are stacked on NodeSlide #5 so the -core package boundary can be reviewed independently. They must not be retargeted -to `main` until their base dependency lands. +| Trace consumer registration | [NodeTrace #3](https://github.com/HomenShum/NodeTrace/pull/3) | Truthful L1/L2 NodeKit adoption and deterministic trace path, restacked after P0 merged | Canonical event ingestion adapter | +| Memory consumer registration | [NodeMem #3](https://github.com/HomenShum/NodeMem/pull/3) | Truthful L1/L2 adoption, repaired dev command, broader deterministic proof, restacked after P0 merged | Canonical event ingestion and runtime memory adapter | +| NodeSlide second-consumer proof | [NodeRoom #218](https://github.com/HomenShum/NodeRoom/pull/218) (merged) | Package/tarball consumption, review, accept, stale CAS, versions, receipts, NodeRoom-auth normalization | Mounted UI, production repository adapter, ActorProof server binding, PPTX browser proof | +| NodeRoom contract reconciliation | [NodeRoom #219](https://github.com/HomenShum/NodeRoom/pull/219) | Existing Pi adapter and merged NodeSlide deck/patch consumption are explicitly classified with zero architecture exceptions | Merge after NodeKit ownership registry lands; no runtime behavior changes | +| Brownfield application alignment | [NodeBenchAI #592](https://github.com/HomenShum/NodeBenchAI/pull/592) | Canonical manifests, logical Entity Intelligence pack, existing eval bindings, compiled definition, policy-context consumption, Convex preflight derived from `convex.json` | No-key profile, native runtime shadow parity, Pi promotion, canonical receipts | +| Existing voice harness map | [NodeVoice #4](https://github.com/HomenShum/NodeVoice/pull/4) | Existing `src/nodeagents` runtime, logical voice-room pack, deterministic eval, and honest missing-receipt boundary are content-bound | Canonical durable receipt and production runtime parity | +| Existing Eve harness map | [NodeVideo #30](https://github.com/HomenShum/NodeVideo/pull/30) | Existing Eve directory, three adapter-authored subagents, song-conditioned pack, and eval bindings are content-bound | Default 5-second test gate, credentialed live eval, and shared-path parity | +| Task-corpus registration | [NodeTasks #1](https://github.com/HomenShum/NodeTasks/pull/1) | 9,155-task corpus, source index, score-claim boundary, provenance, and content hashes validate deterministically | Consumer evaluation composition; no official benchmark score is claimed | +| PR handoff to Present | [BetterPRHandoff #4](https://github.com/HomenShum/BetterPRHandoff/pull/4) | Real handoff payload compiles into Change Story, claims, Evidence Index, architecture diff, limitations, and a hashed receipt | Direct NodeSlide deck compilation and release presentation proof | +| Verified media to Present | [FeatureClipStudio #4](https://github.com/HomenShum/FeatureClipStudio/pull/4) | Tracked clips and screenshots project into a content-addressed Evidence Index with strict containment, provenance, media-signature, and drift checks | Independent browser/judge receipts, clean dependency install, dependency audit repair, and release certification | + +NodeSlide core #5, React surface #7, and NodeRoom consumer proof #218 are merged. The obsolete stacked React #6 +and external-agent #8 were closed rather than force-pushed; external-agent #10 is +restacked directly on current `main` with hosted checks green. NodeRoom #218 superseded #217 with +the exact consumer-proof tree and a correctly classified commit. +NodeBench #592 similarly supersedes #591 without force-pushing its review history. ## Dependency order @@ -30,10 +44,10 @@ NodeKit contracts + NodeAgent event/Pi seam +--> NodeBench brownfield mapping -> shadow parity later +--> NodeTrace / NodeMem consumer adapters later -NodeSlide core - +--> external CLI/MCP - +--> controlled React/headless UI - +--> NodeRoom consumer proof -> mounted production integration later +NodeSlide core (merged) + +--> controlled React surfaces (merged) + +--> external CLI/MCP (#10) + +--> NodeRoom consumer proof (merged #218) -> mounted production integration later ProofLoop receipt envelope +--> application receipt migration and release certification later @@ -52,12 +66,17 @@ Dependency extraction and process startup are the current bottleneck; the factory must preserve every observed timing rather than publishing only the fastest run. +The assembled 13-repository conformance workspace also passed with zero +architecture exceptions. Its observed runtime varied from 14.59 seconds on one +optimized run to 69.8 seconds on the final cold rerun, so that audit path is not +presented as an under-30 guarantee either. + ## Review-complete gate P1 is ready for coordinated merge review only when: -1. every listed pull request remains mergeable against its documented base; -2. stacked NodeSlide changes name NodeSlide #5 as their base dependency; +1. every open pull request remains mergeable against its documented base; +2. superseded branches remain closed and replacement PRs target the merged base; 3. local validation evidence and available hosted checks are green; 4. the central registry still passes with no undeclared protocol copy; 5. no branch claims npm publication, production deployment, or browser proof @@ -69,7 +88,8 @@ P1 is ready for coordinated merge review only when: ## P1 boundary This rollout creates the reusable factory, runtime/provider seam, proof -envelope, presentation core, brownfield bridge, and first real consumer proof. +envelope, merged presentation core/React surface, external-agent transport, +brownfield bridge, task corpus, handoff compiler, and first real consumer proof. It does not yet finish production installation of NodeSlide inside NodeRoom, replace NodeBench's mature runtime, migrate every repository to canonical events/receipts, publish packages, or certify a fully live hackathon app from a @@ -89,12 +109,12 @@ look uniform. | Repository | Current truth | P1 action | |---|---|---| -| NodeVideo | Flat NodeKit registration; production-shaped Eve agent under `apps/eve-agent/agent`; no root application manifest | Map the existing Eve directory, packs, and evals through one root `nodeagent.yaml`; do not move the live harness first | -| NodeVoice | Flat NodeKit registration; runtime under `src/nodeagents`; proof receipt is not yet canonical | Brownfield-map the current runtime, logically extract one voice-room pack, and define only receipts supported by actual execution | -| NodeTasks | Unregistered 9,155-task corpus with useful fixtures plus vendored upstream snapshots | Register as a task corpus, emit a corpus validation receipt, and replace vendored runtime ownership with provenance references; no product-agent manifest | -| agentic-ui-qa | Valid NodeKit protocol with a self-check receipt | Install it from NodeKit as the default QA skill/pack; no product-agent manifest | -| BetterPRHandoff | Focused handoff protocol | Feed its structured handoff into NodeKit Present and the Change Story contract | -| FeatureClipStudio | Reproducible demo-video transport | Add a presentation evidence adapter for verified clips and screenshots | +| NodeVideo | Existing Eve harness mapped in #30 | Run the credentialed live eval and prove local/eval/production path parity without replacing Eve first | +| NodeVoice | Existing runtime and voice-room pack mapped in #4 | Add a canonical durable receipt supported by real execution | +| NodeTasks | Corpus registered and validated in #1 | Compose selected corpus lanes into application evaluation plans; retain the no-official-score boundary | +| agentic-ui-qa | Installed by NodeKit as the default `nodekit-qa` skill | Promote only after cross-host browser proof; no product-agent manifest | +| BetterPRHandoff | Real handoff payload compiles into NodeKit Present inputs in #4 | Feed the compiled evidence directly into NodeSlide and verify presentation export | +| FeatureClipStudio | Presentation-evidence bridge proposed in #4 | Add independent browser/judge receipts and feed verified clips into NodeSlide without upgrading observed artifacts into workflow proof | | parity-studio | UI-kit staging surface that still embeds NodeSlide domain code | Keep it as a staging product, but freeze new embedded NodeSlide capability after standalone package parity | NodeBenchAI remains registered as `untracked` until it has an honest finite diff --git a/nodekit.yaml b/nodekit.yaml index 6f4225b..2185bde 100644 --- a/nodekit.yaml +++ b/nodekit.yaml @@ -7,8 +7,16 @@ commandProfile: platform canonicalFor: - nodeplatform.repo-contract + - nodeplatform.change-story -consumes: [] +consumes: + - betterprhandoff.handoff + - betterprhandoff.nodekit-present-receipt + - featureclip.evidence-manifest + - featureclip.evidence-receipt + - nodetasks.corpus-receipt + - nodeslide.deck + - nodeslide.deck-patch commands: doctor: diff --git a/ownership.yaml b/ownership.yaml index 4896c57..796904f 100644 --- a/ownership.yaml +++ b/ownership.yaml @@ -17,6 +17,64 @@ concepts: - NodeMem - NodeProof - agentic-ui-qa + - NodeTasks + - BetterPRHandoff + - FeatureClipStudio + + nodeplatform.change-story: + owner: node-platform + package: "@homenshum/nodekit" + targetPackage: "@homenshum/nodekit" + status: canonical + contractVersion: nodekit.change-story/v1 + consumers: + - BetterPRHandoff + - FeatureClipStudio + + featureclip.evidence-manifest: + owner: FeatureClipStudio + package: null + targetPackage: null + status: canonical-domain + contractVersion: featureclip.evidence-manifest/v1 + consumers: + - node-platform + + featureclip.evidence-receipt: + owner: FeatureClipStudio + package: null + targetPackage: null + status: canonical-domain + contractVersion: featureclip.evidence-receipt/v1 + consumers: + - node-platform + + betterprhandoff.handoff: + owner: BetterPRHandoff + package: "@homenshum/easier-to-read-submissions" + targetPackage: "@homenshum/easier-to-read-submissions" + status: canonical + contractVersion: betterprhandoff.handoff/v1 + consumers: + - node-platform + + betterprhandoff.nodekit-present-receipt: + owner: BetterPRHandoff + package: "@homenshum/easier-to-read-submissions" + targetPackage: "@homenshum/easier-to-read-submissions" + status: canonical + contractVersion: betterprhandoff.nodekit-present-receipt/v1 + consumers: + - node-platform + + nodetasks.corpus-receipt: + owner: NodeTasks + package: null + targetPackage: null + status: canonical-domain + contractVersion: nodetasks.corpus-receipt/v1 + consumers: + - node-platform nodeplatform.environment: owner: node-platform @@ -54,6 +112,7 @@ concepts: status: canonical-unpackaged contractVersion: nodeagent.policy/v1 consumers: + - NodeBenchAI - NodeRoom - NodeSlide - NodeVideo @@ -92,7 +151,8 @@ concepts: targetPackage: "@nodeagent/provider-pi" status: canonical-unpackaged contractVersion: nodeagent.provider-pi/v1 - consumers: [] + consumers: + - NodeRoom signatures: - id: agent-model-adapter regex: '^\s*export\s+function\s+createPiAiAdapter\b' @@ -177,19 +237,23 @@ concepts: nodeslide.deck: owner: NodeSlide - package: null - targetPackage: null - status: canonical-domain + package: "@nodeslide/contracts" + targetPackage: "@nodeslide/contracts" + status: canonical-unpackaged contractVersion: nodeslide.slidelang/v1 - consumers: [] + consumers: + - node-platform + - NodeRoom nodeslide.deck-patch: owner: NodeSlide - package: null - targetPackage: null - status: canonical-domain + package: "@nodeslide/contracts" + targetPackage: "@nodeslide/contracts" + status: canonical-unpackaged contractVersion: nodeslide.deck-patch/v1 - consumers: [] + consumers: + - node-platform + - NodeRoom signatures: - id: nodeslide-patch-operation regex: '^\s*export\s+type\s+PatchOperation\b' diff --git a/repositories.yaml b/repositories.yaml index 7f8ea32..62c112e 100644 --- a/repositories.yaml +++ b/repositories.yaml @@ -71,6 +71,27 @@ repositories: role: qa-protocol commandProfile: protocol + - name: NodeTasks + github: HomenShum/NodeTasks + lifecycle: preview + support: active + role: evaluation-corpus + commandProfile: protocol + + - name: BetterPRHandoff + github: HomenShum/BetterPRHandoff + lifecycle: production + support: active + role: handoff-protocol + commandProfile: protocol + + - name: FeatureClipStudio + github: HomenShum/FeatureClipStudio + lifecycle: preview + support: active + role: presentation-evidence-tool + commandProfile: protocol + - name: NodeAgentSpec github: HomenShum/NodeAgentSpec lifecycle: reference diff --git a/src/lib/files.mjs b/src/lib/files.mjs index 56db012..21060ab 100644 --- a/src/lib/files.mjs +++ b/src/lib/files.mjs @@ -6,7 +6,6 @@ const SOURCE_EXTENSIONS = new Set([ ".cjs", ".js", ".jsx", - ".json", ".mjs", ".ts", ".tsx", @@ -54,6 +53,7 @@ export async function listSourceFiles(root) { for (const entry of entries) { if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith(".node-platform")) continue; + if (entry.isDirectory() && entry.name.startsWith(".tmp")) continue; const absolute = path.join(directory, entry.name); if (entry.isDirectory()) { diff --git a/src/lib/repo-check.mjs b/src/lib/repo-check.mjs index e7bc59e..7581081 100644 --- a/src/lib/repo-check.mjs +++ b/src/lib/repo-check.mjs @@ -54,9 +54,8 @@ function declaredNpmCommandExists(command, packageJson) { return Boolean(match && typeof packageJson?.scripts?.[match[1]] === "string"); } -async function scanContracts(repoRoot, registry, manifest) { +async function scanContracts(registry, manifest, files) { const findings = []; - const files = await listSourceFiles(repoRoot); const declarations = new Set( (manifest.contractDeclarations ?? []).map((entry) => declarationKey(entry.concept, entry.signature, entry.path), @@ -86,9 +85,8 @@ async function scanContracts(repoRoot, registry, manifest) { return findings; } -async function scanArchitecture(repoRoot, registry, manifest) { +async function scanArchitecture(registry, manifest, files) { const findings = []; - const files = await listSourceFiles(repoRoot); const exceptions = new Set( (manifest.architectureExceptions ?? []).map( (entry) => `${entry.rule}\0${normalizePath(entry.path)}`, @@ -317,7 +315,12 @@ export async function checkRepository(repoRoot, registry) { } } - const contractFindings = await scanContracts(repoRoot, registry, manifest); + // Source discovery can dominate ecosystem checks for mature repositories. + // Discover executable source once, then reuse it for contract and + // architecture scans. JSON manifests/catalogs are schema-validated through + // their dedicated paths and are intentionally not treated as source code. + const sourceFiles = await listSourceFiles(repoRoot); + const contractFindings = await scanContracts(registry, manifest, sourceFiles); const findingKeys = new Set( contractFindings.map((finding) => declarationKey(finding.concept, finding.signature, finding.path)), ); @@ -348,7 +351,7 @@ export async function checkRepository(repoRoot, registry) { } } - const sourceFindings = await scanArchitecture(repoRoot, registry, manifest); + const sourceFindings = await scanArchitecture(registry, manifest, sourceFiles); const sourceFindingKeys = new Set( sourceFindings.map((finding) => `${finding.rule}\0${normalizePath(finding.path)}`), ); diff --git a/test/conformance.test.mjs b/test/conformance.test.mjs index 4d2e328..8f39978 100644 --- a/test/conformance.test.mjs +++ b/test/conformance.test.mjs @@ -101,6 +101,31 @@ test("a classified adapter signature is accepted", async (t) => { assert.equal(result.passed, true, result.errors.join("\n")); }); +test("generated JSON metadata is not scanned as executable source", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-json-metadata-")); + t.after(() => rm(root, { force: true, recursive: true })); + await writeFixture(root, { declaration: true }); + await writeFile( + path.join(root, "src", "components", "generated.json"), + JSON.stringify({ renderedCode: 'import OpenAI from "openai";' }), + ); + const result = await checkRepository(root, await loadRegistry(platformRoot)); + assert.equal(result.passed, true, result.errors.join("\n")); +}); + +test("ignored temporary proof copies are not scanned as repository source", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-temp-copy-")); + t.after(() => rm(root, { force: true, recursive: true })); + await writeFixture(root, { declaration: true }); + await mkdir(path.join(root, ".tmp", "copied-repo"), { recursive: true }); + await writeFile( + path.join(root, ".tmp", "copied-repo", "runtime.ts"), + "export type AgentRunResult = { copied: true };\n", + ); + const result = await checkRepository(root, await loadRegistry(platformRoot)); + assert.equal(result.passed, true, result.errors.join("\n")); +}); + test("provider SDK imports in UI fail architecture conformance", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-ui-provider-")); t.after(() => rm(root, { force: true, recursive: true })); From ec1d8bfe53fd205711d11cc7a1cf337f93381dd2 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 22:10:00 -0700 Subject: [PATCH 12/13] test: bind ecosystem evidence graph --- .../presentation/slide-design-plans.json | 4 +- changes/nodekit-factory-p1/story/claims.json | 2 +- .../story/evidence-index.json | 12 +-- src/lib/files.mjs | 9 ++- test/change-story.test.mjs | 80 +++++++++++++++++++ test/conformance.test.mjs | 14 ++++ 6 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 test/change-story.test.mjs diff --git a/changes/nodekit-factory-p1/presentation/slide-design-plans.json b/changes/nodekit-factory-p1/presentation/slide-design-plans.json index 44140f0..5473907 100644 --- a/changes/nodekit-factory-p1/presentation/slide-design-plans.json +++ b/changes/nodekit-factory-p1/presentation/slide-design-plans.json @@ -32,7 +32,7 @@ "takeaway": "Brief, context, and sponsor requirements compile into a researched vertical slice, QA evidence, and presentation artifacts.", "narrativeRole": "workflow", "dominantVisual": "workflow", - "evidenceIds": ["nodekit-tests-28"], + "evidenceIds": ["nodekit-tests-30"], "maxVisibleWords": 45, "speakerNoteGoal": "Keep the 30-minute goal visible without presenting it as certified." }, @@ -65,7 +65,7 @@ "takeaway": "Local factory, package boundaries, CAS review, and registry contracts have executable evidence across merged foundations and open review branches.", "narrativeRole": "proof", "dominantVisual": "benchmark", - "evidenceIds": ["nodekit-tests-28", "nodekit-hosted-quality", "nodeslide-change-card-application", "nodeslide-change-card-transport", "noderoom-pr-218", "nodetasks-pr-1", "betterprhandoff-pr-4", "featureclip-pr-4"], + "evidenceIds": ["nodekit-tests-30", "nodekit-hosted-quality", "nodeslide-change-card-application", "nodeslide-change-card-transport", "noderoom-pr-218", "nodetasks-pr-1", "betterprhandoff-pr-4", "featureclip-pr-4"], "maxVisibleWords": 48, "speakerNoteGoal": "Name local and hosted evidence separately and avoid cumulative vanity counts." }, diff --git a/changes/nodekit-factory-p1/story/claims.json b/changes/nodekit-factory-p1/story/claims.json index 1dca246..d2a0029 100644 --- a/changes/nodekit-factory-p1/story/claims.json +++ b/changes/nodekit-factory-p1/story/claims.json @@ -6,7 +6,7 @@ "id": "claim-empty-directory-factory", "text": "NodeKit can create a deterministic, local-ready agent application from an empty directory and can additively adopt an existing repository.", "status": "verified", - "evidenceIds": ["nodekit-tests-28", "nodekit-pr-4"], + "evidenceIds": ["nodekit-tests-30", "nodekit-pr-4"], "limitations": ["Live sponsor research, deployment, and production browser proof are separate gates."] }, { diff --git a/changes/nodekit-factory-p1/story/evidence-index.json b/changes/nodekit-factory-p1/story/evidence-index.json index 1a867e5..feb9196 100644 --- a/changes/nodekit-factory-p1/story/evidence-index.json +++ b/changes/nodekit-factory-p1/story/evidence-index.json @@ -3,11 +3,11 @@ "changeId": "nodekit-factory-p1", "evidence": [ { - "id": "nodekit-tests-28", + "id": "nodekit-tests-30", "kind": "test-run", "status": "verified", "location": "local://node-platform/npm-test", - "summary": "NodeKit contract, compiler, factory, adoption, no-key proof, arbitrary authoring-root binding, and registry suite passed 28 of 28 tests." + "summary": "NodeKit contract, compiler, factory, adoption, no-key proof, arbitrary authoring-root binding, evidence-graph integrity, and registry suite passed 30 of 30 tests." }, { "id": "nodekit-hosted-quality", @@ -146,15 +146,15 @@ "id": "nodetasks-pr-1", "kind": "corpus-receipt", "status": "verified", - "location": "https://github.com/HomenShum/NodeTasks/blob/922d202568c64cc154c698eed961b6f6fc71d47f/proof/corpus-receipt.json", - "summary": "At immutable head 922d202, draft pull request #1 validates the 9,155-task corpus with zero source drift and zero official-score claims while preserving task ID/order, score-flag, and provenance semantics." + "location": "https://github.com/HomenShum/NodeTasks/blob/8e9b6495ba3bf716549825d05f48faaefc73dcfb/proof/corpus-receipt.json", + "summary": "At immutable head 8e9b649, draft pull request #1 validates the 9,155-task corpus with zero source drift and zero official-score claims while preserving task ID/order, score-flag, and provenance semantics." }, { "id": "betterprhandoff-pr-4", "kind": "handoff-receipt", "status": "verified", - "location": "https://github.com/HomenShum/BetterPRHandoff/blob/79a1e25c02df89ac0031cbfffa3cce7252f687fe/changes/nodebench-redesign-chat-sprints-1-4-v2/evidence/tests/nodekit-present-receipt.json", - "summary": "At immutable head 79a1e25, draft pull request #4 deterministically transforms an archived handoff into Change Story, claims, Evidence Index, architecture diff, limitations, and a content-hashed receipt; it does not independently re-verify the current implementation." + "location": "https://github.com/HomenShum/BetterPRHandoff/blob/941b17611d422997216dd4f2b1816b76774bc4ad/changes/nodebench-redesign-chat-sprints-1-4-v2/evidence/tests/nodekit-present-receipt.json", + "summary": "At immutable head 941b176, draft pull request #4 deterministically transforms an archived handoff into Change Story, claims, Evidence Index, architecture diff, limitations, and a content-hashed receipt; it does not independently re-verify the current implementation." }, { "id": "featureclip-pr-4", diff --git a/src/lib/files.mjs b/src/lib/files.mjs index 21060ab..889858e 100644 --- a/src/lib/files.mjs +++ b/src/lib/files.mjs @@ -45,6 +45,7 @@ export async function readYaml(target) { export async function listSourceFiles(root) { const files = []; + const resolvedRoot = path.resolve(root); async function visit(directory) { const entries = await readdir(directory, { withFileTypes: true }); @@ -53,7 +54,13 @@ export async function listSourceFiles(root) { for (const entry of entries) { if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith(".node-platform")) continue; - if (entry.isDirectory() && entry.name.startsWith(".tmp")) continue; + if ( + entry.isDirectory() && + path.resolve(directory) === resolvedRoot && + entry.name.startsWith(".tmp") + ) { + continue; + } const absolute = path.join(directory, entry.name); if (entry.isDirectory()) { diff --git a/test/change-story.test.mjs b/test/change-story.test.mjs new file mode 100644 index 0000000..f2c3fb4 --- /dev/null +++ b/test/change-story.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const platformRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const changeRoot = path.join(platformRoot, "changes", "nodekit-factory-p1"); +const storyRoot = path.join(changeRoot, "story"); +const presentationRoot = path.join(changeRoot, "presentation"); + +async function readJson(target) { + return JSON.parse(await readFile(target, "utf8")); +} + +test("P1 Change Story evidence and NodeSlide application graph stay internally bound", async () => { + const evidenceIndex = await readJson(path.join(storyRoot, "evidence-index.json")); + const claims = await readJson(path.join(storyRoot, "claims.json")); + const slidePlans = await readJson(path.join(presentationRoot, "slide-design-plans.json")); + const v1 = await readJson(path.join(presentationRoot, "change-card.v1.json")); + const v2 = await readJson(path.join(presentationRoot, "change-card.v2.json")); + const proposal = await readJson(path.join(presentationRoot, "change-card.proposal.json")); + const application = await readJson(path.join(presentationRoot, "change-card.application.json")); + const transport = await readJson(path.join(presentationRoot, "change-card.transport-proof.json")); + + const evidenceIds = evidenceIndex.evidence.map((entry) => entry.id); + assert.equal(new Set(evidenceIds).size, evidenceIds.length, "evidence IDs must be unique"); + const evidenceIdSet = new Set(evidenceIds); + + for (const claim of claims.claims) { + for (const evidenceId of claim.evidenceIds) { + assert.equal(evidenceIdSet.has(evidenceId), true, `claim ${claim.id} references ${evidenceId}`); + } + } + for (const slide of slidePlans.slides) { + for (const evidenceId of slide.evidenceIds) { + assert.equal(evidenceIdSet.has(evidenceId), true, `slide ${slide.slideId} references ${evidenceId}`); + } + } + + for (const evidence of evidenceIndex.evidence) { + if (/^(?:https?:|local:)/.test(evidence.location)) continue; + await access(path.resolve(storyRoot, evidence.location)); + } + + for (const snapshot of [v1, v2]) { + const sourceIds = new Set(snapshot.sources.map((source) => source.id)); + for (const element of snapshot.elements) { + for (const sourceId of element.sourceIds ?? []) { + assert.equal(sourceIds.has(sourceId), true, `element ${element.id} references ${sourceId}`); + } + } + for (const source of snapshot.sources) { + if (/^(?:https?:|local:)/.test(source.citation)) continue; + await access(path.resolve(presentationRoot, source.citation)); + } + } + + assert.equal(proposal.base.deckVersion, v1.deck.version); + assert.equal(proposal.patch.baseDeckVersion, v1.deck.version); + assert.equal(proposal.candidate.deckVersion, v2.deck.version); + assert.deepEqual(application.snapshot, v2); + assert.equal(application.receipt.proposalId, proposal.id); + assert.equal(transport.application.proposalId, proposal.id); + assert.equal(application.receipt.baseSnapshotDigest, proposal.base.snapshotDigest); + assert.equal(transport.validation.baseSnapshotDigest, proposal.base.snapshotDigest); + assert.equal(application.receipt.resultingSnapshotDigest, proposal.candidate.snapshotDigest); + assert.equal(transport.validation.validateCandidateDigest, proposal.candidate.snapshotDigest); + assert.equal(transport.validation.proposalCandidateDigest, proposal.candidate.snapshotDigest); + assert.equal(transport.application.resultingSnapshotDigest, proposal.candidate.snapshotDigest); + assert.equal(application.receipt.patchDigest, transport.validation.patchDigest); + assert.equal(transport.validation.deterministicCandidateParity, true); + assert.equal(transport.boundary.sourceBound, true); + assert.equal(transport.boundary.digestBound, true); + assert.equal(transport.boundary.exactApproval, true); + assert.equal(transport.boundary.claimsEvidenceCertified, false); + assert.equal(transport.boundary.pptxExportVerified, false); + assert.equal(transport.boundary.productionDeployed, false); + assert.equal(transport.boundary.packagePublished, false); +}); diff --git a/test/conformance.test.mjs b/test/conformance.test.mjs index 8f39978..7f855f1 100644 --- a/test/conformance.test.mjs +++ b/test/conformance.test.mjs @@ -126,6 +126,20 @@ test("ignored temporary proof copies are not scanned as repository source", asyn assert.equal(result.passed, true, result.errors.join("\n")); }); +test("nested temporary-looking source directories remain fail-closed", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-nested-temp-source-")); + t.after(() => rm(root, { force: true, recursive: true })); + await writeFixture(root, { declaration: true }); + await mkdir(path.join(root, "src", ".tmp-runtime"), { recursive: true }); + await writeFile( + path.join(root, "src", ".tmp-runtime", "runtime.ts"), + "export type AgentRunResult = { hidden: true };\n", + ); + const result = await checkRepository(root, await loadRegistry(platformRoot)); + assert.equal(result.passed, false); + assert.match(result.errors.join("\n"), /undeclared contract signature agent-run-result/); +}); + test("provider SDK imports in UI fail architecture conformance", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "nodekit-ui-provider-")); t.after(() => rm(root, { force: true, recursive: true })); From 2ac7bad0fe1f3a1a34522b7278d85f1ed51da618 Mon Sep 17 00:00:00 2001 From: homen Date: Sun, 19 Jul 2026 22:11:01 -0700 Subject: [PATCH 13/13] docs: bind final P1 rollout receipt --- proof/p1-factory-rollout.json | 254 +++++++++++++++++++++++++--------- 1 file changed, 191 insertions(+), 63 deletions(-) diff --git a/proof/p1-factory-rollout.json b/proof/p1-factory-rollout.json index cca485b..f41b890 100644 --- a/proof/p1-factory-rollout.json +++ b/proof/p1-factory-rollout.json @@ -1,15 +1,23 @@ { "schemaVersion": "nodeplatform.p1-factory-rollout/v1", - "generatedAt": "2026-07-20T03:02:56Z", - "status": "draft-pull-requests-open", + "generatedAt": "2026-07-20T05:10:09.0418196Z", + "status": "implementation-complete-release-gates-open", "verification": { "passed": false, - "reason": "The implementation branches are locally proven but remain unmerged drafts; the stacked NodeSlide external-agent PR and cross-repository release gates are not all complete.", + "reason": "The reusable seams and assembled ecosystem conformance are proven, but open dependency-ordered reviews, package publication, production deployment, browser certification, PPTX export/reopen certification, and a repeatable under-30 live launch remain release gates.", + "validatedImplementationHead": "ec1d8bfe53fd205711d11cc7a1cf337f93381dd2", + "receiptBinding": "This receipt is committed after the immutable validated implementation head; it does not claim a recursive self-hash.", "nodekit": { - "tests": 26, + "tests": 30, + "testsResult": "pass-30-of-30", "registryCheck": "pass", - "productionAudit": "pass-zero-known-vulnerabilities", - "hostedQualityRun": "https://github.com/HomenShum/node-platform/actions/runs/29713688834", + "productionAudit": "pass-zero-known-production-vulnerabilities", + "packageDryRun": { + "result": "pass", + "files": 72, + "packedBytes": 50744 + }, + "hostedQualityRun": "https://github.com/HomenShum/node-platform/actions/runs/29718493272", "hostedQualityResult": "pass" }, "timing": { @@ -19,117 +27,237 @@ "latestPnpmLocalReadySeconds": 31.86, "latestPnpmInstallSeconds": 29.5, "repeatablyUnderTarget": false + }, + "ecosystemConformance": { + "passed": true, + "scope": "assembled workspace pinned to reviewed pull-request heads", + "repositoriesPassed": 13, + "repositoriesFailed": 0, + "canonicalSignaturesClassified": 14, + "architectureExceptions": 0, + "fastestObservedSeconds": 14.59, + "finalColdRerunSeconds": 69.8, + "repeatablyUnderThirtySeconds": false, + "workspace": "D:/VSCode Projects/cafecorner_nodebench/nodebench_ai4/.tmp-nodekit-ecosystem-audit-20260719-2145" + }, + "neighboringDefaultCheckoutAudit": { + "passed": false, + "classification": "environment-assembly-mismatch", + "reason": "Several neighboring checkouts were absent or remained on default/stale branches, so they did not represent the reviewed heads. The pinned assembled workspace is the authoritative coordinated audit." } }, + "githubAudit": { + "snapshotDate": "2026-07-19", + "repositoriesReturned": 100, + "resultSetSaturated": true, + "ownerAuthored": 78, + "forks": 22, + "private": 13, + "githubArchived": 0, + "pushedSince2026-06-19": 36, + "report": "../docs/GITHUB_ECOSYSTEM_AUDIT.md" + }, "pullRequests": [ { "capability": "nodekit-factory-and-contracts", "repository": "HomenShum/node-platform", "pullRequest": "https://github.com/HomenShum/node-platform/pull/4", - "implementationHead": "b79535f45c4c7acf0ba61607b12c0f36fedd39f8", - "state": "draft-open" + "implementationHead": "ec1d8bfe53fd205711d11cc7a1cf337f93381dd2", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "quality-pass" }, { "capability": "nodeagent-event-and-pi-adapter", "repository": "HomenShum/NodeAgent", "pullRequest": "https://github.com/HomenShum/NodeAgent/pull/2", - "head": "c0ad523", - "state": "draft-open" + "head": "c0ad5236d5424fca60008ed500cb565683bcd1d5", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "own-ci-pass-central-conformance-waits-for-node-platform-4" }, { "capability": "nodeslide-injectable-core", "repository": "HomenShum/NodeSlide", "pullRequest": "https://github.com/HomenShum/NodeSlide/pull/5", - "head": "0669be4", - "state": "draft-open" + "head": "81bb2c81b93c5461184bb7b31bb1a6736d64ef01", + "mergeCommit": "ade92f567459657c4c52a339bb27a1cd04e2205a", + "state": "merged", + "hostedStatus": "pass" }, { "capability": "nodeslide-controlled-react", "repository": "HomenShum/NodeSlide", - "pullRequest": "https://github.com/HomenShum/NodeSlide/pull/6", - "head": "baa2d04", + "pullRequest": "https://github.com/HomenShum/NodeSlide/pull/7", + "head": "5f367932fe10cc9631f522e1e2148519ed2ff369", + "mergeCommit": "0e7f289fe375035fe3aa824f357685be59f48ee4", + "state": "merged", + "hostedStatus": "pass" + }, + { + "capability": "nodeslide-external-cli-mcp", + "repository": "HomenShum/NodeSlide", + "pullRequest": "https://github.com/HomenShum/NodeSlide/pull/10", + "head": "7815b44fe24d6673745cb5d51af219ef4f6652c9", "state": "draft-open", - "dependsOn": "https://github.com/HomenShum/NodeSlide/pull/5" + "mergeable": true, + "hostedStatus": "all-pass" }, { "capability": "proofloop-receipt-envelope", "repository": "HomenShum/NodeProof", "pullRequest": "https://github.com/HomenShum/NodeProof/pull/22", - "head": "802f63c", + "head": "802f63c0c938cfab06479459c31ff11280a3b00b", "state": "draft-open", - "hostedStatus": "vercel-preview-success" + "mergeable": true, + "hostedStatus": "all-pass" }, { "capability": "nodetrace-nodekit-consumer", "repository": "HomenShum/NodeTrace", - "pullRequest": "https://github.com/HomenShum/NodeTrace/pull/2", - "head": "09deaef", - "state": "draft-open" + "pullRequest": "https://github.com/HomenShum/NodeTrace/pull/3", + "head": "22db52beafcc906a0fb20aa8ee5d6e2a7dea328c", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "own-ci-pass-central-conformance-waits-for-node-platform-4" }, { "capability": "nodemem-nodekit-consumer", "repository": "HomenShum/NodeMem", - "pullRequest": "https://github.com/HomenShum/NodeMem/pull/2", - "head": "8d71920", - "state": "draft-open" + "pullRequest": "https://github.com/HomenShum/NodeMem/pull/3", + "head": "d9be0f86d944c4129dac1d1e6cfa4423cb922afd", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "local-proof-pass-central-conformance-waits-for-node-platform-4" }, { "capability": "noderoom-nodeslide-consumer-proof", "repository": "HomenShum/NodeRoom", - "pullRequest": "https://github.com/HomenShum/NodeRoom/pull/217", - "head": "b7962334", + "pullRequest": "https://github.com/HomenShum/NodeRoom/pull/218", + "head": "2cced7395df623392c8692b40cdad791f5a1cf4d", + "mergeCommit": "51da4d3240137f60bd2d14dec7acf352df054c19", + "state": "merged", + "hostedStatus": "all-pass" + }, + { + "capability": "noderoom-contract-reconciliation", + "repository": "HomenShum/NodeRoom", + "pullRequest": "https://github.com/HomenShum/NodeRoom/pull/219", + "head": "65f8cfe4b2bd0c3665e7d111ce3e9e1b0df702d4", "state": "draft-open", - "dependsOn": "https://github.com/HomenShum/NodeSlide/pull/5", - "hostedStatus": "vercel-preview-success" + "mergeable": true, + "hostedStatus": "own-ci-pass-central-conformance-waits-for-node-platform-4" }, { "capability": "nodebench-brownfield-contract-alignment", "repository": "HomenShum/NodeBenchAI", - "pullRequest": "https://github.com/HomenShum/NodeBenchAI/pull/591", - "head": "795164fbd9f8c17925d411fc72de8033429ed1c5", + "pullRequest": "https://github.com/HomenShum/NodeBenchAI/pull/592", + "head": "8eb9872e094df1a240394fa1352ab644946d2406", "state": "draft-open", - "hostedStatus": "vercel-preview-success" + "mergeable": true, + "hostedStatus": "all-16-checks-pass", + "supersedes": "https://github.com/HomenShum/NodeBenchAI/pull/591" + }, + { + "capability": "nodevoice-brownfield-map", + "repository": "HomenShum/NodeVoice", + "pullRequest": "https://github.com/HomenShum/NodeVoice/pull/4", + "head": "ee5e3f02dd206cce6658e9ec1274e3cbb5a009ee", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "all-pass" + }, + { + "capability": "nodevideo-eve-brownfield-map", + "repository": "HomenShum/NodeVideo", + "pullRequest": "https://github.com/HomenShum/NodeVideo/pull/30", + "head": "31319bdc962801328850d171903eae9cc611ed20", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "all-pass" + }, + { + "capability": "nodetasks-corpus-receipt", + "repository": "HomenShum/NodeTasks", + "pullRequest": "https://github.com/HomenShum/NodeTasks/pull/1", + "head": "8e9b6495ba3bf716549825d05f48faaefc73dcfb", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "gitguardian-pass-local-proof-pass" + }, + { + "capability": "betterprhandoff-to-present", + "repository": "HomenShum/BetterPRHandoff", + "pullRequest": "https://github.com/HomenShum/BetterPRHandoff/pull/4", + "head": "941b17611d422997216dd4f2b1816b76774bc4ad", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "gitguardian-pass-local-proof-pass" + }, + { + "capability": "featureclip-to-presentation-evidence", + "repository": "HomenShum/FeatureClipStudio", + "pullRequest": "https://github.com/HomenShum/FeatureClipStudio/pull/4", + "head": "36dd7741e53a6cc66e4ea0eac48d8717fa60ee69", + "state": "draft-open", + "mergeable": true, + "hostedStatus": "gitguardian-pass-local-proof-pass" } ], - "ecosystemAudit": { - "p1": [ - "Map the existing NodeVideo Eve harness without relocating it.", - "Map the existing NodeVoice runtime and logically declare one voice-room pack.", - "Register NodeTasks as a task corpus rather than inventing a product-agent runtime.", - "Distribute agentic-ui-qa as the default NodeKit QA skill/pack.", - "Connect BetterPRHandoff and FeatureClipStudio to NodeKit Present.", - "Freeze new embedded NodeSlide domain capability in parity-studio after standalone parity." - ], - "p2": [ - "NodeGraph capability pack", - "NodeSEO proof pack", - "AgentRedteam and maturity-model evaluation inputs", - "FreeAgentResources research catalog", - "NodeRL unique reward/repair/export extraction" - ], - "consolidationCandidates": [ - "NodeBenchBoilerplate", - "NodeAgentSpec", - "solo-founder-agent-builder", - "agent-workspace-template", - "NodeBenchClean after public parity", - "VisualJudge as a standalone product", - "copied NodeTrace/NodeMem/NodeEval code in NodeRL", - "embedded NodeSlide domain code in parity-studio after parity" - ], - "nodesheet": "No standalone GitHub repository found; keep it as a finance/spreadsheet pack until a second host justifies extraction." + "presentationProof": { + "nodeSlideHead": "7815b44fe24d6673745cb5d51af219ef4f6652c9", + "baseSnapshotDigest": "sha256:464cd9d5021cf2c1dcf556822ae58828ab8b3f6f54ec0ca09d293fca31b74031", + "patchDigest": "sha256:c1be9d504068abd4360a4eb37d2fafed75c640bb0e4ba661a37163154343695c", + "candidateDigest": "sha256:b742c7321effa324e0a99f6942ef49596d6eaecfb21666d5d49db7ae7e36abaf", + "proposalId": "proposal:6a06b6872de43772d0db038fda160e5a", + "applicationReceiptId": "application:039db09490de59dce3d36d60b922b101", + "sourceBound": true, + "digestBound": true, + "exactApproval": true, + "deterministicCandidateParity": true, + "claimsEvidenceCertified": false, + "pptxExportVerified": false, + "productionDeployed": false, + "packagePublished": false + }, + "supportingReceipts": { + "nodeTasks": { + "tasks": 9155, + "corpusHash": "7aa41b0f5e15bbb7b74af5a307322f695c8231bc61301f4a98fba39efe8058ee", + "officialScoreClaims": 0 + }, + "betterPRHandoff": { + "source": "real archived handoff fixture", + "outputs": ["change-story", "claims", "evidence-index", "architecture-diff", "limitations", "content-hashed-receipt"], + "directNodeSlideExport": false + }, + "featureClipStudio": { + "trackedMediaArtifacts": 3, + "independentBrowserOrJudgeReceipts": 0, + "releaseReady": false, + "productWorkflowProof": "not-certified" + } }, - "pendingBeforeReviewComplete": [ - "Add and verify the stacked NodeSlide external CLI/MCP pull request.", - "Record and verify the NodeVideo and NodeVoice brownfield-map pull requests.", - "Run central registry validation after all final branch heads are recorded.", - "Forward-test every branch against its documented base before coordinated merge review." + "pendingReleaseGates": [ + "Merge node-platform #4 before rerunning dependent NodeAgent, NodeTrace, NodeMem, and NodeRoom conformance checks.", + "Review and merge the remaining dependency-ordered pull requests.", + "Publish versioned packages only after package-consumer and release approval gates pass.", + "Mount NodeSlide's controlled React UI and a production repository adapter inside NodeRoom.", + "Certify PPTX export and reopen fidelity.", + "Certify one live sponsor-backed hackathon application from vague brief through fresh-browser judge workflow.", + "Reduce setup variance enough to prove the under-30 target repeatedly rather than from the fastest sample." ], "notPerformed": [ "npm publication", "production deployment", "paid-resource activation", "destructive data migration", - "merge to any default branch" + "remaining pull-request merges", + "public release" + ], + "observedDefaultBranchMerges": [ + "HomenShum/NodeSlide#5", + "HomenShum/NodeSlide#7", + "HomenShum/NodeRoom#218" ] }