@mlmcps/ml-specs 1.0.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.
Files changed (86) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +106 -0
  4. package/ml-specs/.claude-plugin/plugin.json +11 -0
  5. package/ml-specs/.mcp.json +17 -0
  6. package/ml-specs/CHANGELOG.md +860 -0
  7. package/ml-specs/LICENSE +21 -0
  8. package/ml-specs/README.md +347 -0
  9. package/ml-specs/agents/coder.md +76 -0
  10. package/ml-specs/agents/developer.md +78 -0
  11. package/ml-specs/agents/pr-author.md +36 -0
  12. package/ml-specs/agents/reviewer.md +65 -0
  13. package/ml-specs/agents/scanner.md +66 -0
  14. package/ml-specs/agents/spec-author.md +91 -0
  15. package/ml-specs/agents/spec-reviewer.md +59 -0
  16. package/ml-specs/commands/code.md +29 -0
  17. package/ml-specs/commands/fix.md +67 -0
  18. package/ml-specs/commands/nfr.md +114 -0
  19. package/ml-specs/commands/pr.md +32 -0
  20. package/ml-specs/commands/repo-adopt.md +86 -0
  21. package/ml-specs/commands/repo-doctor.md +57 -0
  22. package/ml-specs/commands/repo-estate.md +79 -0
  23. package/ml-specs/commands/repo-impact.md +77 -0
  24. package/ml-specs/commands/repo-init.md +155 -0
  25. package/ml-specs/commands/repo-refresh.md +58 -0
  26. package/ml-specs/commands/repo-rollout.md +84 -0
  27. package/ml-specs/commands/repo-status.md +59 -0
  28. package/ml-specs/commands/spec-advance.md +81 -0
  29. package/ml-specs/commands/spec-build.md +66 -0
  30. package/ml-specs/commands/spec-fanout.md +64 -0
  31. package/ml-specs/commands/spec-review.md +24 -0
  32. package/ml-specs/commands/spec-verify.md +55 -0
  33. package/ml-specs/commands/spec.md +73 -0
  34. package/ml-specs/hooks/hooks.json +23 -0
  35. package/ml-specs/hooks/knowledge-drift.sh +43 -0
  36. package/ml-specs/hooks/secret-scan.sh +56 -0
  37. package/ml-specs/mcp/README.md +173 -0
  38. package/ml-specs/mcp/ml-specs-server.mjs +708 -0
  39. package/ml-specs/mcp/ml-specs-server.test.mjs +225 -0
  40. package/ml-specs/package.json +44 -0
  41. package/ml-specs/scripts/branch-policy.mjs +71 -0
  42. package/ml-specs/scripts/fix-specs.mjs +289 -0
  43. package/ml-specs/scripts/lib/cli.mjs +43 -0
  44. package/ml-specs/scripts/lib/estate.mjs +108 -0
  45. package/ml-specs/scripts/lib/http.mjs +73 -0
  46. package/ml-specs/scripts/lib/knowledge.mjs +91 -0
  47. package/ml-specs/scripts/lib/knowledge.test.mjs +70 -0
  48. package/ml-specs/scripts/lib/nfr.mjs +119 -0
  49. package/ml-specs/scripts/lib/nfr.test.mjs +89 -0
  50. package/ml-specs/scripts/lib/policy.mjs +114 -0
  51. package/ml-specs/scripts/lib/policy.test.mjs +139 -0
  52. package/ml-specs/scripts/lib/scm.mjs +189 -0
  53. package/ml-specs/scripts/lib/scm.test.mjs +155 -0
  54. package/ml-specs/scripts/lib/specs.mjs +192 -0
  55. package/ml-specs/scripts/lib/specs.test.mjs +69 -0
  56. package/ml-specs/scripts/lib/trace.mjs +90 -0
  57. package/ml-specs/scripts/lib/trace.test.mjs +110 -0
  58. package/ml-specs/scripts/lib/tracker.mjs +257 -0
  59. package/ml-specs/scripts/lib/tracker.test.mjs +177 -0
  60. package/ml-specs/scripts/nfr-compile.mjs +120 -0
  61. package/ml-specs/scripts/scripts.test.mjs +182 -0
  62. package/ml-specs/scripts/spec-brief.mjs +127 -0
  63. package/ml-specs/scripts/spec-dashboard.mjs +331 -0
  64. package/ml-specs/scripts/spec-fanout.mjs +120 -0
  65. package/ml-specs/scripts/spec-gate.mjs +329 -0
  66. package/ml-specs/scripts/spec-trace.mjs +91 -0
  67. package/ml-specs/scripts/survey-estate.mjs +230 -0
  68. package/ml-specs/scripts/tracker-sync.mjs +91 -0
  69. package/ml-specs/skills/knowledge-retrieval/SKILL.md +75 -0
  70. package/ml-specs/templates/CLAUDE.fragment.md +87 -0
  71. package/ml-specs/templates/ci/azure-pipelines-spec-gate.yml +64 -0
  72. package/ml-specs/templates/ci/knowledge-check.mjs +176 -0
  73. package/ml-specs/templates/ci/knowledge-layer.yml +35 -0
  74. package/ml-specs/templates/ci/spec-gate.yml +69 -0
  75. package/ml-specs/templates/docs/ARCHITECTURE.template.md +110 -0
  76. package/ml-specs/templates/docs/ESTATE.template.md +55 -0
  77. package/ml-specs/templates/docs/PATTERNS.template.md +80 -0
  78. package/ml-specs/templates/gitattributes +18 -0
  79. package/ml-specs/templates/hooks/settings.hooks.example.json +32 -0
  80. package/ml-specs/templates/mcp/.mcp.json +34 -0
  81. package/ml-specs/templates/settings.json +18 -0
  82. package/ml-specs/templates/specs/AGENTS.md +57 -0
  83. package/ml-specs/templates/specs/README.md +104 -0
  84. package/ml-specs/templates/specs/TEMPLATE.md +114 -0
  85. package/ml-specs/templates/standards/.mlskills.json +24 -0
  86. package/package.json +36 -0
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ // Governed sync between a spec and its work item in Azure DevOps or Jira.
3
+ //
4
+ // node tracker-sync.mjs 0031 --dry-run # print the exact requests, send nothing
5
+ // node tracker-sync.mjs 0031
6
+ // node tracker-sync.mjs 0031 --json
7
+ //
8
+ // Credentials from the environment: ADO_ORG/ADO_PROJECT/ADO_PAT, or
9
+ // JIRA_BASE_URL/JIRA_EMAIL/JIRA_API_TOKEN/JIRA_PROJECT_KEY with SDD_PM_TOOL=jira.
10
+ //
11
+ // Exit code 1 if the link is broken or a write is refused.
12
+ //
13
+ // Why this exists: the spec lives in the repo and the work item lives in the
14
+ // tracker, and both can hold a title, a status and a list of criteria. Two
15
+ // stores for one truth diverge silently. So exactly one system may write each
16
+ // field — the spec owns the CONTRACT, the tracker owns the SCHEDULE — and an
17
+ // attempt to write the other's field is refused rather than winning.
18
+ //
19
+ // Test cases are pushed as a DERIVATION of approved criteria, never authored
20
+ // beside them: that is what turns QA from an author into an auditor of something
21
+ // the Product Owner already signed off.
22
+
23
+ import { listSpecs } from './lib/specs.mjs';
24
+ import { adoTracker, jiraTracker, governedWriter, readOnly } from './lib/tracker.mjs';
25
+ import { testCaseId } from './lib/trace.mjs';
26
+ import { args, colours, transportFor, trackerConfig, printTranscript } from './lib/cli.mjs';
27
+
28
+ const { positional, json, dryRun, root } = args();
29
+ const [target] = positional;
30
+ const C = colours(process.stdout.isTTY && !json);
31
+
32
+ if (!target) { console.error('usage: tracker-sync.mjs <spec-id> [--dry-run] [--json]'); process.exit(1); }
33
+
34
+ const spec = listSpecs(root).find((s) => s.id === target || s.file.endsWith(target));
35
+ if (!spec) { console.error(`no spec matching "${target}" under ${root}`); process.exit(1); }
36
+
37
+ const transport = transportFor(dryRun);
38
+ const { tool, config } = trackerConfig(transport);
39
+ const tracker = tool === 'jira' ? jiraTracker(config) : adoTracker(config);
40
+ const writer = governedWriter(tracker);
41
+
42
+ if (dryRun && !json) console.log(`${C.bold('dry run')} ${C.dim('— nothing is sent')}\n`);
43
+
44
+ const out = { spec: spec.id, tool, link: null, pushed: 0, refused: [] };
45
+
46
+ // --- pull: does the tracker still point at this spec? -----------------------
47
+ if (!spec.ticket) {
48
+ out.link = { ok: false, detail: `spec ${spec.id} has no Ticket in its header table` };
49
+ } else {
50
+ const item = await readOnly(tracker).getWorkItem(spec.ticket);
51
+ out.link = !item
52
+ ? { ok: false, detail: `work item ${spec.ticket} not found in ${tool}` }
53
+ : item.specKey && item.specKey !== spec.id
54
+ ? { ok: false, detail: `work item ${item.id} carries SPEC-${item.specKey}, expected ${spec.id}` }
55
+ : { ok: true, detail: `${spec.id} <-> ${tool}#${item.id}`, status: item.status };
56
+ }
57
+
58
+ // --- push: test cases, derived only from approved criteria ------------------
59
+ if (!spec.status || spec.status === 'Draft') {
60
+ out.refused.push('spec is Draft — nothing crosses the approval gate');
61
+ } else if (!(spec.criteria ?? []).length) {
62
+ out.refused.push('no acceptance criteria to derive test cases from');
63
+ } else if (out.link?.ok) {
64
+ const cases = spec.criteria.map((ac) => ({
65
+ id: testCaseId(spec.id, ac.ordinal),
66
+ title: ac.text,
67
+ from: ac.id,
68
+ specKey: spec.id,
69
+ steps: [
70
+ `Set up the preconditions described by ${spec.id} "${spec.title}".`,
71
+ `Exercise the behaviour: ${ac.text}`,
72
+ `Assert the criterion holds, and record the result against ${ac.id}.`,
73
+ ],
74
+ }));
75
+ try { out.pushed = (await writer.pushTestCases(spec, cases)).length; }
76
+ catch (e) { out.refused.push(e.message); }
77
+ }
78
+
79
+ const ok = Boolean(out.link?.ok) && out.refused.length === 0;
80
+
81
+ if (json) {
82
+ console.log(JSON.stringify({ ...out, ok }, null, 2));
83
+ } else {
84
+ console.log(` ${out.link.ok ? C.green('link ') : C.red('link ')} ${out.link.detail}`);
85
+ if (out.pushed) console.log(` ${C.green('push ')} ${out.pushed} test case(s) derived from acceptance criteria`);
86
+ for (const r of out.refused) console.log(` ${C.dim(`skip ${r}`)}`);
87
+ console.log(`\n ${C.dim('the spec owns the contract; the tracker owns status, assignee and sprint')}`);
88
+ printTranscript(transport, C);
89
+ }
90
+
91
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: knowledge-retrieval
3
+ description: How to navigate a repo's learned knowledge layer (CLAUDE.md, docs/PATTERNS.md, docs/ARCHITECTURE.md, docs/ESTATE.md) without blowing up context — sharded architecture docs, the 1-hop dependency closure for interlinked modules, and designing a change that crosses a module or service boundary. Load when the repo's docs are sharded (docs/architecture/*.md), when a task touches a shared event/API/type/table, or when a change spans more than one module or service.
4
+ ---
5
+
6
+ # Retrieving from a sharded / interlinked knowledge layer
7
+
8
+ The top-level ladder (read `CLAUDE.md` → `docs/PATTERNS.md` before writing code → `docs/ARCHITECTURE.md`
9
+ to find things → grep last) lives in the repo's `CLAUDE.md`, because it has to be known *before*
10
+ you know you need it. This skill is the part that only matters sometimes, so it loads only then.
11
+
12
+ The governing rule: **the cost of a task is the context you load, and most of it is loaded by
13
+ reflex.** Every doc you open should answer a question the task actually asked.
14
+
15
+ ## Sharded docs (large apps and monorepos)
16
+
17
+ When a repo is big, `docs/ARCHITECTURE.md` is not the map — it's a **router**: a blurb plus a
18
+ Structure table whose rows link to `docs/architecture/<module>.md` shards (≤~150 lines each), with
19
+ `docs/patterns/<module>.md` where a module's conventions genuinely differ.
20
+
21
+ Read, in order:
22
+ 1. The **router** — always. It's small and tells you which shard you need.
23
+ 2. The **one shard** for the module you're touching.
24
+ 3. Nothing else. Never load shards for modules the task doesn't involve — that's the whole reason
25
+ the docs were sharded.
26
+
27
+ If a `docs/architecture/_core.md` exists, it holds contracts shared widely enough to be worth
28
+ always reading for cross-cutting work. Read it for cross-cutting work only, not for a change that
29
+ sits inside one module.
30
+
31
+ ## The 1-hop dependency closure (interlinked modules)
32
+
33
+ Modules have edges, not just contents. A change that looks local is only local until it touches a
34
+ contract someone else depends on.
35
+
36
+ Each shard carries a `## Depends on / Used by` header; the router carries a **module contract
37
+ index** (shared event / API / type / table → producers and consumers). Use them like this:
38
+
39
+ 1. Open the target shard. Read its `Depends on / Used by`.
40
+ 2. Does the change touch anything named there — a shared event, an API the neighbor calls, a type
41
+ or table you don't solely own?
42
+ - **No** → you're done. Load nothing else. This is the common case; don't go looking for ripple
43
+ that isn't there.
44
+ - **Yes** → for each affected neighbor, load **only that neighbor's contract section** — not its
45
+ whole shard. That is the 1-hop closure, and it is the whole budget. Do not follow the
46
+ neighbor's own edges to a second hop unless the change provably reaches that far.
47
+ 3. Design against **both sides** of every contract you touched, and write both sides into the
48
+ spec's §4 contracts and §7 rollout.
49
+
50
+ ## Crossing a service boundary
51
+
52
+ Same shape, one level up. If the task touches an event, a cross-service HTTP/RPC call, or shared
53
+ data, read `docs/ESTATE.md` — the contract index generated by `/repo-estate`. Find the edge, then
54
+ open the peer's summary doc.
55
+
56
+ Then be careful about three things the in-repo docs cannot tell you:
57
+
58
+ - **The peer's side may be `(inferred)`.** The estate index marks rows that were never confirmed
59
+ against the peer's real code. An inferred contract is a hypothesis — verify it in the peer repo
60
+ before designing against it, or say in the spec that you didn't.
61
+ - **Deploy order is part of the design.** Two services that must change together need a sequence
62
+ that is safe at every intermediate step (usually: additive change to the consumer first, then the
63
+ producer, then remove the old path). That belongs in the spec's §7, not in the PR description.
64
+ - **Backward compatibility is the default assumption.** Something you don't control is already
65
+ calling the thing you're changing. If the change isn't additive, the spec has to say what happens
66
+ to in-flight callers and old messages.
67
+
68
+ ## Token discipline (applies throughout)
69
+
70
+ - Prefer `file:line` references over pasting code.
71
+ - Once a summary tells you where to look, read that **range**, not the whole file.
72
+ - Don't re-read a doc already in context.
73
+ - Don't open a doc to confirm something you already know from the code you just read.
74
+ - If a doc contradicts the code, the **code wins** — and the doc is stale: say so, and recommend
75
+ `/repo-refresh`. Don't quietly work around a wrong doc and leave it wrong for the next task.
@@ -0,0 +1,87 @@
1
+ <!--
2
+ Shared CLAUDE.md sections from the ml-specs plugin — merge into each service's CLAUDE.md.
3
+ Loaded on EVERY session in every repo that adopts it, so every line is paid for continuously:
4
+ keep only what is specific to this toolkit or this project. The full coding contract lives in
5
+ the plugin's `coder` agent, which carries it when it runs; don't restate it here.
6
+ -->
7
+
8
+ ## Knowledge layer — indexing, retrieval, summaries
9
+
10
+ These docs load into context, so load the **least** that answers the task, in this order:
11
+
12
+ 0. **`CLAUDE.md` (this file)** — always in context, so it stays a thin index: stack one-liner,
13
+ build/test/lint commands, and links. It points; it never inlines.
14
+ 1. **`docs/PATTERNS.md`** — when writing/changing code (skip for Q&A or trivial edits). New code
15
+ MUST match it. In a monorepo, read only the relevant package's section.
16
+ 2. **`docs/ARCHITECTURE.md`** — when you need to find where something lives. Read the relevant
17
+ section, not the file. If **sharded**, it's a router: read it, then only your module's shard.
18
+ 3. **`docs/ESTATE.md`** — only when the task touches an event or a cross-service call. Generated
19
+ by `/repo-estate`; don't hand-maintain it.
20
+ 4. **Agentic retrieval last** — `Explore` / grep / glob, for exact lines only.
21
+
22
+ **Standards are a different axis from these docs, not another rung on the ladder.**
23
+ `docs/PATTERNS.md` **describes** how this repo happens to write code; the architecture standards
24
+ (`.mlskills.json`, the `ml-skills` MCP server, `npx @mlmcps/ml-skills`) **prescribe** how it must.
25
+ Read the relevant standard before writing code the standard governs — an API contract, a migration,
26
+ a Dockerfile, anything touching auth or personal data. When a standard and the local code
27
+ disagree, the **code** is what is wrong: flag the divergence rather than copying local style. That
28
+ is the single case where "match the surrounding code" does not apply.
29
+
30
+ **Token discipline:** cite `file:line` rather than pasting code; read file *ranges* once a summary
31
+ says where to look; never re-read a doc already in context.
32
+
33
+ Sharded docs, interlinked modules, or a change crossing a module/service boundary? Load the
34
+ ml-specs **`knowledge-retrieval`** skill for that procedure — and not otherwise.
35
+
36
+ **Keep summaries fresh** (`/repo-refresh`) after changing endpoints, listeners, external clients, or
37
+ data-access flavor. A stale summary is worse than none.
38
+
39
+ ## Working agreement (coding-agent contract)
40
+
41
+ The full contract lives in the plugin's `coder` agent. What binds *all* work here:
42
+
43
+ 1. **Inspect before assuming** structure or stack — the code is the source of truth and overrides
44
+ any default you'd otherwise reach for.
45
+ 2. **Smallest correct change**, matching the surrounding file's naming, layering, error handling
46
+ and tests. Reuse what's there; justify any new dependency or abstraction.
47
+ 3. **ASK rather than guess on contracts** — API shape, data model or migration, error/status codes,
48
+ scope boundary, compatibility. A guessed contract costs more to undo than to ask about.
49
+
50
+ **Quality gates (run this repo's real commands — never invent them):** test (unit + integration +
51
+ functional/E2E), lint, and typecheck, filled in below; final acceptance runs the full suite end to
52
+ end. Schema changes ship as **migrations** or schema/index updates, never hand edits. Secrets come
53
+ from env.
54
+
55
+ <!-- /repo-init fills the real commands for this repo, e.g.:
56
+ - Test: `npm test` / `mvn test` / `pytest` …
57
+ - Lint/format: `npm run lint` / `./gradlew spotlessCheck` …
58
+ - Typecheck: `tsc --noEmit` … -->
59
+
60
+ ## Spec-driven development
61
+
62
+ Non-trivial changes start with a reviewable spec, not code. See `specs/README.md`. The loop:
63
+ `/spec <ticket>` → human reviews → `/spec-advance … Approved` → `/spec-build` (test-first, one
64
+ functional/E2E test per user-facing criterion) → `/spec-verify` (adversarial + full suite green,
65
+ §6.1) → `/code-review` → `/spec-advance … Verified` → `/pr` → merge → `/spec-advance … Archived`.
66
+
67
+ Status is only ever written by `/spec-advance`, which refuses a transition whose evidence isn't
68
+ there. `/repo-status` shows the board; `/repo-doctor` flags statuses the repo can't back up.
69
+
70
+ Anything not in the approved spec is out of scope — update the spec first. Trivial one-line fixes
71
+ are exempt: `/code` for those, `/spec` when a change touches an API, data model, event, or several
72
+ files. (`/code-review` and `/security-review` are built-in Claude Code commands.)
73
+
74
+ ## Git & PR workflow
75
+
76
+ - **Branch** off the default branch; never commit straight to it. Follow the repo's existing branch
77
+ naming (e.g. `feature/TICKET-slug`). Commits are small, focused, present-tense imperative, and
78
+ keep the spec in the same branch/PR as its implementation.
79
+ - **Before a PR:** quality gates pass, `/code-review` is clean, every acceptance criterion has a
80
+ passing test. Don't commit secrets, generated artifacts, or unrelated reformatting.
81
+ - **No AI attribution anywhere in the history.** Commit messages, commit trailers, PR titles and
82
+ PR bodies name the humans who own the change and nothing else. Never add a `Co-Authored-By:`
83
+ line for an assistant, a "Generated with"/"Made with" line, a model or vendor name, or a
84
+ tool badge or emoji. A `Co-Authored-By:` trailer is only ever a real teammate. This holds for
85
+ every commit on the branch, not just the last one — a squash merge aggregates trailers from
86
+ all of them, so one stray line resurfaces on the merge commit.
87
+ - Only commit or push when the human asks; surface the diff for review first.
@@ -0,0 +1,64 @@
1
+ # Spec gate for Azure DevOps — seeded by /repo-init (ml-specs).
2
+ #
3
+ # Wire this as a BUILD VALIDATION POLICY on the target branch with "Block merge
4
+ # when the build fails" enabled. A pipeline that runs and reports changes
5
+ # nothing: the gate is the policy, not the build.
6
+ #
7
+ # node scripts/branch-policy.mjs install <repo> --dry-run # read the request first
8
+ # node scripts/branch-policy.mjs install <repo>
9
+ # node scripts/branch-policy.mjs audit <repo> # is it still blocking?
10
+ #
11
+ # Every check below is deterministic — no LLM call, no API key, no network.
12
+
13
+ trigger: none
14
+ pr:
15
+ branches:
16
+ include: [main]
17
+
18
+ pool:
19
+ vmImage: ubuntu-latest
20
+
21
+ steps:
22
+ - task: NodeTool@0
23
+ displayName: Node 20
24
+ inputs:
25
+ versionSpec: '20.x'
26
+
27
+ - script: |
28
+ set -e
29
+ BRANCH="$(System.PullRequest.SourceBranch)"
30
+ BRANCH="${BRANCH#refs/heads/}"
31
+ if ! echo "$BRANCH" | grep -Eq '^(feat|fix|chore)/[0-9]{4}[a-z]?-'; then
32
+ echo "##vso[task.logissue type=error]branch '$BRANCH' carries no spec number. Expected feat/NNNN-slug."
33
+ exit 1
34
+ fi
35
+ ID=$(echo "$BRANCH" | sed -E 's#^[a-z]+/([0-9]{4}[a-z]?)-.*#\1#')
36
+ echo "##vso[task.setvariable variable=SPEC_ID]$ID"
37
+ displayName: 'Gate: branch carries a spec key'
38
+
39
+ - script: |
40
+ set -e
41
+ FILE=$(ls specs/$(SPEC_ID)-*.md 2>/dev/null | head -1)
42
+ [ -z "$FILE" ] && { echo "##vso[task.logissue type=error]no spec file for $(SPEC_ID)."; exit 1; }
43
+ STATUS=$(grep -m1 -E '^\|\s*\*\*Status\*\*' "$FILE" | sed -E 's/.*\|\s*\*\*Status\*\*\s*\|\s*([^|]*).*/\1/' | xargs)
44
+ case "$STATUS" in
45
+ Approved|Implemented|Verified) echo "spec $(SPEC_ID) is $STATUS" ;;
46
+ *) echo "##vso[task.logissue type=error]spec $(SPEC_ID) is '${STATUS:-unset}', not Approved."; exit 1 ;;
47
+ esac
48
+ displayName: 'Gate: spec is approved'
49
+
50
+ - script: node scripts/spec-trace.mjs $(SPEC_ID)
51
+ displayName: 'Gate: trace chain intact'
52
+
53
+ - script: node scripts/nfr-compile.mjs
54
+ displayName: 'Gate: NFRs routed, none flattened into a story'
55
+
56
+ # Self-referential on purpose. The commonest way this stops working is someone
57
+ # demoting the policy to advisory — nothing breaks, no test fails, and merges
58
+ # quietly stop being gated. So the gate audits itself.
59
+ - script: node scripts/branch-policy.mjs audit $(Build.Repository.Name)
60
+ displayName: 'Gate: the branch policy is still blocking'
61
+ env:
62
+ ADO_ORG: $(System.TeamFoundationCollectionUri)
63
+ ADO_PROJECT: $(System.TeamProject)
64
+ ADO_PAT: $(System.AccessToken)
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // Knowledge-layer checks — seeded into a repo by /repo-init (ml-specs).
3
+ // Pure Node, no dependencies.
4
+ //
5
+ // CLI: node .github/scripts/knowledge-check.mjs [--base <ref>] [--warn-only] [--root <dir>]
6
+ // Module: import { runChecks } from './knowledge-check.mjs' (used by the MCP server)
7
+ //
8
+ // This is the MECHANICAL half of /repo-doctor: the checks that need no judgment and so can
9
+ // run in CI on every PR. It does not read code for meaning — it verifies that what the docs
10
+ // claim about the repo is still literally true.
11
+ //
12
+ // /repo-doctor = judgment, run by a human on demand ("is this pattern still how we work?")
13
+ // this script = facts, run by CI on every PR ("does docs/PATTERNS.md:42 still exist?")
14
+ //
15
+ // Exit 1 on errors (a doc asserts something false). Warnings never fail the build.
16
+
17
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
20
+ import { execFileSync } from 'node:child_process';
21
+
22
+ // The knowledge layer, in load order. Missing files are fine — not every repo shards.
23
+ const DOC_FILES = ['CLAUDE.md', 'docs/PATTERNS.md', 'docs/ARCHITECTURE.md', 'docs/ESTATE.md'];
24
+ const DOC_DIRS = ['docs/architecture', 'docs/patterns'];
25
+
26
+ // A `file:line` reference is only a reference if the path looks like a real repo path.
27
+ // Without this, "example.com:443" and "spring-boot:2.0" become false positives.
28
+ const SOURCE_EXT = new Set([
29
+ 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'java', 'kt', 'scala', 'py', 'go', 'rb', 'rs',
30
+ 'cs', 'php', 'swift', 'sql', 'yml', 'yaml', 'json', 'xml', 'sh', 'md', 'tf', 'gradle',
31
+ 'properties', 'toml', 'vue', 'svelte',
32
+ ]);
33
+
34
+ const REF = /(?<![\w:/])([A-Za-z0-9_][\w./-]*\.[A-Za-z0-9]+):(\d+)(?:-(\d+))?\b/g;
35
+
36
+ /**
37
+ * Run the knowledge-layer checks. Pure: returns findings, never logs or exits.
38
+ * @param {{root?: string, base?: string|null}} opts
39
+ */
40
+ export function runChecks({ root = process.cwd(), base = null } = {}) {
41
+ const errors = [];
42
+ const warnings = [];
43
+ const err = (where, msg) => errors.push({ where, msg });
44
+ const warn = (where, msg) => warnings.push({ where, msg });
45
+
46
+ const abs = (p) => join(root, p);
47
+ const has = (p) => existsSync(abs(p));
48
+ const git = (...a) => {
49
+ try {
50
+ return execFileSync('git', ['-C', root, ...a], {
51
+ encoding: 'utf8',
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ }).trim();
54
+ } catch {
55
+ return '';
56
+ }
57
+ };
58
+
59
+ const docs = DOC_FILES.filter(has);
60
+ for (const dir of DOC_DIRS) {
61
+ if (!has(dir)) continue;
62
+ for (const f of readdirSync(abs(dir))) {
63
+ if (f.endsWith('.md')) docs.push(`${dir}/${f}`);
64
+ }
65
+ }
66
+
67
+ if (docs.length === 0) {
68
+ return { docs: [], refsChecked: 0, errors, warnings, empty: true };
69
+ }
70
+
71
+ const lineCount = (p) => readFileSync(abs(p), 'utf8').split('\n').length;
72
+ let refsChecked = 0;
73
+
74
+ for (const doc of docs) {
75
+ const text = readFileSync(abs(doc), 'utf8');
76
+
77
+ // 1. file:line references still resolve.
78
+ for (const m of text.matchAll(REF)) {
79
+ const [, path, startStr, endStr] = m;
80
+ const ext = path.split('.').pop().toLowerCase();
81
+ if (!path.includes('/') && !SOURCE_EXT.has(ext)) continue;
82
+ if (path.startsWith('http')) continue;
83
+
84
+ refsChecked++;
85
+ if (!has(path)) {
86
+ err(doc, `references ${path}:${startStr}, but ${path} does not exist`);
87
+ continue;
88
+ }
89
+ const total = lineCount(path);
90
+ const line = Number(endStr || startStr);
91
+ if (line > total) {
92
+ err(doc, `references ${path}:${startStr}${endStr ? '-' + endStr : ''}, but that file is only ${total} lines`);
93
+ }
94
+ }
95
+
96
+ // 2. Relative markdown links resolve (router → shard is the one that breaks silently).
97
+ for (const m of text.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)) {
98
+ const href = m[1];
99
+ if (/^(https?:|mailto:|#)/.test(href)) continue;
100
+ if (!existsSync(join(dirname(abs(doc)), href.split('#')[0]))) {
101
+ err(doc, `broken link: ${href}`);
102
+ }
103
+ }
104
+
105
+ // 3. Budget. These load into context on every task; bloat is a real cost, not a style nit.
106
+ const lines = text.split('\n').length;
107
+ const budget = doc === 'CLAUDE.md' ? 200 : 250;
108
+ if (lines > budget) {
109
+ warn(doc, `${lines} lines, over the ~${budget}-line budget — trim it (/repo-refresh prunes)`);
110
+ }
111
+ }
112
+
113
+ // 4. Every shard is reachable from the router, and vice versa.
114
+ if (has('docs/ARCHITECTURE.md') && has('docs/architecture')) {
115
+ const router = readFileSync(abs('docs/ARCHITECTURE.md'), 'utf8');
116
+ for (const f of readdirSync(abs('docs/architecture'))) {
117
+ if (!f.endsWith('.md') || f.startsWith('_')) continue;
118
+ if (!router.includes(f)) {
119
+ warn('docs/ARCHITECTURE.md', `no router row links to docs/architecture/${f} — it will never be loaded`);
120
+ }
121
+ }
122
+ }
123
+
124
+ // 5. Advisory: source moved, docs didn't. Not an error — plenty of changes need no doc update.
125
+ if (base) {
126
+ const changed = git('diff', '--name-only', `${base}...HEAD`).split('\n').filter(Boolean);
127
+ if (changed.length) {
128
+ const docSet = new Set(docs);
129
+ const touchedDocs = changed.filter((f) => docSet.has(f));
130
+ const touchedSource = changed.filter(
131
+ (f) => !docSet.has(f) && !f.endsWith('.md') && !f.startsWith('docs/'),
132
+ );
133
+ if (touchedSource.length > 0 && touchedDocs.length === 0) {
134
+ warn(
135
+ 'knowledge layer',
136
+ `${touchedSource.length} source file(s) changed and no knowledge doc did — ` +
137
+ 'if this PR moved an endpoint, listener, external client, or data-access flavor, run /repo-refresh',
138
+ );
139
+ }
140
+ }
141
+ }
142
+
143
+ return { docs, refsChecked, errors, warnings, empty: false };
144
+ }
145
+
146
+ // CLI ------------------------------------------------------------------------
147
+ // Only runs when invoked directly, so importing this module stays silent — an MCP server
148
+ // speaks JSON-RPC on stdout and a stray console.log corrupts the stream.
149
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
150
+ const args = process.argv.slice(2);
151
+ const flag = (name) => {
152
+ const i = args.indexOf(name);
153
+ return i === -1 ? null : args[i + 1];
154
+ };
155
+ const result = runChecks({ root: flag('--root') || process.cwd(), base: flag('--base') });
156
+
157
+ if (result.empty) {
158
+ console.log('knowledge-check: no knowledge layer found (CLAUDE.md / docs/) — nothing to check.');
159
+ console.log('Run /repo-init to create one, or delete this check.');
160
+ process.exit(0);
161
+ }
162
+
163
+ for (const w of result.warnings) console.log(` warning ${w.where}: ${w.msg}`);
164
+ for (const e of result.errors) console.error(` error ${e.where}: ${e.msg}`);
165
+
166
+ const summary = `${result.refsChecked} file:line reference(s) across ${result.docs.length} doc(s)`;
167
+ if (result.errors.length) {
168
+ console.error(`\n✗ knowledge layer is stale: ${result.errors.length} error(s), ${result.warnings.length} warning(s) — ${summary}`);
169
+ console.error(' These are facts the docs assert that are no longer true. Run /repo-refresh to fix,');
170
+ console.error(' or correct the references by hand. A doc that points at deleted code is worse than none.');
171
+ if (!args.includes('--warn-only')) process.exit(1);
172
+ console.error(' (--warn-only: not failing the build)');
173
+ } else {
174
+ console.log(`✓ knowledge layer checks out — ${summary}, ${result.warnings.length} warning(s)`);
175
+ }
176
+ }
@@ -0,0 +1,35 @@
1
+ # Knowledge-layer CI gate — seeded by /repo-init (ml-specs).
2
+ # Copy to .github/workflows/knowledge-layer.yml, with knowledge-check.mjs at .github/scripts/.
3
+ #
4
+ # Fails a PR when the docs assert something about the repo that is no longer true — a
5
+ # `file:line` pointing at deleted code, a router row pointing at a missing shard. Those are
6
+ # facts, not judgment, so they belong in CI. The judgment half stays in /repo-doctor.
7
+ #
8
+ # ADOPTING THIS ON AN EXISTING REPO: the first run usually finds real staleness. Start with
9
+ # `--warn-only` below, run /repo-refresh to clear the backlog, then remove the flag. A gate
10
+ # that fails on day one gets disabled on day two.
11
+
12
+ name: knowledge-layer
13
+
14
+ on:
15
+ pull_request:
16
+ push:
17
+ branches: [main, master]
18
+
19
+ jobs:
20
+ knowledge-layer:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0 # the advisory check diffs against the base branch
26
+
27
+ - uses: actions/setup-node@v4
28
+ with:
29
+ node-version: 20
30
+
31
+ - name: Check the knowledge layer still matches the code
32
+ run: |
33
+ node .github/scripts/knowledge-check.mjs \
34
+ --base "origin/${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}"
35
+ # While adopting, append: --warn-only
@@ -0,0 +1,69 @@
1
+ # Spec gate — seeded by /repo-init (ml-specs).
2
+ # Copy to .github/workflows/spec-gate.yml.
3
+ #
4
+ # Fails a pull request that does not trace back to an approved spec. Every check
5
+ # here is deterministic: no LLM call, no API key, no network. Same verdict every
6
+ # run, in well under a second — which matters more than it sounds, because a gate
7
+ # that adds minutes to CI gets disabled within a month, and then you have
8
+ # governance theatre.
9
+ #
10
+ # ADOPTING THIS ON AN EXISTING REPO: start with `continue-on-error: true` on the
11
+ # blocking steps for a fortnight and watch how often it WOULD have failed. A gate
12
+ # that fails on day one gets disabled on day two. Turn it on deliberately, once
13
+ # you can defend the number.
14
+
15
+ name: spec-gate
16
+
17
+ on:
18
+ pull_request:
19
+
20
+ jobs:
21
+ spec-gate:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: actions/setup-node@v4
26
+ with:
27
+ node-version: 20
28
+
29
+ # --- the branch must carry a spec key --------------------------------
30
+ # The chain starts here. A branch named `fix/payment-thing` cannot be
31
+ # traced to anything, and nothing downstream can recover the link.
32
+ - name: Branch carries a spec key
33
+ run: |
34
+ BRANCH="${{ github.head_ref }}"
35
+ if ! echo "$BRANCH" | grep -Eq '^(feat|fix|chore)/[0-9]{4}[a-z]?-'; then
36
+ echo "::error::branch '$BRANCH' carries no spec number."
37
+ echo "Expected feat/NNNN-slug. See specs/README.md."
38
+ exit 1
39
+ fi
40
+ echo "SPEC_ID=$(echo "$BRANCH" | sed -E 's#^[a-z]+/([0-9]{4}[a-z]?)-.*#\1#')" >> "$GITHUB_ENV"
41
+
42
+ # --- that spec must exist and be past the approval gate ---------------
43
+ - name: Spec is approved
44
+ run: |
45
+ FILE=$(ls specs/${SPEC_ID}-*.md 2>/dev/null | head -1)
46
+ if [ -z "$FILE" ]; then
47
+ echo "::error::no spec file for ${SPEC_ID}."
48
+ exit 1
49
+ fi
50
+ STATUS=$(grep -m1 -E '^\|\s*\*\*Status\*\*' "$FILE" | sed -E 's/.*\|\s*\*\*Status\*\*\s*\|\s*([^|]*).*/\1/' | xargs)
51
+ case "$STATUS" in
52
+ Approved|Implemented|Verified) echo "spec ${SPEC_ID} is $STATUS" ;;
53
+ *)
54
+ echo "::error::spec ${SPEC_ID} is '${STATUS:-unset}', not Approved."
55
+ echo "Nothing crosses into execution before the approval gate."
56
+ exit 1 ;;
57
+ esac
58
+
59
+ # --- the whole chain still resolves ----------------------------------
60
+ - name: Trace chain intact
61
+ run: node .github/scripts/spec-trace.mjs "$SPEC_ID"
62
+
63
+ # --- NFRs are routed, and none has been flattened into a story --------
64
+ - name: NFRs routed
65
+ run: node .github/scripts/nfr-compile.mjs
66
+
67
+ # --- the mechanical half of the lifecycle gate ------------------------
68
+ - name: Spec evidence
69
+ run: node .github/scripts/spec-gate.mjs "$(ls specs/${SPEC_ID}-*.md | head -1)"