@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.
- package/README.md +52 -12
- package/agents/developer.md +31 -16
- package/agents/scrum-master.md +18 -17
- package/agents/tester.md +25 -15
- package/bin/jenga.js +10 -0
- package/lib/commands/dashboard.js +92 -0
- package/lib/skill-allow-list.json +7 -2
- package/package.json +21 -2
- package/project/app/api/lib/resolve-project-root.js +120 -0
- package/project/app/api/package.json +16 -0
- package/project/app/api/parsers/architecture.js +72 -0
- package/project/app/api/parsers/board.js +141 -0
- package/project/app/api/parsers/documentation.js +125 -0
- package/project/app/api/parsers/git-log.js +52 -0
- package/project/app/api/parsers/ideas.js +62 -0
- package/project/app/api/parsers/knowledge-graph.js +73 -0
- package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
- package/project/app/api/parsers/rapports.js +148 -0
- package/project/app/api/parsers/todo.js +179 -0
- package/project/app/api/response.js +47 -0
- package/project/app/api/routes/architecture.js +23 -0
- package/project/app/api/routes/board.js +46 -0
- package/project/app/api/routes/documentation.js +24 -0
- package/project/app/api/routes/health.js +25 -0
- package/project/app/api/routes/history.js +55 -0
- package/project/app/api/routes/rapports.js +24 -0
- package/project/app/api/scripts/capture-snapshot.js +294 -0
- package/project/app/api/server.js +112 -0
- package/project/app/api/types.js +40 -0
- package/project/app/package.json +21 -0
- package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
- package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
- package/project/app/ui/dist/index.html +13 -0
- package/project/app/ui/package.json +23 -0
- package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
- package/project/app/ui/scripts/dashboard-open.cjs +88 -0
- package/project/app/ui/scripts/dashboard-start.cjs +87 -0
- package/scripts/acquire-concurrency-slot.sh +220 -0
- package/scripts/audit-twin-divergence.sh +625 -0
- package/scripts/check-public-playbook-steps.sh +136 -0
- package/scripts/compute-deploy-reconcile.sh +439 -0
- package/scripts/jenga-permission-level-switch.sh +19 -3
- package/scripts/mark-deployed.sh +532 -0
- package/scripts/populate-knowledge-graph.js +429 -0
- package/scripts/release-concurrency-slot.sh +129 -0
- package/scripts/validate-board.sh +60 -2
- package/scripts/verify-consumer-install.sh +470 -0
- package/skills/j-close-story/SKILL.md +1 -1
- package/skills/j-cloud-connect/SKILL.md +95 -0
- package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
- package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
- package/skills/j-dashboard/SKILL.md +144 -0
- package/skills/j-dashboard/scripts/launch.sh +121 -0
- package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
- package/skills/j-dashboard/scripts/snapshot.sh +267 -0
- package/skills/j-dashboard-share/SKILL.md +96 -0
- package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
- package/skills/j-do/SKILL.md +19 -19
- package/skills/j-doc-sync/SKILL.md +12 -1
- package/skills/j-idea/SKILL.md +1 -1
- package/skills/j-init/SKILL.md +5 -4
- package/skills/j-init/assets/directory_structure.txt +1 -0
- package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
- package/skills/j-init/scripts/init.sh +13 -2
- package/skills/j-playbook/SKILL.md +93 -0
- package/skills/j-playbook-new/SKILL.md +155 -0
- package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
- package/skills/j-proceed/SKILL.md +1 -1
- package/skills/j-publish/SKILL.md +1 -1
- package/skills/j-publish/adapters/npm-ci.md +29 -0
- package/skills/j-publish/scripts/npm_ci_pipeline.sh +9 -0
- package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
- package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
- package/skills/j-reconcile/SKILL.md +1 -0
- package/skills/j-redo/SKILL.md +1 -1
- package/skills/j-status/SKILL.md +12 -0
- package/skills/j-todo/SKILL.md +2 -2
- package/skills/j-uncharted/SKILL.md +8 -7
- package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
- package/skills/jenga/SKILL.md +55 -16
- package/skills/jenga/playbooks/idea-to-committed.json +20 -0
- package/skills/jenga/playbooks/schema.json +1 -1
- package/skills/jenga/scripts/load-nl-catalog.js +22 -6
- package/skills/jenga/scripts/load-playbooks.sh +968 -41
- package/skills/jenga/scripts/match-playbook.sh +1 -1
- package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
- package/skills/jenga/scripts/run-playbook-step.sh +535 -42
- package/skills/jenga-permission-level/SKILL.md +4 -4
- package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
- package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
- package/templates/playbook-types.json +8 -0
- 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
|
+
};
|