@jenga-ai/agent 3.1.1 → 3.4.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 (92) hide show
  1. package/README.md +52 -12
  2. package/agents/developer.md +31 -16
  3. package/agents/scrum-master.md +18 -17
  4. package/agents/tester.md +25 -15
  5. package/bin/jenga.js +10 -0
  6. package/lib/commands/dashboard.js +92 -0
  7. package/lib/skill-allow-list.json +7 -2
  8. package/package.json +21 -2
  9. package/project/app/api/lib/resolve-project-root.js +120 -0
  10. package/project/app/api/package.json +16 -0
  11. package/project/app/api/parsers/architecture.js +72 -0
  12. package/project/app/api/parsers/board.js +141 -0
  13. package/project/app/api/parsers/documentation.js +125 -0
  14. package/project/app/api/parsers/git-log.js +52 -0
  15. package/project/app/api/parsers/ideas.js +62 -0
  16. package/project/app/api/parsers/knowledge-graph.js +73 -0
  17. package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
  18. package/project/app/api/parsers/rapports.js +148 -0
  19. package/project/app/api/parsers/todo.js +179 -0
  20. package/project/app/api/response.js +47 -0
  21. package/project/app/api/routes/architecture.js +23 -0
  22. package/project/app/api/routes/board.js +46 -0
  23. package/project/app/api/routes/documentation.js +24 -0
  24. package/project/app/api/routes/health.js +25 -0
  25. package/project/app/api/routes/history.js +55 -0
  26. package/project/app/api/routes/rapports.js +24 -0
  27. package/project/app/api/scripts/capture-snapshot.js +294 -0
  28. package/project/app/api/server.js +112 -0
  29. package/project/app/api/types.js +40 -0
  30. package/project/app/package.json +21 -0
  31. package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
  32. package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
  33. package/project/app/ui/dist/index.html +13 -0
  34. package/project/app/ui/package.json +23 -0
  35. package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
  36. package/project/app/ui/scripts/dashboard-open.cjs +88 -0
  37. package/project/app/ui/scripts/dashboard-start.cjs +87 -0
  38. package/scripts/acquire-concurrency-slot.sh +220 -0
  39. package/scripts/audit-twin-divergence.sh +625 -0
  40. package/scripts/check-public-playbook-steps.sh +136 -0
  41. package/scripts/compute-deploy-reconcile.sh +439 -0
  42. package/scripts/jenga-permission-level-switch.sh +19 -3
  43. package/scripts/mark-deployed.sh +532 -0
  44. package/scripts/populate-knowledge-graph.js +429 -0
  45. package/scripts/release-concurrency-slot.sh +129 -0
  46. package/scripts/validate-board.sh +60 -2
  47. package/scripts/verify-consumer-install.sh +470 -0
  48. package/skills/j-close-story/SKILL.md +1 -1
  49. package/skills/j-cloud-connect/SKILL.md +95 -0
  50. package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
  51. package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
  52. package/skills/j-dashboard/SKILL.md +144 -0
  53. package/skills/j-dashboard/scripts/launch.sh +121 -0
  54. package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
  55. package/skills/j-dashboard/scripts/snapshot.sh +267 -0
  56. package/skills/j-dashboard-share/SKILL.md +96 -0
  57. package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
  58. package/skills/j-do/SKILL.md +19 -19
  59. package/skills/j-doc-sync/SKILL.md +12 -1
  60. package/skills/j-idea/SKILL.md +1 -1
  61. package/skills/j-init/SKILL.md +5 -4
  62. package/skills/j-init/assets/directory_structure.txt +1 -0
  63. package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
  64. package/skills/j-init/scripts/init.sh +13 -2
  65. package/skills/j-playbook/SKILL.md +93 -0
  66. package/skills/j-playbook-new/SKILL.md +155 -0
  67. package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
  68. package/skills/j-proceed/SKILL.md +1 -1
  69. package/skills/j-publish/SKILL.md +1 -1
  70. package/skills/j-publish/adapters/npm-ci.md +29 -0
  71. package/skills/j-publish/scripts/npm_ci_pipeline.sh +9 -0
  72. package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
  73. package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
  74. package/skills/j-reconcile/SKILL.md +1 -0
  75. package/skills/j-redo/SKILL.md +1 -1
  76. package/skills/j-status/SKILL.md +12 -0
  77. package/skills/j-todo/SKILL.md +2 -2
  78. package/skills/j-uncharted/SKILL.md +8 -7
  79. package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
  80. package/skills/jenga/SKILL.md +55 -16
  81. package/skills/jenga/playbooks/idea-to-committed.json +20 -0
  82. package/skills/jenga/playbooks/schema.json +1 -1
  83. package/skills/jenga/scripts/load-nl-catalog.js +22 -6
  84. package/skills/jenga/scripts/load-playbooks.sh +968 -41
  85. package/skills/jenga/scripts/match-playbook.sh +1 -1
  86. package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
  87. package/skills/jenga/scripts/run-playbook-step.sh +535 -42
  88. package/skills/jenga-permission-level/SKILL.md +4 -4
  89. package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
  90. package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
  91. package/templates/playbook-types.json +8 -0
  92. package/skills/jenga/playbooks/brainstorm-to-mirror.json +0 -22
@@ -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 };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @file project/app/api/parsers/rapports.js
3
+ * Lists rapport markdown files and extracts summary metadata.
4
+ *
5
+ * Also exports `readRapportsFull()` (E58_S01_T03) — a full-content, 6-category aggregate built
6
+ * on `./lib/markdown-dir-reader`'s `readMarkdownDirRecursive()` (E58_S01_T01) and `./ideas`'s
7
+ * `readIdeas()` (E58_S01_T02), covering `project/rapports/{analysis,problems,tests}`,
8
+ * `project/documentation/{summaries,plans}`, and `project/ideas.md`. This coexists with, and does
9
+ * not alter, the original `readRapports()` below — that function and its truncated
10
+ * `content_summary` shape are `GET /v1/history`'s exclusive, unchanged dependency.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const matter = require('gray-matter');
16
+ const { resolveProjectRoot } = require('../lib/resolve-project-root');
17
+ const { readMarkdownDirRecursive } = require('./lib/markdown-dir-reader');
18
+ const { readIdeas } = require('./ideas');
19
+
20
+ // Resolved relative to the invoking project's own root (E47_S02_T01/T02), not a fixed __dirname
21
+ // climb — see project/app/api/lib/resolve-project-root.js.
22
+ const RAPPORTS_ROOT = path.join(resolveProjectRoot(), 'project', 'rapports');
23
+ const DOCUMENTATION_SUMMARIES_ROOT = path.join(
24
+ resolveProjectRoot(),
25
+ 'project',
26
+ 'documentation',
27
+ 'summaries'
28
+ );
29
+ const DOCUMENTATION_PLANS_ROOT = path.join(
30
+ resolveProjectRoot(),
31
+ 'project',
32
+ 'documentation',
33
+ 'plans'
34
+ );
35
+ const DATE_PATTERN = /(\d{4}-\d{2}-\d{2})/;
36
+
37
+ // Maps project/rapports/*'s top-level subdirectories onto the 6-category taxonomy named in
38
+ // E58_S01's story description ("Rapports: analysis/problems/tests/summaries/plans/ideas").
39
+ const RAPPORTS_CATEGORIZE = { analysis: 'analysis', problems: 'problems', tests: 'tests' };
40
+
41
+ /**
42
+ * Recursively list .md files under a directory.
43
+ * @param {string} dir
44
+ * @returns {string[]} absolute file paths
45
+ */
46
+ function listMdFiles(dir) {
47
+ if (!fs.existsSync(dir)) return [];
48
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
49
+ const files = [];
50
+ for (const entry of entries) {
51
+ const full = path.join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ files.push(...listMdFiles(full));
54
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
55
+ files.push(full);
56
+ }
57
+ }
58
+ return files;
59
+ }
60
+
61
+ /**
62
+ * Read rapport files and return summary objects.
63
+ * Returns [] if the rapports directory does not exist.
64
+ * @returns {Promise<Object[]>}
65
+ */
66
+ async function readRapports() {
67
+ if (!fs.existsSync(RAPPORTS_ROOT)) return [];
68
+
69
+ const files = listMdFiles(RAPPORTS_ROOT);
70
+ const results = [];
71
+
72
+ for (const filePath of files) {
73
+ try {
74
+ const raw = fs.readFileSync(filePath, 'utf8');
75
+ const { data: frontmatter, content } = matter(raw);
76
+ const filename = path.relative(RAPPORTS_ROOT, filePath);
77
+
78
+ // Date: prefer frontmatter, then extract from filename
79
+ let date = frontmatter.date || null;
80
+ if (!date) {
81
+ const match = DATE_PATTERN.exec(path.basename(filePath));
82
+ if (match) date = match[1];
83
+ }
84
+
85
+ const content_summary = content.trim().slice(0, 300);
86
+
87
+ results.push({
88
+ type: 'rapport',
89
+ filename,
90
+ date: date ? String(date) : null,
91
+ content_summary,
92
+ });
93
+ } catch (err) {
94
+ console.warn(`[rapports] Skipping malformed file: ${filePath} — ${err.message}`);
95
+ }
96
+ }
97
+
98
+ return results;
99
+ }
100
+
101
+ /**
102
+ * Resolve a `date` field for a `readMarkdownDirRecursive()` entry, mirroring the exact
103
+ * frontmatter-then-filename fallback rule `readRapports()` already uses above — factored out here
104
+ * so `readRapportsFull()` can apply the identical rule across all of its directory-reader sources
105
+ * without duplicating it per call site. Does not alter `readRapports()` itself.
106
+ * @param {Object} data - gray-matter-parsed frontmatter object.
107
+ * @param {string} relFile - path relative to the source root (used for filename-based fallback).
108
+ * @returns {string|null}
109
+ */
110
+ function extractDate(data, relFile) {
111
+ let date = (data && data.date) || null;
112
+ if (!date) {
113
+ const match = DATE_PATTERN.exec(path.basename(relFile));
114
+ if (match) date = match[1];
115
+ }
116
+ return date ? String(date) : null;
117
+ }
118
+
119
+ /**
120
+ * Aggregate full (untruncated) content across the 6-category taxonomy named in E58_S01's story
121
+ * description: `analysis`/`problems`/`tests` (from `project/rapports/*`), `summaries`/`plans`
122
+ * (from `project/documentation/*`), and `idea` (from `project/ideas.md`).
123
+ *
124
+ * Each entry carries: `{ file, data, content, category, date }` — `file` is a path relative to
125
+ * its own source root (POSIX-style, per `readMarkdownDirRecursive()`'s contract), `data` is the
126
+ * parsed frontmatter object (`{}` if none), `content` is the full untruncated markdown body, and
127
+ * `date` follows the same frontmatter-then-filename fallback rule as `readRapports()`. A missing
128
+ * source directory/file contributes zero entries rather than throwing (inherited from
129
+ * `readMarkdownDirRecursive()`'s and `readIdeas()`'s own non-throwing guards).
130
+ *
131
+ * Does not alter `readRapports()` or its truncated `content_summary` shape — `GET /v1/history`
132
+ * is unaffected by this function's existence.
133
+ * @returns {Promise<Object[]>}
134
+ */
135
+ async function readRapportsFull() {
136
+ const withDates = (entries) => entries.map((e) => ({ ...e, date: extractDate(e.data, e.file) }));
137
+
138
+ const rapportEntries = withDates(readMarkdownDirRecursive(RAPPORTS_ROOT, RAPPORTS_CATEGORIZE));
139
+ const summaryEntries = withDates(
140
+ readMarkdownDirRecursive(DOCUMENTATION_SUMMARIES_ROOT, () => 'summaries')
141
+ );
142
+ const planEntries = withDates(readMarkdownDirRecursive(DOCUMENTATION_PLANS_ROOT, () => 'plans'));
143
+ const ideaEntries = await readIdeas();
144
+
145
+ return [...rapportEntries, ...summaryEntries, ...planEntries, ...ideaEntries];
146
+ }
147
+
148
+ module.exports = { readRapports, readRapportsFull };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @file project/app/api/parsers/todo.js
3
+ *
4
+ * Reads the invoking project's `project/todo.md` and extracts the board refs its **active**
5
+ * (non-comment) entries name, so the dashboard can tell which board items the user has already
6
+ * queued for execution. Consumed by `parsers/board.js`, which tags each matching epic/story/task
7
+ * with `_queued: true` (E06_S05_T04) — the Active Sprint tab's In Progress column promotes those
8
+ * items even though their board status hasn't moved yet.
9
+ *
10
+ * ── Path resolution ───────────────────────────────────────────────────────────────────────────
11
+ * `todo.md` is resolved via `resolveProjectRoot()` (`../lib/resolve-project-root.js`), exactly the
12
+ * way `parsers/board.js` resolves `BOARD_ROOT` — never a fixed `path.resolve(__dirname, '../../..')`
13
+ * climb. A `__dirname` climb only ever lands correctly inside this monorepo's own checkout; from a
14
+ * consumer's `node_modules/@jenga-ai/agent/` it lands inside or above `node_modules` and silently
15
+ * reads the wrong file (or none). That is the exact E46 / E47_S02 defect pattern.
16
+ *
17
+ * Unlike `board.js`, the path is resolved **inside** `readTodoRefs()` rather than at module load.
18
+ * Two reasons: requiring this module can then never throw at import time (a board read must not be
19
+ * taken down by an unresolvable root before the route handler can even produce an error envelope),
20
+ * and tests can pass an explicit path without touching `JENGA_PROJECT_ROOT`. Since `server.js`
21
+ * pins the resolved root into that env var before any parser loads, the per-call resolution is a
22
+ * cheap env-var read, not a repeated filesystem walk.
23
+ *
24
+ * ── Line shapes in the real file ──────────────────────────────────────────────────────────────
25
+ * `project/todo.md`'s documented format is `<mission title>: <E##_S##>` with the ref **optional**
26
+ * (its own header comment says so). The live file contains all of these, and all of them must be
27
+ * handled:
28
+ *
29
+ * 1. `Implement set-break.sh and clear-break.sh: E44_S01_T02` → task ref, extracted
30
+ * 2. `Handoff document schema & ... checkpoint: E23_S01` → story ref, extracted
31
+ * 3. `invoke /brainstorm project/rapports/analysis/...md` → no ref, ignored
32
+ * 4. `<!-- RECONCILED: ...: E32_S01_T01 -->` → comment, skipped entirely
33
+ * 5. `# Todo` / `<!-- Format: ... -->` / blank lines → ignored
34
+ * 6. `Fix hardcoded project/ paths ... (see project/rapports/problems/E31_S05_T01-hidden-mode-path-resolution-gaps.md, F1/F2): E34`
35
+ *
36
+ * Shape 6 is why the ref pattern is **end-anchored and colon-preceded** rather than "any ref
37
+ * anywhere in the line": that single real line both embeds `E31_S05_T01` inside a rapport filename
38
+ * and ends in the epic-only ref `E34`. A loose `/E\d+_S\d+(_T\d+)?/g` scan would spuriously report
39
+ * `E31_S05_T01` as queued — a board item that line is merely *citing*, not queueing.
40
+ *
41
+ * ── Epic-only refs are deliberately not matched ───────────────────────────────────────────────
42
+ * A trailing `E##` (e.g. `E34`, `E15_S04` is a story so it matches, but bare `E34` does not) is
43
+ * ignored. E06_S05_T04's Acceptance Criteria scope resolvable refs to `E##_S##` / `E##_S##_T##`,
44
+ * and its Description states a ref "may point at a story or at a task". Promoting a whole epic off
45
+ * a single queued line would also drag an item with a much wider blast radius into the In Progress
46
+ * column than the user queued. This is a decision, not an oversight.
47
+ */
48
+
49
+ 'use strict';
50
+
51
+ const fs = require('fs');
52
+ const path = require('path');
53
+ const { resolveProjectRoot } = require('../lib/resolve-project-root');
54
+
55
+ /**
56
+ * Matches a whole line of the documented `<mission title>: <ref>` form, where `<ref>` is a story or
57
+ * task id and is the last non-whitespace token on the line. `.*?` for the title is lazy but the
58
+ * `$` anchor forces the ref to be the final token regardless. Case-insensitive so a hand-typed
59
+ * `e06_s05_t04` still resolves; refs are upper-cased on the way out and `board.js` matches ids
60
+ * case-insensitively too, so the whole path is case-agnostic.
61
+ */
62
+ const TODO_ENTRY_PATTERN = /^(.*?):[ \t]*(E\d+_S\d+(?:_T\d+)?)[ \t]*$/i;
63
+
64
+ /** A balanced HTML comment block, `s`-flag-free so it works on older Node too. */
65
+ const HTML_COMMENT_BLOCK = /<!--[\s\S]*?-->/g;
66
+
67
+ /**
68
+ * Removes every balanced `<!-- ... -->` block, then drops everything from an unterminated trailing
69
+ * `<!--` onward (matching how a browser/markdown renderer treats it: the rest of the document is
70
+ * commented out). Blocks are replaced with a single space rather than deleted so a comment that
71
+ * opens mid-line can't silently glue the surrounding text into a fake `title: ref` pair.
72
+ *
73
+ * The replacement is **newline-preserving**: a block spanning N newlines is replaced with a space
74
+ * followed by those N newlines, so every surviving line keeps its original 1-based line number and
75
+ * any text trailing a multi-line comment's `-->` stays on the line it was actually written on.
76
+ * Collapsing the block to a bare space instead (the original behavior) shifted every subsequent
77
+ * line up by N, which made `parseTodoContent`'s `entries[].line` wrong for any file containing a
78
+ * multi-line comment — and `project/todo.md` contains several.
79
+ *
80
+ * The dangling-open truncation needs no equivalent treatment: it only ever discards lines *after*
81
+ * the unterminated `<!--`, so the line numbers of everything that survives are untouched.
82
+ *
83
+ * @param {string} content
84
+ * @returns {string}
85
+ */
86
+ function stripComments(content) {
87
+ const withoutBalanced = String(content == null ? '' : content).replace(
88
+ HTML_COMMENT_BLOCK,
89
+ (block) => ' ' + '\n'.repeat((block.match(/\n/g) || []).length)
90
+ );
91
+ const danglingOpen = withoutBalanced.indexOf('<!--');
92
+ return danglingOpen === -1 ? withoutBalanced : withoutBalanced.slice(0, danglingOpen);
93
+ }
94
+
95
+ /**
96
+ * Pure parse of `todo.md` content into the board refs its active entries name.
97
+ *
98
+ * @param {string} content raw file content
99
+ * @returns {{ refs: string[], entries: { title: string, ref: string, line: number }[] }}
100
+ * `refs` is upper-cased and de-duplicated, in first-appearance order. `entries` keeps every
101
+ * matching line for debuggability; its 1-based `line` numbers always match the original content,
102
+ * since `stripComments` preserves the newlines of the blocks it removes.
103
+ */
104
+ function parseTodoContent(content) {
105
+ const lines = stripComments(content).split(/\r?\n/);
106
+ const entries = [];
107
+ const seen = new Set();
108
+ const refs = [];
109
+
110
+ lines.forEach((rawLine, index) => {
111
+ const line = rawLine.trim();
112
+ if (!line || line.startsWith('#')) return;
113
+
114
+ const match = TODO_ENTRY_PATTERN.exec(line);
115
+ if (!match) return;
116
+
117
+ const title = match[1].trim();
118
+ const ref = match[2].toUpperCase();
119
+
120
+ entries.push({ title, ref, line: index + 1 });
121
+ if (!seen.has(ref)) {
122
+ seen.add(ref);
123
+ refs.push(ref);
124
+ }
125
+ });
126
+
127
+ return { refs, entries };
128
+ }
129
+
130
+ /**
131
+ * Absolute path to the invoking project's `project/todo.md`.
132
+ * @returns {string}
133
+ */
134
+ function resolveTodoPath() {
135
+ return path.join(resolveProjectRoot(), 'project', 'todo.md');
136
+ }
137
+
138
+ /**
139
+ * Read and parse the project's `todo.md`.
140
+ *
141
+ * A missing file is a first-class, non-exceptional case: it returns `{ exists: false, refs: [] }`,
142
+ * which makes every downstream consumer behave exactly as it did before this feature existed. An
143
+ * unreadable file (permissions, a directory where a file was expected) is likewise reported as
144
+ * `exists: false` with the reason attached, never thrown — a broken `todo.md` must not be able to
145
+ * take down `GET /v1/board`.
146
+ *
147
+ * @param {string} [filePath] explicit path; defaults to `<projectRoot>/project/todo.md`
148
+ * @returns {{ exists: boolean, path: string, refs: string[], entries: Object[], error?: string }}
149
+ */
150
+ function readTodoRefs(filePath) {
151
+ let todoPath;
152
+ try {
153
+ todoPath = filePath || resolveTodoPath();
154
+ } catch (err) {
155
+ return { exists: false, path: '', refs: [], entries: [], error: err.message };
156
+ }
157
+
158
+ let raw;
159
+ try {
160
+ raw = fs.readFileSync(todoPath, 'utf8');
161
+ } catch (err) {
162
+ if (err.code !== 'ENOENT') {
163
+ console.warn(`[todo] Could not read ${todoPath} — ${err.message}`);
164
+ return { exists: false, path: todoPath, refs: [], entries: [], error: err.message };
165
+ }
166
+ return { exists: false, path: todoPath, refs: [], entries: [] };
167
+ }
168
+
169
+ const { refs, entries } = parseTodoContent(raw);
170
+ return { exists: true, path: todoPath, refs, entries };
171
+ }
172
+
173
+ module.exports = {
174
+ readTodoRefs,
175
+ parseTodoContent,
176
+ stripComments,
177
+ resolveTodoPath,
178
+ TODO_ENTRY_PATTERN,
179
+ };