@jenga-ai/agent 3.4.0 → 3.6.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 (97) hide show
  1. package/README.md +85 -78
  2. package/agents/developer.md +1 -1
  3. package/agents/scrum-master.md +20 -2
  4. package/agents/tester.md +3 -3
  5. package/hooks/on_session_end.sh +5 -5
  6. package/lib/generate-agent-context.js +2 -2
  7. package/lib/generate-copilot-hooks.js +1 -1
  8. package/lib/generate-skill-allow-list.js +37 -3
  9. package/lib/mirror.js +1 -1
  10. package/lib/postinstall-manifest.js +1 -1
  11. package/lib/skill-allow-list.json +2 -2
  12. package/mcp/help/index.js +8 -17
  13. package/mcp/help/scan.js +73 -0
  14. package/package.json +6 -1
  15. package/project/app/api/parsers/knowledge-graph.js +100 -9
  16. package/project/app/api/routes/health.js +36 -0
  17. package/project/app/api/scripts/capture-snapshot.js +9 -6
  18. package/project/app/ui/dist/assets/{index-CdK3Qrep.css → index-BVR_7Owg.css} +1 -1
  19. package/project/app/ui/dist/assets/index-CtU2xLQm.js +104 -0
  20. package/project/app/ui/dist/index.html +2 -2
  21. package/project/app/ui/package.json +4 -0
  22. package/project/app/ui/scripts/build-snapshot-html.cjs +63 -2
  23. package/scripts/acquire-concurrency-slot.sh +1 -1
  24. package/scripts/apply-j-prefix.sh +46 -5
  25. package/scripts/audit-twin-divergence.sh +73 -5
  26. package/scripts/build-pages-site.sh +1 -1
  27. package/scripts/check-public-playbook-steps.sh +158 -52
  28. package/scripts/check-publicignore-match.sh +2 -2
  29. package/scripts/compute-deploy-reconcile.sh +5 -5
  30. package/scripts/delete-bare-skill-dirs.sh +330 -0
  31. package/scripts/generate-legacy-shipped-paths.js +2 -2
  32. package/scripts/idea_manager.sh +258 -3
  33. package/scripts/mark-deployed.sh +2 -2
  34. package/scripts/populate-knowledge-graph.entity-resolution.test.js +254 -0
  35. package/scripts/populate-knowledge-graph.js +213 -5
  36. package/scripts/populate-knowledge-graph.staleness.test.js +130 -0
  37. package/scripts/postinstall.js +1 -1
  38. package/scripts/repoint-skill-refs.sh +539 -0
  39. package/scripts/todo_manager.sh +1 -1
  40. package/scripts/verify-legacy-seed-reconcile.sh +10 -10
  41. package/scripts/verify-postinstall-reconcile.sh +7 -7
  42. package/scripts/write-context-digest.sh +1 -1
  43. package/skills/j-clearify/SKILL.md +2 -2
  44. package/skills/j-close-story/scripts/check-privatized.sh +4 -4
  45. package/skills/j-distribute/CONFIG_SCHEMA.md +82 -5
  46. package/skills/j-do/SKILL.md +101 -17
  47. package/skills/j-doc-sync/SKILL.md +1 -0
  48. package/skills/j-gitignore/SKILL.md +157 -0
  49. package/skills/j-gitignore/assets/jenga-paths.txt +50 -0
  50. package/skills/j-gitignore/scripts/_catalog.sh +105 -0
  51. package/skills/j-gitignore/scripts/audit-gitignore.sh +194 -0
  52. package/skills/j-gitignore/scripts/repair-gitignore.sh +226 -0
  53. package/skills/j-gitignore/scripts/untrack-jenga-files.sh +210 -0
  54. package/skills/j-idea/SKILL.md +78 -6
  55. package/skills/j-idea/assets/idea_template.md +1 -1
  56. package/skills/j-improve/SKILL.md +1 -1
  57. package/skills/j-init/SKILL.md +53 -14
  58. package/skills/j-init/assets/.gitignore_template +1 -2
  59. package/skills/j-init/assets/scope-thresholds_template.json +5 -2
  60. package/skills/j-init/scripts/apply-scaffold-visibility.sh +192 -0
  61. package/skills/j-init/scripts/init.sh +22 -8
  62. package/skills/j-playbook/SKILL.md +1 -1
  63. package/skills/j-publish/SKILL.md +1 -1
  64. package/skills/j-publish/adapters/npm-ci.md +6 -1
  65. package/skills/j-publish/adapters/npm.md +1 -1
  66. package/skills/j-publish/scripts/generate_release_notes.sh +1 -1
  67. package/skills/j-publish/scripts/npm_stage_inspect.sh +61 -0
  68. package/skills/j-reconcile/SKILL.md +2 -2
  69. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +11 -11
  70. package/skills/j-redo/SKILL.md +1 -1
  71. package/skills/j-skillify/assets/init-new/assets/.gitignore_template +1 -2
  72. package/skills/j-spinoff/SKILL.md +1 -1
  73. package/skills/j-status/SKILL.md +15 -0
  74. package/skills/j-todo/SKILL.md +3 -1
  75. package/skills/j-uncharted/SKILL.md +55 -8
  76. package/skills/j-uncharted/assets/NODE_QUESTION_TEMPLATE.md +69 -0
  77. package/skills/j-uncharted/scripts/detect-dependencies.sh +1 -1
  78. package/skills/j-uncharted/scripts/detect-tests.sh +1 -1
  79. package/skills/j-uncharted/scripts/elicitation-state.sh +46 -8
  80. package/skills/j-uncharted/scripts/validate-proposed-items.sh +1 -1
  81. package/skills/j-wtf/SKILL.md +1 -1
  82. package/skills/jenga/SKILL.md +43 -9
  83. package/skills/jenga/playbooks/board-hygiene.json +32 -0
  84. package/skills/jenga/playbooks/schema.json +73 -6
  85. package/skills/jenga/playbooks/understand-then-commit.json +19 -0
  86. package/skills/jenga/scripts/load-nl-catalog.sh +1 -1
  87. package/skills/jenga/scripts/load-playbooks.sh +23 -11
  88. package/skills/jenga/scripts/match-playbook.sh +1 -1
  89. package/templates/SCRUM_BOARD_SCHEMA.md +14 -1
  90. package/templates/SKILL_TEMPLATE.md +12 -0
  91. package/templates/permission-levels/level-4-elevated.json +1 -1
  92. package/templates/permission-levels/level-5-unrestricted.json +1 -1
  93. package/templates/playbook-types.json +34 -6
  94. package/project/app/ui/dist/assets/index-7fj-vllY.js +0 -104
  95. package/scripts/generate-j-alias.sh +0 -333
  96. package/skills/j-dev-done/SKILL.md +0 -53
  97. package/skills/j-dev-done/scripts/classify-commit-outcome.sh +0 -114
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file scripts/populate-knowledge-graph.entity-resolution.test.js
4
+ * Plain Node `assert`-based unit tests for E20_S10_T04's canonical-key entity-resolution
5
+ * merge-on-write logic in scripts/populate-knowledge-graph.js (canonicalKey, resolveNodeIdentity,
6
+ * mergeGraph's key-based merge branch). No test-framework dependency — same convention as
7
+ * project/app/api/parsers/knowledge-graph.test.js.
8
+ *
9
+ * Why a unit-level test file rather than only CLI-level bats coverage (tests/populate-knowledge-graph.bats):
10
+ * this populator currently only ever computes `source: 'board'` nodes from board frontmatter, whose
11
+ * `id` IS already the canonical key input (an Epic/Story's own stable board id) — so there is no
12
+ * live producer of a "different id, same key" scenario to exercise end-to-end through the CLI yet.
13
+ * The merge machinery is general-purpose infrastructure ahead of its real caller (a future
14
+ * `human`/`ast` writer) — exercising it directly against synthetic node fixtures is the correct
15
+ * level for that, per E20_S10_T04-plan.md.
16
+ *
17
+ * Run directly:
18
+ * node scripts/populate-knowledge-graph.entity-resolution.test.js
19
+ */
20
+
21
+ import assert from 'node:assert';
22
+ import {
23
+ canonicalKey,
24
+ resolveNodeIdentity,
25
+ mergeGraph,
26
+ } from './populate-knowledge-graph.js';
27
+
28
+ async function main() {
29
+
30
+ const tests = [];
31
+ function test(name, fn) {
32
+ tests.push({ name, fn });
33
+ }
34
+
35
+ // -----------------------------------------------------------------------------------------
36
+ // canonicalKey()
37
+ // -----------------------------------------------------------------------------------------
38
+
39
+ test('canonicalKey: epic/story key on their own normalized id', () => {
40
+ assert.strictEqual(canonicalKey({ id: 'E90', type: 'epic' }), 'epic:e90');
41
+ assert.strictEqual(canonicalKey({ id: 'E90_S01', type: 'story' }), 'story:e90_s01');
42
+ });
43
+
44
+ test('canonicalKey: epic/story ids are case- and whitespace-normalized', () => {
45
+ assert.strictEqual(canonicalKey({ id: ' E90 ', type: 'epic' }), canonicalKey({ id: 'e90', type: 'epic' }));
46
+ });
47
+
48
+ test('canonicalKey: other node types key on normalized label', () => {
49
+ assert.strictEqual(
50
+ canonicalKey({ id: 'svc-1', type: 'service', label: 'Billing Worker' }),
51
+ canonicalKey({ id: 'svc-2', type: 'service', label: ' billing worker ' })
52
+ );
53
+ });
54
+
55
+ test('canonicalKey: distinct labels never collide (conservative, exact-normalized match only)', () => {
56
+ assert.notStrictEqual(
57
+ canonicalKey({ id: 'svc-1', type: 'service', label: 'Billing Worker' }),
58
+ canonicalKey({ id: 'svc-2', type: 'service', label: 'Billing Workers' })
59
+ );
60
+ });
61
+
62
+ test('canonicalKey: a node with no usable key returns null (never over-merges)', () => {
63
+ assert.strictEqual(canonicalKey({ id: 'x', type: 'service' }), null); // no label
64
+ assert.strictEqual(canonicalKey(null), null);
65
+ assert.strictEqual(canonicalKey({ id: 'x' }), null); // no type
66
+ });
67
+
68
+ test('canonicalKey: different types with the same label never collide', () => {
69
+ assert.notStrictEqual(
70
+ canonicalKey({ id: 'a', type: 'service', label: 'Auth' }),
71
+ canonicalKey({ id: 'b', type: 'module', label: 'Auth' })
72
+ );
73
+ });
74
+
75
+ // -----------------------------------------------------------------------------------------
76
+ // resolveNodeIdentity() — genuine merge case
77
+ // -----------------------------------------------------------------------------------------
78
+
79
+ test('resolveNodeIdentity: a computed node matching an existing key under a DIFFERENT id merges into the existing id', () => {
80
+ const nodeMap = new Map([
81
+ ['svc-existing', { id: 'svc-existing', type: 'service', label: 'Billing Worker', description: 'old desc', source: 'ast' }],
82
+ ]);
83
+ const keyIndex = new Map([[canonicalKey({ type: 'service', label: 'Billing Worker' }), 'svc-existing']]);
84
+ const mergeLog = [];
85
+
86
+ resolveNodeIdentity(
87
+ nodeMap,
88
+ 'svc-new',
89
+ { id: 'svc-new', type: 'service', label: 'Billing Worker', description: 'new desc', source: 'ast' },
90
+ keyIndex,
91
+ mergeLog
92
+ );
93
+
94
+ assert.strictEqual(nodeMap.size, 1, 'no second node should be created for the same entity');
95
+ assert.ok(!nodeMap.has('svc-new'), 'the incoming id must not become a new map entry');
96
+ const merged = nodeMap.get('svc-existing');
97
+ assert.strictEqual(merged.id, 'svc-existing', 'the existing (target) id must remain stable');
98
+ assert.strictEqual(merged.description, 'new desc', 'fields update from the incoming node');
99
+ assert.strictEqual(mergeLog.length, 1);
100
+ assert.strictEqual(mergeLog[0].targetId, 'svc-existing');
101
+ assert.strictEqual(mergeLog[0].mergedFromId, 'svc-new');
102
+ assert.ok(mergeLog[0].changedFields.includes('description'));
103
+ });
104
+
105
+ test('resolveNodeIdentity: merging never downgrades an existing human/ast source via a lower-precedence board write', () => {
106
+ const nodeMap = new Map([
107
+ ['svc-existing', { id: 'svc-existing', type: 'service', label: 'Billing Worker', source: 'human' }],
108
+ ]);
109
+ const keyIndex = new Map([[canonicalKey({ type: 'service', label: 'Billing Worker' }), 'svc-existing']]);
110
+ const mergeLog = [];
111
+
112
+ resolveNodeIdentity(
113
+ nodeMap,
114
+ 'svc-new',
115
+ { id: 'svc-new', type: 'service', label: 'Billing Worker', source: 'board' },
116
+ keyIndex,
117
+ mergeLog
118
+ );
119
+
120
+ assert.strictEqual(nodeMap.get('svc-existing').source, 'human', 'source must not be downgraded by a board write');
121
+ });
122
+
123
+ test('resolveNodeIdentity: merging upgrades a board source to human/ast when the incoming write is verified', () => {
124
+ const nodeMap = new Map([
125
+ ['svc-existing', { id: 'svc-existing', type: 'service', label: 'Billing Worker', source: 'board' }],
126
+ ]);
127
+ const keyIndex = new Map([[canonicalKey({ type: 'service', label: 'Billing Worker' }), 'svc-existing']]);
128
+ const mergeLog = [];
129
+
130
+ resolveNodeIdentity(
131
+ nodeMap,
132
+ 'svc-new',
133
+ { id: 'svc-new', type: 'service', label: 'Billing Worker', source: 'ast' },
134
+ keyIndex,
135
+ mergeLog
136
+ );
137
+
138
+ assert.strictEqual(nodeMap.get('svc-existing').source, 'ast');
139
+ assert.ok(mergeLog[0].changedFields.includes('source'));
140
+ });
141
+
142
+ // -----------------------------------------------------------------------------------------
143
+ // resolveNodeIdentity() — legitimate near-miss (must NOT merge)
144
+ // -----------------------------------------------------------------------------------------
145
+
146
+ test('resolveNodeIdentity: a near-miss (distinct canonical key) creates a separate node, no merge', () => {
147
+ const nodeMap = new Map([
148
+ ['svc-existing', { id: 'svc-existing', type: 'service', label: 'Billing Worker', source: 'ast' }],
149
+ ]);
150
+ // keyIndex reflects only the existing node's own key — "Billing Workers" (plural) has a
151
+ // distinct canonical key and is absent from the index, so no match is possible.
152
+ const keyIndex = new Map([[canonicalKey({ type: 'service', label: 'Billing Worker' }), 'svc-existing']]);
153
+ const mergeLog = [];
154
+
155
+ resolveNodeIdentity(
156
+ nodeMap,
157
+ 'svc-other',
158
+ { id: 'svc-other', type: 'service', label: 'Billing Workers', source: 'ast' },
159
+ keyIndex,
160
+ mergeLog
161
+ );
162
+
163
+ assert.strictEqual(nodeMap.size, 2, 'a legitimately distinct entity must get its own node');
164
+ assert.ok(nodeMap.has('svc-other'));
165
+ assert.strictEqual(mergeLog.length, 0, 'no merge should be logged for a near-miss');
166
+ });
167
+
168
+ test('resolveNodeIdentity: a computed node whose key matches an existing node under the SAME id is a no-op merge (not logged as a key-based merge)', () => {
169
+ const nodeMap = new Map([
170
+ ['E90', { id: 'E90', type: 'epic', label: 'Test Epic', description: 'old', source: 'board' }],
171
+ ]);
172
+ const keyIndex = new Map([[canonicalKey({ id: 'E90', type: 'epic' }), 'E90']]);
173
+ const mergeLog = [];
174
+
175
+ resolveNodeIdentity(
176
+ nodeMap,
177
+ 'E90',
178
+ { id: 'E90', type: 'epic', label: 'Test Epic', description: 'new', source: 'board' },
179
+ keyIndex,
180
+ mergeLog
181
+ );
182
+
183
+ assert.strictEqual(nodeMap.size, 1);
184
+ assert.strictEqual(nodeMap.get('E90').description, 'new', 'plain id-keyed update still applies');
185
+ assert.strictEqual(mergeLog.length, 0, 'matching under the same id is the pre-existing idempotent case, not a key-based merge');
186
+ });
187
+
188
+ // -----------------------------------------------------------------------------------------
189
+ // mergeGraph() — end-to-end through the public merge entrypoint
190
+ // -----------------------------------------------------------------------------------------
191
+
192
+ test('mergeGraph: existing idempotent-on-id behavior (E20_S09) is not regressed', () => {
193
+ const existing = {
194
+ nodes: [{ id: 'E90', type: 'epic', label: 'Test Epic', description: 'd', source: 'board' }],
195
+ edges: [],
196
+ };
197
+ const computed = {
198
+ nodes: new Map([['E90', { id: 'E90', type: 'epic', label: 'Test Epic', description: 'd', source: 'board' }]]),
199
+ edges: new Map(),
200
+ };
201
+
202
+ const first = mergeGraph(existing, computed);
203
+ const second = mergeGraph({ nodes: first.nodes, edges: first.edges }, computed);
204
+
205
+ assert.deepStrictEqual(first.nodes, second.nodes);
206
+ assert.strictEqual(second.nodes.length, 1);
207
+ assert.strictEqual(second.mergeLog.length, 0);
208
+ });
209
+
210
+ test('mergeGraph: a genuine key-based merge reduces two entries to one and logs the merge', () => {
211
+ const existing = {
212
+ nodes: [{ id: 'svc-existing', type: 'service', label: 'Billing Worker', description: 'old', source: 'ast' }],
213
+ edges: [],
214
+ };
215
+ const computed = {
216
+ nodes: new Map([
217
+ ['svc-new', { id: 'svc-new', type: 'service', label: 'Billing Worker', description: 'updated', source: 'ast' }],
218
+ ]),
219
+ edges: new Map(),
220
+ };
221
+
222
+ const result = mergeGraph(existing, computed);
223
+
224
+ assert.strictEqual(result.nodes.length, 1);
225
+ assert.strictEqual(result.nodes[0].id, 'svc-existing');
226
+ assert.strictEqual(result.nodes[0].description, 'updated');
227
+ assert.strictEqual(result.mergeLog.length, 1);
228
+ });
229
+
230
+ test('mergeGraph: return value is JSON-serializable as exactly {nodes, edges} for graph.json (mergeLog is reporting-only)', () => {
231
+ const existing = { nodes: [], edges: [] };
232
+ const computed = { nodes: new Map(), edges: new Map() };
233
+ const result = mergeGraph(existing, computed);
234
+ const { nodes, edges } = result;
235
+ assert.deepStrictEqual(JSON.parse(JSON.stringify({ nodes, edges })), { nodes: [], edges: [] });
236
+ });
237
+
238
+ let failures = 0;
239
+ for (const { name, fn } of tests) {
240
+ try {
241
+ await fn();
242
+ console.log(`PASS - ${name}`);
243
+ } catch (err) {
244
+ failures += 1;
245
+ console.error(`FAIL - ${name}`);
246
+ console.error(err && err.stack ? err.stack : err);
247
+ }
248
+ }
249
+
250
+ console.log(`\n${tests.length - failures}/${tests.length} passed`);
251
+ if (failures > 0) process.exitCode = 1;
252
+ }
253
+
254
+ main();
@@ -42,6 +42,7 @@
42
42
  import fs from 'node:fs';
43
43
  import path from 'node:path';
44
44
  import { fileURLToPath } from 'node:url';
45
+ import { createHash } from 'node:crypto';
45
46
 
46
47
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
47
48
  const REPO_ROOT = path.join(__dirname, '..');
@@ -49,7 +50,21 @@ const REPO_ROOT = path.join(__dirname, '..');
49
50
  const EPIC_ID_RE = /^E\d+$/;
50
51
  const STORY_ID_RE = /^E\d+_S\d+$/;
51
52
 
52
- const NODE_KEY_ORDER = ['id', 'type', 'label', 'description', 'source', 'status', 'superseded_by'];
53
+ // E20_S10_T05: extracted_at/content_hash/needs_revalidation are appended after the pre-existing
54
+ // fields — never inserted in the middle — so a graph.json written before E20_S10_T05 diffs
55
+ // cleanly against one written after it for every node that doesn't carry the new fields.
56
+ const NODE_KEY_ORDER = [
57
+ 'id',
58
+ 'type',
59
+ 'label',
60
+ 'description',
61
+ 'source',
62
+ 'status',
63
+ 'superseded_by',
64
+ 'extracted_at',
65
+ 'content_hash',
66
+ 'needs_revalidation',
67
+ ];
53
68
  const EDGE_KEY_ORDER = ['id', 'from', 'to', 'type', 'description'];
54
69
 
55
70
  // ── CLI args ────────────────────────────────────────────────────────────────
@@ -326,6 +341,168 @@ function isBoardOwnedEdgeId(id) {
326
341
  return BOARD_EDGE_ID_PREFIXES.some((prefix) => id.startsWith(prefix));
327
342
  }
328
343
 
344
+ // ── Entity resolution (E20_S10_T04) ────────────────────────────────────────
345
+ //
346
+ // This governs *whether two nodes are the same node at all* — distinct from, and does not
347
+ // replace, STUB_SCHEMA.md's Evidence-Wins Conflict Rule (which governs *which description wins*
348
+ // once two nodes are already known to be the same entity). A node's literal `id` is already this
349
+ // populator's own stable identity for its own `board`-sourced writes (an Epic/Story's own board
350
+ // id never changes shape between runs), so key-based merging is infrastructure ahead of its real
351
+ // caller: a future `human`/`ast` writer describing the same real-world entity under a different
352
+ // literal `id` than an existing node. Deliberately conservative (under-merging on purpose, per the
353
+ // task's own instruction to start narrow and expand only on observed false negatives) — a node
354
+ // with no usable key returns `null` and is matched by `id` only, never over-merged.
355
+
356
+ const SOURCE_PRECEDENCE = { human: 2, ast: 2, board: 1 };
357
+
358
+ function sourcePrecedence(source) {
359
+ return SOURCE_PRECEDENCE[source] ?? 0;
360
+ }
361
+
362
+ function normalizeKeyPart(value) {
363
+ return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
364
+ }
365
+
366
+ /**
367
+ * Compute a node's canonical identity key, or `null` if this node type has no reliable key yet.
368
+ * `epic`/`story` key on their own (already-stable) board id, matching this populator's existing
369
+ * identity scheme. Any other type (e.g. a future `service`/`module`/`function` from an `ast`/
370
+ * `human` writer) keys on its normalized `label` — exact-normalized, not fuzzy, per the
371
+ * conservative-by-design instruction above.
372
+ * @param {{id: string, type: string, label?: string}} node
373
+ * @returns {string|null}
374
+ */
375
+ function canonicalKey(node) {
376
+ if (!node || !node.type) return null;
377
+ if (node.type === 'epic' || node.type === 'story') {
378
+ return node.id ? `${node.type}:${normalizeKeyPart(node.id)}` : null;
379
+ }
380
+ return node.label ? `${node.type}:${normalizeKeyPart(node.label)}` : null;
381
+ }
382
+
383
+ /**
384
+ * Resolve a computed node against an existing node map using canonical-key identity resolution,
385
+ * merging in place (and logging the merge) when a genuine match is found under a *different*
386
+ * literal id. Falls back to plain `id`-keyed merge/create — completely unchanged from the
387
+ * pre-E20_S10_T04 behavior — whenever no key-based match applies.
388
+ * @param {Map<string, object>} nodeMap mutated in place
389
+ * @param {string} computedId
390
+ * @param {object} computedNode
391
+ * @param {Map<string, string>} keyIndex canonical key -> existing node id, built before this run's
392
+ * computed nodes are applied (so a computed node is only ever matched against pre-existing data,
393
+ * never against another computed node from the same run)
394
+ * @param {Array<object>} mergeLog appended to in place with a record of every key-based merge
395
+ */
396
+ function resolveNodeIdentity(nodeMap, computedId, computedNode, keyIndex, mergeLog) {
397
+ const key = canonicalKey(computedNode);
398
+ const matchedExistingId = key ? keyIndex.get(key) : undefined;
399
+
400
+ if (matchedExistingId && matchedExistingId !== computedId) {
401
+ // Genuine key-based merge: same canonical identity, different literal id. Merge into the
402
+ // EXISTING node's id (stable target) rather than creating a second node for the same entity.
403
+ const existingNode = nodeMap.get(matchedExistingId) ?? {};
404
+ const resolvedSource =
405
+ sourcePrecedence(computedNode.source) >= sourcePrecedence(existingNode.source)
406
+ ? computedNode.source
407
+ : existingNode.source; // never downgrade an existing node's provenance via a lower-precedence write
408
+
409
+ const changedFields = [];
410
+ for (const field of ['label', 'description', 'type']) {
411
+ if (computedNode[field] !== undefined && computedNode[field] !== existingNode[field]) {
412
+ changedFields.push(field);
413
+ }
414
+ }
415
+ if (existingNode.source !== resolvedSource) changedFields.push('source');
416
+
417
+ const merged = {
418
+ ...existingNode,
419
+ ...computedNode,
420
+ id: existingNode.id, // keep the existing (target) id stable — never renamed by a merge
421
+ source: resolvedSource,
422
+ };
423
+ nodeMap.set(matchedExistingId, canonicalizeKeys(ensureProvenanceMetadata(merged), NODE_KEY_ORDER));
424
+
425
+ mergeLog.push({
426
+ key,
427
+ targetId: matchedExistingId,
428
+ mergedFromId: computedId,
429
+ changedFields,
430
+ existingSource: existingNode.source,
431
+ incomingSource: computedNode.source,
432
+ });
433
+ return;
434
+ }
435
+
436
+ // No key-based match (or the match IS this same id, i.e. the pre-existing idempotent-on-id
437
+ // case) — fall back to plain id-keyed merge/create, unchanged from pre-E20_S10_T04 behavior.
438
+ const merged = { ...(nodeMap.get(computedId) ?? {}), ...computedNode };
439
+ nodeMap.set(computedId, canonicalizeKeys(ensureProvenanceMetadata(merged), NODE_KEY_ORDER));
440
+ }
441
+
442
+ // ── Staleness / revalidation (E20_S10_T05) ─────────────────────────────────
443
+ //
444
+ // Gating on `source` alone (E20_S10_T01) relocates, rather than solves, the original "board goes
445
+ // stale" defect: a non-`board` node can itself drift from the source it was extracted from with no
446
+ // signal to the user. `hashSource`/`checkStaleness` are the general-purpose, directly-testable
447
+ // utilities this closes with — deliberately NOT new scheduling infrastructure, per the task's own
448
+ // instruction to hook into an existing lifecycle trigger point instead. This populator has no live
449
+ // `ast`/`human` writer yet (same caveat as E20_S10_T04's entity resolution), so it applies these
450
+ // utilities to its own passthrough of any non-`board` node it merges, using that node's own
451
+ // descriptive text as a content proxy — a future dedicated writer with real source-file text
452
+ // should call `hashSource()` directly with its own normalized content instead of relying on this
453
+ // proxy.
454
+
455
+ /**
456
+ * Hash arbitrary content (already normalized by the caller, if normalization is available) into a
457
+ * short, stable fingerprint. Prefer passing normalized/parsed structure rather than raw bytes when
458
+ * the caller has one available — e.g. a future AST writer hashing a normalized AST dump rather than
459
+ * raw file text — so purely cosmetic/formatting-only source changes don't flip staleness.
460
+ * @param {string} content
461
+ * @returns {string} hex-encoded sha256 digest
462
+ */
463
+ function hashSource(content) {
464
+ return createHash('sha256').update(String(content ?? '')).digest('hex');
465
+ }
466
+
467
+ /**
468
+ * Best-effort "source content" proxy for a node with no separately-tracked raw source file: its
469
+ * own label + description. Used only by this populator's own write path below, which has nothing
470
+ * else to hash against; a dedicated ast/human writer with real source-file text should hash that
471
+ * directly via hashSource() instead of going through this proxy.
472
+ */
473
+ function contentFingerprint(node) {
474
+ return `${node.label ?? ''}${node.description ?? ''}`;
475
+ }
476
+
477
+ /**
478
+ * Compare a node's stored `content_hash` against a freshly computed hash of its current source
479
+ * content. A node with no stored `content_hash` yet has nothing to compare against — reported as
480
+ * not stale (never a false "needs re-verification" for a node that was never hash-stamped at all).
481
+ * @param {{content_hash?: string}} node
482
+ * @param {string} currentContent
483
+ * @returns {{stale: boolean}}
484
+ */
485
+ function checkStaleness(node, currentContent) {
486
+ if (!node || !node.content_hash) return { stale: false };
487
+ return { stale: hashSource(currentContent) !== node.content_hash };
488
+ }
489
+
490
+ /**
491
+ * Stamp `extracted_at`/`content_hash` on any non-`board` node being written, refreshing
492
+ * `extracted_at` only when the computed content hash actually changes (so re-running against
493
+ * unchanged content never bumps the timestamp). `board` nodes are left untouched — this metadata
494
+ * is scoped to non-`board` provenance per the task's own Acceptance Criteria.
495
+ * @param {object} node
496
+ * @param {string} [now] ISO 8601 UTC timestamp; overridable for tests
497
+ * @returns {object} a new node object (or the same reference, if source is 'board')
498
+ */
499
+ function ensureProvenanceMetadata(node, now = new Date().toISOString()) {
500
+ if (!node || node.source === 'board') return node;
501
+ const hash = hashSource(contentFingerprint(node));
502
+ if (node.content_hash === hash) return node; // unchanged — keep the existing extracted_at as-is
503
+ return { ...node, content_hash: hash, extracted_at: now };
504
+ }
505
+
329
506
  function mergeGraph(existing, computed) {
330
507
  const nodeMap = new Map(existing.nodes.map((node) => [node.id, node]));
331
508
  // Prune stale populator-owned nodes no longer produced this run (e.g. an Epic or Story board
@@ -339,9 +516,18 @@ function mergeGraph(existing, computed) {
339
516
  nodeMap.delete(id);
340
517
  }
341
518
  }
519
+
520
+ // Built BEFORE this run's computed nodes are applied, so key-based resolution only ever matches
521
+ // against pre-existing graph state — never against a sibling computed node from the same pass.
522
+ const keyIndex = new Map();
523
+ for (const node of nodeMap.values()) {
524
+ const key = canonicalKey(node);
525
+ if (key && !keyIndex.has(key)) keyIndex.set(key, node.id);
526
+ }
527
+
528
+ const mergeLog = [];
342
529
  for (const [id, computedNode] of computed.nodes) {
343
- const merged = { ...(nodeMap.get(id) ?? {}), ...computedNode };
344
- nodeMap.set(id, canonicalizeKeys(merged, NODE_KEY_ORDER));
530
+ resolveNodeIdentity(nodeMap, id, computedNode, keyIndex, mergeLog);
345
531
  }
346
532
 
347
533
  const edgeMap = new Map(existing.edges.map((edge) => [edge.id, edge]));
@@ -360,7 +546,7 @@ function mergeGraph(existing, computed) {
360
546
 
361
547
  const nodes = [...nodeMap.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
362
548
  const edges = [...edgeMap.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
363
- return { nodes, edges };
549
+ return { nodes, edges, mergeLog };
364
550
  }
365
551
 
366
552
  // ── Main ─────────────────────────────────────────────────────────────────
@@ -375,9 +561,26 @@ function run(argv) {
375
561
  const computed = deriveNodesAndEdges(epics, stories);
376
562
 
377
563
  const existing = loadExistingGraph(graphPath);
378
- const merged = mergeGraph(existing, computed);
564
+ const { nodes, edges, mergeLog } = mergeGraph(existing, computed);
565
+ // graph.json's on-disk shape is strictly {nodes, edges} per STUB_SCHEMA.md — mergeLog is
566
+ // reporting-only and must never be serialized into it.
567
+ const merged = { nodes, edges };
379
568
  const nextContent = `${JSON.stringify(merged, null, 2)}\n`;
380
569
 
570
+ // E20_S10_T04: every key-based merge is logged (what changed, which sources were involved) so a
571
+ // silent merge never hides a legitimate content difference between sources — printed regardless
572
+ // of --dry-run, since a merge decision is worth surfacing even on a preview run.
573
+ for (const entry of mergeLog) {
574
+ process.stdout.write(
575
+ `[merge] ${entry.mergedFromId} -> ${entry.targetId} (key: ${entry.key}) — ` +
576
+ `source ${entry.existingSource ?? 'none'} + ${entry.incomingSource ?? 'none'} -> ${
577
+ entry.changedFields.includes('source')
578
+ ? 'upgraded'
579
+ : entry.existingSource ?? entry.incomingSource
580
+ }; changed fields: ${entry.changedFields.length ? entry.changedFields.join(', ') : 'none'}\n`,
581
+ );
582
+ }
583
+
381
584
  let currentContent = null;
382
585
  try {
383
586
  currentContent = fs.readFileSync(graphPath, 'utf8');
@@ -425,5 +628,10 @@ export {
425
628
  deriveNodesAndEdges,
426
629
  loadExistingGraph,
427
630
  mergeGraph,
631
+ canonicalKey,
632
+ resolveNodeIdentity,
633
+ hashSource,
634
+ checkStaleness,
635
+ ensureProvenanceMetadata,
428
636
  run,
429
637
  };
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file scripts/populate-knowledge-graph.staleness.test.js
4
+ * Plain Node `assert`-based unit tests for E20_S10_T05's staleness/revalidation logic in
5
+ * scripts/populate-knowledge-graph.js (hashSource, checkStaleness, ensureProvenanceMetadata). Same
6
+ * no-framework convention as knowledge-graph.test.js and
7
+ * populate-knowledge-graph.entity-resolution.test.js.
8
+ *
9
+ * Run directly:
10
+ * node scripts/populate-knowledge-graph.staleness.test.js
11
+ */
12
+
13
+ import assert from 'node:assert';
14
+ import {
15
+ hashSource,
16
+ checkStaleness,
17
+ ensureProvenanceMetadata,
18
+ } from './populate-knowledge-graph.js';
19
+
20
+ async function main() {
21
+ const tests = [];
22
+ function test(name, fn) {
23
+ tests.push({ name, fn });
24
+ }
25
+
26
+ // -----------------------------------------------------------------------------------------
27
+ // hashSource()
28
+ // -----------------------------------------------------------------------------------------
29
+
30
+ test('hashSource: identical content produces identical hashes', () => {
31
+ assert.strictEqual(hashSource('hello world'), hashSource('hello world'));
32
+ });
33
+
34
+ test('hashSource: different content produces different hashes', () => {
35
+ assert.notStrictEqual(hashSource('hello world'), hashSource('hello mars'));
36
+ });
37
+
38
+ test('hashSource: is defined for empty/undefined content (never throws)', () => {
39
+ assert.doesNotThrow(() => hashSource(''));
40
+ assert.doesNotThrow(() => hashSource(undefined));
41
+ assert.strictEqual(hashSource(''), hashSource(undefined));
42
+ });
43
+
44
+ // -----------------------------------------------------------------------------------------
45
+ // checkStaleness()
46
+ // -----------------------------------------------------------------------------------------
47
+
48
+ test('checkStaleness: reports "unchanged, no badge" (stale: false) when the source hash still matches', () => {
49
+ const content = 'function billingWorker() { reconcileLedger(); }';
50
+ const node = { content_hash: hashSource(content) };
51
+ assert.deepStrictEqual(checkStaleness(node, content), { stale: false });
52
+ });
53
+
54
+ test('checkStaleness: reports "needs re-verification" (stale: true) when the source has changed', () => {
55
+ const originalContent = 'function billingWorker() { reconcileLedger(); }';
56
+ const changedContent = 'function billingWorker() { retryCharge(); }'; // materially different
57
+ const node = { content_hash: hashSource(originalContent) };
58
+ assert.deepStrictEqual(checkStaleness(node, changedContent), { stale: true });
59
+ });
60
+
61
+ test('checkStaleness: a node with no stored content_hash is never reported stale (nothing to compare against)', () => {
62
+ assert.deepStrictEqual(checkStaleness({}, 'anything'), { stale: false });
63
+ assert.deepStrictEqual(checkStaleness(null, 'anything'), { stale: false });
64
+ });
65
+
66
+ // -----------------------------------------------------------------------------------------
67
+ // ensureProvenanceMetadata()
68
+ // -----------------------------------------------------------------------------------------
69
+
70
+ test('ensureProvenanceMetadata: a board node is left untouched (no extracted_at/content_hash)', () => {
71
+ const node = { id: 'E90', type: 'epic', label: 'Epic', description: 'd', source: 'board' };
72
+ const result = ensureProvenanceMetadata(node, '2026-01-01T00:00:00Z');
73
+ assert.strictEqual(result, node);
74
+ assert.strictEqual(result.content_hash, undefined);
75
+ assert.strictEqual(result.extracted_at, undefined);
76
+ });
77
+
78
+ test('ensureProvenanceMetadata: a new non-board node is stamped with content_hash + extracted_at', () => {
79
+ const node = { id: 'svc-1', type: 'service', label: 'Billing Worker', description: 'd', source: 'ast' };
80
+ const result = ensureProvenanceMetadata(node, '2026-01-01T00:00:00Z');
81
+ assert.ok(result.content_hash);
82
+ assert.strictEqual(result.extracted_at, '2026-01-01T00:00:00Z');
83
+ });
84
+
85
+ test('ensureProvenanceMetadata: re-stamping unchanged content does NOT bump extracted_at (no noisy re-timestamping)', () => {
86
+ const node = { id: 'svc-1', type: 'service', label: 'Billing Worker', description: 'd', source: 'ast' };
87
+ const first = ensureProvenanceMetadata(node, '2026-01-01T00:00:00Z');
88
+ const second = ensureProvenanceMetadata(first, '2026-06-01T00:00:00Z'); // later "now", same content
89
+ assert.strictEqual(second.extracted_at, '2026-01-01T00:00:00Z', 'extracted_at must not change when content is unchanged');
90
+ assert.strictEqual(second.content_hash, first.content_hash);
91
+ });
92
+
93
+ test('ensureProvenanceMetadata: content that actually changes DOES bump both content_hash and extracted_at', () => {
94
+ const node = { id: 'svc-1', type: 'service', label: 'Billing Worker', description: 'd', source: 'ast' };
95
+ const first = ensureProvenanceMetadata(node, '2026-01-01T00:00:00Z');
96
+ const changed = { ...first, description: 'materially different description' };
97
+ const second = ensureProvenanceMetadata(changed, '2026-06-01T00:00:00Z');
98
+ assert.notStrictEqual(second.content_hash, first.content_hash);
99
+ assert.strictEqual(second.extracted_at, '2026-06-01T00:00:00Z');
100
+ });
101
+
102
+ test('a purely cosmetic/formatting-only change to normalized input does not flip staleness when the caller passes normalized content', () => {
103
+ // Simulates a future caller that normalizes whitespace before hashing (e.g. a normalized AST
104
+ // dump rather than raw file bytes) — per the task's "prefer hashing normalized/parsed
105
+ // structure over raw bytes where feasible" guidance.
106
+ const normalize = (raw) => raw.trim().replace(/\s+/g, ' ');
107
+ const original = 'function billingWorker() {\n reconcileLedger();\n}\n';
108
+ const cosmeticallyReformatted = ' function billingWorker() { reconcileLedger(); } ';
109
+
110
+ const node = { content_hash: hashSource(normalize(original)) };
111
+ assert.deepStrictEqual(checkStaleness(node, normalize(cosmeticallyReformatted)), { stale: false });
112
+ });
113
+
114
+ let failures = 0;
115
+ for (const { name, fn } of tests) {
116
+ try {
117
+ await fn();
118
+ console.log(`PASS - ${name}`);
119
+ } catch (err) {
120
+ failures += 1;
121
+ console.error(`FAIL - ${name}`);
122
+ console.error(err && err.stack ? err.stack : err);
123
+ }
124
+ }
125
+
126
+ console.log(`\n${tests.length - failures}/${tests.length} passed`);
127
+ if (failures > 0) process.exitCode = 1;
128
+ }
129
+
130
+ main();
@@ -49,7 +49,7 @@
49
49
  * "package_version": "3.0.1",
50
50
  * "generated_at": "<ISO 8601>",
51
51
  * "dest_root": ".agents",
52
- * "paths": ["agents/developer.md", "skills/do/SKILL.md"]
52
+ * "paths": ["agents/developer.md", "skills/j-do/SKILL.md"]
53
53
  * }
54
54
  * `paths` are relative to the destination root, POSIX-separated,
55
55
  * deduped and sorted, and record REGULAR FILES ONLY — never