@jenga-ai/agent 3.1.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +52 -12
  2. package/agents/developer.md +31 -16
  3. package/agents/scrum-master.md +18 -17
  4. package/agents/tester.md +25 -15
  5. package/bin/jenga.js +10 -0
  6. package/lib/commands/dashboard.js +92 -0
  7. package/lib/skill-allow-list.json +7 -2
  8. package/package.json +21 -2
  9. package/project/app/api/lib/resolve-project-root.js +120 -0
  10. package/project/app/api/package.json +16 -0
  11. package/project/app/api/parsers/architecture.js +72 -0
  12. package/project/app/api/parsers/board.js +141 -0
  13. package/project/app/api/parsers/documentation.js +125 -0
  14. package/project/app/api/parsers/git-log.js +52 -0
  15. package/project/app/api/parsers/ideas.js +62 -0
  16. package/project/app/api/parsers/knowledge-graph.js +73 -0
  17. package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
  18. package/project/app/api/parsers/rapports.js +148 -0
  19. package/project/app/api/parsers/todo.js +179 -0
  20. package/project/app/api/response.js +47 -0
  21. package/project/app/api/routes/architecture.js +23 -0
  22. package/project/app/api/routes/board.js +46 -0
  23. package/project/app/api/routes/documentation.js +24 -0
  24. package/project/app/api/routes/health.js +25 -0
  25. package/project/app/api/routes/history.js +55 -0
  26. package/project/app/api/routes/rapports.js +24 -0
  27. package/project/app/api/scripts/capture-snapshot.js +294 -0
  28. package/project/app/api/server.js +112 -0
  29. package/project/app/api/types.js +40 -0
  30. package/project/app/package.json +21 -0
  31. package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
  32. package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
  33. package/project/app/ui/dist/index.html +13 -0
  34. package/project/app/ui/package.json +23 -0
  35. package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
  36. package/project/app/ui/scripts/dashboard-open.cjs +88 -0
  37. package/project/app/ui/scripts/dashboard-start.cjs +87 -0
  38. package/scripts/acquire-concurrency-slot.sh +220 -0
  39. package/scripts/audit-twin-divergence.sh +625 -0
  40. package/scripts/check-public-playbook-steps.sh +136 -0
  41. package/scripts/compute-deploy-reconcile.sh +439 -0
  42. package/scripts/jenga-permission-level-switch.sh +19 -3
  43. package/scripts/mark-deployed.sh +532 -0
  44. package/scripts/populate-knowledge-graph.js +429 -0
  45. package/scripts/release-concurrency-slot.sh +129 -0
  46. package/scripts/validate-board.sh +60 -2
  47. package/scripts/verify-consumer-install.sh +470 -0
  48. package/skills/j-close-story/SKILL.md +1 -1
  49. package/skills/j-cloud-connect/SKILL.md +95 -0
  50. package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
  51. package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
  52. package/skills/j-dashboard/SKILL.md +144 -0
  53. package/skills/j-dashboard/scripts/launch.sh +121 -0
  54. package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
  55. package/skills/j-dashboard/scripts/snapshot.sh +267 -0
  56. package/skills/j-dashboard-share/SKILL.md +96 -0
  57. package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
  58. package/skills/j-do/SKILL.md +19 -19
  59. package/skills/j-doc-sync/SKILL.md +12 -1
  60. package/skills/j-idea/SKILL.md +1 -1
  61. package/skills/j-init/SKILL.md +5 -4
  62. package/skills/j-init/assets/directory_structure.txt +1 -0
  63. package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
  64. package/skills/j-init/scripts/init.sh +13 -2
  65. package/skills/j-playbook/SKILL.md +93 -0
  66. package/skills/j-playbook-new/SKILL.md +155 -0
  67. package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
  68. package/skills/j-proceed/SKILL.md +1 -1
  69. package/skills/j-publish/SKILL.md +1 -1
  70. package/skills/j-publish/adapters/npm-ci.md +29 -0
  71. package/skills/j-publish/scripts/npm_ci_pipeline.sh +9 -0
  72. package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
  73. package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
  74. package/skills/j-reconcile/SKILL.md +1 -0
  75. package/skills/j-redo/SKILL.md +1 -1
  76. package/skills/j-status/SKILL.md +12 -0
  77. package/skills/j-todo/SKILL.md +2 -2
  78. package/skills/j-uncharted/SKILL.md +8 -7
  79. package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
  80. package/skills/jenga/SKILL.md +55 -16
  81. package/skills/jenga/playbooks/idea-to-committed.json +20 -0
  82. package/skills/jenga/playbooks/schema.json +1 -1
  83. package/skills/jenga/scripts/load-nl-catalog.js +22 -6
  84. package/skills/jenga/scripts/load-playbooks.sh +968 -41
  85. package/skills/jenga/scripts/match-playbook.sh +1 -1
  86. package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
  87. package/skills/jenga/scripts/run-playbook-step.sh +535 -42
  88. package/skills/jenga-permission-level/SKILL.md +4 -4
  89. package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
  90. package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
  91. package/templates/playbook-types.json +8 -0
  92. package/skills/jenga/playbooks/brainstorm-to-mirror.json +0 -22
@@ -0,0 +1,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")