discipline-md 0.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/LICENSE +21 -0
- package/README.md +80 -0
- package/bin/discipline.js +587 -0
- package/package.json +40 -0
- package/templates/.claude/settings.json +58 -0
- package/templates/AGENTS.md +463 -0
- package/templates/AGENT_TRACKER.md +138 -0
- package/templates/API_REFERENCE.md +131 -0
- package/templates/ARCHITECTURE.md +89 -0
- package/templates/ASSETS.md +90 -0
- package/templates/AUTONOMOUS_QUEUE.md +119 -0
- package/templates/BUILD_PLAN.md +89 -0
- package/templates/CHANGELOG.md +90 -0
- package/templates/CLAUDE.md +89 -0
- package/templates/CREDITS.md +109 -0
- package/templates/DATA_MODEL.md +88 -0
- package/templates/DECISIONS.md +120 -0
- package/templates/DEPLOYMENT.md +342 -0
- package/templates/HANDOFF.md +289 -0
- package/templates/IMPROVEMENT_LOOP.md +103 -0
- package/templates/INVESTIGATION.md +154 -0
- package/templates/LICENSE +68 -0
- package/templates/NOTICE +55 -0
- package/templates/OPEN_DECISIONS.md +61 -0
- package/templates/PLAYBOOK_FEEDBACK.md +87 -0
- package/templates/PROJECT_CONTEXT.md +91 -0
- package/templates/README.md +60 -0
- package/templates/ROADMAP.md +96 -0
- package/templates/SECURITY_AUDIT.md +235 -0
- package/templates/SETUP.md +162 -0
- package/templates/SPEC.md +105 -0
- package/templates/SPEC_WORKFLOW.md +173 -0
- package/templates/TODO.md +118 -0
- package/templates/USAGE.md +153 -0
- package/templates/VERIFICATION_GATE.md +68 -0
- package/templates/agents/CROSS_REPO_SYNC.md +124 -0
- package/templates/agents/DEBUGGER.md +112 -0
- package/templates/agents/PLANNER.md +111 -0
- package/templates/agents/README.md +64 -0
- package/templates/agents/RECON.md +99 -0
- package/templates/agents/SECURITY_REVIEWER.md +123 -0
- package/templates/agents/SPEC_ARCHITECT.md +133 -0
- package/templates/agents/STAKEHOLDER.md +197 -0
- package/templates/agents/_TEMPLATE.md +116 -0
- package/templates/agents/optional/ARCHITECT.md +109 -0
- package/templates/agents/optional/BACKEND_IMPACT.md +108 -0
- package/templates/agents/optional/DOC_AUDIT.md +108 -0
- package/templates/agents/optional/FRONTEND_IMPACT.md +109 -0
- package/templates/agents/optional/QUEUE_CURATOR.md +114 -0
- package/templates/agents/optional/TEST_STRATEGIST.md +107 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const TEMPLATES_DIR = resolve(__dirname, '..', 'templates');
|
|
9
|
+
|
|
10
|
+
// ── Templates split: 11 core, 16 optional ───────────────────────────────
|
|
11
|
+
// The Spec & Design phase (SPEC_WORKFLOW + SPEC + BUILD_PLAN) is CORE, not
|
|
12
|
+
// optional: a good spec is the load-bearing input to every build, and the
|
|
13
|
+
// framework's whole point is to get you there. See docs/DECISIONS.md 2026-06-14.
|
|
14
|
+
const CORE = [
|
|
15
|
+
'README.md',
|
|
16
|
+
'HANDOFF.md',
|
|
17
|
+
'AGENTS.md',
|
|
18
|
+
'PROJECT_CONTEXT.md',
|
|
19
|
+
'TODO.md',
|
|
20
|
+
'DECISIONS.md',
|
|
21
|
+
'ROADMAP.md',
|
|
22
|
+
'CHANGELOG.md',
|
|
23
|
+
'SPEC_WORKFLOW.md',
|
|
24
|
+
'SPEC.md',
|
|
25
|
+
'BUILD_PLAN.md',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const OPTIONAL = [
|
|
29
|
+
'API_REFERENCE.md',
|
|
30
|
+
'ARCHITECTURE.md',
|
|
31
|
+
'ASSETS.md',
|
|
32
|
+
'AUTONOMOUS_QUEUE.md',
|
|
33
|
+
'AGENT_TRACKER.md',
|
|
34
|
+
'CREDITS.md',
|
|
35
|
+
'DATA_MODEL.md',
|
|
36
|
+
'DEPLOYMENT.md',
|
|
37
|
+
'INVESTIGATION.md',
|
|
38
|
+
'OPEN_DECISIONS.md',
|
|
39
|
+
'PLAYBOOK_FEEDBACK.md',
|
|
40
|
+
'IMPROVEMENT_LOOP.md',
|
|
41
|
+
'VERIFICATION_GATE.md',
|
|
42
|
+
'SECURITY_AUDIT.md',
|
|
43
|
+
'USAGE.md',
|
|
44
|
+
'CLAUDE.md',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// ── Agent role split: 6 core, 6 optional ─────────────────────────────────
|
|
48
|
+
// Core role contracts get installed by `discipline-md init`. Optional roles
|
|
49
|
+
// (the retired-from-default-set roles per docs/DECISIONS.md 2026-05-09)
|
|
50
|
+
// stay in templates/agents/optional/ and are only installed on demand
|
|
51
|
+
// via `discipline-md add-role <NAME>` for projects that need them.
|
|
52
|
+
const CORE_ROLES = [
|
|
53
|
+
'RECON.md',
|
|
54
|
+
'PLANNER.md',
|
|
55
|
+
'DEBUGGER.md',
|
|
56
|
+
'SECURITY_REVIEWER.md',
|
|
57
|
+
'CROSS_REPO_SYNC.md',
|
|
58
|
+
'SPEC_ARCHITECT.md',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const OPTIONAL_ROLES = [
|
|
62
|
+
'ARCHITECT.md',
|
|
63
|
+
'DOC_AUDIT.md',
|
|
64
|
+
'TEST_STRATEGIST.md',
|
|
65
|
+
'BACKEND_IMPACT.md',
|
|
66
|
+
'FRONTEND_IMPACT.md',
|
|
67
|
+
'QUEUE_CURATOR.md',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// Meta files inside templates/agents/ that always travel with the core
|
|
71
|
+
// install (they're not roles themselves — they're scaffolding for adding
|
|
72
|
+
// project-local roles).
|
|
73
|
+
const AGENT_META_FILES = ['_TEMPLATE.md', 'README.md', 'STAKEHOLDER.md'];
|
|
74
|
+
|
|
75
|
+
const REPO_ROOT_FILES = ['LICENSE', 'NOTICE'];
|
|
76
|
+
|
|
77
|
+
// ── helpers ─────────────────────────────────────────────────────────────
|
|
78
|
+
function args() {
|
|
79
|
+
const argv = process.argv.slice(2);
|
|
80
|
+
const command = argv[0] ?? 'help';
|
|
81
|
+
const flags = {};
|
|
82
|
+
const positional = [];
|
|
83
|
+
for (let i = 1; i < argv.length; i++) {
|
|
84
|
+
const a = argv[i];
|
|
85
|
+
if (a.startsWith('--')) {
|
|
86
|
+
flags[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
|
|
87
|
+
} else {
|
|
88
|
+
positional.push(a);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { command, flags, positional };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function copyOne(src, dest) {
|
|
95
|
+
if (existsSync(dest)) {
|
|
96
|
+
console.log(` skip (exists): ${dest}`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
100
|
+
copyFileSync(src, dest);
|
|
101
|
+
console.log(` wrote: ${dest}`);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Copy only the immediate children of a directory (files only, no subdirs).
|
|
106
|
+
// Used to copy templates/agents/ without dragging the optional/ subfolder along.
|
|
107
|
+
function copyTopLevelFiles(srcDir, destDir) {
|
|
108
|
+
if (!existsSync(srcDir)) return;
|
|
109
|
+
for (const entry of readdirSync(srcDir)) {
|
|
110
|
+
const src = join(srcDir, entry);
|
|
111
|
+
if (statSync(src).isDirectory()) continue;
|
|
112
|
+
copyOne(src, join(destDir, entry));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── commands ────────────────────────────────────────────────────────────
|
|
117
|
+
function cmdInit(flags) {
|
|
118
|
+
const target = flags.target ? resolve(flags.target) : process.cwd();
|
|
119
|
+
console.log(`\nDiscipline init -> ${target}\n`);
|
|
120
|
+
|
|
121
|
+
console.log('Core 11 templates -> docs/');
|
|
122
|
+
for (const f of CORE) {
|
|
123
|
+
const src = join(TEMPLATES_DIR, f);
|
|
124
|
+
const dest = join(target, 'docs', f);
|
|
125
|
+
if (existsSync(src)) copyOne(src, dest);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Core role contracts — top level of templates/agents/ (excluding optional/)
|
|
129
|
+
const agentsSrc = join(TEMPLATES_DIR, 'agents');
|
|
130
|
+
if (existsSync(agentsSrc)) {
|
|
131
|
+
console.log('\nCore 6 role contracts + meta files -> docs/agents/');
|
|
132
|
+
copyTopLevelFiles(agentsSrc, join(target, 'docs', 'agents'));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log('\nRepo-root files (LICENSE, NOTICE) -> repo root');
|
|
136
|
+
for (const f of REPO_ROOT_FILES) {
|
|
137
|
+
const src = join(TEMPLATES_DIR, f);
|
|
138
|
+
const dest = join(target, f);
|
|
139
|
+
if (existsSync(src)) copyOne(src, dest);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log('\nDone. Next:');
|
|
143
|
+
console.log(' 1. Fill in placeholders in docs/ (search for "[" or "<" markers).');
|
|
144
|
+
console.log(' 2. Pick a license in LICENSE (default ships as All Rights Reserved).');
|
|
145
|
+
console.log(' 3. Point your AI agent at docs/AGENTS.md.');
|
|
146
|
+
console.log(' 4. Add optional templates with: npx discipline-md add <name>');
|
|
147
|
+
console.log(` (available: ${OPTIONAL.join(', ')})`);
|
|
148
|
+
console.log(' 5. Add optional agent roles with: npx discipline-md add-role <NAME>');
|
|
149
|
+
console.log(` (available: ${OPTIONAL_ROLES.map((r) => r.replace('.md', '')).join(', ')})`);
|
|
150
|
+
console.log();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cmdAdd(positional) {
|
|
154
|
+
const target = process.cwd();
|
|
155
|
+
if (!positional.length) {
|
|
156
|
+
console.log('Usage: npx discipline-md add <template> [<template>...]');
|
|
157
|
+
console.log('Available optional templates:');
|
|
158
|
+
for (const f of OPTIONAL) console.log(` ${f}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
console.log(`\nDiscipline add -> ${target}/docs/\n`);
|
|
162
|
+
for (const arg of positional) {
|
|
163
|
+
const name = arg.endsWith('.md') ? arg : `${arg}.md`;
|
|
164
|
+
if (!OPTIONAL.includes(name) && !CORE.includes(name)) {
|
|
165
|
+
console.log(` skip (unknown template): ${name}`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const src = join(TEMPLATES_DIR, name);
|
|
169
|
+
const dest = join(target, 'docs', name);
|
|
170
|
+
if (existsSync(src)) copyOne(src, dest);
|
|
171
|
+
}
|
|
172
|
+
console.log();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function cmdAddRole(positional) {
|
|
176
|
+
const target = process.cwd();
|
|
177
|
+
if (!positional.length) {
|
|
178
|
+
console.log('Usage: npx discipline-md add-role <ROLE> [<ROLE>...]');
|
|
179
|
+
console.log('Available optional agent roles:');
|
|
180
|
+
for (const f of OPTIONAL_ROLES) console.log(` ${f.replace('.md', '')}`);
|
|
181
|
+
console.log('\nThese roles were retired from the lean default set on 2026-05-09');
|
|
182
|
+
console.log('(see docs/DECISIONS.md). Add them only if your project needs them.');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
console.log(`\nDiscipline add-role -> ${target}/docs/agents/\n`);
|
|
186
|
+
for (const arg of positional) {
|
|
187
|
+
const upper = arg.toUpperCase();
|
|
188
|
+
const name = upper.endsWith('.MD') ? upper : `${upper}.md`;
|
|
189
|
+
const properName = name.endsWith('.md') ? name : `${name}.md`;
|
|
190
|
+
if (!OPTIONAL_ROLES.includes(properName) && !CORE_ROLES.includes(properName)) {
|
|
191
|
+
console.log(` skip (unknown role): ${arg}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Optional roles live in templates/agents/optional/; core roles at templates/agents/.
|
|
195
|
+
const subdir = OPTIONAL_ROLES.includes(properName) ? 'optional' : '';
|
|
196
|
+
const src = subdir
|
|
197
|
+
? join(TEMPLATES_DIR, 'agents', subdir, properName)
|
|
198
|
+
: join(TEMPLATES_DIR, 'agents', properName);
|
|
199
|
+
const dest = join(target, 'docs', 'agents', properName);
|
|
200
|
+
if (existsSync(src)) copyOne(src, dest);
|
|
201
|
+
}
|
|
202
|
+
console.log();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── lint ────────────────────────────────────────────────────────────────
|
|
206
|
+
// Mechanically checkable slices of the framework's gates: a gate a weak
|
|
207
|
+
// model can forget is not a gate. Lints the TARGET's docs/ only — never
|
|
208
|
+
// this package's own templates/ (templates legitimately contain
|
|
209
|
+
// placeholders and example residue).
|
|
210
|
+
|
|
211
|
+
// Allowed tag values per the legends in templates/AGENTS.md, templates/TODO.md
|
|
212
|
+
// and templates/AUTONOMOUS_QUEUE.md (union of the Claude-specific and
|
|
213
|
+
// model-agnostic spellings those legends document).
|
|
214
|
+
const TAG_VOCAB = {
|
|
215
|
+
autonomy: ['safe', 'review', 'needs-human-collab'],
|
|
216
|
+
size: ['XS', 'S', 'M', 'L', 'XL'],
|
|
217
|
+
risk: ['low', 'med', 'high'],
|
|
218
|
+
scope: ['isolated', 'cross-repo', 'cross-cutting', 'infra'],
|
|
219
|
+
tier: ['haiku', 'sonnet', 'opus', 'frontier', 'workhorse', 'recon'],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Hot-path size budgets (bytes). Over budget = cold content leaking in.
|
|
223
|
+
const SIZE_BUDGETS = [
|
|
224
|
+
['docs/AGENTS.md', 36 * 1024],
|
|
225
|
+
['docs/HANDOFF.md', 24 * 1024],
|
|
226
|
+
['docs/TODO.md', 16 * 1024],
|
|
227
|
+
['CLAUDE.md', 12 * 1024],
|
|
228
|
+
['docs/CLAUDE.md', 12 * 1024],
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
// Placeholder patterns confirmed present in the shipped templates.
|
|
232
|
+
const PLACEHOLDER_PATTERNS = [
|
|
233
|
+
/\[TBD\]/,
|
|
234
|
+
/<repo-path>/,
|
|
235
|
+
/(?<!_)<YYYY-MM-DD>/, // `SECURITY_AUDIT_<YYYY-MM-DD>.md` is a filename convention, not residue
|
|
236
|
+
/<e\.g\.[^>]*>/,
|
|
237
|
+
/<docs\/[^>]*>/,
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
function lintRead(path) {
|
|
241
|
+
if (!existsSync(path)) return null;
|
|
242
|
+
return readFileSync(path, 'utf8').split(/\r?\n/);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Per-line mask: true where the line is inside a fenced code block or an
|
|
246
|
+
// HTML comment — examples and commented-out scaffolding are not findings.
|
|
247
|
+
function maskedLines(lines) {
|
|
248
|
+
const mask = [];
|
|
249
|
+
let fence = false;
|
|
250
|
+
let comment = false;
|
|
251
|
+
for (const line of lines) {
|
|
252
|
+
const wasMasked = fence || comment;
|
|
253
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence;
|
|
254
|
+
if (!fence) {
|
|
255
|
+
if (line.includes('<!--')) comment = true;
|
|
256
|
+
mask.push(wasMasked || fence || comment || line.includes('<!--'));
|
|
257
|
+
if (line.includes('-->')) comment = false;
|
|
258
|
+
} else {
|
|
259
|
+
mask.push(true);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return mask;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Loose containment normalizer for cross-file title matching.
|
|
266
|
+
function normalize(s) {
|
|
267
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// First plain title fragment of a queue/TODO bullet: text up to the first
|
|
271
|
+
// em-dash separator or tag block, stripped of markdown decoration.
|
|
272
|
+
function bulletTitle(text) {
|
|
273
|
+
let t = text.split(/\s+—\s+/)[0];
|
|
274
|
+
t = t.split('`[')[0];
|
|
275
|
+
return t.replace(/[*_`]/g, '').trim();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cmdLint(flags) {
|
|
279
|
+
const target = flags.target ? resolve(flags.target) : process.cwd();
|
|
280
|
+
const docsDir = join(target, 'docs');
|
|
281
|
+
const findings = [];
|
|
282
|
+
const add = (file, line, level, rule, msg) => findings.push({ file, line, level, rule, msg });
|
|
283
|
+
|
|
284
|
+
if (!existsSync(docsDir)) {
|
|
285
|
+
console.log(`\nDiscipline lint: no docs/ directory at ${target} — nothing to lint.\n`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const todoPath = join(docsDir, 'TODO.md');
|
|
290
|
+
const queuePath = join(docsDir, 'AUTONOMOUS_QUEUE.md');
|
|
291
|
+
const todo = lintRead(todoPath);
|
|
292
|
+
const queue = lintRead(queuePath);
|
|
293
|
+
const roadmap = lintRead(join(docsDir, 'ROADMAP.md'));
|
|
294
|
+
|
|
295
|
+
// todo-done-residue (ERROR): the cleanup gate deletes shipped entries.
|
|
296
|
+
if (todo) {
|
|
297
|
+
const mask = maskedLines(todo);
|
|
298
|
+
todo.forEach((line, i) => {
|
|
299
|
+
if (!mask[i] && /^\s*[-*]\s*\[[xX]\]/.test(line)) {
|
|
300
|
+
add(todoPath, i + 1, 'ERROR', 'todo-done-residue',
|
|
301
|
+
'checked-off entry — shipped TODO entries are DELETED once captured in CHANGELOG, never marked [x]');
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// tag-validity (ERROR): unknown value in a [key: value] tag.
|
|
307
|
+
for (const [path, lines] of [[todoPath, todo], [join(docsDir, 'ROADMAP.md'), roadmap], [queuePath, queue]]) {
|
|
308
|
+
if (!lines) continue;
|
|
309
|
+
const mask = maskedLines(lines);
|
|
310
|
+
lines.forEach((line, i) => {
|
|
311
|
+
if (mask[i]) return;
|
|
312
|
+
for (const m of line.matchAll(/\[(autonomy|size|risk|scope|tier):\s*([^\]]+)\]/g)) {
|
|
313
|
+
const [, key, raw] = m;
|
|
314
|
+
const value = raw.trim();
|
|
315
|
+
// Legend lines spell the alternatives ("safe|review|…") — skip those.
|
|
316
|
+
if (value.includes('|') || value.includes('…') || value.includes('<')) continue;
|
|
317
|
+
const allowed = TAG_VOCAB[key];
|
|
318
|
+
const ok = key === 'size'
|
|
319
|
+
? allowed.includes(value.toUpperCase())
|
|
320
|
+
: allowed.includes(value.toLowerCase());
|
|
321
|
+
if (!ok) {
|
|
322
|
+
add(path, i + 1, 'ERROR', 'tag-validity',
|
|
323
|
+
`[${key}: ${value}] is not a documented value (allowed: ${allowed.join('|')})`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// queue-orphan (WARN): Active Queue entry with no trace in TODO/ROADMAP.
|
|
330
|
+
if (queue && (todo || roadmap)) {
|
|
331
|
+
const haystack = normalize([...(todo ?? []), ...(roadmap ?? [])].join('\n'));
|
|
332
|
+
const mask = maskedLines(queue);
|
|
333
|
+
let inActive = false;
|
|
334
|
+
queue.forEach((line, i) => {
|
|
335
|
+
if (/^##\s/.test(line)) inActive = /^##\s+Active Queue/i.test(line);
|
|
336
|
+
if (!inActive || mask[i]) return;
|
|
337
|
+
const m = line.match(/^\s*-\s*\[ \]\s*(.+)/);
|
|
338
|
+
if (!m || m[1].startsWith('(')) return;
|
|
339
|
+
const title = normalize(bulletTitle(m[1]));
|
|
340
|
+
if (title.length < 8) return; // too short to match meaningfully
|
|
341
|
+
if (!haystack.includes(title)) {
|
|
342
|
+
add(queuePath, i + 1, 'WARN', 'queue-orphan',
|
|
343
|
+
`queue entry "${bulletTitle(m[1])}" not found in docs/TODO.md or docs/ROADMAP.md — the queue is pointers, not a second backlog`);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// decisions-structure (WARN): entry missing the pinned fields from
|
|
349
|
+
// templates/DECISIONS.md (Status / Decision / Context / Consequences;
|
|
350
|
+
// bold-label and H3 spellings both accepted).
|
|
351
|
+
const decisionsPath = join(docsDir, 'DECISIONS.md');
|
|
352
|
+
const decisions = lintRead(decisionsPath);
|
|
353
|
+
if (decisions) {
|
|
354
|
+
const headings = [];
|
|
355
|
+
decisions.forEach((line, i) => {
|
|
356
|
+
if (/^##\s+\d{4}-\d{2}-\d{2}\b/.test(line)) headings.push(i);
|
|
357
|
+
});
|
|
358
|
+
headings.forEach((start, idx) => {
|
|
359
|
+
const end = idx + 1 < headings.length ? headings[idx + 1] : decisions.length;
|
|
360
|
+
const body = decisions.slice(start + 1, end).join('\n');
|
|
361
|
+
const missing = ['Status', 'Decision', 'Context', 'Consequences'].filter((f) =>
|
|
362
|
+
!new RegExp(`(^|\\n)\\s*(\\*\\*)?${f}(\\*\\*)?\\s*:|(^|\\n)###\\s+${f}`).test(body));
|
|
363
|
+
if (missing.length) {
|
|
364
|
+
add(decisionsPath, start + 1, 'WARN', 'decisions-structure',
|
|
365
|
+
`entry missing pinned field(s): ${missing.join(', ')}`);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// hot-doc-size (WARN)
|
|
371
|
+
for (const [rel, budget] of SIZE_BUDGETS) {
|
|
372
|
+
const path = join(target, rel);
|
|
373
|
+
if (!existsSync(path)) continue;
|
|
374
|
+
const size = statSync(path).size;
|
|
375
|
+
if (size > budget) {
|
|
376
|
+
add(path, null, 'WARN', 'hot-doc-size',
|
|
377
|
+
`${(size / 1024).toFixed(1)}KB exceeds the ${budget / 1024}KB hot-path budget — cold content may be leaking into the hot path`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// placeholder-residue (WARN): unfilled scaffold placeholders in docs/.
|
|
382
|
+
const walk = (dir) => readdirSync(dir).flatMap((entry) => {
|
|
383
|
+
const p = join(dir, entry);
|
|
384
|
+
return statSync(p).isDirectory() ? walk(p) : (p.endsWith('.md') ? [p] : []);
|
|
385
|
+
});
|
|
386
|
+
for (const path of walk(docsDir)) {
|
|
387
|
+
const lines = lintRead(path);
|
|
388
|
+
const mask = maskedLines(lines);
|
|
389
|
+
lines.forEach((line, i) => {
|
|
390
|
+
if (mask[i]) return;
|
|
391
|
+
const hit = PLACEHOLDER_PATTERNS.find((re) => re.test(line));
|
|
392
|
+
if (hit) {
|
|
393
|
+
add(path, i + 1, 'WARN', 'placeholder-residue',
|
|
394
|
+
`unfilled scaffold placeholder (${line.match(hit)[0]})`);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// handoff-stale (WARN)
|
|
400
|
+
const handoffPath = join(docsDir, 'HANDOFF.md');
|
|
401
|
+
const handoff = lintRead(handoffPath);
|
|
402
|
+
if (handoff) {
|
|
403
|
+
handoff.forEach((line, i) => {
|
|
404
|
+
const m = line.match(/last updated\s*[:—–-]?\s*\**\s*(\d{4}-\d{2}-\d{2})/i);
|
|
405
|
+
if (!m) return;
|
|
406
|
+
const age = (Date.now() - new Date(m[1]).getTime()) / 86400000;
|
|
407
|
+
if (Number.isFinite(age) && age > 60) {
|
|
408
|
+
add(handoffPath, i + 1, 'WARN', 'handoff-stale',
|
|
409
|
+
`Last updated ${m[1]} is ${Math.floor(age)} days old — treat as a smell, verify before trusting`);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// todo-two-gate (WARN): [autonomy: safe] is necessary but NOT sufficient —
|
|
415
|
+
// the item must also be in the curated queue. Skipped when the project
|
|
416
|
+
// has no AUTONOMOUS_QUEUE.md at all.
|
|
417
|
+
if (todo && queue) {
|
|
418
|
+
const queueText = normalize(queue.join('\n'));
|
|
419
|
+
const mask = maskedLines(todo);
|
|
420
|
+
todo.forEach((line, i) => {
|
|
421
|
+
if (mask[i] || !line.includes('[autonomy: safe]')) return;
|
|
422
|
+
const m = line.match(/^\s*[-*]\s*(?:\[.\]\s*)?(.+)/);
|
|
423
|
+
if (!m) return;
|
|
424
|
+
const title = normalize(bulletTitle(m[1]));
|
|
425
|
+
if (title.length < 8) return;
|
|
426
|
+
if (!queueText.includes(title)) {
|
|
427
|
+
add(todoPath, i + 1, 'WARN', 'todo-two-gate',
|
|
428
|
+
'tagged [autonomy: safe] but not listed in docs/AUTONOMOUS_QUEUE.md — the tag alone is not sufficient for unattended execution');
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── Spec & Design phase rules (SPEC.md / BUILD_PLAN.md) ──
|
|
434
|
+
// These only fire when a project has opted into the spec phase (the files
|
|
435
|
+
// exist); they skip gracefully otherwise.
|
|
436
|
+
|
|
437
|
+
// spec-req-untagged (WARN): every requirement in SPEC.md ## Requirements must
|
|
438
|
+
// carry [AUTO] or [HUMAN] — an untagged requirement has no verification
|
|
439
|
+
// posture, and the verifier suite can't know whether to check it.
|
|
440
|
+
// spec-auto-coverage (WARN): each [AUTO] requirement needs an entry in
|
|
441
|
+
// ## Acceptance Tests — that section is the seed of the verifier suite.
|
|
442
|
+
const specPath = join(docsDir, 'SPEC.md');
|
|
443
|
+
const spec = lintRead(specPath);
|
|
444
|
+
if (spec) {
|
|
445
|
+
const mask = maskedLines(spec);
|
|
446
|
+
const autoReqs = new Set();
|
|
447
|
+
let section = '';
|
|
448
|
+
spec.forEach((line, i) => {
|
|
449
|
+
const h = line.match(/^##\s+(.+?)\s*$/);
|
|
450
|
+
if (h) section = normalize(h[1]);
|
|
451
|
+
if (mask[i]) return;
|
|
452
|
+
const m = line.match(/^\s*[-*]\s*\*\*(R\d+)\*\*\s*(.*)$/);
|
|
453
|
+
if (!m || section !== 'requirements') return;
|
|
454
|
+
const [, id, rest] = m;
|
|
455
|
+
const hasAuto = /\[AUTO\]/.test(rest);
|
|
456
|
+
const hasHuman = /\[HUMAN\]/.test(rest);
|
|
457
|
+
if (!hasAuto && !hasHuman) {
|
|
458
|
+
add(specPath, i + 1, 'WARN', 'spec-req-untagged',
|
|
459
|
+
`requirement ${id} is not tagged [AUTO] or [HUMAN] — every requirement needs a verification posture`);
|
|
460
|
+
}
|
|
461
|
+
if (hasAuto) autoReqs.add(id);
|
|
462
|
+
});
|
|
463
|
+
const covered = new Set();
|
|
464
|
+
let inAccept = false;
|
|
465
|
+
spec.forEach((line) => {
|
|
466
|
+
const h = line.match(/^##\s+(.+?)\s*$/);
|
|
467
|
+
if (h) inAccept = normalize(h[1]) === 'acceptance tests';
|
|
468
|
+
if (!inAccept) return;
|
|
469
|
+
for (const m of line.matchAll(/\bR\d+\b/g)) covered.add(m[0]);
|
|
470
|
+
});
|
|
471
|
+
for (const id of autoReqs) {
|
|
472
|
+
if (!covered.has(id)) {
|
|
473
|
+
add(specPath, null, 'WARN', 'spec-auto-coverage',
|
|
474
|
+
`[AUTO] requirement ${id} has no entry in ## Acceptance Tests — it cannot become a verifier check`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// story-dep-tag (WARN): every BUILD_PLAN.md story carries a [dep: …] marker
|
|
480
|
+
// (the dependency graph IS the build schedule — [dep: none] = parallel-safe).
|
|
481
|
+
// story-traceability (WARN): every story traces back to a SPEC.md requirement
|
|
482
|
+
// via "satisfies: R#".
|
|
483
|
+
const planPath = join(docsDir, 'BUILD_PLAN.md');
|
|
484
|
+
const plan = lintRead(planPath);
|
|
485
|
+
if (plan) {
|
|
486
|
+
const mask = maskedLines(plan);
|
|
487
|
+
const heads = [];
|
|
488
|
+
plan.forEach((line, i) => {
|
|
489
|
+
if (!mask[i] && /^###\s+S-\d+\b/.test(line)) heads.push(i);
|
|
490
|
+
});
|
|
491
|
+
heads.forEach((start, idx) => {
|
|
492
|
+
const end = idx + 1 < heads.length ? heads[idx + 1] : plan.length;
|
|
493
|
+
const title = plan[start].replace(/^###\s+/, '').trim();
|
|
494
|
+
const body = plan.slice(start + 1, end).join('\n');
|
|
495
|
+
if (!/\[dep:\s*[^\]]+\]/.test(body)) {
|
|
496
|
+
add(planPath, start + 1, 'WARN', 'story-dep-tag',
|
|
497
|
+
`story "${title}" has no [dep: …] marker — mark [dep: none] (parallel-safe) or [dep: S-xx] (serial)`);
|
|
498
|
+
}
|
|
499
|
+
if (!/satisfies\**\s*:\s*\**\s*R\d+/i.test(body)) {
|
|
500
|
+
add(planPath, start + 1, 'WARN', 'story-traceability',
|
|
501
|
+
`story "${title}" has no "satisfies: R#" — every story must trace to a SPEC.md requirement`);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── report ──
|
|
507
|
+
const errors = findings.filter((f) => f.level === 'ERROR').length;
|
|
508
|
+
const warnings = findings.length - errors;
|
|
509
|
+
console.log(`\nDiscipline lint -> ${docsDir}\n`);
|
|
510
|
+
const byFile = new Map();
|
|
511
|
+
for (const f of findings) {
|
|
512
|
+
if (!byFile.has(f.file)) byFile.set(f.file, []);
|
|
513
|
+
byFile.get(f.file).push(f);
|
|
514
|
+
}
|
|
515
|
+
for (const [file, list] of byFile) {
|
|
516
|
+
console.log(file);
|
|
517
|
+
for (const f of list) {
|
|
518
|
+
console.log(` ${f.line ? `line ${f.line}` : '(file)'} [${f.level}] ${f.rule}: ${f.msg}`);
|
|
519
|
+
}
|
|
520
|
+
console.log();
|
|
521
|
+
}
|
|
522
|
+
console.log(`${errors} errors, ${warnings} warnings`);
|
|
523
|
+
if (errors > 0 || (flags.strict && warnings > 0)) process.exitCode = 1;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function cmdList() {
|
|
527
|
+
console.log('\nCore 11 templates (always installed by `discipline-md init`):');
|
|
528
|
+
for (const f of CORE) console.log(` ${f}`);
|
|
529
|
+
console.log('\nOptional templates (opt-in via `discipline-md add <name>`):');
|
|
530
|
+
for (const f of OPTIONAL) console.log(` ${f}`);
|
|
531
|
+
console.log('\nCore 6 agent role contracts (always installed by `discipline-md init`):');
|
|
532
|
+
for (const f of CORE_ROLES) console.log(` ${f.replace('.md', '')}`);
|
|
533
|
+
console.log('\nOptional agent roles (opt-in via `discipline-md add-role <NAME>`):');
|
|
534
|
+
for (const f of OPTIONAL_ROLES) console.log(` ${f.replace('.md', '')}`);
|
|
535
|
+
console.log('\nRepo-root files (installed by `discipline-md init`):');
|
|
536
|
+
for (const f of REPO_ROOT_FILES) console.log(` ${f}`);
|
|
537
|
+
console.log('\nHygiene: `discipline-md lint [--target <path>] [--strict]` checks docs/ against');
|
|
538
|
+
console.log('the cleanup gate, tag legend, and two-gate autonomy rule.');
|
|
539
|
+
console.log();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function cmdHelp() {
|
|
543
|
+
console.log(`
|
|
544
|
+
Discipline — opinionated agentic-workflow framework
|
|
545
|
+
|
|
546
|
+
Usage:
|
|
547
|
+
npx discipline-md init [--target <path>] Scaffold Core 11 + Core 6 roles into <path>
|
|
548
|
+
npx discipline-md add <template> Add an optional template to ./docs/
|
|
549
|
+
npx discipline-md add-role <ROLE> Add an optional agent role to ./docs/agents/
|
|
550
|
+
npx discipline-md lint [--target <path>] Lint <path>/docs/ for gate violations
|
|
551
|
+
[--strict] (--strict: warnings also fail the run)
|
|
552
|
+
npx discipline-md list Show all templates and roles
|
|
553
|
+
npx discipline-md help This message
|
|
554
|
+
|
|
555
|
+
Lint checks the mechanical half of the framework's gates: [x] residue in
|
|
556
|
+
TODO.md (cleanup gate), unknown tag values, queue entries orphaned from
|
|
557
|
+
TODO/ROADMAP, [autonomy: safe] items missing from AUTONOMOUS_QUEUE.md
|
|
558
|
+
(two-gate rule), oversized hot-path docs, unfilled placeholders, stale
|
|
559
|
+
handoffs. It also checks the Spec & Design phase: SPEC.md requirements are
|
|
560
|
+
[AUTO]/[HUMAN]-tagged and acceptance-covered, and BUILD_PLAN.md stories carry
|
|
561
|
+
[dep: …] markers and trace to a requirement. Exit 1 on any error (or any
|
|
562
|
+
warning with --strict).
|
|
563
|
+
|
|
564
|
+
Lean defaults: Core 11 templates + Core 6 roles ship by default — including the
|
|
565
|
+
Spec & Design phase (SPEC_WORKFLOW + SPEC + BUILD_PLAN + the SPEC_ARCHITECT
|
|
566
|
+
role), which is core because a good spec is the load-bearing input to every
|
|
567
|
+
build. The remaining 16 templates and 6 retired roles are opt-in for projects
|
|
568
|
+
that need them — see docs/DECISIONS.md for the rationale.
|
|
569
|
+
|
|
570
|
+
Docs: https://github.com/realmwalk/discipline
|
|
571
|
+
`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function main() {
|
|
575
|
+
const { command, flags, positional } = args();
|
|
576
|
+
switch (command) {
|
|
577
|
+
case 'init': return cmdInit(flags);
|
|
578
|
+
case 'add': return cmdAdd(positional);
|
|
579
|
+
case 'add-role': return cmdAddRole(positional);
|
|
580
|
+
case 'lint': return cmdLint(flags);
|
|
581
|
+
case 'list': return cmdList();
|
|
582
|
+
case 'help':
|
|
583
|
+
default: return cmdHelp();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "discipline-md",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Opinionated agentic-workflow framework for teams running AI coding agents that have hit the maintenance wall.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agents",
|
|
7
|
+
"ai-coding",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"cursor",
|
|
10
|
+
"documentation",
|
|
11
|
+
"framework",
|
|
12
|
+
"playbook",
|
|
13
|
+
"scaffold"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/realmwalk/discipline.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/realmwalk/discipline#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/realmwalk/discipline/issues"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "John Hardy",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"files": [
|
|
27
|
+
"bin/",
|
|
28
|
+
"templates/",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "echo \"No tests yet\" && exit 0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18.0.0"
|
|
37
|
+
},
|
|
38
|
+
"private": false,
|
|
39
|
+
"bin": "bin/discipline.js"
|
|
40
|
+
}
|