@jenga-ai/agent 3.2.0 → 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 +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-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/jenga/scripts/load-nl-catalog.js +22 -6
- package/skills/jenga/scripts/load-playbooks.sh +123 -24
- package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/populate-knowledge-graph.js — Mechanical Board-to-Graph Populator (E20_S09_T02)
|
|
4
|
+
*
|
|
5
|
+
* Reads `project/board/epics/*.md` and `project/board/stories/*.md` frontmatter (read-only) and
|
|
6
|
+
* writes/updates nodes and edges into `project/knowledge-graph/graph.json`, per
|
|
7
|
+
* `project/knowledge-graph/STUB_SCHEMA.md`'s node/edge shape, using `source: "board"` (defined in
|
|
8
|
+
* E20_S09_T01 — see STUB_SCHEMA.md's "Where `board` Fits" section for its exact semantics: lowest
|
|
9
|
+
* precedence, one-way superseded-by relationship, never supersedes human/ast nodes).
|
|
10
|
+
*
|
|
11
|
+
* Scope, by design (see STUB_SCHEMA.md and E20_S09's story):
|
|
12
|
+
* - One node per Epic, one node per Story. Tasks are never nodes via this populator.
|
|
13
|
+
* - Containment edges (Epic -> Story) and `depends_on`-derived dependency edges only — nothing
|
|
14
|
+
* fabricated beyond what board frontmatter actually states.
|
|
15
|
+
* - Idempotent on `id`: re-running merges into the existing graph rather than duplicating or
|
|
16
|
+
* blindly overwriting unrelated content.
|
|
17
|
+
* - Strictly read-only against `project/board/` — this script never creates, modifies, or
|
|
18
|
+
* deletes anything under it.
|
|
19
|
+
* - No network calls, no external dependencies. The repo root `package.json` has no runtime
|
|
20
|
+
* `dependencies` (verified) — every other script under `scripts/`/`lib/` is dependency-free
|
|
21
|
+
* Node ESM using only `node:*` builtins (e.g. `postinstall.js`, `generate-legacy-shipped-paths.js`),
|
|
22
|
+
* so this script follows the same convention with its own small hand-rolled frontmatter parser
|
|
23
|
+
* rather than adding `gray-matter` (only present in the separate `project/app/` workspace) as a
|
|
24
|
+
* new root-level dependency for a single script.
|
|
25
|
+
*
|
|
26
|
+
* Note: `scripts/e25_s01_extract_board_graph.py` is an existing throwaway spike with its own
|
|
27
|
+
* hand-rolled frontmatter parser, explicitly marked "Do not import" in its own header comment. It
|
|
28
|
+
* was consulted only as prior art on the shape of the problem — nothing from it is imported or
|
|
29
|
+
* reused here.
|
|
30
|
+
*
|
|
31
|
+
* CLI:
|
|
32
|
+
* node scripts/populate-knowledge-graph.js [--project-root <path>] [--dry-run]
|
|
33
|
+
*
|
|
34
|
+
* --project-root Path to a directory containing board/ and knowledge-graph/ (default: the
|
|
35
|
+
* repo's own `project/` directory, resolved relative to this script). Lets
|
|
36
|
+
* E20_S09_T03's test coverage point the populator at fixture board directories
|
|
37
|
+
* without touching the real board.
|
|
38
|
+
* --dry-run Compute the merged graph and report whether it would change, but never write
|
|
39
|
+
* to disk.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
|
|
46
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const REPO_ROOT = path.join(__dirname, '..');
|
|
48
|
+
|
|
49
|
+
const EPIC_ID_RE = /^E\d+$/;
|
|
50
|
+
const STORY_ID_RE = /^E\d+_S\d+$/;
|
|
51
|
+
|
|
52
|
+
const NODE_KEY_ORDER = ['id', 'type', 'label', 'description', 'source', 'status', 'superseded_by'];
|
|
53
|
+
const EDGE_KEY_ORDER = ['id', 'from', 'to', 'type', 'description'];
|
|
54
|
+
|
|
55
|
+
// ── CLI args ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function parseArgs(argv) {
|
|
58
|
+
const args = { projectRoot: path.join(REPO_ROOT, 'project'), dryRun: false };
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const arg = argv[i];
|
|
61
|
+
if (arg === '--project-root') {
|
|
62
|
+
args.projectRoot = path.resolve(argv[++i] ?? '');
|
|
63
|
+
} else if (arg.startsWith('--project-root=')) {
|
|
64
|
+
args.projectRoot = path.resolve(arg.slice('--project-root='.length));
|
|
65
|
+
} else if (arg === '--dry-run') {
|
|
66
|
+
args.dryRun = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return args;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Minimal frontmatter parsing ────────────────────────────────────────────
|
|
73
|
+
// Tailored to this project's actual board schema: flat `key: value` scalar pairs, plus one
|
|
74
|
+
// multi-line list shape (` - item` continuation lines under a bare `key:`). Verified against
|
|
75
|
+
// every epic/story file in this repo before writing — no general YAML support is attempted.
|
|
76
|
+
|
|
77
|
+
function unquote(value) {
|
|
78
|
+
const text = value.trim();
|
|
79
|
+
if (
|
|
80
|
+
(text.startsWith('"') && text.endsWith('"') && text.length >= 2) ||
|
|
81
|
+
(text.startsWith("'") && text.endsWith("'") && text.length >= 2)
|
|
82
|
+
) {
|
|
83
|
+
return text.slice(1, -1);
|
|
84
|
+
}
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseFrontmatter(raw) {
|
|
89
|
+
if (!raw.startsWith('---\n') && raw !== '---') {
|
|
90
|
+
return { frontmatter: {}, body: raw };
|
|
91
|
+
}
|
|
92
|
+
const closeIdx = raw.indexOf('\n---', 4);
|
|
93
|
+
if (closeIdx === -1) {
|
|
94
|
+
return { frontmatter: {}, body: raw };
|
|
95
|
+
}
|
|
96
|
+
const rawFrontmatter = raw.slice(4, closeIdx);
|
|
97
|
+
// Body starts after the closing `---` line.
|
|
98
|
+
const afterClose = raw.indexOf('\n', closeIdx + 1);
|
|
99
|
+
const body = afterClose === -1 ? '' : raw.slice(afterClose + 1);
|
|
100
|
+
|
|
101
|
+
const frontmatter = {};
|
|
102
|
+
let currentListKey = null;
|
|
103
|
+
for (const line of rawFrontmatter.split('\n')) {
|
|
104
|
+
if (line.trim() === '') continue;
|
|
105
|
+
if (/^\s+-\s?/.test(line) && currentListKey) {
|
|
106
|
+
const item = unquote(line.replace(/^\s+-\s?/, ''));
|
|
107
|
+
frontmatter[currentListKey].push(item);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const colonIdx = line.indexOf(':');
|
|
111
|
+
if (colonIdx === -1) {
|
|
112
|
+
currentListKey = null;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const key = line.slice(0, colonIdx).trim();
|
|
116
|
+
const rawValue = line.slice(colonIdx + 1).trim();
|
|
117
|
+
if (rawValue === '') {
|
|
118
|
+
// Could be the start of a list (`stories:` followed by ` - E20_S01`) or a genuinely
|
|
119
|
+
// empty scalar field (e.g. `date_completed:`). Assume list; if no `- item` lines follow,
|
|
120
|
+
// it naturally stays an empty array, which is falsy-equivalent for our purposes.
|
|
121
|
+
frontmatter[key] = [];
|
|
122
|
+
currentListKey = key;
|
|
123
|
+
} else {
|
|
124
|
+
frontmatter[key] = unquote(rawValue);
|
|
125
|
+
currentListKey = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { frontmatter, body };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Description derivation ─────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function firstParagraphAfterHeading(body) {
|
|
134
|
+
const lines = body.split('\n');
|
|
135
|
+
let idx = 0;
|
|
136
|
+
while (idx < lines.length && !lines[idx].trim().startsWith('# ')) idx++;
|
|
137
|
+
if (idx < lines.length) idx++; // past the heading line itself
|
|
138
|
+
while (idx < lines.length && lines[idx].trim() === '') idx++;
|
|
139
|
+
const paragraph = [];
|
|
140
|
+
while (idx < lines.length && lines[idx].trim() !== '' && !lines[idx].trim().startsWith('#')) {
|
|
141
|
+
paragraph.push(lines[idx].trim());
|
|
142
|
+
idx++;
|
|
143
|
+
}
|
|
144
|
+
return paragraph.join(' ').trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function purposeSection(body) {
|
|
148
|
+
const headingMatch = body.match(/^##\s*Purpose\s*$/m);
|
|
149
|
+
if (!headingMatch) return null;
|
|
150
|
+
const rest = body.slice(headingMatch.index + headingMatch[0].length);
|
|
151
|
+
const nextHeadingMatch = rest.match(/\n##\s/);
|
|
152
|
+
const section = nextHeadingMatch ? rest.slice(0, nextHeadingMatch.index) : rest;
|
|
153
|
+
return section.trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function epicDescription(body) {
|
|
157
|
+
const purpose = purposeSection(body);
|
|
158
|
+
if (purpose) return purpose;
|
|
159
|
+
// Fallback for board files that predate the `## Purpose` convention (e.g. E24).
|
|
160
|
+
return firstParagraphAfterHeading(body);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function storyDescription(body) {
|
|
164
|
+
return firstParagraphAfterHeading(body);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Board scan ──────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function listMarkdownFiles(dir) {
|
|
170
|
+
let entries;
|
|
171
|
+
try {
|
|
172
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err.code === 'ENOENT') return [];
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
return entries
|
|
178
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
179
|
+
.map((entry) => path.join(dir, entry.name))
|
|
180
|
+
.sort();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function scanEpics(boardDir) {
|
|
184
|
+
const files = listMarkdownFiles(path.join(boardDir, 'epics'));
|
|
185
|
+
const epics = [];
|
|
186
|
+
for (const file of files) {
|
|
187
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
188
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
189
|
+
if (!frontmatter.id || !EPIC_ID_RE.test(frontmatter.id)) continue;
|
|
190
|
+
epics.push({
|
|
191
|
+
id: frontmatter.id,
|
|
192
|
+
title: frontmatter.title ?? '',
|
|
193
|
+
description: epicDescription(body),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return epics;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function scanStories(boardDir) {
|
|
200
|
+
const files = listMarkdownFiles(path.join(boardDir, 'stories'));
|
|
201
|
+
const stories = [];
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
204
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
205
|
+
if (!frontmatter.id || !STORY_ID_RE.test(frontmatter.id)) continue;
|
|
206
|
+
stories.push({
|
|
207
|
+
id: frontmatter.id,
|
|
208
|
+
title: frontmatter.title ?? '',
|
|
209
|
+
description: storyDescription(body),
|
|
210
|
+
epicId: typeof frontmatter.epic_id === 'string' ? frontmatter.epic_id : '',
|
|
211
|
+
dependsOn: typeof frontmatter.depends_on === 'string' ? frontmatter.depends_on : '',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return stories;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseDependsOn(raw) {
|
|
218
|
+
if (!raw) return [];
|
|
219
|
+
return raw
|
|
220
|
+
.split(',')
|
|
221
|
+
.map((token) => token.trim())
|
|
222
|
+
.filter((token) => token !== '' && token.toLowerCase() !== 'none');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Node/edge derivation ────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function deriveNodesAndEdges(epics, stories) {
|
|
228
|
+
const nodes = new Map();
|
|
229
|
+
const edges = new Map();
|
|
230
|
+
|
|
231
|
+
for (const epic of epics) {
|
|
232
|
+
nodes.set(epic.id, {
|
|
233
|
+
id: epic.id,
|
|
234
|
+
type: 'epic',
|
|
235
|
+
label: epic.title,
|
|
236
|
+
description: epic.description,
|
|
237
|
+
source: 'board',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
for (const story of stories) {
|
|
241
|
+
nodes.set(story.id, {
|
|
242
|
+
id: story.id,
|
|
243
|
+
type: 'story',
|
|
244
|
+
label: story.title,
|
|
245
|
+
description: story.description,
|
|
246
|
+
source: 'board',
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const epicIds = new Set(epics.map((e) => e.id));
|
|
251
|
+
const storyIds = new Set(stories.map((s) => s.id));
|
|
252
|
+
const knownIds = new Set([...epicIds, ...storyIds]);
|
|
253
|
+
|
|
254
|
+
// Containment: derived from each story's own `epic_id` field (not the epic's `stories:` list),
|
|
255
|
+
// so a stale/out-of-sync list on the epic side can never produce a dangling edge.
|
|
256
|
+
for (const story of stories) {
|
|
257
|
+
if (story.epicId && epicIds.has(story.epicId)) {
|
|
258
|
+
const edgeId = `board:contains:${story.epicId}:${story.id}`;
|
|
259
|
+
edges.set(edgeId, {
|
|
260
|
+
id: edgeId,
|
|
261
|
+
from: story.epicId,
|
|
262
|
+
to: story.id,
|
|
263
|
+
type: 'contains',
|
|
264
|
+
description: `${story.epicId} contains ${story.id}.`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Dependency edges: only for targets that resolve to a real, known Epic or Story node — a
|
|
270
|
+
// `depends_on` referencing a Task (or any unrecognised id) is skipped rather than fabricated.
|
|
271
|
+
for (const story of stories) {
|
|
272
|
+
for (const targetId of parseDependsOn(story.dependsOn)) {
|
|
273
|
+
if (!knownIds.has(targetId)) continue;
|
|
274
|
+
const edgeId = `board:depends-on:${story.id}:${targetId}`;
|
|
275
|
+
edges.set(edgeId, {
|
|
276
|
+
id: edgeId,
|
|
277
|
+
from: story.id,
|
|
278
|
+
to: targetId,
|
|
279
|
+
type: 'depends-on',
|
|
280
|
+
description: `${story.id} depends on ${targetId} (per board depends_on frontmatter).`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { nodes, edges };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Merge into existing graph.json ─────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
function canonicalizeKeys(obj, order) {
|
|
291
|
+
const result = {};
|
|
292
|
+
for (const key of order) {
|
|
293
|
+
if (key in obj) result[key] = obj[key];
|
|
294
|
+
}
|
|
295
|
+
for (const key of Object.keys(obj)) {
|
|
296
|
+
if (!(key in result)) result[key] = obj[key];
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function loadExistingGraph(graphPath) {
|
|
302
|
+
let raw;
|
|
303
|
+
try {
|
|
304
|
+
raw = fs.readFileSync(graphPath, 'utf8');
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err.code === 'ENOENT') return { nodes: [], edges: [] };
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const parsed = JSON.parse(raw);
|
|
311
|
+
return {
|
|
312
|
+
nodes: Array.isArray(parsed.nodes) ? parsed.nodes : [],
|
|
313
|
+
edges: Array.isArray(parsed.edges) ? parsed.edges : [],
|
|
314
|
+
};
|
|
315
|
+
} catch {
|
|
316
|
+
// Unparsable existing file: treat as empty rather than crash. This is a graceful-degradation
|
|
317
|
+
// choice, not silent data loss — the populator only ever writes back a superset (merge), and a
|
|
318
|
+
// corrupt file was already not a valid graph to begin with.
|
|
319
|
+
return { nodes: [], edges: [] };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const BOARD_EDGE_ID_PREFIXES = ['board:contains:', 'board:depends-on:'];
|
|
324
|
+
|
|
325
|
+
function isBoardOwnedEdgeId(id) {
|
|
326
|
+
return BOARD_EDGE_ID_PREFIXES.some((prefix) => id.startsWith(prefix));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function mergeGraph(existing, computed) {
|
|
330
|
+
const nodeMap = new Map(existing.nodes.map((node) => [node.id, node]));
|
|
331
|
+
// Prune stale populator-owned nodes no longer produced this run (e.g. an Epic or Story board
|
|
332
|
+
// file was deleted or renamed, so its id no longer appears in the current board scan). Only
|
|
333
|
+
// nodes this populator itself owns — `source: "board"` — are ever eligible for pruning; a
|
|
334
|
+
// `human`- or `ast`-sourced node is never touched here, mirroring the edge-pruning namespace
|
|
335
|
+
// guard below (E20_S09_T02 rapport: a stale board node was previously left behind forever while
|
|
336
|
+
// its containment edge was correctly pruned on the same re-run — this closes that gap).
|
|
337
|
+
for (const [id, node] of [...nodeMap.entries()]) {
|
|
338
|
+
if (node.source === 'board' && !computed.nodes.has(id)) {
|
|
339
|
+
nodeMap.delete(id);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const [id, computedNode] of computed.nodes) {
|
|
343
|
+
const merged = { ...(nodeMap.get(id) ?? {}), ...computedNode };
|
|
344
|
+
nodeMap.set(id, canonicalizeKeys(merged, NODE_KEY_ORDER));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const edgeMap = new Map(existing.edges.map((edge) => [edge.id, edge]));
|
|
348
|
+
// Prune stale populator-owned edges no longer produced this run (e.g. a `depends_on` was
|
|
349
|
+
// removed from a story). Only ids in this populator's own `board:contains:`/`board:depends-on:`
|
|
350
|
+
// namespace are ever touched — never a node, and never an edge belonging to another source.
|
|
351
|
+
for (const id of [...edgeMap.keys()]) {
|
|
352
|
+
if (isBoardOwnedEdgeId(id) && !computed.edges.has(id)) {
|
|
353
|
+
edgeMap.delete(id);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const [id, computedEdge] of computed.edges) {
|
|
357
|
+
const merged = { ...(edgeMap.get(id) ?? {}), ...computedEdge };
|
|
358
|
+
edgeMap.set(id, canonicalizeKeys(merged, EDGE_KEY_ORDER));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const nodes = [...nodeMap.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
362
|
+
const edges = [...edgeMap.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
363
|
+
return { nodes, edges };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Main ─────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
function run(argv) {
|
|
369
|
+
const args = parseArgs(argv);
|
|
370
|
+
const boardDir = path.join(args.projectRoot, 'board');
|
|
371
|
+
const graphPath = path.join(args.projectRoot, 'knowledge-graph', 'graph.json');
|
|
372
|
+
|
|
373
|
+
const epics = scanEpics(boardDir);
|
|
374
|
+
const stories = scanStories(boardDir);
|
|
375
|
+
const computed = deriveNodesAndEdges(epics, stories);
|
|
376
|
+
|
|
377
|
+
const existing = loadExistingGraph(graphPath);
|
|
378
|
+
const merged = mergeGraph(existing, computed);
|
|
379
|
+
const nextContent = `${JSON.stringify(merged, null, 2)}\n`;
|
|
380
|
+
|
|
381
|
+
let currentContent = null;
|
|
382
|
+
try {
|
|
383
|
+
currentContent = fs.readFileSync(graphPath, 'utf8');
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err.code !== 'ENOENT') throw err;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const changed = currentContent !== nextContent;
|
|
389
|
+
|
|
390
|
+
if (args.dryRun) {
|
|
391
|
+
process.stdout.write(
|
|
392
|
+
changed
|
|
393
|
+
? `[dry-run] graph.json would change (${merged.nodes.length} nodes, ${merged.edges.length} edges).\n`
|
|
394
|
+
: `[dry-run] graph.json is already up to date (${merged.nodes.length} nodes, ${merged.edges.length} edges).\n`,
|
|
395
|
+
);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (changed) {
|
|
400
|
+
fs.mkdirSync(path.dirname(graphPath), { recursive: true });
|
|
401
|
+
fs.writeFileSync(graphPath, nextContent);
|
|
402
|
+
process.stdout.write(
|
|
403
|
+
`Wrote ${graphPath} (${merged.nodes.length} nodes, ${merged.edges.length} edges).\n`,
|
|
404
|
+
);
|
|
405
|
+
} else {
|
|
406
|
+
process.stdout.write(
|
|
407
|
+
`${graphPath} already up to date (${merged.nodes.length} nodes, ${merged.edges.length} edges) — no write.\n`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const invokedPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : null;
|
|
414
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
415
|
+
process.exitCode = run(process.argv.slice(2));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export {
|
|
419
|
+
parseFrontmatter,
|
|
420
|
+
epicDescription,
|
|
421
|
+
storyDescription,
|
|
422
|
+
scanEpics,
|
|
423
|
+
scanStories,
|
|
424
|
+
parseDependsOn,
|
|
425
|
+
deriveNodesAndEdges,
|
|
426
|
+
loadExistingGraph,
|
|
427
|
+
mergeGraph,
|
|
428
|
+
run,
|
|
429
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# scripts/release-concurrency-slot.sh
|
|
4
|
+
#
|
|
5
|
+
# Release a role-scoped concurrency slot previously acquired via
|
|
6
|
+
# scripts/acquire-concurrency-slot.sh, for E32_S15 (Per-Session Concurrency
|
|
7
|
+
# Cap for Developer/Tester Dispatch). Removes the caller's holder entry from
|
|
8
|
+
# project/queue/concurrency-slots-<session_id>.json, unconditionally and
|
|
9
|
+
# idempotently — mirroring scripts/with-lock.sh's own idempotent-cleanup
|
|
10
|
+
# discipline (no error if the counter file, the role, or the holder entry is
|
|
11
|
+
# already absent).
|
|
12
|
+
#
|
|
13
|
+
# The counter file belongs to exactly the session_id in its own filename.
|
|
14
|
+
# This script never reads or writes a counter file for any session_id other
|
|
15
|
+
# than the one passed as its own argument.
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# scripts/release-concurrency-slot.sh <role> <holder-id> <session_id>
|
|
19
|
+
#
|
|
20
|
+
# Exit codes:
|
|
21
|
+
# 0 released (or already absent — idempotent no-op is still success)
|
|
22
|
+
# 1 usage error (bad arguments)
|
|
23
|
+
# 2 scripts/with-lock.sh could not acquire the lock within its timeout
|
|
24
|
+
# 5 environment error (jq missing)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
|
|
29
|
+
SELF="$(basename "$0")"
|
|
30
|
+
|
|
31
|
+
die() {
|
|
32
|
+
local code="$1"; shift
|
|
33
|
+
printf '%s: error: %s\n' "$SELF" "$*" >&2
|
|
34
|
+
exit "$code"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
usage() {
|
|
38
|
+
echo "Usage: $0 <role> <holder-id> <session_id>" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Mirrors scripts/acquire-concurrency-slot.sh's own project-root resolution
|
|
43
|
+
# (and scripts/write-context-digest.sh's before it) for consistency.
|
|
44
|
+
resolve_project_dir() {
|
|
45
|
+
if [ -n "${JENGA_PROJECT_DIR:-}" ]; then
|
|
46
|
+
printf '%s\n' "$JENGA_PROJECT_DIR"
|
|
47
|
+
return 0
|
|
48
|
+
fi
|
|
49
|
+
if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
|
50
|
+
printf '%s\n' "$CLAUDE_PROJECT_DIR"
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
git rev-parse --show-toplevel 2>/dev/null || pwd
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
SCRIPT_ABS_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Internal locked-mutation entrypoint. Not intended to be invoked directly —
|
|
60
|
+
# scripts/with-lock.sh calls back into this same script with this hidden
|
|
61
|
+
# subcommand once the lock on the counter file is held.
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
if [ "${1:-}" = "__locked-release" ]; then
|
|
64
|
+
shift
|
|
65
|
+
ROLE="$1"
|
|
66
|
+
HOLDER_ID="$2"
|
|
67
|
+
COUNTER_FILE="$3"
|
|
68
|
+
|
|
69
|
+
command -v jq >/dev/null 2>&1 || die 5 "jq is required but not found on PATH"
|
|
70
|
+
|
|
71
|
+
# Race: the file may have been removed between the outer existence check
|
|
72
|
+
# and actually acquiring the lock. That's still a no-op success, not an
|
|
73
|
+
# error — release is idempotent by contract.
|
|
74
|
+
[ -f "$COUNTER_FILE" ] || exit 0
|
|
75
|
+
|
|
76
|
+
TMP_FILE="$(mktemp "${COUNTER_FILE}.tmp.XXXXXX")"
|
|
77
|
+
jq \
|
|
78
|
+
--arg role "$ROLE" \
|
|
79
|
+
--arg holder "$HOLDER_ID" \
|
|
80
|
+
'
|
|
81
|
+
if has($role) then
|
|
82
|
+
.[$role].holders |= ((. // {}) | del(.[$holder]))
|
|
83
|
+
else
|
|
84
|
+
.
|
|
85
|
+
end
|
|
86
|
+
' "$COUNTER_FILE" > "$TMP_FILE"
|
|
87
|
+
mv "$TMP_FILE" "$COUNTER_FILE"
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Public entrypoint
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
[ "$#" -eq 3 ] || usage
|
|
95
|
+
|
|
96
|
+
ROLE="$1"
|
|
97
|
+
HOLDER_ID="$2"
|
|
98
|
+
SESSION_ID="$3"
|
|
99
|
+
|
|
100
|
+
case "$ROLE" in
|
|
101
|
+
developer|tester) ;;
|
|
102
|
+
*) die 1 "invalid role '$ROLE' (expected developer or tester)" ;;
|
|
103
|
+
esac
|
|
104
|
+
|
|
105
|
+
[ -n "$HOLDER_ID" ] || die 1 "<holder-id> must not be empty"
|
|
106
|
+
[ -n "$SESSION_ID" ] || die 1 "<session_id> must not be empty"
|
|
107
|
+
|
|
108
|
+
case "$SESSION_ID" in
|
|
109
|
+
*/*|*..*) die 1 "invalid <session_id> '$SESSION_ID': must not contain '/' or '..'" ;;
|
|
110
|
+
esac
|
|
111
|
+
|
|
112
|
+
PROJECT_DIR="$(resolve_project_dir)"
|
|
113
|
+
COUNTER_FILE="$PROJECT_DIR/project/queue/concurrency-slots-${SESSION_ID}.json"
|
|
114
|
+
|
|
115
|
+
command -v jq >/dev/null 2>&1 || die 5 "jq is required but not found on PATH"
|
|
116
|
+
[ -f "$PROJECT_DIR/scripts/with-lock.sh" ] \
|
|
117
|
+
|| die 5 "scripts/with-lock.sh not found at $PROJECT_DIR/scripts/with-lock.sh"
|
|
118
|
+
|
|
119
|
+
# No counter file at all yet: nothing to release, idempotent no-op, no lock
|
|
120
|
+
# needed.
|
|
121
|
+
[ -f "$COUNTER_FILE" ] || exit 0
|
|
122
|
+
|
|
123
|
+
set +e
|
|
124
|
+
"$PROJECT_DIR/scripts/with-lock.sh" "$COUNTER_FILE" -- \
|
|
125
|
+
"$SCRIPT_ABS_PATH" __locked-release "$ROLE" "$HOLDER_ID" "$COUNTER_FILE"
|
|
126
|
+
STATUS=$?
|
|
127
|
+
set -e
|
|
128
|
+
|
|
129
|
+
exit "$STATUS"
|
|
@@ -66,6 +66,14 @@ CLOSE_STORY_KEYS = {
|
|
|
66
66
|
"actual_files_changed", "actual_lines_delta", "scope_divergence_flag", "divergence_flag",
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
# E51_S05_T02. Written by scripts/mark-deployed.sh in the same locked write window a ticket is
|
|
70
|
+
# set to status: Deployed to Prod (never on a Deployed to Stage-only write). Allow-listed for
|
|
71
|
+
# epic, story, AND task per this field's own AC, even though in practice only tasks (and
|
|
72
|
+
# occasionally stories) are expected to ever carry it.
|
|
73
|
+
DEPLOY_KEYS = {
|
|
74
|
+
"date_deployed_prod",
|
|
75
|
+
}
|
|
76
|
+
|
|
69
77
|
# E39 tiered item-level caution/escalation fields. OPTIONAL and story/task-only — epics do not
|
|
70
78
|
# carry these; an epic's risk gating is already handled by epic_scope_approval. See
|
|
71
79
|
# templates/SCRUM_BOARD_SCHEMA.md.
|
|
@@ -90,18 +98,19 @@ ALLOWED_KEYS = {
|
|
|
90
98
|
# (value: backfilled). Absence means the epic was authored normally, so every existing
|
|
91
99
|
# epic that omits it stays valid. See templates/SCRUM_BOARD_SCHEMA.md.
|
|
92
100
|
"provenance",
|
|
101
|
+
*DEPLOY_KEYS,
|
|
93
102
|
},
|
|
94
103
|
"story": {
|
|
95
104
|
"id", "epic_id", "title", "status", "date_created", "date_started", "date_completed",
|
|
96
105
|
"dates_previously_completed", "reopened_on", "reopened_reason", "tasks", "docs",
|
|
97
106
|
"priority", "depends_on",
|
|
98
|
-
*CRUCIAL_KEYS,
|
|
107
|
+
*CRUCIAL_KEYS, *DEPLOY_KEYS,
|
|
99
108
|
},
|
|
100
109
|
"task": {
|
|
101
110
|
"id", "story_id", "epic_id", "title", "status", "date_created", "date_started", "date_completed",
|
|
102
111
|
"dates_previously_completed", "reopened_on", "reopened_reason", "assigned_to", "docs",
|
|
103
112
|
"depends_on",
|
|
104
|
-
*EXECUTION_SCOPE_KEYS, *CLOSE_STORY_KEYS, *CRUCIAL_KEYS,
|
|
113
|
+
*EXECUTION_SCOPE_KEYS, *CLOSE_STORY_KEYS, *CRUCIAL_KEYS, *DEPLOY_KEYS,
|
|
105
114
|
},
|
|
106
115
|
}
|
|
107
116
|
|
|
@@ -196,9 +205,58 @@ def validate_docs(source: Path, docs):
|
|
|
196
205
|
):
|
|
197
206
|
raise ValueError(f"{source}: docs entry must be repo-relative without leading './' or '/' ({entry})")
|
|
198
207
|
|
|
208
|
+
def assert_parses_as_yaml(lines: list[str], source: Path) -> None:
|
|
209
|
+
"""Reject frontmatter that a real YAML parser cannot read.
|
|
210
|
+
|
|
211
|
+
parse_frontmatter() below is a hand-rolled line splitter, and it is far more
|
|
212
|
+
lenient than YAML itself. That gap was not theoretical: 36 board files
|
|
213
|
+
accumulated frontmatter this script called valid but every real consumer --
|
|
214
|
+
the dashboard's board parser among them -- silently skipped, so the board
|
|
215
|
+
under-reported itself with no error surfaced anywhere. The three shapes that
|
|
216
|
+
got through were an unquoted colon in a scalar (`title: Adopt j: prefix`), a
|
|
217
|
+
value merely starting with a quote (`title: "Merged" Status via /self-sync`),
|
|
218
|
+
and a duplicated key.
|
|
219
|
+
|
|
220
|
+
PyYAML is stdlib-adjacent but not guaranteed present on every consumer, so a
|
|
221
|
+
missing import degrades to a skip rather than a hard failure -- the targeted
|
|
222
|
+
checks below still run either way.
|
|
223
|
+
"""
|
|
224
|
+
body = "\n".join(lines)
|
|
225
|
+
try:
|
|
226
|
+
import yaml
|
|
227
|
+
except ImportError:
|
|
228
|
+
pass
|
|
229
|
+
else:
|
|
230
|
+
try:
|
|
231
|
+
yaml.safe_load(body)
|
|
232
|
+
except yaml.YAMLError as exc:
|
|
233
|
+
detail = str(exc).splitlines()[0]
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"{source}: frontmatter is not valid YAML ({detail}). "
|
|
236
|
+
"Free-text values containing ':' must be quoted, and multiple "
|
|
237
|
+
"reopen reasons must be a YAML list -- see "
|
|
238
|
+
"templates/SCRUM_BOARD_SCHEMA.md's Reopen Tracking Fields."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Duplicate keys are legal-ish to some YAML loaders (last wins) but always a
|
|
242
|
+
# board-authoring bug: one of the two values is being silently discarded.
|
|
243
|
+
seen = set()
|
|
244
|
+
for line in lines:
|
|
245
|
+
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):", line)
|
|
246
|
+
if not match:
|
|
247
|
+
continue
|
|
248
|
+
key = match.group(1)
|
|
249
|
+
if key in seen:
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"{source}: duplicated frontmatter key '{key}' -- one of its two "
|
|
252
|
+
"values is silently discarded; merge them into a single entry"
|
|
253
|
+
)
|
|
254
|
+
seen.add(key)
|
|
255
|
+
|
|
199
256
|
def validate_file(source: Path):
|
|
200
257
|
text = source.read_text(encoding="utf-8")
|
|
201
258
|
frontmatter_lines = extract_frontmatter(text, source)
|
|
259
|
+
assert_parses_as_yaml(frontmatter_lines, source)
|
|
202
260
|
data = parse_frontmatter(frontmatter_lines, source)
|
|
203
261
|
|
|
204
262
|
item_id = data.get("id")
|