@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.
- package/README.md +52 -12
- package/agents/developer.md +16 -1
- package/agents/scrum-master.md +1 -0
- package/bin/jenga.js +10 -0
- package/lib/commands/dashboard.js +92 -0
- package/lib/skill-allow-list.json +6 -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/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-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-init/SKILL.md +52 -13
- package/skills/j-init/assets/.gitignore_template +1 -2
- package/skills/j-init/scripts/apply-scaffold-visibility.sh +192 -0
- package/skills/j-init/scripts/init.sh +19 -5
- package/skills/j-playbook/SKILL.md +12 -0
- package/skills/j-playbook-new/SKILL.md +155 -0
- package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
- package/skills/j-publish/scripts/npm_ci_pipeline.sh +6 -0
- package/skills/j-skillify/assets/init-new/assets/.gitignore_template +1 -2
- package/skills/j-uncharted/SKILL.md +54 -7
- package/skills/j-uncharted/assets/NODE_QUESTION_TEMPLATE.md +69 -0
- package/skills/j-uncharted/scripts/elicitation-state.sh +45 -7
- package/skills/jenga/scripts/load-nl-catalog.js +22 -6
- package/skills/jenga/scripts/load-playbooks.sh +146 -35
- 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 };
|