@jenga-ai/agent 3.2.0 → 3.5.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 (68) hide show
  1. package/README.md +52 -12
  2. package/agents/developer.md +16 -1
  3. package/agents/scrum-master.md +1 -0
  4. package/bin/jenga.js +10 -0
  5. package/lib/commands/dashboard.js +92 -0
  6. package/lib/skill-allow-list.json +6 -2
  7. package/package.json +21 -2
  8. package/project/app/api/lib/resolve-project-root.js +120 -0
  9. package/project/app/api/package.json +16 -0
  10. package/project/app/api/parsers/architecture.js +72 -0
  11. package/project/app/api/parsers/board.js +141 -0
  12. package/project/app/api/parsers/documentation.js +125 -0
  13. package/project/app/api/parsers/git-log.js +52 -0
  14. package/project/app/api/parsers/ideas.js +62 -0
  15. package/project/app/api/parsers/knowledge-graph.js +73 -0
  16. package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
  17. package/project/app/api/parsers/rapports.js +148 -0
  18. package/project/app/api/parsers/todo.js +179 -0
  19. package/project/app/api/response.js +47 -0
  20. package/project/app/api/routes/architecture.js +23 -0
  21. package/project/app/api/routes/board.js +46 -0
  22. package/project/app/api/routes/documentation.js +24 -0
  23. package/project/app/api/routes/health.js +25 -0
  24. package/project/app/api/routes/history.js +55 -0
  25. package/project/app/api/routes/rapports.js +24 -0
  26. package/project/app/api/scripts/capture-snapshot.js +294 -0
  27. package/project/app/api/server.js +112 -0
  28. package/project/app/api/types.js +40 -0
  29. package/project/app/package.json +21 -0
  30. package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
  31. package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
  32. package/project/app/ui/dist/index.html +13 -0
  33. package/project/app/ui/package.json +23 -0
  34. package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
  35. package/project/app/ui/scripts/dashboard-open.cjs +88 -0
  36. package/project/app/ui/scripts/dashboard-start.cjs +87 -0
  37. package/scripts/acquire-concurrency-slot.sh +220 -0
  38. package/scripts/compute-deploy-reconcile.sh +439 -0
  39. package/scripts/jenga-permission-level-switch.sh +19 -3
  40. package/scripts/mark-deployed.sh +532 -0
  41. package/scripts/populate-knowledge-graph.js +429 -0
  42. package/scripts/release-concurrency-slot.sh +129 -0
  43. package/scripts/validate-board.sh +60 -2
  44. package/scripts/verify-consumer-install.sh +470 -0
  45. package/skills/j-cloud-connect/SKILL.md +95 -0
  46. package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
  47. package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
  48. package/skills/j-dashboard/SKILL.md +144 -0
  49. package/skills/j-dashboard/scripts/launch.sh +121 -0
  50. package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
  51. package/skills/j-dashboard/scripts/snapshot.sh +267 -0
  52. package/skills/j-dashboard-share/SKILL.md +96 -0
  53. package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
  54. package/skills/j-init/SKILL.md +52 -13
  55. package/skills/j-init/assets/.gitignore_template +1 -2
  56. package/skills/j-init/scripts/apply-scaffold-visibility.sh +192 -0
  57. package/skills/j-init/scripts/init.sh +19 -5
  58. package/skills/j-playbook/SKILL.md +12 -0
  59. package/skills/j-playbook-new/SKILL.md +155 -0
  60. package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
  61. package/skills/j-publish/scripts/npm_ci_pipeline.sh +6 -0
  62. package/skills/j-skillify/assets/init-new/assets/.gitignore_template +1 -2
  63. package/skills/j-uncharted/SKILL.md +54 -7
  64. package/skills/j-uncharted/assets/NODE_QUESTION_TEMPLATE.md +69 -0
  65. package/skills/j-uncharted/scripts/elicitation-state.sh +45 -7
  66. package/skills/jenga/scripts/load-nl-catalog.js +22 -6
  67. package/skills/jenga/scripts/load-playbooks.sh +146 -35
  68. package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @file project/app/api/parsers/board.js
3
+ * Parses markdown files from project/board/ into a nested epic→story→task tree.
4
+ *
5
+ * E06_S05_T04 — each item whose id is named by an active entry in the project's `project/todo.md`
6
+ * additionally carries `_queued: true`. This is purely *informational* provenance ("the user has
7
+ * queued this for execution"); the decision of what to do with it belongs to the consumer. The
8
+ * Active Sprint tab's column logic (`project/app/ui/src/components/board/kanbanColumns.js`) uses it
9
+ * to promote `Pending`/`Backlog` items into the In Progress column; the Backlog tab ignores it
10
+ * entirely. Items that aren't referenced get **no new field at all**, so a project with no
11
+ * `todo.md` produces a byte-identical payload to the pre-T04 parser.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const matter = require('gray-matter');
17
+ const { resolveProjectRoot } = require('../lib/resolve-project-root');
18
+ const { readTodoRefs } = require('./todo');
19
+
20
+ // Resolved relative to the invoking project's own root (E47_S02_T01/T02), not a fixed __dirname
21
+ // climb — the old `path.resolve(__dirname, '../../../board')` only ever landed correctly when this
22
+ // module ran from this monorepo's own checkout.
23
+ const BOARD_ROOT = path.join(resolveProjectRoot(), 'project', 'board');
24
+
25
+ /**
26
+ * Read all .md files from a directory (non-recursive).
27
+ * @param {string} dir
28
+ * @returns {{ file: string, data: Object, content: string }[]}
29
+ */
30
+ function readMarkdownDir(dir) {
31
+ if (!fs.existsSync(dir)) return [];
32
+ return fs
33
+ .readdirSync(dir)
34
+ .filter((f) => f.endsWith('.md'))
35
+ .map((f) => {
36
+ const filePath = path.join(dir, f);
37
+ try {
38
+ const raw = fs.readFileSync(filePath, 'utf8');
39
+ const parsed = matter(raw);
40
+ return { file: f, data: parsed.data, content: parsed.content.trim() };
41
+ } catch (err) {
42
+ console.warn(`[board] Skipping malformed file: ${filePath} — ${err.message}`);
43
+ return null;
44
+ }
45
+ })
46
+ .filter(Boolean);
47
+ }
48
+
49
+ /**
50
+ * Board ids referenced by active entries in the project's `project/todo.md`, as an upper-cased Set
51
+ * for case-insensitive lookup (E06_S05_T04).
52
+ *
53
+ * Failure-tolerant by design: `readTodoRefs()` already reports a missing/unreadable file as
54
+ * `exists: false` rather than throwing, and this extra guard covers anything unexpected beyond that
55
+ * (e.g. an unresolvable project root). A broken or absent `todo.md` degrades to "nothing is queued"
56
+ * — i.e. exactly the pre-T04 behavior — and never turns a working board read into a 500.
57
+ *
58
+ * @returns {Set<string>}
59
+ */
60
+ function loadQueuedIds() {
61
+ try {
62
+ const { refs } = readTodoRefs();
63
+ return new Set(refs.map((r) => String(r).toUpperCase()));
64
+ } catch (err) {
65
+ console.warn(`[board] Could not read todo.md refs — ${err.message}. No items will be marked queued.`);
66
+ return new Set();
67
+ }
68
+ }
69
+
70
+ /**
71
+ * `{ _queued: true }` if `id` is queued, otherwise an empty object — spread into an item so
72
+ * unreferenced items keep their exact pre-T04 shape (no `_queued: false` noise).
73
+ * @param {Set<string>} queuedIds
74
+ * @param {string|undefined} id
75
+ * @returns {{ _queued?: boolean }}
76
+ */
77
+ function queuedFlagFor(queuedIds, id) {
78
+ return typeof id === 'string' && queuedIds.has(id.toUpperCase()) ? { _queued: true } : {};
79
+ }
80
+
81
+ /**
82
+ * Parse the full board into an array of epic objects with nested stories and tasks.
83
+ * @returns {Promise<Object[]>}
84
+ */
85
+ async function parseBoard() {
86
+ const queuedIds = loadQueuedIds();
87
+
88
+ const epicsDir = path.join(BOARD_ROOT, 'epics');
89
+ const storiesDir = path.join(BOARD_ROOT, 'stories');
90
+ const tasksDir = path.join(BOARD_ROOT, 'tasks');
91
+
92
+ const epicFiles = readMarkdownDir(epicsDir);
93
+ const storyFiles = readMarkdownDir(storiesDir);
94
+ const taskFiles = readMarkdownDir(tasksDir);
95
+
96
+ // Build tasks map keyed by story_id
97
+ const tasksByStory = {};
98
+ for (const t of taskFiles) {
99
+ const sid = t.data.story_id;
100
+ if (!sid) continue;
101
+ if (!tasksByStory[sid]) tasksByStory[sid] = [];
102
+ tasksByStory[sid].push({
103
+ ...t.data,
104
+ ...queuedFlagFor(queuedIds, t.data.id),
105
+ _content: t.content,
106
+ _file: t.file,
107
+ });
108
+ }
109
+
110
+ // Build stories map keyed by epic_id
111
+ const storiesByEpic = {};
112
+ for (const s of storyFiles) {
113
+ const eid = s.data.epic_id;
114
+ if (!eid) continue;
115
+ if (!storiesByEpic[eid]) storiesByEpic[eid] = [];
116
+ const storyId = s.data.id;
117
+ storiesByEpic[eid].push({
118
+ ...s.data,
119
+ ...queuedFlagFor(queuedIds, storyId),
120
+ _content: s.content,
121
+ _file: s.file,
122
+ tasks: storyId ? (tasksByStory[storyId] || []) : [],
123
+ });
124
+ }
125
+
126
+ // Build epic objects
127
+ const epics = epicFiles.map((e) => {
128
+ const epicId = e.data.id;
129
+ return {
130
+ ...e.data,
131
+ ...queuedFlagFor(queuedIds, epicId),
132
+ _content: e.content,
133
+ _file: e.file,
134
+ stories: epicId ? (storiesByEpic[epicId] || []) : [],
135
+ };
136
+ });
137
+
138
+ return epics;
139
+ }
140
+
141
+ module.exports = { parseBoard };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file project/app/api/parsers/documentation.js
3
+ * Aggregates the project's curated reference documentation into one flat, categorized list of
4
+ * full-content entries — the "Documentation" tab's backend source (`E58_S01_T04`, consumed by
5
+ * `E58_S01_T05`'s `GET /v1/documentation` route).
6
+ *
7
+ * Four sources, none of which any existing parser touches today:
8
+ * - `project/PROJECT_SUMMARY.md` -> category `summary`
9
+ * - `README.md` (repo root) -> category `readme`
10
+ * - `docs/STRATEGY.md` -> category `strategy`
11
+ * - every `.md` file under `project/documentation/examples/` -> category `example`
12
+ *
13
+ * The first three are single named files, read directly and parsed with `gray-matter` for
14
+ * consistency with the directory-based path even though they rarely carry frontmatter. The fourth
15
+ * reuses `E58_S01_T01`'s shared `readMarkdownDirRecursive()` directory reader.
16
+ *
17
+ * Every returned entry shares the same field shape used by `E58_S01_T03`'s rapports aggregate:
18
+ * { file, data, content, category, date }
19
+ * `readMarkdownDirRecursive()`'s own return contract does not include `date` (see its JSDoc), so
20
+ * it is added here per entry — same pattern already used by `parsers/ideas.js` (E58_S01_T02) and
21
+ * `parsers/rapports.js`: prefer frontmatter's `date` field, stringified, else `null`.
22
+ *
23
+ * A missing single-file source (e.g. no `docs/STRATEGY.md` yet in some projects) is skipped, not
24
+ * thrown — the same non-throwing precedent used throughout `api/parsers/`.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const matter = require('gray-matter');
32
+ const { resolveProjectRoot } = require('../lib/resolve-project-root');
33
+ const { readMarkdownDirRecursive } = require('./lib/markdown-dir-reader');
34
+
35
+ /**
36
+ * Derive the `date` field for an entry from its parsed frontmatter, matching the
37
+ * `rapports.js`/`ideas.js` convention: prefer frontmatter's `date`, stringified; else `null`.
38
+ *
39
+ * Unquoted `YYYY-MM-DD` frontmatter values are parsed by gray-matter/js-yaml into a native `Date`
40
+ * object rather than a plain string (the same gotcha `E06_S05_T03` hit and fixed for
41
+ * `isStaleDeployedProd()` — see `project/rapports/problems/E06_S05_T03-stale-filter-fails-on-real-gray-matter-date-objects.md`).
42
+ * A bare `String(date)` on a `Date` produces a full JS date-with-timezone string, not the
43
+ * `YYYY-MM-DD` form callers expect, so `Date` inputs are explicitly reduced to their ISO calendar
44
+ * date (`toISOString().slice(0, 10)`) instead.
45
+ * @param {Object} data - gray-matter-parsed frontmatter object.
46
+ * @returns {string|null}
47
+ */
48
+ function deriveDate(data) {
49
+ if (!data || !data.date) return null;
50
+ if (data.date instanceof Date) return data.date.toISOString().slice(0, 10);
51
+ return String(data.date);
52
+ }
53
+
54
+ /**
55
+ * Read and parse a single markdown file into an aggregate entry, or `null` if the file does not
56
+ * exist or fails to read/parse (skipped with a `console.warn`, never thrown).
57
+ * @param {string} absPath - absolute path to the file.
58
+ * @param {string} fileLabel - value for the returned entry's `file` field (e.g. "README.md").
59
+ * @param {string} category - value for the returned entry's `category` field.
60
+ * @returns {{ file: string, data: Object, content: string, category: string, date: string|null }|null}
61
+ */
62
+ function readSingleMarkdownFile(absPath, fileLabel, category) {
63
+ if (!fs.existsSync(absPath)) return null;
64
+
65
+ try {
66
+ const raw = fs.readFileSync(absPath, 'utf8');
67
+ const parsed = matter(raw);
68
+ return {
69
+ file: fileLabel,
70
+ data: parsed.data,
71
+ content: parsed.content.trim(),
72
+ category,
73
+ date: deriveDate(parsed.data),
74
+ };
75
+ } catch (err) {
76
+ console.warn(`[documentation] Skipping malformed file: ${absPath} — ${err.message}`);
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Aggregate all documentation sources into one flat list of full-content, categorized entries.
83
+ * Returns [] entries for any source that does not exist rather than throwing.
84
+ * @returns {Promise<Object[]>}
85
+ */
86
+ async function readDocumentation() {
87
+ const root = resolveProjectRoot();
88
+ const results = [];
89
+
90
+ const singleFileSources = [
91
+ {
92
+ absPath: path.join(root, 'project', 'PROJECT_SUMMARY.md'),
93
+ fileLabel: 'PROJECT_SUMMARY.md',
94
+ category: 'summary',
95
+ },
96
+ {
97
+ absPath: path.join(root, 'README.md'),
98
+ fileLabel: 'README.md',
99
+ category: 'readme',
100
+ },
101
+ {
102
+ absPath: path.join(root, 'docs', 'STRATEGY.md'),
103
+ fileLabel: 'STRATEGY.md',
104
+ category: 'strategy',
105
+ },
106
+ ];
107
+
108
+ for (const source of singleFileSources) {
109
+ const entry = readSingleMarkdownFile(source.absPath, source.fileLabel, source.category);
110
+ if (entry) results.push(entry);
111
+ }
112
+
113
+ const examplesRoot = path.join(root, 'project', 'documentation', 'examples');
114
+ // Fixed category for every file under this root, regardless of subdirectory — passed as a
115
+ // function per readMarkdownDirRecursive()'s categorize contract (a bare string is not one of
116
+ // its two supported forms and would silently fall back to 'uncategorized').
117
+ const exampleEntries = readMarkdownDirRecursive(examplesRoot, () => 'example');
118
+ for (const entry of exampleEntries) {
119
+ results.push({ ...entry, date: deriveDate(entry.data) });
120
+ }
121
+
122
+ return results;
123
+ }
124
+
125
+ module.exports = { readDocumentation };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @file project/app/api/parsers/git-log.js
3
+ * Reads git log history as an array of commit objects.
4
+ */
5
+
6
+ const { execFile } = require('child_process');
7
+ const { resolveProjectRoot } = require('../lib/resolve-project-root');
8
+
9
+ // Resolved relative to the invoking project's own root (E47_S02_T01/T02), not a fixed __dirname
10
+ // climb — see project/app/api/lib/resolve-project-root.js.
11
+ const REPO_ROOT = resolveProjectRoot();
12
+ const SEP = '|||';
13
+ const FORMAT = `%H${SEP}%an${SEP}%aI${SEP}%s${SEP}%b`;
14
+
15
+ /**
16
+ * Run git log and return parsed commit entries.
17
+ * Returns [] if not a git repo or git is unavailable.
18
+ * @returns {Promise<Object[]>}
19
+ */
20
+ function readGitLog() {
21
+ return new Promise((resolve) => {
22
+ execFile(
23
+ 'git',
24
+ ['log', `--format=${FORMAT}`, '--max-count=200'],
25
+ { cwd: REPO_ROOT, timeout: 10000 },
26
+ (err, stdout) => {
27
+ if (err) {
28
+ // Not a git repo or git unavailable — return empty
29
+ return resolve([]);
30
+ }
31
+ const commits = stdout
32
+ .split('\n')
33
+ .filter((line) => line.includes(SEP))
34
+ .map((line) => {
35
+ const [sha, author, date, subject, ...bodyParts] = line.split(SEP);
36
+ return {
37
+ type: 'git_commit',
38
+ sha: sha.trim(),
39
+ author: author.trim(),
40
+ date: date.trim(),
41
+ subject: subject.trim(),
42
+ body: bodyParts.join(SEP).trim(),
43
+ };
44
+ })
45
+ .filter((c) => c.sha);
46
+ resolve(commits);
47
+ }
48
+ );
49
+ });
50
+ }
51
+
52
+ module.exports = { readGitLog };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @file project/app/api/parsers/ideas.js
3
+ * Parses project/ideas.md — a single flat file (one idea per line, written by the /idea skill)
4
+ * with no YAML frontmatter — into entries shaped consistently with the directory-reader entries
5
+ * from E58_S01_T01, so both can be merged into the same aggregate list.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { resolveProjectRoot } = require('../lib/resolve-project-root');
11
+
12
+ // Resolved relative to the invoking project's own root (E47_S02_T01/T02), not a fixed __dirname
13
+ // climb — see project/app/api/lib/resolve-project-root.js.
14
+ const IDEAS_FILE = path.join(resolveProjectRoot(), 'project', 'ideas.md');
15
+
16
+ /**
17
+ * Determine whether a (trimmed) line should be skipped rather than treated as an idea entry.
18
+ * Mirrors scripts/idea_manager.sh's `real_entries()` filter — blank lines, markdown headers
19
+ * (`#`), and HTML comments (`<!--`) are structural/template scaffolding, not idea content.
20
+ * @param {string} trimmedLine
21
+ * @returns {boolean}
22
+ */
23
+ function isSkippableLine(trimmedLine) {
24
+ return trimmedLine === '' || trimmedLine.startsWith('#') || trimmedLine.startsWith('<!--');
25
+ }
26
+
27
+ /**
28
+ * Read project/ideas.md and return one entry per non-blank idea line.
29
+ * Returns [] if project/ideas.md does not exist (same non-throwing precedent as the other parsers).
30
+ * @returns {Promise<Object[]>}
31
+ */
32
+ async function readIdeas() {
33
+ if (!fs.existsSync(IDEAS_FILE)) return [];
34
+
35
+ let raw;
36
+ try {
37
+ raw = fs.readFileSync(IDEAS_FILE, 'utf8');
38
+ } catch (err) {
39
+ console.warn(`[ideas] Skipping unreadable file: ${IDEAS_FILE} — ${err.message}`);
40
+ return [];
41
+ }
42
+
43
+ const lines = raw.split('\n');
44
+ const results = [];
45
+
46
+ for (const line of lines) {
47
+ const trimmed = line.trim();
48
+ if (isSkippableLine(trimmed)) continue;
49
+
50
+ results.push({
51
+ file: 'ideas.md',
52
+ data: {},
53
+ content: trimmed,
54
+ category: 'idea',
55
+ date: null,
56
+ });
57
+ }
58
+
59
+ return results;
60
+ }
61
+
62
+ module.exports = { readIdeas };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file project/app/api/parsers/knowledge-graph.js
3
+ * Reads `project/knowledge-graph/graph.json` (E20_S09) and transforms it into the UI-facing
4
+ * `{nodes:[{id,label,type}], edges:[{from,to,label}]}` shape consumed by the Architecture tab's
5
+ * SAD map — see `project/knowledge-graph/STUB_SCHEMA.md` for the on-disk node/edge shape this
6
+ * reads from (id/type/label/description/source/status?/superseded_by? for nodes;
7
+ * id/from/to/type/description for edges).
8
+ *
9
+ * Kept deliberately defensive: the stub schema is explicitly throwaway (pending E20_S01's real
10
+ * schema), so this reader tolerates a missing file, an empty/malformed file, or unexpected field
11
+ * shapes by degrading to an empty map rather than throwing.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { resolveProjectRoot } = require('../lib/resolve-project-root');
17
+
18
+ // Resolved relative to the invoking project's own root, not a fixed __dirname climb — same fix as
19
+ // the other 4 parsers (E47_S02_T01/T02). Not one of the 4 files originally named in E47_S02_T02's
20
+ // scope, but it has the identical defect and directly feeds architecture.js's sad_map field, which
21
+ // the story's own Acceptance Criteria names explicitly ("architecture" data must resolve relative
22
+ // to the invoking project) — see E47_S02_T02-plan.md's "Scope addition" section for the full
23
+ // reasoning.
24
+ const ROOT = resolveProjectRoot();
25
+ const GRAPH_JSON_PATH = path.join(ROOT, 'project/knowledge-graph/graph.json');
26
+
27
+ /**
28
+ * Safely read and parse a JSON file, returning null on any error (including a missing file).
29
+ * @param {string} filePath
30
+ * @returns {Object|null}
31
+ */
32
+ function readJsonSafe(filePath) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Read and transform `project/knowledge-graph/graph.json` into the UI-facing SAD map shape.
42
+ * `status: superseded` nodes are excluded. Never throws — returns `{ nodes: [], edges: [] }` for
43
+ * a missing, empty, or malformed graph.json.
44
+ * @returns {{ nodes: Array<{id: string, label: string, type: string}>, edges: Array<{from: string, to: string, label: string}> }}
45
+ */
46
+ function readSADMap() {
47
+ try {
48
+ const graph = readJsonSafe(GRAPH_JSON_PATH);
49
+
50
+ const rawNodes = graph && Array.isArray(graph.nodes) ? graph.nodes : [];
51
+ const rawEdges = graph && Array.isArray(graph.edges) ? graph.edges : [];
52
+
53
+ const nodes = rawNodes
54
+ .filter((node) => node && node.status !== 'superseded')
55
+ .map((node) => ({
56
+ id: node.id,
57
+ label: node.label,
58
+ type: node.type,
59
+ }));
60
+
61
+ const edges = rawEdges.map((edge) => ({
62
+ from: edge.from,
63
+ to: edge.to,
64
+ label: edge.description || edge.type,
65
+ }));
66
+
67
+ return { nodes, edges };
68
+ } catch {
69
+ return { nodes: [], edges: [] };
70
+ }
71
+ }
72
+
73
+ module.exports = { readSADMap };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @file project/app/api/parsers/lib/markdown-dir-reader.js
3
+ * Shared recursive markdown-directory reader with frontmatter parsing and caller-derived
4
+ * categorization.
5
+ *
6
+ * Generalizes two pre-existing, independently-written directory readers in this codebase:
7
+ * - `board.js`'s `readMarkdownDir()` — non-recursive, parses frontmatter + content via
8
+ * `gray-matter`, but only reads a single flat directory (epics/stories/tasks are each read
9
+ * separately).
10
+ * - `rapports.js`'s `listMdFiles()` — recursively walks a directory tree, but only collects file
11
+ * paths; frontmatter/content extraction happens separately in `readRapports()`.
12
+ *
13
+ * This module merges both: a single recursive walk that also parses frontmatter/content per file,
14
+ * plus a category derived from a mapping supplied by the caller (this module never hardcodes any
15
+ * category name — different consumers need different taxonomies from the same walk/parse logic).
16
+ *
17
+ * ---------------------------------------------------------------------------------------------
18
+ * RETURN SHAPE CONTRACT (depended on by E58_S01_T03 [rapports.js] and E58_S01_T04
19
+ * [documentation.js] — do not change without updating both):
20
+ *
21
+ * readMarkdownDirRecursive(rootDir, categorize) -> Array<{
22
+ * file: string, // path relative to rootDir, POSIX-style separators (e.g. "problems/foo.md"
23
+ * // or "foo.md" for a file directly under rootDir). Never an absolute path.
24
+ * data: Object, // gray-matter-parsed frontmatter object (`{}` if the file has none).
25
+ * content: string, // full markdown body, `gray-matter`'s `content` field with leading/
26
+ * // trailing whitespace trimmed (`.trim()`). Never truncated.
27
+ * category: string, // derived via the caller-supplied `categorize` argument — see below.
28
+ * }>
29
+ *
30
+ * `categorize` contract:
31
+ * - May be a function: `(topLevelSegment, relativeFilePath) => string`
32
+ * `topLevelSegment` is the first path segment of the file's path relative to `rootDir`
33
+ * (e.g. "problems" for "problems/foo.md"), or `null` when the file sits directly under
34
+ * `rootDir` with no subdirectory (e.g. "foo.md").
35
+ * `relativeFilePath` is the same POSIX-style relative path that ends up in the returned
36
+ * entry's `file` field, in case a consumer needs finer-grained categorization than the
37
+ * top-level segment alone.
38
+ * The function's return value is used verbatim as `category`.
39
+ * - May be a plain lookup object: `{ [topLevelSegment]: categoryString }`. Looked up by the
40
+ * file's top-level segment; if the segment is `null` (file directly under rootDir) or has no
41
+ * matching key, falls back to the object's own `_default` key if present, else `'uncategorized'`.
42
+ * - May be omitted entirely, in which case every entry gets `category: 'uncategorized'`.
43
+ *
44
+ * Malformed files (parse errors) are skipped with a `console.warn` — matching the existing
45
+ * `board.js`/`rapports.js` pattern — rather than aborting the whole walk. A root directory that
46
+ * does not exist returns `[]` rather than throwing (matches the existing `fs.existsSync` guard
47
+ * used by both `board.js` and `rapports.js`).
48
+ * ---------------------------------------------------------------------------------------------
49
+ */
50
+
51
+ 'use strict';
52
+
53
+ const fs = require('fs');
54
+ const path = require('path');
55
+ const matter = require('gray-matter');
56
+
57
+ const DEFAULT_CATEGORY = 'uncategorized';
58
+
59
+ /**
60
+ * Recursively collect absolute paths of every `.md` file under `dir`.
61
+ * Mirrors rapports.js's listMdFiles(), kept private to this module.
62
+ * @param {string} dir
63
+ * @returns {string[]} absolute file paths
64
+ */
65
+ function listMdFilesRecursive(dir) {
66
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
67
+ const files = [];
68
+ for (const entry of entries) {
69
+ const full = path.join(dir, entry.name);
70
+ if (entry.isDirectory()) {
71
+ files.push(...listMdFilesRecursive(full));
72
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
73
+ files.push(full);
74
+ }
75
+ }
76
+ return files;
77
+ }
78
+
79
+ /**
80
+ * Normalize a relative path to POSIX-style separators so API payloads never leak `\` on Windows
81
+ * dev environments.
82
+ * @param {string} relPath
83
+ * @returns {string}
84
+ */
85
+ function toPosixPath(relPath) {
86
+ return relPath.split(path.sep).join('/');
87
+ }
88
+
89
+ /**
90
+ * Derive the top-level subdirectory segment of a POSIX-style relative path, or `null` if the file
91
+ * sits directly under the root with no subdirectory.
92
+ * @param {string} posixRelPath
93
+ * @returns {string|null}
94
+ */
95
+ function topLevelSegmentOf(posixRelPath) {
96
+ const idx = posixRelPath.indexOf('/');
97
+ return idx === -1 ? null : posixRelPath.slice(0, idx);
98
+ }
99
+
100
+ /**
101
+ * Resolve a category string for a file given the caller-supplied `categorize` mapping.
102
+ * @param {Function|Object|undefined} categorize
103
+ * @param {string|null} topLevelSegment
104
+ * @param {string} posixRelPath
105
+ * @returns {string}
106
+ */
107
+ function resolveCategory(categorize, topLevelSegment, posixRelPath) {
108
+ if (typeof categorize === 'function') {
109
+ const result = categorize(topLevelSegment, posixRelPath);
110
+ return typeof result === 'string' && result.length > 0 ? result : DEFAULT_CATEGORY;
111
+ }
112
+ if (categorize && typeof categorize === 'object') {
113
+ if (topLevelSegment !== null && Object.prototype.hasOwnProperty.call(categorize, topLevelSegment)) {
114
+ return categorize[topLevelSegment];
115
+ }
116
+ if (Object.prototype.hasOwnProperty.call(categorize, '_default')) {
117
+ return categorize._default;
118
+ }
119
+ return DEFAULT_CATEGORY;
120
+ }
121
+ return DEFAULT_CATEGORY;
122
+ }
123
+
124
+ /**
125
+ * Recursively read all `.md` files under `rootDir`, parsing frontmatter + content via
126
+ * `gray-matter` and deriving a `category` per file via the caller-supplied `categorize` mapping.
127
+ *
128
+ * Returns `[]` if `rootDir` does not exist. Skips (with `console.warn`) any file that fails to
129
+ * read or parse, rather than aborting the whole walk.
130
+ *
131
+ * @param {string} rootDir - absolute path to the directory to walk.
132
+ * @param {Function|Object} [categorize] - see module-level JSDoc for the full contract.
133
+ * @returns {{ file: string, data: Object, content: string, category: string }[]}
134
+ */
135
+ function readMarkdownDirRecursive(rootDir, categorize) {
136
+ if (!fs.existsSync(rootDir)) return [];
137
+
138
+ const absolutePaths = listMdFilesRecursive(rootDir);
139
+ const results = [];
140
+
141
+ for (const absPath of absolutePaths) {
142
+ try {
143
+ const raw = fs.readFileSync(absPath, 'utf8');
144
+ const parsed = matter(raw);
145
+ const posixRelPath = toPosixPath(path.relative(rootDir, absPath));
146
+ const topLevelSegment = topLevelSegmentOf(posixRelPath);
147
+ const category = resolveCategory(categorize, topLevelSegment, posixRelPath);
148
+
149
+ results.push({
150
+ file: posixRelPath,
151
+ data: parsed.data,
152
+ content: parsed.content.trim(),
153
+ category,
154
+ });
155
+ } catch (err) {
156
+ console.warn(`[markdown-dir-reader] Skipping malformed file: ${absPath} — ${err.message}`);
157
+ }
158
+ }
159
+
160
+ return results;
161
+ }
162
+
163
+ module.exports = { readMarkdownDirRecursive };