@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "4.13.0",
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 decisions for the project |
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
- // Matches both bold-style (**Requirements:**) and heading-style (### Requirements)
132
- // followed by a list block.
133
- const listMatch = section.match(/(?:\*\*Requirements(?::\*\*|\*\*:)|#{1,4}\s*Requirements\s*:?)[^\n]*\n((?:\s*(?:\d+\.|[-*])\s+[^\n]+\n?)+)/i);
134
- if (listMatch) {
135
- return listMatch[1].split('\n')
136
- .map((l) => l.replace(/^\s*(?:\d+\.|[-*])\s+/, '').trim())
137
- .filter(Boolean);
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
- // Also capture REQ-IDs from inline lines like:
141
- // **REQs:** REQ-004, REQ-010, REQ-020
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
- if (!/REQ-/i.test(line)) continue;
150
- const ids = line.match(reqIdRe) || [];
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
- // Matches both bold-style (**Success Criteria:**) and heading-style (### Success Criteria)
160
- const match = section.match(/(?:\*\*Success Criteria\*\*[^\n]*:|#{1,4}\s*Success Criteria\s*:?)\s*\n((?:\s*(?:\d+\.|[-*])\s+[^\n]+\n?)+)/i);
161
- if (!match) return [];
162
- return match[1].split('\n')
163
- .map((l) => l.replace(/^\s*(?:\d+\.|[-*])\s+/, '').trim())
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) {