@mlmcps/ml-specs-mcp 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.
- package/LICENSE +21 -0
- package/README.md +347 -0
- package/agents/coder.md +76 -0
- package/agents/developer.md +78 -0
- package/agents/pr-author.md +36 -0
- package/agents/reviewer.md +65 -0
- package/agents/scanner.md +66 -0
- package/agents/spec-author.md +91 -0
- package/agents/spec-reviewer.md +59 -0
- package/commands/code.md +29 -0
- package/commands/fix.md +67 -0
- package/commands/nfr.md +114 -0
- package/commands/pr.md +32 -0
- package/commands/repo-adopt.md +86 -0
- package/commands/repo-doctor.md +57 -0
- package/commands/repo-estate.md +79 -0
- package/commands/repo-impact.md +77 -0
- package/commands/repo-init.md +155 -0
- package/commands/repo-refresh.md +58 -0
- package/commands/repo-rollout.md +84 -0
- package/commands/repo-status.md +59 -0
- package/commands/spec-advance.md +81 -0
- package/commands/spec-build.md +66 -0
- package/commands/spec-fanout.md +64 -0
- package/commands/spec-review.md +24 -0
- package/commands/spec-verify.md +55 -0
- package/commands/spec.md +73 -0
- package/mcp/README.md +173 -0
- package/mcp/ml-specs-server.mjs +708 -0
- package/package.json +44 -0
- package/scripts/branch-policy.mjs +71 -0
- package/scripts/fix-specs.mjs +289 -0
- package/scripts/lib/cli.mjs +43 -0
- package/scripts/lib/estate.mjs +108 -0
- package/scripts/lib/http.mjs +73 -0
- package/scripts/lib/knowledge.mjs +91 -0
- package/scripts/lib/nfr.mjs +119 -0
- package/scripts/lib/policy.mjs +114 -0
- package/scripts/lib/scm.mjs +189 -0
- package/scripts/lib/specs.mjs +192 -0
- package/scripts/lib/trace.mjs +90 -0
- package/scripts/lib/tracker.mjs +257 -0
- package/scripts/nfr-compile.mjs +120 -0
- package/scripts/spec-brief.mjs +127 -0
- package/scripts/spec-dashboard.mjs +331 -0
- package/scripts/spec-fanout.mjs +120 -0
- package/scripts/spec-gate.mjs +329 -0
- package/scripts/spec-trace.mjs +91 -0
- package/scripts/survey-estate.mjs +230 -0
- package/scripts/tracker-sync.mjs +91 -0
- package/templates/ci/knowledge-check.mjs +176 -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,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
|
+
}
|