@starci/skills 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INDEX.md +74 -0
- package/INDEX.vi.md +75 -0
- package/README.md +44 -0
- package/README.vi.md +43 -0
- package/SKILL.md +135 -0
- package/SKILL.vi.md +128 -0
- package/alias/INDEX.md +104 -0
- package/alias/INDEX.vi.md +104 -0
- package/alias/alias.json +297 -0
- package/bin/starci-skills.mjs +219 -0
- package/knowledge/INDEX.md +22 -0
- package/knowledge/INDEX.vi.md +22 -0
- package/knowledge/grammars/starci/DNA.md +146 -0
- package/knowledge/grammars/starci/DNA.vi.md +146 -0
- package/knowledge/grammars/starci/INDEX.md +25 -0
- package/knowledge/grammars/starci/INDEX.vi.md +25 -0
- package/knowledge/grammars/starci/family.md +50 -0
- package/knowledge/grammars/starci/family.vi.md +50 -0
- package/knowledge/grammars/starci/idioms.md +120 -0
- package/knowledge/grammars/starci/idioms.vi.md +121 -0
- package/knowledge/grammars/starci/playbook.md +36 -0
- package/knowledge/grammars/starci/playbook.vi.md +35 -0
- package/knowledge/patterns/be/INDEX.md +29 -0
- package/knowledge/patterns/be/INDEX.vi.md +29 -0
- package/knowledge/patterns/be/comment.md +80 -0
- package/knowledge/patterns/be/comment.vi.md +80 -0
- package/knowledge/patterns/be/error.md +86 -0
- package/knowledge/patterns/be/error.vi.md +86 -0
- package/knowledge/patterns/be/folder.md +87 -0
- package/knowledge/patterns/be/folder.vi.md +86 -0
- package/knowledge/patterns/be/function.md +80 -0
- package/knowledge/patterns/be/function.vi.md +80 -0
- package/knowledge/patterns/be/imports.md +79 -0
- package/knowledge/patterns/be/imports.vi.md +79 -0
- package/knowledge/patterns/be/naming.md +87 -0
- package/knowledge/patterns/be/naming.vi.md +87 -0
- package/knowledge/patterns/be/test.md +79 -0
- package/knowledge/patterns/be/test.vi.md +79 -0
- package/knowledge/patterns/be/typing.md +73 -0
- package/knowledge/patterns/be/typing.vi.md +73 -0
- package/knowledge/patterns/fe/INDEX.md +29 -0
- package/knowledge/patterns/fe/INDEX.vi.md +29 -0
- package/knowledge/patterns/fe/comment.md +70 -0
- package/knowledge/patterns/fe/comment.vi.md +70 -0
- package/knowledge/patterns/fe/error.md +61 -0
- package/knowledge/patterns/fe/error.vi.md +61 -0
- package/knowledge/patterns/fe/folder.md +98 -0
- package/knowledge/patterns/fe/folder.vi.md +95 -0
- package/knowledge/patterns/fe/function.md +70 -0
- package/knowledge/patterns/fe/function.vi.md +70 -0
- package/knowledge/patterns/fe/imports.md +87 -0
- package/knowledge/patterns/fe/imports.vi.md +87 -0
- package/knowledge/patterns/fe/naming.md +77 -0
- package/knowledge/patterns/fe/naming.vi.md +77 -0
- package/knowledge/patterns/fe/test.md +73 -0
- package/knowledge/patterns/fe/test.vi.md +73 -0
- package/knowledge/patterns/fe/typing.md +67 -0
- package/knowledge/patterns/fe/typing.vi.md +67 -0
- package/knowledge/ui/INDEX.md +101 -0
- package/knowledge/ui/INDEX.vi.md +101 -0
- package/knowledge/ui/composition/INDEX.md +60 -0
- package/knowledge/ui/composition/INDEX.vi.md +63 -0
- package/knowledge/ui/composition/accent.md +73 -0
- package/knowledge/ui/composition/accent.vi.md +74 -0
- package/knowledge/ui/composition/action.md +55 -0
- package/knowledge/ui/composition/action.vi.md +56 -0
- package/knowledge/ui/composition/coverage.md +37 -0
- package/knowledge/ui/composition/coverage.vi.md +37 -0
- package/knowledge/ui/composition/cta.md +79 -0
- package/knowledge/ui/composition/cta.vi.md +79 -0
- package/knowledge/ui/composition/feedback.md +55 -0
- package/knowledge/ui/composition/feedback.vi.md +56 -0
- package/knowledge/ui/composition/hierarchy.md +83 -0
- package/knowledge/ui/composition/hierarchy.vi.md +82 -0
- package/knowledge/ui/composition/layout.md +91 -0
- package/knowledge/ui/composition/layout.vi.md +93 -0
- package/knowledge/ui/composition/responsive.md +67 -0
- package/knowledge/ui/composition/responsive.vi.md +67 -0
- package/knowledge/ui/composition/state.md +105 -0
- package/knowledge/ui/composition/state.vi.md +106 -0
- package/knowledge/ui/presentation/INDEX.md +94 -0
- package/knowledge/ui/presentation/INDEX.vi.md +69 -0
- package/knowledge/ui/presentation/boundary.md +170 -0
- package/knowledge/ui/presentation/boundary.vi.md +169 -0
- package/knowledge/ui/presentation/font.md +155 -0
- package/knowledge/ui/presentation/font.vi.md +156 -0
- package/knowledge/ui/presentation/gap.md +181 -0
- package/knowledge/ui/presentation/gap.vi.md +181 -0
- package/knowledge/ui/presentation/margin.md +168 -0
- package/knowledge/ui/presentation/margin.vi.md +171 -0
- package/knowledge/ui/presentation/measure.md +174 -0
- package/knowledge/ui/presentation/measure.vi.md +178 -0
- package/knowledge/ui/presentation/overflow.md +142 -0
- package/knowledge/ui/presentation/overflow.vi.md +142 -0
- package/knowledge/ui/presentation/padding.md +293 -0
- package/knowledge/ui/presentation/padding.vi.md +292 -0
- package/knowledge/ui/presentation/surface.md +166 -0
- package/knowledge/ui/presentation/surface.vi.md +166 -0
- package/knowledge/ui/presentation/text-flow.md +123 -0
- package/knowledge/ui/presentation/text-flow.vi.md +123 -0
- package/knowledge/ui/presentation/tone.md +114 -0
- package/knowledge/ui/presentation/tone.vi.md +113 -0
- package/knowledge/ui/proof/INDEX.md +55 -0
- package/knowledge/ui/proof/INDEX.vi.md +55 -0
- package/knowledge/ui/proof/accessibility.md +67 -0
- package/knowledge/ui/proof/accessibility.vi.md +68 -0
- package/knowledge/ui/proof/contrast.md +58 -0
- package/knowledge/ui/proof/contrast.vi.md +57 -0
- package/knowledge/ui/proof/focus.md +76 -0
- package/knowledge/ui/proof/focus.vi.md +76 -0
- package/knowledge/ui/proof/motion.md +63 -0
- package/knowledge/ui/proof/motion.vi.md +64 -0
- package/knowledge/ui/proof/render-truth.md +63 -0
- package/knowledge/ui/proof/render-truth.vi.md +63 -0
- package/operators/INDEX.md +199 -0
- package/operators/INDEX.vi.md +199 -0
- package/operators/architecture-decide/errors.json +115 -0
- package/operators/architecture-decide/operator.json +20 -0
- package/operators/architecture-decide/operator.md +133 -0
- package/operators/architecture-decide/operator.vi.md +130 -0
- package/operators/architecture-decide/self-test.mjs +221 -0
- package/operators/architecture-decide/validate.mjs +112 -0
- package/operators/backend-source-apply/errors.json +66 -0
- package/operators/backend-source-apply/operator.json +20 -0
- package/operators/backend-source-apply/operator.md +189 -0
- package/operators/backend-source-apply/operator.vi.md +185 -0
- package/operators/backend-source-apply/self-test.mjs +257 -0
- package/operators/backend-source-apply/validate.mjs +240 -0
- package/operators/business-decide/errors.json +78 -0
- package/operators/business-decide/operator.json +20 -0
- package/operators/business-decide/operator.md +176 -0
- package/operators/business-decide/operator.vi.md +174 -0
- package/operators/business-decide/self-test.mjs +225 -0
- package/operators/business-decide/validate.mjs +277 -0
- package/operators/content-generate/errors.json +106 -0
- package/operators/content-generate/operator.json +21 -0
- package/operators/content-generate/operator.md +155 -0
- package/operators/content-generate/operator.vi.md +155 -0
- package/operators/content-generate/self-test.mjs +288 -0
- package/operators/content-generate/validate.mjs +196 -0
- package/operators/errors.json +178 -0
- package/operators/frontend-direction-decide/errors.json +135 -0
- package/operators/frontend-direction-decide/operator.json +21 -0
- package/operators/frontend-direction-decide/operator.md +167 -0
- package/operators/frontend-direction-decide/operator.vi.md +167 -0
- package/operators/frontend-direction-decide/self-test.mjs +226 -0
- package/operators/frontend-direction-decide/validate.mjs +146 -0
- package/operators/frontend-presentation-resolve/errors.json +42 -0
- package/operators/frontend-presentation-resolve/operator.json +19 -0
- package/operators/frontend-presentation-resolve/operator.md +142 -0
- package/operators/frontend-presentation-resolve/operator.vi.md +140 -0
- package/operators/frontend-presentation-resolve/self-test.mjs +163 -0
- package/operators/frontend-presentation-resolve/validate.mjs +143 -0
- package/operators/frontend-source-apply/errors.json +30 -0
- package/operators/frontend-source-apply/operator.json +20 -0
- package/operators/frontend-source-apply/operator.md +127 -0
- package/operators/frontend-source-apply/operator.vi.md +126 -0
- package/operators/frontend-source-apply/self-test.mjs +214 -0
- package/operators/frontend-source-apply/validate.mjs +133 -0
- package/operators/frontend-surface-audit/errors.json +5 -0
- package/operators/frontend-surface-audit/operator.json +22 -0
- package/operators/frontend-surface-audit/operator.md +121 -0
- package/operators/frontend-surface-audit/operator.vi.md +121 -0
- package/operators/frontend-surface-audit/self-test.mjs +185 -0
- package/operators/frontend-surface-audit/validate.mjs +127 -0
- package/operators/git-publish/errors.json +54 -0
- package/operators/git-publish/operator.json +20 -0
- package/operators/git-publish/operator.md +180 -0
- package/operators/git-publish/operator.vi.md +178 -0
- package/operators/git-publish/self-test.mjs +177 -0
- package/operators/git-publish/validate.mjs +106 -0
- package/operators/platform-operate/errors.json +90 -0
- package/operators/platform-operate/operator.json +22 -0
- package/operators/platform-operate/operator.md +162 -0
- package/operators/platform-operate/operator.vi.md +160 -0
- package/operators/platform-operate/self-test.mjs +202 -0
- package/operators/platform-operate/validate.mjs +193 -0
- package/operators/quality-verify/errors.json +54 -0
- package/operators/quality-verify/operator.json +20 -0
- package/operators/quality-verify/operator.md +185 -0
- package/operators/quality-verify/operator.vi.md +177 -0
- package/operators/quality-verify/self-test.mjs +210 -0
- package/operators/quality-verify/validate.mjs +197 -0
- package/operators/release-deploy/errors.json +158 -0
- package/operators/release-deploy/operator.json +23 -0
- package/operators/release-deploy/operator.md +183 -0
- package/operators/release-deploy/operator.vi.md +181 -0
- package/operators/release-deploy/self-test.mjs +235 -0
- package/operators/release-deploy/validate.mjs +129 -0
- package/operators/uat-verify/errors.json +78 -0
- package/operators/uat-verify/operator.json +25 -0
- package/operators/uat-verify/operator.md +162 -0
- package/operators/uat-verify/operator.vi.md +161 -0
- package/operators/uat-verify/self-test.mjs +270 -0
- package/operators/uat-verify/validate.mjs +202 -0
- package/operators/workspace-bind/errors.json +90 -0
- package/operators/workspace-bind/operator.json +21 -0
- package/operators/workspace-bind/operator.md +148 -0
- package/operators/workspace-bind/operator.vi.md +148 -0
- package/operators/workspace-bind/self-test.mjs +205 -0
- package/operators/workspace-bind/validate.mjs +152 -0
- package/package.json +50 -0
- package/readiness/initialization/workspaces/commit-policy.json +91 -0
- package/readiness/initialization/workspaces/config.schema.json +35 -0
- package/readiness/initialization/workspaces/device-state.schema.json +54 -0
- package/readiness/initialization/workspaces/local-route.schema.json +206 -0
- package/readiness/initialization/workspaces/portable-route.schema.json +200 -0
- package/resources/INDEX.md +96 -0
- package/resources/INDEX.vi.md +99 -0
- package/resources/agents/profiles/claude.json +131 -0
- package/resources/agents/profiles/openai.json +131 -0
- package/resources/orchestrator.json +71 -0
- package/resources/tools.json +85 -0
- package/routing.json +272 -0
- package/scripts/alias-registry.mjs +31 -0
- package/scripts/device-state.mjs +497 -0
- package/scripts/device-state.spec.mjs +18 -0
- package/scripts/errors-registry.mjs +68 -0
- package/scripts/generate-alias-doc.mjs +66 -0
- package/scripts/generate-grammar-dna.mjs +387 -0
- package/scripts/generate-operators-index.mjs +95 -0
- package/scripts/generate-presentation-owned.mjs +681 -0
- package/scripts/install-cli.spec.mjs +74 -0
- package/scripts/json-schema.mjs +94 -0
- package/scripts/operator-md.mjs +96 -0
- package/scripts/run-operator-self-tests.mjs +36 -0
- package/scripts/validate-alias.mjs +165 -0
- package/scripts/validate-defaults.mjs +72 -0
- package/scripts/validate-knowledge-citations.mjs +90 -0
- package/scripts/validate-operator.mjs +125 -0
- package/scripts/validate-request.mjs +80 -0
- package/scripts/validate-resources.mjs +117 -0
- package/scripts/validate-response.mjs +141 -0
- package/scripts/validate-routing.mjs +91 -0
- package/scripts/validate-step.mjs +50 -0
- package/scripts/validate-templates.mjs +226 -0
- package/scripts/validate-templates.spec.mjs +144 -0
- package/scripts/validate-workflows.mjs +106 -0
- package/scripts/workspace-portable.mjs +389 -0
- package/scripts/workspace-portable.spec.mjs +246 -0
- package/templates/README.md +47 -0
- package/templates/README.vi.md +45 -0
- package/templates/changes.example.md +27 -0
- package/templates/grammars.template.md +34 -0
- package/templates/kinds/architecture-decision.contract.json +14 -0
- package/templates/kinds/architecture-decision.skeleton.md +52 -0
- package/templates/kinds/backend-source-application.contract.json +10 -0
- package/templates/kinds/backend-source-application.skeleton.md +34 -0
- package/templates/kinds/business-promise-authority.contract.json +13 -0
- package/templates/kinds/business-promise-authority.skeleton.md +67 -0
- package/templates/kinds/capture.schema.json +33 -0
- package/templates/kinds/changes.contract.json +10 -0
- package/templates/kinds/changes.skeleton.md +26 -0
- package/templates/kinds/checks.schema.json +44 -0
- package/templates/kinds/claims.schema.json +165 -0
- package/templates/kinds/conformance.schema.json +15 -0
- package/templates/kinds/content-brief.contract.json +12 -0
- package/templates/kinds/content-brief.skeleton.md +45 -0
- package/templates/kinds/content-generation-receipt.contract.json +11 -0
- package/templates/kinds/content-generation-receipt.skeleton.md +40 -0
- package/templates/kinds/content-review.contract.json +11 -0
- package/templates/kinds/content-review.skeleton.md +45 -0
- package/templates/kinds/contract.schema.json +28 -0
- package/templates/kinds/coverage-matrix.schema.json +86 -0
- package/templates/kinds/coverage.schema.json +62 -0
- package/templates/kinds/current-state.schema.json +40 -0
- package/templates/kinds/delta.schema.json +95 -0
- package/templates/kinds/e2e.schema.json +63 -0
- package/templates/kinds/frontend-direction-decision.contract.json +93 -0
- package/templates/kinds/frontend-direction-decision.skeleton.md +68 -0
- package/templates/kinds/frontend-presentation-resolution.contract.json +11 -0
- package/templates/kinds/frontend-presentation-resolution.skeleton.md +32 -0
- package/templates/kinds/frontend-source-application.contract.json +10 -0
- package/templates/kinds/frontend-source-application.skeleton.md +30 -0
- package/templates/kinds/frontend-surface-audit.contract.json +52 -0
- package/templates/kinds/frontend-surface-audit.skeleton.md +32 -0
- package/templates/kinds/gate-result.schema.json +64 -0
- package/templates/kinds/git-publication.contract.json +82 -0
- package/templates/kinds/git-publication.skeleton.md +61 -0
- package/templates/kinds/independent-critique.contract.json +9 -0
- package/templates/kinds/independent-critique.skeleton.md +28 -0
- package/templates/kinds/inventory.schema.json +35 -0
- package/templates/kinds/model.schema.json +99 -0
- package/templates/kinds/mutations.schema.json +65 -0
- package/templates/kinds/platform-operation-receipt.contract.json +76 -0
- package/templates/kinds/platform-operation-receipt.skeleton.md +54 -0
- package/templates/kinds/probes.schema.json +130 -0
- package/templates/kinds/proof.schema.json +17 -0
- package/templates/kinds/quality-verification.contract.json +87 -0
- package/templates/kinds/quality-verification.skeleton.md +58 -0
- package/templates/kinds/release-deployment.contract.json +85 -0
- package/templates/kinds/release-deployment.skeleton.md +67 -0
- package/templates/kinds/route.schema.json +293 -0
- package/templates/kinds/stack-model.schema.json +90 -0
- package/templates/kinds/uat-capture.schema.json +35 -0
- package/templates/kinds/uat-flow-verification.contract.json +11 -0
- package/templates/kinds/uat-flow-verification.skeleton.md +47 -0
- package/templates/kinds/uat-snapshot.schema.json +96 -0
- package/templates/kinds/uat-verdicts.schema.json +41 -0
- package/templates/kinds/ui-coverage.schema.json +77 -0
- package/templates/kinds/verdicts.schema.json +39 -0
- package/templates/kinds/workspace-route-binding.contract.json +12 -0
- package/templates/kinds/workspace-route-binding.skeleton.md +60 -0
- package/templates/kinds/writes.schema.json +37 -0
- package/templates/operator.template.md +73 -0
- package/templates/patterns.template.md +31 -0
- package/templates/step/request.schema.json +42 -0
- package/templates/step/response.schema.json +144 -0
- package/templates/ui-composition.template.md +36 -0
- package/templates/ui-presentation.template.md +57 -0
- package/templates/ui-proof.template.md +34 -0
- package/workflows/README.md +37 -0
- package/workflows/README.vi.md +37 -0
- package/workflows/backend-feature.json +59 -0
- package/workflows/content-unit.json +19 -0
- package/workflows/frontend-new-surface.json +81 -0
- package/workflows/frontend-reconstruct.json +67 -0
- package/workflows/frontend-refine.json +67 -0
- package/workflows/frontend-with-uat.json +78 -0
- package/workflows/full-feature.json +104 -0
- package/workflows/release.json +29 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The installer must produce a tree the validators accept, refuse to overwrite a .claude it did not
|
|
2
|
+
// install, keep a person's local edit across update, and write the two bootstraps exactly once.
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { test as nodeTest } from 'node:test';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
// An installed tree has no bin/ and no package.json beside scripts/, so doctor skips this file there.
|
|
10
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const isPackage = existsSync(path.join(root, 'bin', 'starci-skills.mjs')) && existsSync(path.join(root, 'package.json'));
|
|
12
|
+
const { PAYLOAD, init, update, doctor } = isPackage ? await import('../bin/starci-skills.mjs') : {};
|
|
13
|
+
const pkg = isPackage ? JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) : null;
|
|
14
|
+
const test = (name, fn) => nodeTest(name, { skip: isPackage ? false : 'not the package checkout' }, fn);
|
|
15
|
+
const quiet = () => {};
|
|
16
|
+
|
|
17
|
+
function freshRepo() {
|
|
18
|
+
const repo = mkdtempSync(path.join(tmpdir(), 'starci-skills-'));
|
|
19
|
+
writeFileSync(path.join(repo, '.gitignore'), 'node_modules/\n');
|
|
20
|
+
return repo;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('package.json ships exactly the runtime paths the installer copies, plus bin and the READMEs', () => {
|
|
24
|
+
const shipped = pkg.files.map((f) => f.replace(/\/$/, ''));
|
|
25
|
+
for (const p of PAYLOAD) assert.ok(shipped.includes(p), `${p} is copied by init but not in package.json files`);
|
|
26
|
+
for (const stale of ['sites', 'docs', 'tests', 'onichan.md', '.github']) assert.ok(!shipped.includes(stale), `${stale} must not ship`);
|
|
27
|
+
assert.equal(pkg.bin['starci-skills'], 'bin/starci-skills.mjs');
|
|
28
|
+
assert.equal(pkg.publishConfig.access, 'public');
|
|
29
|
+
assert.match(readFileSync(path.join(root, 'INDEX.md'), 'utf8'), new RegExp(`^# StarCi Skills ${pkg.version.replace(/\./g, '\\.')}$`, 'm'));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('init installs the tree, writes both bootstraps and the sessions ignore, and doctor --quick passes', () => {
|
|
33
|
+
const repo = freshRepo();
|
|
34
|
+
try {
|
|
35
|
+
const manifest = init({ dir: repo, force: false, bootstrap: true }, quiet);
|
|
36
|
+
assert.equal(manifest.version, pkg.version);
|
|
37
|
+
for (const p of PAYLOAD) assert.ok(existsSync(path.join(repo, '.claude', p)), `${p} not installed`);
|
|
38
|
+
assert.ok(!existsSync(path.join(repo, '.claude', 'package.json')), 'the package manifest is not part of the runtime');
|
|
39
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md']) assert.match(readFileSync(path.join(repo, name), 'utf8'), /\.claude\/INDEX\.md/);
|
|
40
|
+
assert.match(readFileSync(path.join(repo, '.gitignore'), 'utf8'), /^\.worktrees\/sessions\/$/m);
|
|
41
|
+
assert.equal(doctor({ dir: repo, quick: true }, quiet), 0);
|
|
42
|
+
} finally { rmSync(repo, { recursive: true, force: true }); }
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('init refuses a populated .claude it did not install, and --force replaces only runtime paths', () => {
|
|
46
|
+
const repo = freshRepo();
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(path.join(repo, '.claude'));
|
|
49
|
+
writeFileSync(path.join(repo, '.claude', 'settings.json'), '{}\n');
|
|
50
|
+
assert.throws(() => init({ dir: repo, force: false, bootstrap: false }, quiet), /was not installed by/);
|
|
51
|
+
init({ dir: repo, force: true, bootstrap: false }, quiet);
|
|
52
|
+
assert.ok(existsSync(path.join(repo, '.claude', 'settings.json')), 'a file outside the runtime paths survives --force');
|
|
53
|
+
assert.ok(!existsSync(path.join(repo, 'CLAUDE.md')), '--no-bootstrap writes no bootstrap');
|
|
54
|
+
} finally { rmSync(repo, { recursive: true, force: true }); }
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('update keeps a locally changed file and lists it; --force takes the package version', () => {
|
|
58
|
+
const repo = freshRepo();
|
|
59
|
+
try {
|
|
60
|
+
init({ dir: repo, force: false, bootstrap: false }, quiet);
|
|
61
|
+
const target = path.join(repo, '.claude', 'workflows', 'README.md');
|
|
62
|
+
const original = readFileSync(target, 'utf8');
|
|
63
|
+
writeFileSync(target, `${original}\nlocal note\n`);
|
|
64
|
+
unlinkSync(path.join(repo, '.claude', 'routing.json'));
|
|
65
|
+
const lines = [];
|
|
66
|
+
const manifest = update({ dir: repo, force: false }, (l) => lines.push(l));
|
|
67
|
+
assert.deepEqual(manifest.keptLocal, ['workflows/README.md']);
|
|
68
|
+
assert.match(readFileSync(target, 'utf8'), /local note/);
|
|
69
|
+
assert.ok(existsSync(path.join(repo, '.claude', 'routing.json')), 'a deleted runtime file comes back');
|
|
70
|
+
assert.ok(lines.some((l) => l.startsWith('kept workflows/README.md')));
|
|
71
|
+
update({ dir: repo, force: true }, quiet);
|
|
72
|
+
assert.equal(readFileSync(target, 'utf8'), original);
|
|
73
|
+
} finally { rmSync(repo, { recursive: true, force: true }); }
|
|
74
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The one JSON Schema checker the tree uses: the same subset every operator's validation.mjs carried
|
|
2
|
+
// (types, enum, const, $ref local, allOf/oneOf/anyOf, if/then/else, string, number, array and object
|
|
3
|
+
// keywords), lifted to scripts/ so validate-step, the kind schemas under templates/kinds, and the step
|
|
4
|
+
// gates under templates/step share one implementation.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
8
|
+
function jsonType(value) {
|
|
9
|
+
if (value === null) return 'null';
|
|
10
|
+
if (Array.isArray(value)) return 'array';
|
|
11
|
+
if (Number.isInteger(value)) return 'integer';
|
|
12
|
+
return typeof value;
|
|
13
|
+
}
|
|
14
|
+
function resolveLocalRef(schema, ref) {
|
|
15
|
+
if (!ref.startsWith('#/')) throw new Error(`unsupported non-local schema reference ${ref}`);
|
|
16
|
+
return ref.slice(2).split('/').reduce((current, key) => current?.[key.replaceAll('~1', '/').replaceAll('~0', '~')], schema);
|
|
17
|
+
}
|
|
18
|
+
function inspect(schema, rule, value, at, errors) {
|
|
19
|
+
if (rule.$ref) return inspect(schema, resolveLocalRef(schema, rule.$ref), value, at, errors);
|
|
20
|
+
for (const item of rule.allOf ?? []) inspect(schema, item, value, at, errors);
|
|
21
|
+
if (rule.oneOf || rule.anyOf) {
|
|
22
|
+
const branches = rule.oneOf ?? rule.anyOf;
|
|
23
|
+
const matches = branches.filter((branch) => { const e = []; inspect(schema, branch, value, at, e); return e.length === 0; }).length;
|
|
24
|
+
if ((rule.oneOf && matches !== 1) || (rule.anyOf && matches === 0)) errors.push(`${at}: no unique allowed schema branch`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (rule.if) {
|
|
28
|
+
const c = []; inspect(schema, rule.if, value, at, c);
|
|
29
|
+
if (c.length === 0 && rule.then) inspect(schema, rule.then, value, at, errors);
|
|
30
|
+
if (c.length > 0 && rule.else) inspect(schema, rule.else, value, at, errors);
|
|
31
|
+
}
|
|
32
|
+
if (Object.hasOwn(rule, 'const') && value !== rule.const) errors.push(`${at}: expected ${JSON.stringify(rule.const)}`);
|
|
33
|
+
if (rule.enum && !rule.enum.includes(value)) errors.push(`${at}: value is outside the allowed enum`);
|
|
34
|
+
if (rule.type) {
|
|
35
|
+
const types = Array.isArray(rule.type) ? rule.type : [rule.type];
|
|
36
|
+
const actual = jsonType(value);
|
|
37
|
+
if (!types.some((type) => type === actual || (type === 'number' && typeof value === 'number'))) { errors.push(`${at}: expected ${types.join('|')}, got ${actual}`); return; }
|
|
38
|
+
}
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
if (rule.minLength !== undefined && value.length < rule.minLength) errors.push(`${at}: string is too short`);
|
|
41
|
+
if (rule.maxLength !== undefined && value.length > rule.maxLength) errors.push(`${at}: string is too long`);
|
|
42
|
+
if (rule.pattern && !new RegExp(rule.pattern).test(value)) errors.push(`${at}: string does not match ${rule.pattern}`);
|
|
43
|
+
if (rule.format === 'date-time' && Number.isNaN(Date.parse(value))) errors.push(`${at}: invalid date-time`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof value === 'number') {
|
|
46
|
+
if (rule.minimum !== undefined && value < rule.minimum) errors.push(`${at}: value is below minimum`);
|
|
47
|
+
if (rule.maximum !== undefined && value > rule.maximum) errors.push(`${at}: value exceeds maximum`);
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
if (rule.minItems !== undefined && value.length < rule.minItems) errors.push(`${at}: array is too short`);
|
|
51
|
+
if (rule.maxItems !== undefined && value.length > rule.maxItems) errors.push(`${at}: array is too long`);
|
|
52
|
+
if (rule.uniqueItems && new Set(value.map((item) => JSON.stringify(item))).size !== value.length) errors.push(`${at}: duplicate items are forbidden`);
|
|
53
|
+
if (rule.items) value.forEach((item, index) => inspect(schema, rule.items, item, `${at}[${index}]`, errors));
|
|
54
|
+
}
|
|
55
|
+
if (isObject(value)) {
|
|
56
|
+
for (const key of rule.required ?? []) if (!Object.hasOwn(value, key)) errors.push(`${at}.${key}: required`);
|
|
57
|
+
const properties = rule.properties ?? {};
|
|
58
|
+
if (rule.additionalProperties === false) for (const key of Object.keys(value)) if (!Object.hasOwn(properties, key)) errors.push(`${at}.${key}: unexpected property`);
|
|
59
|
+
else if (isObject(rule.additionalProperties)) for (const [key, child] of Object.entries(value)) if (!Object.hasOwn(properties, key)) inspect(schema, rule.additionalProperties, child, `${at}.${key}`, errors);
|
|
60
|
+
for (const [key, child] of Object.entries(properties)) if (Object.hasOwn(value, key)) inspect(schema, child, value[key], `${at}.${key}`, errors);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Path traversal and unbounded strings are refused everywhere, whatever the schema says.
|
|
64
|
+
function hygiene(value) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
const visit = (current, at) => {
|
|
67
|
+
if (typeof current === 'string') {
|
|
68
|
+
if (current.length > 8192) errors.push(`${at}: string exceeds the contract limit`);
|
|
69
|
+
if (!at.endsWith('.$schema') && /(^|[\\/])\.\.([\\/]|$)/.test(current) && !/^\.\.\/step-\d+-\d+\//.test(current)) errors.push(`${at}: path traversal is forbidden`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(current)) { if (current.length > 512) errors.push(`${at}: array exceeds the contract limit`); current.forEach((item, i) => visit(item, `${at}[${i}]`)); return; }
|
|
73
|
+
if (isObject(current)) for (const [key, child] of Object.entries(current)) visit(child, `${at}.${key}`);
|
|
74
|
+
};
|
|
75
|
+
visit(value, '$');
|
|
76
|
+
return errors;
|
|
77
|
+
}
|
|
78
|
+
export function validateAgainst(schema, value, at = '$') {
|
|
79
|
+
const errors = [];
|
|
80
|
+
inspect(schema, schema, value, at, errors);
|
|
81
|
+
if (errors.length === 0) errors.push(...hygiene(value));
|
|
82
|
+
return errors;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The shape the old per-operator validation.mjs exported; scripts/workspace-portable.mjs (called by the
|
|
86
|
+
// backend package.json) still builds its route validators this way.
|
|
87
|
+
export function validatorFor(schemaUrl, semantic = () => []) {
|
|
88
|
+
const schema = JSON.parse(readFileSync(schemaUrl, 'utf8'));
|
|
89
|
+
return (value) => {
|
|
90
|
+
const errors = validateAgainst(schema, value);
|
|
91
|
+
if (errors.length === 0) errors.push(...semantic(value));
|
|
92
|
+
return { valid: errors.length === 0, errors };
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// operator.md is the one authored file of an operator. This module is the one place it is parsed, so
|
|
2
|
+
// validate-operator, validate-alias, validate-routing, generate-operators-index and validate-step
|
|
3
|
+
// cannot disagree about which table says what. Tables are read by position, so the English file and
|
|
4
|
+
// its Vietnamese mirror parse identically; the headings differ per language and are listed here.
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const HEADINGS = {
|
|
9
|
+
en: { job: '## Job', context: '## Context', inputs: '## Inputs', requirements: '## Requirements', steps: '## Steps', outputs: '## Outputs', stops: '## Stops', next: '## Next' },
|
|
10
|
+
vi: { job: '## Việc', context: '## Context', inputs: '## Đầu vào', requirements: '## Yêu cầu', steps: '## Các bước', outputs: '## Đầu ra', stops: '## Dừng', next: '## Kế tiếp' },
|
|
11
|
+
};
|
|
12
|
+
export const COLUMNS = {
|
|
13
|
+
context: ['alias', 'bind', 'required'],
|
|
14
|
+
inputs: ['kind', 'from', 'required'],
|
|
15
|
+
requirements: ['field', 'type', 'default', 'ask'],
|
|
16
|
+
steps: ['n', 'step', 'params', 'reads', 'writes', 'stops'],
|
|
17
|
+
outputs: ['kind', 'file', 'type', 'required'],
|
|
18
|
+
stops: ['code', 'disposition'],
|
|
19
|
+
next: ['when', 'operator'],
|
|
20
|
+
};
|
|
21
|
+
export const YES = new Set(['yes', 'có']);
|
|
22
|
+
export const ALIAS = /@[a-z][a-z-]*(?:\/[A-Za-z0-9_<>.@#:-]+)*/g;
|
|
23
|
+
const strip = (s) => s.replace(/^`|`$/g, '');
|
|
24
|
+
|
|
25
|
+
function firstTable(lines, from, to) {
|
|
26
|
+
for (let i = from; i < to - 1; i += 1) {
|
|
27
|
+
if (lines[i].startsWith('|') && /^\|\s*-{3,}/.test(lines[i + 1])) {
|
|
28
|
+
const rows = [];
|
|
29
|
+
for (let j = i + 2; j < to && lines[j].startsWith('|'); j += 1) rows.push(lines[j].split('|').slice(1, -1).map((c) => c.trim()));
|
|
30
|
+
return { header: lines[i].split('|').slice(1, -1).map((c) => c.trim()), rows, line: i + 1 };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseOperatorMd(text, lang = 'en') {
|
|
37
|
+
const lines = text.split(/\r?\n/);
|
|
38
|
+
const h = HEADINGS[lang];
|
|
39
|
+
const heads = lines.map((l, i) => ({ l: l.trimEnd(), i })).filter((x) => x.l.startsWith('## '));
|
|
40
|
+
const sectionOf = (heading) => {
|
|
41
|
+
const idx = heads.findIndex((x) => x.l === heading);
|
|
42
|
+
if (idx === -1) return null;
|
|
43
|
+
const from = heads[idx].i + 1;
|
|
44
|
+
const to = idx + 1 < heads.length ? heads[idx + 1].i : lines.length;
|
|
45
|
+
return { from, to, line: heads[idx].i + 1 };
|
|
46
|
+
};
|
|
47
|
+
const out = { id: (lines[0] ?? '').replace(/^#\s*/, '').replace(/`/g, '').trim(), lang, tables: {}, job: '' };
|
|
48
|
+
const job = sectionOf(h.job);
|
|
49
|
+
if (job) out.job = lines.slice(job.from, job.to).map((l) => l.trim()).filter(Boolean).join(' ');
|
|
50
|
+
for (const key of Object.keys(COLUMNS)) {
|
|
51
|
+
const sec = sectionOf(h[key]);
|
|
52
|
+
if (!sec) { out.tables[key] = null; continue; }
|
|
53
|
+
const table = firstTable(lines, sec.from, sec.to);
|
|
54
|
+
if (!table) { out.tables[key] = null; continue; }
|
|
55
|
+
const cols = COLUMNS[key];
|
|
56
|
+
out.tables[key] = {
|
|
57
|
+
line: table.line,
|
|
58
|
+
header: table.header,
|
|
59
|
+
rows: table.rows.map((cells, r) => {
|
|
60
|
+
const row = { _line: table.line + 2 + r };
|
|
61
|
+
cols.forEach((c, i) => { row[c] = cells[i] ?? ''; });
|
|
62
|
+
return row;
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Convenience views over the parsed tables.
|
|
70
|
+
export const cellCodes = (cell) => [...cell.matchAll(/`([A-Z][A-Z0-9_]+)`/g)].map((m) => m[1]);
|
|
71
|
+
export const cellParams = (cell) => (cell.trim() === '—' || cell.trim() === '' ? [] : [...cell.matchAll(/`([a-zA-Z][A-Za-z0-9_]*)`/g)].map((m) => m[1]));
|
|
72
|
+
export const cellAliases = (cell) => [...cell.matchAll(ALIAS)].map((m) => m[0]);
|
|
73
|
+
// Files are branch-relative: response/<x>.md, response/data/…, response/artifacts/…, response/response.json,
|
|
74
|
+
// or <exchange>/response/… for a nested exchange.
|
|
75
|
+
export const cellFiles = (cell) => [...cell.matchAll(/`((?:[a-z][a-z-]*\/)?response\/(?:(?:[a-z][a-z-]*\/)*[A-Za-z0-9_<>.-]+\.md|data\/[A-Za-z0-9_<>./-]+|artifacts\/[A-Za-z0-9_<>./-]+|response\.json))`/g)].map((m) => m[1]);
|
|
76
|
+
export const exchangeOf = (file) => { const m = /^([a-z][a-z-]*)\/response\//.exec(file); return m ? m[1] : null; };
|
|
77
|
+
export const isYes = (cell) => YES.has(strip(cell).trim().toLowerCase());
|
|
78
|
+
export const kindOf = (cell) => strip(cell.trim());
|
|
79
|
+
|
|
80
|
+
export async function loadOperatorPackages(root) {
|
|
81
|
+
const { readdir } = await import('node:fs/promises');
|
|
82
|
+
const { existsSync } = await import('node:fs');
|
|
83
|
+
const dir = path.join(root, 'operators');
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const entry of (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
86
|
+
const full = path.join(dir, entry.name);
|
|
87
|
+
const manifest = JSON.parse(await readFile(path.join(full, 'operator.json'), 'utf8'));
|
|
88
|
+
const mdPath = path.join(full, 'operator.md');
|
|
89
|
+
if (!existsSync(mdPath)) { out.push({ dir: full, name: entry.name, manifest, shape: 'v8' }); continue; }
|
|
90
|
+
const en = parseOperatorMd(await readFile(mdPath, 'utf8'), 'en');
|
|
91
|
+
const viPath = path.join(full, 'operator.vi.md');
|
|
92
|
+
const vi = existsSync(viPath) ? parseOperatorMd(await readFile(viPath, 'utf8'), 'vi') : null;
|
|
93
|
+
out.push({ dir: full, name: entry.name, manifest, shape: 'v9', en, vi });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const operators = path.join(root, 'operators');
|
|
12
|
+
const failures = [];
|
|
13
|
+
let passed = 0;
|
|
14
|
+
|
|
15
|
+
for (const entry of await readdir(operators, { withFileTypes: true })) {
|
|
16
|
+
if (!entry.isDirectory()) continue;
|
|
17
|
+
const selfTest = path.join(operators, entry.name, 'self-test.mjs');
|
|
18
|
+
if (!existsSync(selfTest)) {
|
|
19
|
+
failures.push(`${entry.name}: no self-test.mjs`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await run(process.execPath, [selfTest], { cwd: path.join(operators, entry.name) });
|
|
24
|
+
process.stdout.write(stdout);
|
|
25
|
+
passed += 1;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
failures.push(`${entry.name}: ${(error.stderr || error.message).trim()}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (failures.length > 0) {
|
|
32
|
+
process.stderr.write(`${failures.join('\n')}\n`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
} else {
|
|
35
|
+
process.stdout.write(`${passed} operator self-tests passed\n`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Every reference an operator reads is named by an alias that alias/alias.json resolves to an exact
|
|
2
|
+
// location. An operator declares its aliases in operator.json (`refs`), and its context.md states
|
|
3
|
+
// the same table for the reader. This script rejects an alias nobody registered, a required alias
|
|
4
|
+
// the context table omits, a writer that is not an operator, and a context row that names an alias
|
|
5
|
+
// the operator did not declare. Disk existence is not checked here: most locations are
|
|
6
|
+
// machine-local (`.worktrees`, `.workspaces/local`) and absent on a fresh clone by design.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { loadAliasRegistry, baseOf as baseOfIn } from './alias-registry.mjs';
|
|
13
|
+
import { parseOperatorMd, cellAliases, isYes } from './operator-md.mjs';
|
|
14
|
+
|
|
15
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const registry = await loadAliasRegistry(root);
|
|
17
|
+
const errors = [];
|
|
18
|
+
|
|
19
|
+
if (registry.schemaVersion !== 8) errors.push('alias.json: schemaVersion must be 8');
|
|
20
|
+
const aliases = registry.aliases ?? {};
|
|
21
|
+
const KINDS = new Set(['file', 'dir', 'checkout', 'service', 'caller-supplied', 'tool']);
|
|
22
|
+
for (const [alias, def] of Object.entries(aliases)) {
|
|
23
|
+
if (!/^@[a-z][a-z-]*(?:\/[a-z_][a-z0-9_-]*)*$/.test(alias)) errors.push(`alias.json: alias ${alias} must look like @name or @name/sub/path`);
|
|
24
|
+
for (const key of ['params', 'kind', 'resolvesTo', 'scheme', 'bind', 'writers', 'purpose']) {
|
|
25
|
+
if (def[key] === undefined) errors.push(`alias.json: ${alias} lacks ${key}`);
|
|
26
|
+
}
|
|
27
|
+
if (!KINDS.has(def.kind)) errors.push(`alias.json: ${alias} kind ${def.kind} is not one of ${[...KINDS].join(', ')}`);
|
|
28
|
+
for (const p of def.params ?? []) {
|
|
29
|
+
if (!def.resolvesTo.includes(`<${p}>`) && !def.resolvesTo.includes(`${p}/`) && !def.resolvesTo.includes(`/${p}`)) {
|
|
30
|
+
errors.push(`alias.json: ${alias} declares param ${p} that resolvesTo never uses`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const operatorsDir = path.join(root, 'operators');
|
|
36
|
+
const operatorIds = new Set();
|
|
37
|
+
const manifests = [];
|
|
38
|
+
for (const entry of await readdir(operatorsDir, { withFileTypes: true })) {
|
|
39
|
+
if (!entry.isDirectory()) continue;
|
|
40
|
+
const dir = path.join(operatorsDir, entry.name);
|
|
41
|
+
const manifest = JSON.parse(await readFile(path.join(dir, 'operator.json'), 'utf8'));
|
|
42
|
+
operatorIds.add(manifest.id);
|
|
43
|
+
manifests.push({ dir, manifest });
|
|
44
|
+
}
|
|
45
|
+
for (const [alias, def] of Object.entries(aliases)) {
|
|
46
|
+
for (const w of def.writers ?? []) {
|
|
47
|
+
if (w !== '*' && !operatorIds.has(w)) errors.push(`alias.json: ${alias} names writer ${w}, which is not an operator`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A context table row: | `@alias/...` | resolves to | bind | Required/Optional |
|
|
52
|
+
const ROW = /^\|\s*`(@[a-z][a-z-]*(?:\/[A-Za-z0-9_<>.@#:-]+)*)`\s*\|/;
|
|
53
|
+
// The registered alias an alias string belongs to is its longest registered prefix.
|
|
54
|
+
const baseOf = (alias) => baseOfIn(aliases, alias);
|
|
55
|
+
function tableAliases(text, heading) {
|
|
56
|
+
const lines = text.split(/\r?\n/);
|
|
57
|
+
const start = lines.findIndex((l) => l.trim() === heading);
|
|
58
|
+
if (start === -1) return null;
|
|
59
|
+
const out = [];
|
|
60
|
+
for (let i = start + 1; i < lines.length && !lines[i].startsWith('## '); i += 1) {
|
|
61
|
+
const m = ROW.exec(lines[i]);
|
|
62
|
+
if (m) out.push(m[1]);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// An operator.md package declares its aliases in the Context table and routes by them in the Steps
|
|
68
|
+
// table; both languages are checked. Dynamic inputs are kinds, not aliases, so the @dynamic rule of
|
|
69
|
+
// the old shape does not apply.
|
|
70
|
+
async function checkOperatorMd(dir, id) {
|
|
71
|
+
let count = 0;
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const en = parseOperatorMd(await readFile(path.join(dir, 'operator.md'), 'utf8'), 'en');
|
|
74
|
+
const rows = en.tables.context?.rows ?? [];
|
|
75
|
+
if (rows.length === 0) { errors.push(`${id}: operator.md Context table is empty`); return 0; }
|
|
76
|
+
const declaredBases = new Set();
|
|
77
|
+
const requiredBases = new Set();
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
const alias = cellAliases(row.alias)[0];
|
|
80
|
+
if (!alias) { errors.push(`${id}: operator.md Context row "${row.alias}" names no alias`); continue; }
|
|
81
|
+
const base = baseOf(alias);
|
|
82
|
+
if (!base) errors.push(`${id}: ref ${alias} is not registered in alias/alias.json`);
|
|
83
|
+
if (seen.has(alias)) errors.push(`${id}: ref ${alias} is declared twice`);
|
|
84
|
+
seen.add(alias);
|
|
85
|
+
if (base) { declaredBases.add(base); if (isYes(row.required)) requiredBases.add(base); }
|
|
86
|
+
count += 1;
|
|
87
|
+
}
|
|
88
|
+
// @tools/<id> in a Steps cell is a call, not a read: it must be declared in operator.json → resources.tools.
|
|
89
|
+
const manifest = JSON.parse(await readFile(path.join(dir, 'operator.json'), 'utf8'));
|
|
90
|
+
const declaredTools = new Set(Object.keys(manifest.resources?.tools ?? {}));
|
|
91
|
+
for (const [file, lang] of [['operator.md', 'en'], ['operator.vi.md', 'vi']]) {
|
|
92
|
+
if (!existsSync(path.join(dir, file))) continue;
|
|
93
|
+
const op = lang === 'en' ? en : parseOperatorMd(await readFile(path.join(dir, file), 'utf8'), 'vi');
|
|
94
|
+
const used = new Set();
|
|
95
|
+
const toolsUsed = new Set();
|
|
96
|
+
for (const step of op.tables.steps?.rows ?? []) for (const a of [...cellAliases(step.reads), ...cellAliases(step.writes)]) {
|
|
97
|
+
if (a.startsWith('@dynamic')) { errors.push(`${id}: ${file} step ${step.n} names ${a}; an operator.md package passes dynamic files as kinds, not aliases`); continue; }
|
|
98
|
+
if (a.startsWith('@tools/')) { toolsUsed.add(a); if (!aliases[a]) errors.push(`${id}: ${file} step ${step.n} names ${a}, which resources/tools.json does not define`); else if (!declaredTools.has(a)) errors.push(`${id}: ${file} step ${step.n} calls ${a}, which operator.json resources.tools does not declare`); continue; }
|
|
99
|
+
used.add(baseOf(a) ?? a);
|
|
100
|
+
}
|
|
101
|
+
if (lang === 'en') for (const t of declaredTools) if (!toolsUsed.has(t) && t !== '@tools/fileread') errors.push(`${id}: operator.json declares ${t} but no step names it`);
|
|
102
|
+
for (const u of used) if (!declaredBases.has(u)) errors.push(`${id}: ${file} Steps read or write ${u}, which the Context table does not declare`);
|
|
103
|
+
for (const r of requiredBases) if (!used.has(r)) errors.push(`${id}: ${file} Steps never read or write required ${r}`);
|
|
104
|
+
if (used.size === 0) errors.push(`${id}: ${file} Steps name no alias at all`);
|
|
105
|
+
}
|
|
106
|
+
return count;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let declared = 0;
|
|
110
|
+
for (const { dir, manifest } of manifests) {
|
|
111
|
+
const id = manifest.id;
|
|
112
|
+
if (existsSync(path.join(dir, 'operator.md'))) { declared += await checkOperatorMd(dir, id); continue; }
|
|
113
|
+
const refs = manifest.refs;
|
|
114
|
+
if (!Array.isArray(refs) || refs.length === 0) { errors.push(`${id}: operator.json must declare refs`); continue; }
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const ref of refs) {
|
|
117
|
+
const base = baseOf(ref.alias ?? '');
|
|
118
|
+
if (!base) errors.push(`${id}: ref ${ref.alias} is not registered in alias/alias.json`);
|
|
119
|
+
if (typeof ref.required !== 'boolean') errors.push(`${id}: ref ${ref.alias} must say required true or false`);
|
|
120
|
+
if (typeof ref.purpose !== 'string' || ref.purpose.length === 0) errors.push(`${id}: ref ${ref.alias} needs a purpose`);
|
|
121
|
+
if (seen.has(ref.alias)) errors.push(`${id}: ref ${ref.alias} is declared twice`);
|
|
122
|
+
seen.add(ref.alias);
|
|
123
|
+
declared += 1;
|
|
124
|
+
}
|
|
125
|
+
if (!refs.some((r) => r.alias.startsWith('@dynamic/') && r.required)) {
|
|
126
|
+
errors.push(`${id}: a required @dynamic/<file> ref is missing; every step writes its receipt there`);
|
|
127
|
+
}
|
|
128
|
+
// execute.md routes by alias: every @alias in the Sequence table's Reads/Writes cells must be a
|
|
129
|
+
// declared ref, and every required ref must be read or written by some step. A step that names a
|
|
130
|
+
// location the operator never declared is how an agent wanders outside its region.
|
|
131
|
+
for (const [file, heading] of [['execute.md', '## Sequence'], ['execute.vi.md', '## Trình tự']]) {
|
|
132
|
+
const text = await readFile(path.join(dir, file), 'utf8');
|
|
133
|
+
const lines = text.split(/\r?\n/);
|
|
134
|
+
const start = lines.findIndex((l) => l.trim() === heading);
|
|
135
|
+
if (start === -1) { errors.push(`${id}: ${file} has no ${heading} section`); continue; }
|
|
136
|
+
const used = new Set();
|
|
137
|
+
for (let i = start + 1; i < lines.length && !lines[i].startsWith('## '); i += 1) {
|
|
138
|
+
const cells = lines[i].split('|').map((c) => c.trim());
|
|
139
|
+
if (cells.length < 6 || !/^\d+$/.test(cells[1])) continue;
|
|
140
|
+
for (const cell of [cells[3], cells[4]]) for (const m of cell.matchAll(/@[a-z][a-z-]*(?:\/[A-Za-z0-9_<>.@#:-]+)*/g)) { const b = baseOf(m[0]); used.add(b ?? m[0]); }
|
|
141
|
+
}
|
|
142
|
+
const declaredBases = new Set(refs.map((r) => baseOf(r.alias)));
|
|
143
|
+
for (const u of used) if (!declaredBases.has(u)) errors.push(`${id}: ${file} Sequence reads or writes ${u}, which operator.json does not declare`);
|
|
144
|
+
for (const r of refs) {
|
|
145
|
+
const b = baseOf(r.alias);
|
|
146
|
+
if (r.required && !used.has(b)) errors.push(`${id}: ${file} Sequence never reads or writes required ${r.alias}`);
|
|
147
|
+
}
|
|
148
|
+
if (used.size === 0) errors.push(`${id}: ${file} Sequence names no alias at all`);
|
|
149
|
+
}
|
|
150
|
+
for (const [file, heading] of [['context.md', '## Refs'], ['context.vi.md', '## Ref']]) {
|
|
151
|
+
const text = await readFile(path.join(dir, file), 'utf8');
|
|
152
|
+
const rows = tableAliases(text, heading);
|
|
153
|
+
if (rows === null) { errors.push(`${id}: ${file} has no ${heading} section`); continue; }
|
|
154
|
+
const declaredAliases = new Set(refs.map((r) => r.alias));
|
|
155
|
+
for (const r of refs) if (r.required && !rows.includes(r.alias)) errors.push(`${id}: ${file} ${heading} table omits required ${r.alias}`);
|
|
156
|
+
for (const row of rows) if (!declaredAliases.has(row)) errors.push(`${id}: ${file} ${heading} table names ${row}, which operator.json does not declare`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (errors.length > 0) {
|
|
161
|
+
process.stderr.write(`${errors.join('\n')}\n`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
} else {
|
|
164
|
+
process.stdout.write(`alias closed: ${Object.keys(aliases).length} aliases, ${declared} bindings across ${manifests.length} operators\n`);
|
|
165
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// A request made only of an operator's own defaults must pass validate-request. This is the static
|
|
2
|
+
// half of "the tables agree with the gates": every Requirements row whose Default is not — is turned
|
|
3
|
+
// into a value, every required Input is pointed at a placeholder file, the request is written into a
|
|
4
|
+
// throwaway session, and validate-request judges it. A Default the gate cannot accept, or a required
|
|
5
|
+
// field the orchestrator could never fill, fails here instead of in a real session.
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { loadOperatorPackages, cellAliases, kindOf, isYes } from './operator-md.mjs';
|
|
12
|
+
import { validateRequest, isRequiredField } from './validate-request.mjs';
|
|
13
|
+
|
|
14
|
+
const unquote = (s) => String(s ?? '').trim().replace(/^`|`$/g, '');
|
|
15
|
+
const head = 'a'.repeat(40);
|
|
16
|
+
|
|
17
|
+
// A Default cell is prose for a person; the orchestrator turns it into a value of the field's Type.
|
|
18
|
+
function valueFor(row) {
|
|
19
|
+
const d = unquote(row.default).trim();
|
|
20
|
+
const type = row.type.trim().toLowerCase();
|
|
21
|
+
if (isRequiredField(row)) {
|
|
22
|
+
if (type.startsWith('number')) return 1;
|
|
23
|
+
if (type.startsWith('list')) return ['placeholder'];
|
|
24
|
+
if (type.startsWith('choice')) { const m = /\(([^)]+)\)/.exec(row.type) || /(\w+(?:\s*\|\s*\w+)+)/.exec(row.ask ?? ''); return m ? m[1].split(/\s*\|\s*/)[0].trim() : 'placeholder'; }
|
|
25
|
+
return 'placeholder';
|
|
26
|
+
}
|
|
27
|
+
if (d === 'null' || d === 'none' && type === 'token') return null;
|
|
28
|
+
if (/^\d+$/.test(d)) return Number(d);
|
|
29
|
+
if (type.startsWith('list')) return d === '[]' || d === 'empty' || d === 'rỗng' ? [] : d.split(',').map((s) => s.trim());
|
|
30
|
+
if (d === '[]') return [];
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function validateDefaults(root) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
const packages = (await loadOperatorPackages(root)).filter((p) => p.shape === 'v9');
|
|
37
|
+
const session = mkdtempSync(path.join(tmpdir(), 'defaults-'));
|
|
38
|
+
try {
|
|
39
|
+
let n = 0;
|
|
40
|
+
for (const pkg of packages) {
|
|
41
|
+
const op = pkg.en;
|
|
42
|
+
n += 1;
|
|
43
|
+
const branch = path.join(session, `step-${n}`, 'parallel-1');
|
|
44
|
+
mkdirSync(path.join(branch, 'request'), { recursive: true });
|
|
45
|
+
const requirements = {};
|
|
46
|
+
for (const row of op.tables.requirements?.rows ?? []) requirements[unquote(row.field)] = valueFor(row);
|
|
47
|
+
const inputs = {};
|
|
48
|
+
for (const row of op.tables.inputs?.rows ?? []) {
|
|
49
|
+
if (!isYes(row.required)) continue;
|
|
50
|
+
const kind = kindOf(row.kind);
|
|
51
|
+
const producer = path.join(session, 'step-0', 'parallel-1', 'response');
|
|
52
|
+
mkdirSync(path.join(producer, 'data'), { recursive: true });
|
|
53
|
+
const file = `step-0/parallel-1/response/${kind}.md`;
|
|
54
|
+
writeFileSync(path.join(session, file), `# ${kind}\n`);
|
|
55
|
+
inputs[kind] = file;
|
|
56
|
+
}
|
|
57
|
+
const contexts = [];
|
|
58
|
+
for (const row of op.tables.context?.rows ?? []) { const a = cellAliases(row.alias)[0]; if (a) contexts.push({ alias: a.replace(/<[^>]+>/g, 'x'), head: a.startsWith('@workspaces/') ? head : null }); }
|
|
59
|
+
const request = { schemaVersion: 9, operatorId: pkg.manifest.id, step: n, parallel: 1, sessionId: 'defaults', contexts, requirements, inputs, resume: null };
|
|
60
|
+
writeFileSync(path.join(branch, 'request', 'request.json'), JSON.stringify(request, null, 2));
|
|
61
|
+
const { errors: e } = await validateRequest(root, branch, packages);
|
|
62
|
+
for (const line of e) errors.push(`${pkg.manifest.id}: defaults-only request rejected: ${line}`);
|
|
63
|
+
}
|
|
64
|
+
} finally { rmSync(session, { recursive: true, force: true }); }
|
|
65
|
+
return { errors, count: packages.length };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
69
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
70
|
+
const { errors, count } = await validateDefaults(root);
|
|
71
|
+
if (errors.length) { process.stderr.write(`${errors.join('\n')}\n`); process.exitCode = 1; } else process.stdout.write(`defaults closed: ${count} operators accept a defaults-only request\n`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Every rule identifier a knowledge file cites must be one the knowledge tree publishes.
|
|
2
|
+
//
|
|
3
|
+
// A rule exists only as a `## PREFIX-n — …` heading in a canonical English knowledge file. This
|
|
4
|
+
// script builds that inventory, then scans every canonical knowledge file for citations of the shape
|
|
5
|
+
// `PREFIX-n` or `PREFIX-a..b` and rejects any that resolve to nothing: a number the topic never
|
|
6
|
+
// published, a range that runs past the inventory, or a prefix no file owns. The `.vi.md` mirrors
|
|
7
|
+
// are scanned too, because a mirror that cites a rule the English file does not is drift.
|
|
8
|
+
//
|
|
9
|
+
// Why this exists: `grammars/starci` cited `TONE-4..5`, `RENDER-TRUTH-1..4`, and `GAP-1..5` against a
|
|
10
|
+
// tree that publishes `TONE-1..3`, `TRUTH-1..4`, and `GAP-0..6`. Nothing failed, so nobody noticed.
|
|
11
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import process from 'node:process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'knowledge');
|
|
17
|
+
|
|
18
|
+
async function walk(dir) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
21
|
+
const full = path.join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory()) out.push(...(await walk(full)));
|
|
23
|
+
else if (entry.name.endsWith('.md')) out.push(full);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const files = (await walk(root)).sort();
|
|
29
|
+
const canonical = files.filter((f) => !f.endsWith('.vi.md'));
|
|
30
|
+
|
|
31
|
+
// Inventory: one heading per published rule. Prefixes may contain hyphens (CONTROL-STATE, CORE-SURFACE).
|
|
32
|
+
// A prefix may carry digits (A11Y); the trailing number is always the ordinal.
|
|
33
|
+
const HEADING = /^## ([A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*)-(\d+)\b/;
|
|
34
|
+
const published = new Map(); // prefix -> Set(number)
|
|
35
|
+
const ownerOf = new Map(); // prefix -> file
|
|
36
|
+
for (const file of canonical) {
|
|
37
|
+
const text = await readFile(file, 'utf8');
|
|
38
|
+
for (const line of text.split(/\r?\n/)) {
|
|
39
|
+
const m = HEADING.exec(line);
|
|
40
|
+
if (!m) continue;
|
|
41
|
+
const [, prefix, n] = m;
|
|
42
|
+
if (!published.has(prefix)) {
|
|
43
|
+
published.set(prefix, new Set());
|
|
44
|
+
ownerOf.set(prefix, path.relative(root, file));
|
|
45
|
+
}
|
|
46
|
+
published.get(prefix).add(Number(n));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Citations: PREFIX-n or PREFIX-a..b, where PREFIX is a published prefix or looks like one.
|
|
51
|
+
// Tokens whose prefix is unknown are reported once per file so a fabricated family cannot hide.
|
|
52
|
+
const CITATION = /\b([A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*)-(\d+)(?:\.\.(\d+))?\b/g;
|
|
53
|
+
const IGNORE_PREFIXES = new Set(['SHA', 'UTF', 'ISO', 'RFC', 'HTTP', 'ES', 'V', 'H', 'CVE', 'X', 'GPT']);
|
|
54
|
+
const errors = [];
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
const rel = path.relative(root, file);
|
|
57
|
+
const text = await readFile(file, 'utf8');
|
|
58
|
+
const lines = text.split(/\r?\n/);
|
|
59
|
+
lines.forEach((line, i) => {
|
|
60
|
+
if (HEADING.test(line)) return; // the heading is the publication, not a citation
|
|
61
|
+
// A file may name the numbers it retired, so a reader knows they are not free. Such a line
|
|
62
|
+
// says "retired" (or its mirror "đã nghỉ") and may cite unpublished numbers of a published
|
|
63
|
+
// prefix; it still may not cite a prefix nobody owns.
|
|
64
|
+
const retiredLine = /retired|đã nghỉ/i.test(line);
|
|
65
|
+
for (const m of line.matchAll(CITATION)) {
|
|
66
|
+
const [, prefix, a, b] = m;
|
|
67
|
+
if (IGNORE_PREFIXES.has(prefix)) continue;
|
|
68
|
+
const set = published.get(prefix);
|
|
69
|
+
if (!set) {
|
|
70
|
+
errors.push(`${rel}:${i + 1}: prefix ${prefix} is published by no knowledge file (${m[0]})`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (retiredLine) continue;
|
|
74
|
+
const from = Number(a);
|
|
75
|
+
const to = b === undefined ? from : Number(b);
|
|
76
|
+
if (to < from) errors.push(`${rel}:${i + 1}: inverted range ${m[0]}`);
|
|
77
|
+
for (let n = from; n <= to; n += 1) {
|
|
78
|
+
if (!set.has(n)) errors.push(`${rel}:${i + 1}: ${prefix}-${n} is not published (${ownerOf.get(prefix)} publishes ${[...set].sort((x, y) => x - y).join(',')})`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (errors.length > 0) {
|
|
85
|
+
process.stderr.write(`${errors.join('\n')}\n`);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
} else {
|
|
88
|
+
const total = [...published.values()].reduce((n, s) => n + s.size, 0);
|
|
89
|
+
process.stdout.write(`knowledge citations closed: ${published.size} prefixes, ${total} rules, ${files.length} files\n`);
|
|
90
|
+
}
|