From 7d9b1c1bd6ad41b90d26adda3c2c5e8cf15c75e6 Mon Sep 17 00:00:00 2001 From: PierreDillard <7gaspard77@gmail.com> Date: Thu, 22 Oct 2026 16:01:23 +0200 Subject: [PATCH 1/4] feat(skills): add explicit triggers to skill descriptions --- core/skills/branch-router/SKILL.md | 2 +- core/skills/workflow-doctor/SKILL.md | 2 +- modules/bugs/skills/bug-fix-trace/SKILL.md | 2 +- modules/bugs/skills/bug-triage/SKILL.md | 2 +- modules/continuity/skills/context-relay/SKILL.md | 2 +- modules/continuity/skills/project-handoff/SKILL.md | 2 +- modules/exploration/skills/wayfinder/SKILL.md | 2 +- modules/learning/skills/teach/SKILL.md | 2 +- modules/memory/skills/memory-report/SKILL.md | 2 +- modules/quality/skills/architect/SKILL.md | 2 +- modules/quality/skills/explicit-naming/SKILL.md | 2 +- modules/quality/skills/merge-integrity/SKILL.md | 2 +- modules/quality/skills/perf-gate/SKILL.md | 2 +- modules/quality/skills/resource-ownership/SKILL.md | 2 +- modules/quality/skills/review-gate/SKILL.md | 2 +- modules/quality/skills/validate-task/SKILL.md | 2 +- modules/tasks/skills/ants/SKILL.md | 2 +- modules/tasks/skills/write-task/SKILL.md | 2 +- tests/modules.test.mjs | 11 +++++++++++ 19 files changed, 29 insertions(+), 18 deletions(-) diff --git a/core/skills/branch-router/SKILL.md b/core/skills/branch-router/SKILL.md index a683e9a..bc4dca6 100644 --- a/core/skills/branch-router/SKILL.md +++ b/core/skills/branch-router/SKILL.md @@ -1,6 +1,6 @@ --- name: branch-router -description: Resolve the current Git branch, its task workspace, merge direction and configured branch policy before any versioned change or merge. +description: Resolve the current Git branch, its task workspace, merge direction and configured branch policy before any versioned change or merge. Use before any versioned change, branch creation or merge, and whenever work touches shared files or the default branch. --- # Branch Router diff --git a/core/skills/workflow-doctor/SKILL.md b/core/skills/workflow-doctor/SKILL.md index 9e79a5b..6413e3a 100644 --- a/core/skills/workflow-doctor/SKILL.md +++ b/core/skills/workflow-doctor/SKILL.md @@ -1,6 +1,6 @@ --- name: workflow-doctor -description: Run the installed workflow's read-only health check after installation or workflow changes. +description: Run the installed workflow's read-only health check after installation or workflow changes. Use after installing or updating the kit, after editing any skill, on a new clone or machine, or when the user says "doctor" or "check the workflow". --- # Workflow Doctor diff --git a/modules/bugs/skills/bug-fix-trace/SKILL.md b/modules/bugs/skills/bug-fix-trace/SKILL.md index 4bdd379..1fe37ce 100644 --- a/modules/bugs/skills/bug-fix-trace/SKILL.md +++ b/modules/bugs/skills/bug-fix-trace/SKILL.md @@ -1,6 +1,6 @@ --- name: bug-fix-trace -description: Confirm a bug hypothesis at runtime, add a focused regression test when feasible, and retain the incident in the thematic bug ledger. +description: Confirm a bug hypothesis at runtime, add a focused regression test when feasible, and retain the incident in the thematic bug ledger. Use when diagnosing a functional bug, after fixing one, or when the user says "trace this bug" or "log this fix"; not for pure lint or formatting fixes. --- # Bug Fix Trace diff --git a/modules/bugs/skills/bug-triage/SKILL.md b/modules/bugs/skills/bug-triage/SKILL.md index 10f3b11..2d42be2 100644 --- a/modules/bugs/skills/bug-triage/SKILL.md +++ b/modules/bugs/skills/bug-triage/SKILL.md @@ -1,6 +1,6 @@ --- name: bug-triage -description: Triage a reported defect by observable impact, reproducibility, ownership and existing thematic evidence before diagnosis or implementation. +description: Triage a reported defect by observable impact, reproducibility, ownership and existing thematic evidence before diagnosis or implementation. Use when a defect or regression is reported, before diagnosing it or writing any fix. --- # Bug Triage diff --git a/modules/continuity/skills/context-relay/SKILL.md b/modules/continuity/skills/context-relay/SKILL.md index a56fae4..78478dd 100644 --- a/modules/continuity/skills/context-relay/SKILL.md +++ b/modules/continuity/skills/context-relay/SKILL.md @@ -1,6 +1,6 @@ --- name: context-relay -description: Prepare a branch-scoped handoff before context saturation while preserving the current atomic step and loading boundaries. +description: Prepare a branch-scoped handoff before context saturation while preserving the current atomic step and loading boundaries. Use when the conversation has run long, many files have been read or edited, context is being compressed, or coherence degrades; act proactively. --- # Context Relay diff --git a/modules/continuity/skills/project-handoff/SKILL.md b/modules/continuity/skills/project-handoff/SKILL.md index f466142..9d71371 100644 --- a/modules/continuity/skills/project-handoff/SKILL.md +++ b/modules/continuity/skills/project-handoff/SKILL.md @@ -1,6 +1,6 @@ --- name: project-handoff -description: Create or update the current branch handoff so another session can resume from its task, memory and verified state. +description: Create or update the current branch handoff so another session can resume from its task, memory and verified state. Use before ending a session or clearing context with unfinished work, or when the user asks for a handoff. --- # Project Handoff diff --git a/modules/exploration/skills/wayfinder/SKILL.md b/modules/exploration/skills/wayfinder/SKILL.md index c09f565..708b9a7 100644 --- a/modules/exploration/skills/wayfinder/SKILL.md +++ b/modules/exploration/skills/wayfinder/SKILL.md @@ -1,6 +1,6 @@ --- name: wayfinder -description: Map a multi-session initiative into decision tickets, resolving one frontier ticket at a time before implementation tasks begin. +description: Map a multi-session initiative into decision tickets, resolving one frontier ticket at a time before implementation tasks begin. Use when the user says "wayfinder" or "plan this as tickets", or brings a foggy idea bigger than one session; not for a single well-scoped feature (use write-task). disable-model-invocation: true --- diff --git a/modules/learning/skills/teach/SKILL.md b/modules/learning/skills/teach/SKILL.md index b4eb56a..24ba826 100644 --- a/modules/learning/skills/teach/SKILL.md +++ b/modules/learning/skills/teach/SKILL.md @@ -1,6 +1,6 @@ --- name: teach -description: Teach the user a new skill or concept, within this workspace. +description: Teach the user a new skill or concept, within this workspace. Use when the user asks to learn, understand or be taught a concept, tool or technique rather than only to get a task done. disable-model-invocation: true argument-hint: "What would you like to learn about?" --- diff --git a/modules/memory/skills/memory-report/SKILL.md b/modules/memory/skills/memory-report/SKILL.md index c6dbd53..0d73ec9 100644 --- a/modules/memory/skills/memory-report/SKILL.md +++ b/modules/memory/skills/memory-report/SKILL.md @@ -1,6 +1,6 @@ --- name: memory-report -description: Promote only proven reusable findings into branch or project memory while keeping raw evidence in its canonical task or bug record. +description: Promote only proven reusable findings into branch or project memory while keeping raw evidence in its canonical task or bug record. Use after a bug fix, a failed fix or a regression investigation, or when the user asks to update project memory. --- # Memory Report diff --git a/modules/quality/skills/architect/SKILL.md b/modules/quality/skills/architect/SKILL.md index a800ed9..ec6db60 100644 --- a/modules/quality/skills/architect/SKILL.md +++ b/modules/quality/skills/architect/SKILL.md @@ -1,6 +1,6 @@ --- name: architect -description: Validate file and folder placement against the current project's documented architecture before creating, moving or refactoring code. +description: Validate file and folder placement against the current project's documented architecture before creating, moving or refactoring code. Use when adding a file or directory, implementing a feature or moving code, to confirm the right layer and folder before writing anything. --- # Architect diff --git a/modules/quality/skills/explicit-naming/SKILL.md b/modules/quality/skills/explicit-naming/SKILL.md index f0d7f29..397d706 100644 --- a/modules/quality/skills/explicit-naming/SKILL.md +++ b/modules/quality/skills/explicit-naming/SKILL.md @@ -1,6 +1,6 @@ --- name: explicit-naming -description: Enforce explicit, readable variable and function names when writing or reviewing TypeScript, JavaScript or React code. +description: Enforce explicit, readable variable and function names when writing or reviewing TypeScript, JavaScript or React code. Use when writing or reviewing any such code, including callbacks, loop variables and destructured values. --- # Explicit Naming diff --git a/modules/quality/skills/merge-integrity/SKILL.md b/modules/quality/skills/merge-integrity/SKILL.md index 36a8d94..1785415 100644 --- a/modules/quality/skills/merge-integrity/SKILL.md +++ b/modules/quality/skills/merge-integrity/SKILL.md @@ -1,6 +1,6 @@ --- name: merge-integrity -description: Audit semantic divergence immediately after a cross-branch merge, even when Git reports no textual conflict. +description: Audit semantic divergence immediately after a cross-branch merge, even when Git reports no textual conflict. Use after any cross-branch merge, before declaring it done, and when a test passes on one branch but fails on another. --- # Merge Integrity diff --git a/modules/quality/skills/perf-gate/SKILL.md b/modules/quality/skills/perf-gate/SKILL.md index 324dd3c..8693875 100644 --- a/modules/quality/skills/perf-gate/SKILL.md +++ b/modules/quality/skills/perf-gate/SKILL.md @@ -1,6 +1,6 @@ --- name: perf-gate -description: Require a bounded ceiling, written prediction, fixed protocol and measured comparison before claiming a performance improvement. +description: Require a bounded ceiling, written prediction, fixed protocol and measured comparison before claiming a performance improvement. Use when a task claims a performance gain, when a change is justified by "it feels faster", when reading a profiler capture, or before optimizing anything. --- # Performance Gate diff --git a/modules/quality/skills/resource-ownership/SKILL.md b/modules/quality/skills/resource-ownership/SKILL.md index 50b24d6..0782fb8 100644 --- a/modules/quality/skills/resource-ownership/SKILL.md +++ b/modules/quality/skills/resource-ownership/SKILL.md @@ -1,6 +1,6 @@ --- name: resource-ownership -description: Identify the owner of shared sockets, workers, subscriptions and timers before writing connect, disconnect or cleanup behavior. +description: Identify the owner of shared sockets, workers, subscriptions and timers before writing connect, disconnect or cleanup behavior. Use when writing or reviewing cleanup code that touches a shared resource or singleton, or when removing one consumer freezes or breaks another. --- # Resource Ownership diff --git a/modules/quality/skills/review-gate/SKILL.md b/modules/quality/skills/review-gate/SKILL.md index 8d5890d..28fc16c 100644 --- a/modules/quality/skills/review-gate/SKILL.md +++ b/modules/quality/skills/review-gate/SKILL.md @@ -1,6 +1,6 @@ --- name: review-gate -description: Perform an independent review of a bounded task against a fixed Git SHA before a default-branch merge or when explicitly requested. +description: Perform an independent review of a bounded task against a fixed Git SHA before a default-branch merge or when explicitly requested. Use before any merge into the default branch, or when the user asks for an independent review or to mark a task reviewed. --- # Review Gate diff --git a/modules/quality/skills/validate-task/SKILL.md b/modules/quality/skills/validate-task/SKILL.md index 13773e0..683ccc4 100644 --- a/modules/quality/skills/validate-task/SKILL.md +++ b/modules/quality/skills/validate-task/SKILL.md @@ -1,6 +1,6 @@ --- name: validate-task -description: Confirm objective, invariants, ownership boundaries, scope and acceptance checks before ambiguous, contractual or multi-layer implementation, and after corrections that change them. +description: Confirm objective, invariants, ownership boundaries, scope and acceptance checks before ambiguous, contractual or multi-layer implementation, and after corrections that change them. Use when a request is ambiguous, contractual or spans several layers, before implementing it, and again after a correction changes its scope. --- # Validate Task diff --git a/modules/tasks/skills/ants/SKILL.md b/modules/tasks/skills/ants/SKILL.md index 47d69b9..ef001dc 100644 --- a/modules/tasks/skills/ants/SKILL.md +++ b/modules/tasks/skills/ants/SKILL.md @@ -1,6 +1,6 @@ --- name: ants -description: Structure implementation as small, atomic, traceable changes with one owner, one observable signal and an updated task record. +description: Structure implementation as small, atomic, traceable changes with one owner, one observable signal and an updated task record. Use when planning, implementing, reviewing or refactoring code, especially to avoid big patches, unrelated refactors, duplicated state or unclear ownership. --- # ANTS diff --git a/modules/tasks/skills/write-task/SKILL.md b/modules/tasks/skills/write-task/SKILL.md index 9ea58b5..be7cd5f 100644 --- a/modules/tasks/skills/write-task/SKILL.md +++ b/modules/tasks/skills/write-task/SKILL.md @@ -1,6 +1,6 @@ --- name: write-task -description: Create or restructure ANTS task files at the resolved effort location with atomic steps, observable signals and an explicit trace. +description: Create or restructure ANTS task files at the resolved effort location with atomic steps, observable signals and an explicit trace. Use when asked to create, plan or restructure tasks for a feature, effort or branch; not for status-only updates. --- # Write Task diff --git a/tests/modules.test.mjs b/tests/modules.test.mjs index 6b54f2f..82a2403 100644 --- a/tests/modules.test.mjs +++ b/tests/modules.test.mjs @@ -329,6 +329,17 @@ test('all official modules have a unique skill owner; all profiles and independe assert.equal(state(complete).modules.length, 8); }); +test('every core and official module skill description names when to use it', () => { + const skillDirectories = [join(repository, 'core/skills')]; + for (const moduleId of readdirSync(join(repository, 'modules'))) skillDirectories.push(join(repository, 'modules', moduleId, 'skills')); + const missingTriggers = []; + for (const skillDirectory of skillDirectories.filter(existsSync)) for (const skillName of readdirSync(skillDirectory)) { + const description = readFileSync(join(skillDirectory, skillName, 'SKILL.md'), 'utf8').match(/^description:(.*)$/m)?.[1] ?? ''; + if (!/\b(Use (when|before|after|for)|Utiliser)\b/i.test(description)) missingTriggers.push(skillName); + } + assert.deepEqual(missingTriggers.sort(), []); +}); + test('installation lock and required precommit are checked without overwriting project data', context => { const setup = fixture(context, 'REQUIRE_PRECOMMIT=true\n'); const before = snapshot(setup.project); From e2ab460ddbe7f256ed0e14080074e3b8b7ce5ac3 Mon Sep 17 00:00:00 2001 From: PierreDillard <7gaspard77@gmail.com> Date: Thu, 22 Oct 2026 16:17:54 +0200 Subject: [PATCH 2/4] chore(skills): keep a single editable skill source --- PUBLISHING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PUBLISHING.md b/PUBLISHING.md index cd77ca1..59a089c 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -14,8 +14,9 @@ planning backlog, which is not part of the package. ## Excluded local material - `tasks/`: personal planning, historical evidence and project references. -- `global-skills/`, legacy template skills/scripts/agents, `components.tsv` and the legacy reporting - configurator: retained locally as historical snapshots only; the current installer does not read them. +- Legacy template scripts/agents, `components.tsv` and the legacy reporting configurator: retained + locally as historical snapshots only; the current installer does not read them. Skills have a + single editable source in `core/skills/` and `modules/*/skills/`. The exclusions are declared in `.gitignore`, so `git add .` stages the intended source package without deleting the local snapshots. From 54efc6f5a77c5bf6e03702ad3db51a5ff17cc549 Mon Sep 17 00:00:00 2001 From: PierreDillard <7gaspard77@gmail.com> Date: Thu, 22 Oct 2026 16:37:42 +0200 Subject: [PATCH 3/4] chore(ci): move GitHub actions to v5 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- PUBLISHING.md | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d06ec1..8a352c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,13 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false # Full history: the migration test installs the last 0.1 revision. fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 20 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9f7d318..1ed3dc7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,13 +16,13 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false # Full history: the migration test installs the last 0.1 revision. fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 24 registry-url: https://registry.npmjs.org diff --git a/PUBLISHING.md b/PUBLISHING.md index 59a089c..898f169 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -54,7 +54,8 @@ Change `name` in `package.json` and the commands in README/docs together if the automatically) so that `npx ants-kit@latest` keeps resolving to the last stable release; `npx ants-kit@next` resolves to the latest prerelease. -`npx github:PierreDillard/agent-workflow-kit` installs from the default branch with the same CLI. +Maintainer note: `npx github:PierreDillard/agent-workflow-kit` runs the unreleased default branch for +testing; it is not a user install path (see README). ## Current release posture From ce2f77a30d19d076e88e4d65f8730bdb5cce2679 Mon Sep 17 00:00:00 2001 From: PierreDillard <7gaspard77@gmail.com> Date: Thu, 22 Oct 2026 17:30:35 +0200 Subject: [PATCH 4/4] feat(doctor): warn on oversized themes and broken index links --- docs/modules.md | 10 ++++--- examples/modules/project-notes/module.json | 2 +- modules/bugs/module.json | 22 ++++++++++++-- modules/memory/module.json | 24 ++++++++++++++- modules/tasks/module.json | 16 ++++++++-- .../project/.workflow/runtime/catalog.mjs | 29 ++++++++++++++++-- .../project/.workflow/runtime/compose.mjs | 4 +-- .../project/.workflow/runtime/doctor.mjs | 21 ++++++------- .../project/.workflow/runtime/manage.mjs | 9 ++---- templates/project/.workflow/runtime/paths.mjs | 16 ++++++++++ .../project/.workflow/runtime/update.mjs | 9 ++---- tests/modules.test.mjs | 30 +++++++++++++++++++ 12 files changed, 153 insertions(+), 39 deletions(-) diff --git a/docs/modules.md b/docs/modules.md index 09ddfc7..9137ed8 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -27,7 +27,7 @@ of eight official modules and never automatically activates external discoveries "id": "project-notes", "version": "1.0.0", "description": "A small notebook", - "requires": { "memory": "1.0.0" }, + "requires": { "memory": "1.1.0" }, "config": { "title": "Project notes" }, "skills": [{ "name": "project-notes", "source": "skills/project-notes" }], "files": [{ "source": "templates/notes.md", "target": "{{TASKS_DIR}}/notes/README.md", "editable": true }], @@ -47,7 +47,7 @@ writing any destination. One component/file has one owner; destination collision | `files` | `source` file/directory, `target`, optional boolean `editable` (default false) | | `rules` | Unique `id`, Markdown `source`, `point`, optional `when` array of module IDs | | `hooks` | Unique `id`, module-owned managed `path`, `runner` (`bash` or `node`), `args`, `point`, optional `when` | -| `checks` | Owned `path`, `type` (`file`, `json`, `timezone`), optional positive `maxBytes` | +| `checks` | Owned `path` or `pattern` (`*` within one path segment), `type` (`file`, `json`, `timezone`, `links`), optional positive `maxBytes`, optional `level` (`fail` by default, or `warn`; a `pattern` or `links` check requires `warn`) | | `config` | String defaults; override on first activation with `--set module.key=value` | Collections other than identity fields may be omitted. A source path is relative to its module. @@ -80,8 +80,10 @@ Dependencies run before dependents; unrelated modules use sorted IDs; contributi retain manifest order. Reporting's handoff fragment is a built-in example of conditional composition. The doctor checks dependencies, managed hashes/modes, mirrored skills and declarative checks. -`file` checks presence, `json` additionally parses JSON, and `timezone` also validates its `timezone` -field. It checks no external services and makes no claim about the agent following instructions. +`file` checks presence, `json` additionally parses JSON, `timezone` also validates its `timezone` +field, and `links` requires every relative Markdown link to name an existing file. A `warn` check is +reported by the doctor without failing it, and installation, add and update skip it. It checks no +external services and makes no claim about the agent following instructions. ## Preservation and lifecycle diff --git a/examples/modules/project-notes/module.json b/examples/modules/project-notes/module.json index 49682d2..9bade46 100644 --- a/examples/modules/project-notes/module.json +++ b/examples/modules/project-notes/module.json @@ -3,7 +3,7 @@ "id": "project-notes", "version": "1.0.0", "description": "Keep a small project notebook without enabling weekly reporting", - "requires": { "memory": "1.0.0" }, + "requires": { "memory": "1.1.0" }, "config": { "title": "Project notes" }, "skills": [{ "name": "project-notes", "source": "skills/project-notes" }], "files": [{ "source": "templates/notes.md", "target": "{{TASKS_DIR}}/notes/README.md", "editable": true }], diff --git a/modules/bugs/module.json b/modules/bugs/module.json index bdf7bb6..d1f47bc 100644 --- a/modules/bugs/module.json +++ b/modules/bugs/module.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "bugs", - "version": "1.0.0", + "version": "1.1.0", "description": "Bugs workflow capabilities", "requires": {}, "skills": [ @@ -29,5 +29,23 @@ } ], "hooks": [], - "checks": [] + "checks": [ + { + "path": "{{TASKS_DIR}}/bugs/README.md", + "type": "file", + "maxBytes": 8192, + "level": "warn" + }, + { + "pattern": "{{TASKS_DIR}}/bugs/*.md", + "type": "file", + "maxBytes": 16384, + "level": "warn" + }, + { + "path": "{{TASKS_DIR}}/bugs/README.md", + "type": "links", + "level": "warn" + } + ] } diff --git a/modules/memory/module.json b/modules/memory/module.json index 964f8c5..5ac14aa 100644 --- a/modules/memory/module.json +++ b/modules/memory/module.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "memory", - "version": "1.0.0", + "version": "1.1.0", "description": "Memory workflow capabilities", "requires": {}, "skills": [ @@ -40,6 +40,28 @@ "path": "{{TASKS_DIR}}/DECISIONS.md", "type": "file", "maxBytes": 32768 + }, + { + "pattern": "{{TASKS_DIR}}/memory/*.md", + "type": "file", + "maxBytes": 16384, + "level": "warn" + }, + { + "pattern": "{{TASKS_DIR}}/decisions/*.md", + "type": "file", + "maxBytes": 16384, + "level": "warn" + }, + { + "path": "{{TASKS_DIR}}/PROJECT_MEMORY.md", + "type": "links", + "level": "warn" + }, + { + "path": "{{TASKS_DIR}}/DECISIONS.md", + "type": "links", + "level": "warn" } ] } diff --git a/modules/tasks/module.json b/modules/tasks/module.json index 1ed2c0d..8840abb 100644 --- a/modules/tasks/module.json +++ b/modules/tasks/module.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "tasks", - "version": "1.0.0", + "version": "1.1.0", "description": "Tasks workflow capabilities", "requires": {}, "skills": [ @@ -24,5 +24,17 @@ } ], "hooks": [], - "checks": [] + "checks": [ + { + "pattern": "{{TASKS_DIR}}/todo/*/README.md", + "type": "file", + "maxBytes": 8192, + "level": "warn" + }, + { + "pattern": "{{TASKS_DIR}}/todo/*/README.md", + "type": "links", + "level": "warn" + } + ] } diff --git a/templates/project/.workflow/runtime/catalog.mjs b/templates/project/.workflow/runtime/catalog.mjs index 936e141..a09a9c8 100644 --- a/templates/project/.workflow/runtime/catalog.mjs +++ b/templates/project/.workflow/runtime/catalog.mjs @@ -1,5 +1,5 @@ import { existsSync, readdirSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { assertKeys, readJson, safePath } from './paths.mjs'; export const officialModules = ['tasks', 'memory', 'continuity', 'quality', 'bugs', 'reporting', 'exploration', 'learning']; @@ -51,13 +51,29 @@ export function validateManifest(manifest) { } } for (const check of manifest.checks ?? []) { - assertKeys(check, ['path', 'type', 'maxBytes'], 'check'); - if (typeof check.path !== 'string' || !['file', 'json', 'timezone'].includes(check.type) || + assertKeys(check, ['path', 'pattern', 'type', 'maxBytes', 'level'], 'check'); + // Patterns match user-created files and links point to user content: the kit can only warn about them. + if ((check.path === undefined) === (check.pattern === undefined) || typeof (check.path ?? check.pattern) !== 'string' || + !['file', 'json', 'timezone', 'links'].includes(check.type) || !['fail', 'warn'].includes(check.level ?? 'fail') || + ((check.pattern !== undefined || check.type === 'links') && check.level !== 'warn') || (check.maxBytes !== undefined && (!Number.isInteger(check.maxBytes) || check.maxBytes < 1))) throw new Error('Invalid check'); } return manifest; } +// The single verification of a declarative check, shared by install, add, update and doctor. +export function verifyCheck(check, path, content, projectRoot) { + if (check.maxBytes && content.length > check.maxBytes) throw new Error(`Size limit exceeded: ${path}`); + if (check.type === 'links') { + const broken = relativeLinks(content.toString()).filter(target => !existsSync(join(resolve(projectRoot), dirname(path), target))); + if (broken.length) throw new Error(`Broken link in ${path}: ${broken.join(', ')}`); + } + if (check.type === 'json' || check.type === 'timezone') { + const value = JSON.parse(content); + if (check.type === 'timezone') new Intl.DateTimeFormat('en', { timeZone: value.timezone }); + } +} + export function loadCatalog(moduleDirectories, explicitSources) { const catalog = new Map(); function addSource(source) { @@ -97,3 +113,10 @@ export function resolveModules(catalog, selected, installed = []) { for (const moduleId of [...known.keys(), ...selected].sort()) visit(moduleId); return ordered; } + +// Markdown link targets that name a local file: URLs, anchors and absolute paths are left out. +function relativeLinks(markdown) { + return [...markdown.matchAll(/\]\((<[^>]+>|[^)\s]+)[^)]*\)/g)] + .map(match => decodeURIComponent(match[1].replace(/^<|>$/g, '').split('#')[0])) + .filter(target => target && !target.startsWith('/') && !/^[a-z][a-z0-9+.-]*:/i.test(target)); +} diff --git a/templates/project/.workflow/runtime/compose.mjs b/templates/project/.workflow/runtime/compose.mjs index d546293..680c334 100644 --- a/templates/project/.workflow/runtime/compose.mjs +++ b/templates/project/.workflow/runtime/compose.mjs @@ -85,8 +85,8 @@ export function materializeModule(source, configuration, settings) { }); const hooks = (manifest.hooks ?? []).map(hook => ({ ...hook, path: expandPath(hook.path), args: (hook.args ?? []).map(expandPath) })); for (const hook of hooks) if (!entries.some(entry => entry.root === 'project' && entry.path === hook.path && !entry.editable)) throw new Error(`Hook must reference a module-owned managed file: ${hook.path}`); - const checks = (manifest.checks ?? []).map(check => ({ ...check, path: expandPath(check.path) })); - for (const check of checks) if (!entries.some(entry => entry.root === 'project' && entry.path === check.path)) throw new Error(`Check must reference an owned file: ${check.path}`); + const checks = (manifest.checks ?? []).map(check => check.pattern ? { ...check, pattern: expandPath(check.pattern) } : { ...check, path: expandPath(check.path) }); + for (const check of checks) if (check.path && !entries.some(entry => entry.root === 'project' && entry.path === check.path)) throw new Error(`Check must reference an owned file: ${check.path}`); const fingerprint = digest(JSON.stringify(manifest) + entries.map(entry => `${entry.root}:${entry.path}:${entry.mode}:${digest(entry.content)}`).join('\n')); return { entries, module: { id: manifest.id, version: manifest.version, requires: manifest.requires ?? {}, rules, hooks, checks, components, settings: moduleSettings, fingerprint } }; diff --git a/templates/project/.workflow/runtime/doctor.mjs b/templates/project/.workflow/runtime/doctor.mjs index aab9475..f61d472 100644 --- a/templates/project/.workflow/runtime/doctor.mjs +++ b/templates/project/.workflow/runtime/doctor.mjs @@ -1,8 +1,8 @@ import { readFileSync, existsSync, lstatSync, accessSync, constants } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { resolve } from 'node:path'; -import { digest, safePath, readJson } from './paths.mjs'; -import { resolveModules } from './catalog.mjs'; +import { digest, safePath, readJson, expandPattern } from './paths.mjs'; +import { resolveModules, verifyCheck } from './catalog.mjs'; import { locateInstallation } from './layout.mjs'; export function loadState(projectRoot) { @@ -27,25 +27,26 @@ export function checkState(state, roots) { const content = readFileSync(path); if (!file.editable && (digest(content) !== file.sha256 || (lstatSync(path).mode & 0o777) !== file.mode)) throw new Error(`Managed file changed: ${file.root}:${file.path}`); } + const warnings = []; for (const module of state.modules) for (const check of module.checks) { - const content = readFileSync(safePath(roots.project, check.path)); - if (check.maxBytes && content.length > check.maxBytes) throw new Error(`Size limit exceeded: ${check.path}`); - if (check.type === 'json' || check.type === 'timezone') { - const value = JSON.parse(content); - if (check.type === 'timezone') new Intl.DateTimeFormat('en', { timeZone: value.timezone }); + for (const path of check.pattern ? expandPattern(roots.project, check.pattern) : [check.path]) { + try { verifyCheck(check, path, readFileSync(safePath(roots.project, path)), roots.project); } + catch (error) { if (check.level !== 'warn') throw error; warnings.push(error.message); } } } for (const filename of ['CLAUDE.md', 'AGENTS.md']) if (!readFileSync(safePath(roots.project, filename), 'utf8').includes('')) throw new Error(`Workflow pointer missing: ${filename}`); if (state.configuration.requirePrecommit) checkPrecommit(roots.project); + return warnings; } export function doctor(projectRoot, userRoot) { try { const { state, layout } = loadState(projectRoot); - checkState(state, { project: projectRoot, user: userRoot ?? state.configuration.userRoot }); + const warnings = checkState(state, { project: projectRoot, user: userRoot ?? state.configuration.userRoot }); console.log(`ok ${state.modules.length} modules; managed files, mirrors, dependencies and declarative checks`); - if (layout.legacy) console.log('warn 0.1 root-level layout; run the kit update to move it into the task directory'); - console.log('workflow-doctor: 0 failure(s), 0 warning(s)'); + if (layout.legacy) warnings.push('0.1 root-level layout; run the kit update to move it into the task directory'); + for (const warning of warnings) console.log(`warn ${warning}`); + console.log(`workflow-doctor: 0 failure(s), ${warnings.length} warning(s)`); } catch (error) { console.error(`FAIL ${error.message}\nworkflow-doctor: 1 failure(s)`); process.exitCode = 1; } } diff --git a/templates/project/.workflow/runtime/manage.mjs b/templates/project/.workflow/runtime/manage.mjs index 1363b5c..58cb463 100644 --- a/templates/project/.workflow/runtime/manage.mjs +++ b/templates/project/.workflow/runtime/manage.mjs @@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { execFileSync } from 'node:child_process'; import { digest, safePath } from './paths.mjs'; -import { loadCatalog, resolveModules, officialModules } from './catalog.mjs'; +import { loadCatalog, verifyCheck, resolveModules, officialModules } from './catalog.mjs'; import { materializeModule, materializeCore, composeRules, composeComponents } from './compose.mjs'; import { loadState, checkState, checkPrecommit } from './doctor.mjs'; import { applyPlan } from './transaction.mjs'; @@ -98,12 +98,7 @@ export function installOrAdd(options) { const state = { schemaVersion: 1, version: options.kitVersion ?? previous.version, configuration: { ...configuration, userRoot }, selected: requested, modules, files }; // Validate declarative checks against the planned content without executing module code. for (const module of modules) for (const check of module.checks) { - const content = getContent(check.path); - if (check.maxBytes && content.length > check.maxBytes) throw new Error(`Size limit exceeded: ${check.path}`); - if (check.type === 'json' || check.type === 'timezone') { - const value = JSON.parse(content); - if (check.type === 'timezone') new Intl.DateTimeFormat('en', { timeZone: value.timezone }); - } + if ((check.level ?? 'fail') === 'fail') verifyCheck(check, check.path, getContent(check.path)); } entries.push({ root: 'project', path: layout.inventory, content: Buffer.from(JSON.stringify(state, null, 2) + '\n'), mode: 0o644, ...(previous ? { expected: digest(readFileSync(safePath(projectRoot, layout.inventory))) } : {}) }); diff --git a/templates/project/.workflow/runtime/paths.mjs b/templates/project/.workflow/runtime/paths.mjs index 4923b9a..adf7730 100644 --- a/templates/project/.workflow/runtime/paths.mjs +++ b/templates/project/.workflow/runtime/paths.mjs @@ -37,3 +37,19 @@ export function assertKeys(value, allowed, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid ${label}`); for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`Unknown ${label} field: ${key}`); } + +// Lists the regular files matching `pattern`, where `*` stands for any part of a single path segment. +export function expandPattern(root, pattern) { + safePath(root, pattern); + const base = resolve(root); + let matches = ['']; + for (const segment of pattern.split('/')) { + const wildcard = new RegExp(`^${segment.split('*').map(part => part.replace(/[.+?^$|()[\]{}\\]/g, '\\$&')).join('.*')}$`); + matches = matches.flatMap(prefix => { + const directory = join(base, prefix); + if (!lstatSync(directory).isDirectory()) return []; + return readdirSync(directory).filter(name => wildcard.test(name)).sort().map(name => prefix ? `${prefix}/${name}` : name); + }); + } + return matches.filter(path => lstatSync(join(base, path)).isFile()); +} diff --git a/templates/project/.workflow/runtime/update.mjs b/templates/project/.workflow/runtime/update.mjs index 6e05d15..f3cd725 100644 --- a/templates/project/.workflow/runtime/update.mjs +++ b/templates/project/.workflow/runtime/update.mjs @@ -2,7 +2,7 @@ import { existsSync, readFileSync, lstatSync } from 'node:fs'; import { resolve } from 'node:path'; import { execFileSync } from 'node:child_process'; import { safePath, digest } from './paths.mjs'; -import { loadCatalog, resolveModules } from './catalog.mjs'; +import { loadCatalog, verifyCheck, resolveModules } from './catalog.mjs'; import { materializeModule, materializeCore, composeRules, composeComponents } from './compose.mjs'; import { loadState, checkState } from './doctor.mjs'; import { applyPlan } from './transaction.mjs'; @@ -134,12 +134,7 @@ export function updateKit(options) { if (index < 0) files.push(record); else files[index] = record; } for (const module of modules) for (const check of module.checks) { - const content = getContent(check.path); - if (check.maxBytes && content.length > check.maxBytes) throw new Error(`Size limit exceeded: ${check.path}`); - if (check.type === 'json' || check.type === 'timezone') { - const value = JSON.parse(content); - if (check.type === 'timezone') new Intl.DateTimeFormat('en', { timeZone: value.timezone }); - } + if ((check.level ?? 'fail') === 'fail') verifyCheck(check, check.path, getContent(check.path)); } const state = { ...previous, version, source, modules, files }; const inventory = Buffer.from(JSON.stringify(state, null, 2) + '\n'); diff --git a/tests/modules.test.mjs b/tests/modules.test.mjs index 82a2403..9f5dc61 100644 --- a/tests/modules.test.mjs +++ b/tests/modules.test.mjs @@ -125,6 +125,34 @@ for (const [profile, selection, modules] of [ assert.match(command('bash', [join(setup.project, kit, 'scripts/workflow-doctor.sh')]), /0 failure/); }); +test('doctor warns, without failing, on oversized editable themes and indexes', context => { + const setup = fixture(context); + install(setup, ['--modules', 'tasks,memory,bugs']); + const tasks = join(setup.project, 'planning notes/tasks'); + const oversized = ['memory/networking.md', 'decisions/storage.md', 'bugs/parser.md', 'todo/dev-example/README.md']; + for (const path of [...oversized, 'memory/small.md']) mkdirSync(join(tasks, path, '..'), { recursive: true }); + for (const path of oversized) writeFileSync(join(tasks, path), 'x'.repeat(16385)); + writeFileSync(join(tasks, 'memory/small.md'), '# Small theme\n'); + const output = command('bash', [join(setup.project, kit, 'scripts/workflow-doctor.sh')]); + for (const path of oversized) assert.match(output, new RegExp(`warn Size limit exceeded: planning notes/tasks/${path}`)); + assert.doesNotMatch(output, /small\.md/); + assert.match(output, /0 failure\(s\), 4 warning\(s\)/); +}); + +test('doctor warns, without failing, on index links to missing files', context => { + const setup = fixture(context); + install(setup, ['--modules', 'tasks,memory,bugs']); + const tasks = join(setup.project, 'planning notes/tasks'); + mkdirSync(join(tasks, 'memory')); mkdirSync(join(tasks, 'todo/dev-example'), { recursive: true }); + writeFileSync(join(tasks, 'memory/present.md'), '# Present\n'); + writeFileSync(join(tasks, 'PROJECT_MEMORY.md'), '| [present](memory/present.md) | [gone](memory/gone.md#rules) | [site](https://example.com) | [top](#themes) |\n'); + writeFileSync(join(tasks, 'todo/dev-example/README.md'), '- [step](01-step.md)\n- [memory](<../../memory/present.md>)\n'); + const output = command('bash', [join(setup.project, kit, 'scripts/workflow-doctor.sh')]); + assert.match(output, /warn Broken link in planning notes\/tasks\/PROJECT_MEMORY.md: memory\/gone.md/); + assert.match(output, /warn Broken link in planning notes\/tasks\/todo\/dev-example\/README.md: 01-step.md/); + assert.match(output, /0 failure\(s\), 2 warning\(s\)/); +}); + test('external module: added after install, settings, dependencies, mirrors, editable notes and exact repeat', context => { const setup = fixture(context); install(setup); const source = join(repository, 'examples/modules/project-notes'); @@ -229,6 +257,8 @@ for (const [label, overrides, expected] of [ ['legacy script destination', { files: [{ source: 'payload.md', target: '.claude/scripts/payload.md' }] }, /outside managed destinations/], ['unsafe path', { files: [{ source: 'payload.md', target: '{{TASKS_DIR}}/../escape' }] }, /Unsafe path/], ['source escape', { files: [{ source: '../payload.md', target: '{{TASKS_DIR}}/safe.md' }] }, /Unsafe path/], + ['failing pattern check', { checks: [{ pattern: '{{TASKS_DIR}}/notes/*.md', type: 'file' }] }, /Invalid check/], + ['check with path and pattern', { checks: [{ path: '{{TASKS_DIR}}/a.md', pattern: '{{TASKS_DIR}}/*.md', type: 'file', level: 'warn' }] }, /Invalid check/], ]) test(`reject ${label} before mutation`, context => { const setup = fixture(context); install(setup); const source = makeModule(setup, 'invalid-module', overrides);