@hanzlaa/rcode 4.13.0 → 4.15.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/cli/install.js +61 -5
- package/cli/uninstall.js +13 -0
- package/dist/rcode.js +162 -162
- package/package.json +1 -1
- package/rcode/agents/rcode-project-researcher.md +19 -1
- package/rcode/agents/rules/sprint-checker/dimensions.md +25 -0
- package/rcode/bin/lib/customize.cjs +115 -0
- package/rcode/bin/lib/memlog.cjs +124 -0
- package/rcode/bin/lib/roadmap.cjs +62 -22
- package/rcode/bin/rcode-tools.cjs +197 -3
- package/rcode/references/agent-shared-rules.md +68 -0
- package/rcode/references/planner-playbook.md +11 -0
- package/rcode/references/questioning.md +100 -2
- package/rcode/references/roadmapper-playbook.md +22 -0
- package/rcode/references/universal-anti-patterns.md +26 -0
- package/rcode/references/verifier-playbook.md +12 -0
- package/rcode/workflows/execute.md +11 -0
- package/rcode/workflows/new-project-define-requirements.md +36 -0
- package/rcode/workflows/new-project-research-decision.md +63 -1
- package/rcode/workflows/new-project.md +100 -6
- package/rcode/workflows/plan.md +79 -8
- package/rcode/workflows/resume-work.md +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzlaa/rcode",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.15.0",
|
|
4
4
|
"description": "rcode — the AI team that never forgets. Persistent memory, specialist agents, and slash commands for AI IDEs. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
|
|
5
5
|
"main": "cli/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@ Your files feed the roadmap:
|
|
|
21
21
|
| File | How Roadmap Uses It |
|
|
22
22
|
|------|---------------------|
|
|
23
23
|
| `SUMMARY.md` | Phase structure recommendations, ordering rationale |
|
|
24
|
-
| `STACK.md` | Technology
|
|
24
|
+
| `STACK.md` | Technology **suggestions** for the project, with the premise behind each |
|
|
25
25
|
| `FEATURES.md` | What to build in each phase |
|
|
26
26
|
| `ARCHITECTURE.md` | System structure, component boundaries |
|
|
27
27
|
| `PITFALLS.md` | What phases need deeper research flags |
|
|
@@ -125,3 +125,21 @@ Log each pass as:
|
|
|
125
125
|
## Examples
|
|
126
126
|
|
|
127
127
|
See `.rcode/agents-rules/project-researcher/detailed-guide.md` for full worked examples (happy path, edge case, negative).
|
|
128
|
+
|
|
129
|
+
## STACK.md is a suggestion, never a decision
|
|
130
|
+
|
|
131
|
+
You do not choose the stack. You surface options and the trade-off, and the user
|
|
132
|
+
decides at the stack gate (`new-project-research-decision.md` step 6b).
|
|
133
|
+
|
|
134
|
+
Write STACK.md accordingly:
|
|
135
|
+
|
|
136
|
+
- **Every recommendation carries its premise** — the one condition that makes it
|
|
137
|
+
right. Not "WordPress is popular for content sites" but "WordPress IF a
|
|
138
|
+
non-technical person will update content with no dev retainer."
|
|
139
|
+
- **Name the closest alternative and when it wins.** A recommendation with no
|
|
140
|
+
alternative is a decision wearing a recommendation's clothes.
|
|
141
|
+
- **Never write "Locked", "Final", or "Decided"** in STACK.md. You have no
|
|
142
|
+
authority to lock anything; only a user answer at the gate does that.
|
|
143
|
+
|
|
144
|
+
A premise you cannot state is a recommendation you have not justified. Say you
|
|
145
|
+
don't have enough to recommend one rather than picking the familiar option.
|
|
@@ -194,6 +194,31 @@ issue:
|
|
|
194
194
|
- Single task with 10+ files
|
|
195
195
|
- Complex work (auth, payments) crammed into one plan
|
|
196
196
|
|
|
197
|
+
### Check 5b — Vertical slice, not a layer
|
|
198
|
+
|
|
199
|
+
Scope Sanity above measures plan SIZE. This measures plan SHAPE, which size
|
|
200
|
+
cannot see: a perfectly sized plan that builds one horizontal layer is still
|
|
201
|
+
wrong.
|
|
202
|
+
|
|
203
|
+
For the phase as a whole, answer: **what can someone do after this phase that
|
|
204
|
+
they could not do before?** If the honest answer is "nothing yet, later phases
|
|
205
|
+
need it" → **WARNING**, and name it as layer-first phasing.
|
|
206
|
+
|
|
207
|
+
Specific red flags:
|
|
208
|
+
|
|
209
|
+
- Schema or migration work that creates tables no task in this phase reads or
|
|
210
|
+
writes. Tables belong to the first phase that uses them, not to a phase whose
|
|
211
|
+
purpose is tables.
|
|
212
|
+
- A plan that builds a service, repository, or endpoint with no caller inside
|
|
213
|
+
the same phase and no task wiring it to one.
|
|
214
|
+
- A phase goal naming a layer or artifact type ("the API", "the data model")
|
|
215
|
+
rather than a capability.
|
|
216
|
+
|
|
217
|
+
Warning, not blocker: a genuine foundation phase (auth, migration framework) is
|
|
218
|
+
sometimes correct. But it must name what it unblocks, and be no bigger than that
|
|
219
|
+
— an unbounded foundation is the failure this check exists for. See
|
|
220
|
+
`universal-anti-patterns.md` → Layer-first phasing.
|
|
221
|
+
|
|
197
222
|
**Example issue:**
|
|
198
223
|
```yaml
|
|
199
224
|
issue:
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customize — per-workflow and per-skill overrides that survive an update.
|
|
3
|
+
*
|
|
4
|
+
* The problem: customising rcode meant editing files under `.rcode/workflows/`,
|
|
5
|
+
* `.rcode/references/`, or `~/.claude/skills/rcode-*`. Every one of those is
|
|
6
|
+
* regenerated by the installer, so the edit worked until the next
|
|
7
|
+
* `rcode install` and then silently vanished. Users learned not to customise,
|
|
8
|
+
* or learned not to update.
|
|
9
|
+
*
|
|
10
|
+
* The fix: overrides live in `.rcode/custom/`, which the installer never writes,
|
|
11
|
+
* and are composed onto the shipped file at read time.
|
|
12
|
+
*
|
|
13
|
+
* .rcode/custom/<name>.md — team override, committed
|
|
14
|
+
* .rcode/custom/<name>.user.md — personal override, gitignored
|
|
15
|
+
*
|
|
16
|
+
* Both are APPENDED after the shipped content, in that order. Append rather than
|
|
17
|
+
* replace is deliberate: a replacing override silently drops whatever the next
|
|
18
|
+
* rcode version adds to that workflow, which is the same trap as editing the
|
|
19
|
+
* installed file, just slower to notice. If a shipped rule genuinely has to go,
|
|
20
|
+
* an override that says "ignore the X rule above, here is why" is auditable in a
|
|
21
|
+
* way a deletion never is.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
function customDir(rcodeDir) {
|
|
28
|
+
return path.join(rcodeDir, 'custom');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Layers for one name, base first. Missing files are simply absent. */
|
|
32
|
+
function layers(rcodeDir, name) {
|
|
33
|
+
const dir = customDir(rcodeDir);
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const [kind, file] of [['team', `${name}.md`], ['user', `${name}.user.md`]]) {
|
|
36
|
+
const p = path.join(dir, file);
|
|
37
|
+
if (fs.existsSync(p)) {
|
|
38
|
+
const body = fs.readFileSync(p, 'utf8').trim();
|
|
39
|
+
if (body) out.push({ kind, path: path.join('.rcode', 'custom', file), body });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolve(rcodeDir, name) {
|
|
46
|
+
if (!name) throw new Error('customize resolve requires a name (workflow or skill id)');
|
|
47
|
+
const found = layers(rcodeDir, name);
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
name,
|
|
51
|
+
has_overrides: found.length > 0,
|
|
52
|
+
layers: found.map((l) => ({ kind: l.kind, path: l.path })),
|
|
53
|
+
// Rendered block a workflow can paste after its own content. Empty string
|
|
54
|
+
// when nothing is overridden, so callers never special-case.
|
|
55
|
+
block: found.length === 0 ? '' : [
|
|
56
|
+
'',
|
|
57
|
+
`<!-- rcode custom overrides for "${name}" — appended, not replacing -->`,
|
|
58
|
+
...found.map((l) => `\n### Override (${l.kind}) — ${l.path}\n\n${l.body}`),
|
|
59
|
+
].join('\n'),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function list(rcodeDir) {
|
|
64
|
+
const dir = customDir(rcodeDir);
|
|
65
|
+
if (!fs.existsSync(dir)) return { ok: true, exists: false, overrides: [] };
|
|
66
|
+
const names = new Map();
|
|
67
|
+
for (const f of fs.readdirSync(dir)) {
|
|
68
|
+
if (!f.endsWith('.md')) continue;
|
|
69
|
+
const isUser = f.endsWith('.user.md');
|
|
70
|
+
const name = isUser ? f.slice(0, -'.user.md'.length) : f.slice(0, -'.md'.length);
|
|
71
|
+
if (!names.has(name)) names.set(name, []);
|
|
72
|
+
names.get(name).push(isUser ? 'user' : 'team');
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
exists: true,
|
|
77
|
+
overrides: [...names.entries()].map(([name, kinds]) => ({ name, kinds: kinds.sort() })),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function init(rcodeDir, name) {
|
|
82
|
+
const dir = customDir(rcodeDir);
|
|
83
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
84
|
+
const p = path.join(dir, `${name}.md`);
|
|
85
|
+
if (fs.existsSync(p)) return { ok: true, created: false, path: p };
|
|
86
|
+
fs.writeFileSync(p, `# Override: ${name}
|
|
87
|
+
|
|
88
|
+
Appended after rcode's shipped content for \`${name}\`, every run.
|
|
89
|
+
The installer never touches this file, so it survives \`rcode install\`.
|
|
90
|
+
|
|
91
|
+
Write additional rules, project conventions, or explicit carve-outs here. To
|
|
92
|
+
neutralise a shipped rule, say so and say why — that is auditable in a way
|
|
93
|
+
deleting it from the installed file is not:
|
|
94
|
+
|
|
95
|
+
> Ignore the "no files over 1000 lines" rule for \`src/generated/\` — those are
|
|
96
|
+
> machine-generated and never hand-edited.
|
|
97
|
+
`, 'utf8');
|
|
98
|
+
return { ok: true, created: true, path: p };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function dispatch(rcodeDir, args) {
|
|
102
|
+
const sub = args[0];
|
|
103
|
+
switch (sub) {
|
|
104
|
+
case 'resolve': return resolve(rcodeDir, args[1]);
|
|
105
|
+
case 'list': return list(rcodeDir);
|
|
106
|
+
case 'init': {
|
|
107
|
+
if (!args[1]) throw new Error('customize init requires a name');
|
|
108
|
+
return init(rcodeDir, args[1]);
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
throw new Error(`Unknown customize subcommand: ${sub}. Use: resolve <name> | list | init <name>`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { dispatch, resolve, list, init };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memlog — the run's append-only memory.
|
|
3
|
+
*
|
|
4
|
+
* The problem it solves: rcode recorded decisions with `state add-decision`,
|
|
5
|
+
* manually, usually at the end of a session when someone remembered. Everything
|
|
6
|
+
* decided in between — an override, an assumption, a change of direction —
|
|
7
|
+
* existed only in the conversation, and was gone on the next `/clear` or resume.
|
|
8
|
+
* That is how a project ends up with artifacts nobody can explain and a state
|
|
9
|
+
* file that disagrees with what actually happened.
|
|
10
|
+
*
|
|
11
|
+
* The contract: one line per event, appended AS THE WORK HAPPENS, never
|
|
12
|
+
* reconstructed afterwards. Whatever is not logged is lost on resume.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately append-only and never rewritten. A log you can edit is a log you
|
|
15
|
+
* can quietly correct, which defeats the point — a wrong entry is followed by a
|
|
16
|
+
* correcting entry, so the disagreement itself stays visible.
|
|
17
|
+
*
|
|
18
|
+
* This does NOT replace state.decisions[] / ~/.rcode/decisions.jsonl. Those are
|
|
19
|
+
* the curated, queryable record of decisions that stuck. The memlog is the raw
|
|
20
|
+
* trail, including the ones that were reversed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const TYPES = Object.freeze(['decision', 'change', 'override', 'assumption', 'event', 'blocker']);
|
|
27
|
+
const HEADER = `# Memlog
|
|
28
|
+
|
|
29
|
+
Append-only. One line per event, written as the work happens.
|
|
30
|
+
Never edit or delete a line — append a correcting entry instead, so the
|
|
31
|
+
disagreement stays visible.
|
|
32
|
+
|
|
33
|
+
| When | Type | Entry |
|
|
34
|
+
|------|------|-------|
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function memlogPath(planningDir) {
|
|
38
|
+
return path.join(planningDir, 'MEMLOG.md');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ensureMemlog(planningDir) {
|
|
42
|
+
const p = memlogPath(planningDir);
|
|
43
|
+
if (!fs.existsSync(p)) {
|
|
44
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
45
|
+
fs.writeFileSync(p, HEADER, 'utf8');
|
|
46
|
+
return { created: true, path: p };
|
|
47
|
+
}
|
|
48
|
+
return { created: false, path: p };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Escape pipes so a free-text entry cannot break the markdown table. */
|
|
52
|
+
function cell(text) {
|
|
53
|
+
return String(text).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function append(planningDir, { type, text, phase }) {
|
|
57
|
+
if (!type) throw new Error(`memlog append requires --type (${TYPES.join('|')})`);
|
|
58
|
+
if (!TYPES.includes(type)) {
|
|
59
|
+
throw new Error(`unknown memlog type "${type}" — expected one of: ${TYPES.join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
if (!text || !String(text).trim()) throw new Error('memlog append requires --text');
|
|
62
|
+
|
|
63
|
+
ensureMemlog(planningDir);
|
|
64
|
+
const p = memlogPath(planningDir);
|
|
65
|
+
const when = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
|
66
|
+
const label = phase ? `${type} · phase ${phase}` : type;
|
|
67
|
+
const line = `| ${when} | ${cell(label)} | ${cell(text)} |\n`;
|
|
68
|
+
|
|
69
|
+
// appendFileSync is atomic enough for single-line appends on every platform
|
|
70
|
+
// rcode targets, and an append cannot corrupt earlier lines the way a
|
|
71
|
+
// read-modify-write can when two agents log at once.
|
|
72
|
+
fs.appendFileSync(p, line, 'utf8');
|
|
73
|
+
return { ok: true, appended: true, type, path: path.relative(path.dirname(planningDir), p) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function read(planningDir, { type, limit } = {}) {
|
|
77
|
+
const p = memlogPath(planningDir);
|
|
78
|
+
if (!fs.existsSync(p)) return { ok: true, exists: false, entries: [] };
|
|
79
|
+
const rows = fs.readFileSync(p, 'utf8').split('\n')
|
|
80
|
+
.filter((l) => l.startsWith('| 2') || /^\| \d{4}-/.test(l))
|
|
81
|
+
.map((l) => {
|
|
82
|
+
// Split on unescaped pipes only. cell() writes `\|` for a literal pipe in
|
|
83
|
+
// the text; a naive split('|') tore one entry into three columns and lost
|
|
84
|
+
// the tail. Caught by a test, not by reading the code.
|
|
85
|
+
const parts = l.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
86
|
+
return { when: parts[1] || '', type: parts[2] || '', text: parts[3] || '' };
|
|
87
|
+
});
|
|
88
|
+
let entries = type ? rows.filter((r) => r.type.split(' ')[0] === type) : rows;
|
|
89
|
+
if (limit) entries = entries.slice(-Number(limit));
|
|
90
|
+
return { ok: true, exists: true, total: rows.length, entries };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Unresolved assumptions and overrides — the entries that must not be forgotten
|
|
95
|
+
* at a milestone boundary. An assumption still sitting here after a whole
|
|
96
|
+
* milestone is a finding, not a formality.
|
|
97
|
+
*/
|
|
98
|
+
function open(planningDir) {
|
|
99
|
+
const all = read(planningDir);
|
|
100
|
+
if (!all.exists) return { ok: true, exists: false, open: [] };
|
|
101
|
+
const flagged = all.entries.filter((e) => {
|
|
102
|
+
const t = e.type.split(' ')[0];
|
|
103
|
+
return t === 'assumption' || t === 'override' || t === 'blocker';
|
|
104
|
+
});
|
|
105
|
+
return { ok: true, exists: true, open: flagged, count: flagged.length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function dispatch(planningDir, args) {
|
|
109
|
+
const sub = args[0];
|
|
110
|
+
const flag = (name) => {
|
|
111
|
+
const i = args.indexOf(`--${name}`);
|
|
112
|
+
return i !== -1 ? args[i + 1] : undefined;
|
|
113
|
+
};
|
|
114
|
+
switch (sub) {
|
|
115
|
+
case 'init': return { ok: true, ...ensureMemlog(planningDir) };
|
|
116
|
+
case 'append': return append(planningDir, { type: flag('type'), text: flag('text'), phase: flag('phase') });
|
|
117
|
+
case 'read': return read(planningDir, { type: flag('type'), limit: flag('limit') });
|
|
118
|
+
case 'open': return open(planningDir);
|
|
119
|
+
default:
|
|
120
|
+
throw new Error(`Unknown memlog subcommand: ${sub}. Use: init | append | read | open`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { dispatch, append, read, open, ensureMemlog, TYPES };
|
|
@@ -127,28 +127,69 @@ function extractPhases(content) {
|
|
|
127
127
|
return phases;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// Requirement IDs come in two shapes and BOTH are real:
|
|
131
|
+
// REQ-AUTH, REQ-FOO-BAR — the documented convention
|
|
132
|
+
// FOUND-01, RENT-04, OBJ-06, AUTHZ-04 — what projects actually write
|
|
133
|
+
// This must stay in step with extractReqIds() in rcode-tools.cjs. Two copies of
|
|
134
|
+
// this pattern already drifted once: one was widened for domain prefixes and
|
|
135
|
+
// this one was not, so `roadmap get-phase` kept returning requirements: [] on
|
|
136
|
+
// every domain-prefixed project.
|
|
137
|
+
const REQ_ID_RE = /\bREQ-[A-Z0-9][A-Z0-9-]*\b|\b[A-Z][A-Z0-9]{1,15}-\d+[a-z]?\b/g;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build a matcher for a labelled block, tolerant of how the label is actually
|
|
141
|
+
* written. Roadmapper emits `**Success criteria:**` (colon inside the bold,
|
|
142
|
+
* lowercase c) while the old parser demanded `**Success Criteria**:` (colon
|
|
143
|
+
* outside). Two rcode components disagreeing about rcode's own format is what
|
|
144
|
+
* made get-phase unable to read its own roadmapper's output.
|
|
145
|
+
*
|
|
146
|
+
* Accepts: **Label:** | **Label**: | ## Label | Label:
|
|
147
|
+
* Returns { inline, list } — inline is same-line content, list is the block
|
|
148
|
+
* of bullet/numbered lines that follows. Callers use whichever is present.
|
|
149
|
+
*/
|
|
150
|
+
function matchLabelledBlock(section, label) {
|
|
151
|
+
const l = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
152
|
+
// [ \t]* everywhere the match must NOT cross a line. Using \s* here let the
|
|
153
|
+
// label matcher swallow the newline, so the first bullet of the following
|
|
154
|
+
// list was captured as "inline" and its description text was thrown away —
|
|
155
|
+
// `- CITY-02 per-city page exists` came back as just `CITY-02`.
|
|
156
|
+
const re = new RegExp(
|
|
157
|
+
'(?:\\*\\*[ \\t]*' + l + '[ \\t]*:?[ \\t]*\\*\\*[ \\t]*:?|#{1,4}[ \\t]*' + l + '[ \\t]*:?|^[ \\t]*' + l + '[ \\t]*:)' +
|
|
158
|
+
'([^\\n]*)\\n((?:[ \\t]*(?:\\d+\\.|[-*])[ \\t]+[^\\n]+\\n?)*)',
|
|
159
|
+
'im'
|
|
160
|
+
);
|
|
161
|
+
const m = section.match(re);
|
|
162
|
+
if (!m) return null;
|
|
163
|
+
return { inline: (m[1] || '').trim(), list: m[2] || '' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function splitListBlock(block) {
|
|
167
|
+
return String(block).split('\n')
|
|
168
|
+
.map((l) => l.replace(/^[ \t]*(?:\d+\.|[-*])[ \t]+/, '').trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
|
|
130
172
|
function parseRequirements(section) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
173
|
+
const block = matchLabelledBlock(section, 'Requirements');
|
|
174
|
+
if (block) {
|
|
175
|
+
// A following list block wins; otherwise take the same-line value, which is
|
|
176
|
+
// how roadmapper writes it: `**Requirements:** FOUND-01, FOUND-02, RENT-04`.
|
|
177
|
+
const fromList = splitListBlock(block.list);
|
|
178
|
+
if (fromList.length > 0) return fromList;
|
|
179
|
+
if (block.inline) {
|
|
180
|
+
const ids = block.inline.match(REQ_ID_RE);
|
|
181
|
+
if (ids && ids.length > 0) return [...new Set(ids)];
|
|
182
|
+
return block.inline.split(/\s*,\s*/).map(x => x.trim()).filter(Boolean);
|
|
183
|
+
}
|
|
138
184
|
}
|
|
139
185
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// Requirements: REQ-001, REQ-002
|
|
143
|
-
// **Covers:** REQ-001, REQ-003
|
|
144
|
-
// Collect every line in the section that contains REQ-\d+ patterns.
|
|
186
|
+
// Last resort: sweep the whole section for requirement IDs on any line
|
|
187
|
+
// (covers `**REQs:**`, `**Covers:**`, and prose mentions).
|
|
145
188
|
const seen = new Set();
|
|
146
189
|
const out = [];
|
|
147
|
-
const reqIdRe = /\bREQ-[A-Z0-9][A-Z0-9-]*\b/g;
|
|
148
190
|
for (const line of section.split('\n')) {
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
for (const id of ids) {
|
|
191
|
+
const matches = line.match(REQ_ID_RE) || [];
|
|
192
|
+
for (const id of matches) {
|
|
152
193
|
if (!seen.has(id)) { seen.add(id); out.push(id); }
|
|
153
194
|
}
|
|
154
195
|
}
|
|
@@ -156,12 +197,11 @@ function parseRequirements(section) {
|
|
|
156
197
|
}
|
|
157
198
|
|
|
158
199
|
function parseSuccessCriteria(section) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
.filter(Boolean);
|
|
200
|
+
const block = matchLabelledBlock(section, 'Success criteria');
|
|
201
|
+
if (!block) return [];
|
|
202
|
+
const fromList = splitListBlock(block.list);
|
|
203
|
+
if (fromList.length > 0) return fromList;
|
|
204
|
+
return block.inline ? [block.inline] : [];
|
|
165
205
|
}
|
|
166
206
|
|
|
167
207
|
function parsePlans(section) {
|