coaiajs 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +7 -4
  2. package/agents/custom_gpt/ceremony-media-proxy.instructions.md +86 -0
  3. package/agents/custom_gpt/ceremony-media-proxy.yml +158 -0
  4. package/agents/custom_gpt/ceremony-observations.instructions.md +38 -5
  5. package/agents/custom_gpt/ceremony-observations.yml +737 -287
  6. package/dist/mcp/config.d.ts.map +1 -1
  7. package/dist/mcp/config.js +13 -1
  8. package/dist/mcp/config.js.map +1 -1
  9. package/dist/mcp/server.js +11 -1
  10. package/dist/mcp/server.js.map +1 -1
  11. package/dist/src/cli.js +17 -4
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/config.d.ts +2 -0
  14. package/dist/src/config.d.ts.map +1 -1
  15. package/dist/src/config.js +10 -2
  16. package/dist/src/config.js.map +1 -1
  17. package/dist/src/index.d.ts +1 -1
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +1 -1
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/langfuse/index.d.ts +2 -1
  22. package/dist/src/langfuse/index.d.ts.map +1 -1
  23. package/dist/src/langfuse/index.js +1 -1
  24. package/dist/src/langfuse/index.js.map +1 -1
  25. package/dist/src/langfuse/media.d.ts +30 -0
  26. package/dist/src/langfuse/media.d.ts.map +1 -1
  27. package/dist/src/langfuse/media.js +128 -77
  28. package/dist/src/langfuse/media.js.map +1 -1
  29. package/dist/src/media-upload-proxy.d.ts +32 -0
  30. package/dist/src/media-upload-proxy.d.ts.map +1 -0
  31. package/dist/src/media-upload-proxy.js +210 -0
  32. package/dist/src/media-upload-proxy.js.map +1 -0
  33. package/dist/src/narrative/argument-hygiene.d.ts +46 -0
  34. package/dist/src/narrative/argument-hygiene.d.ts.map +1 -0
  35. package/dist/src/narrative/argument-hygiene.js +155 -0
  36. package/dist/src/narrative/argument-hygiene.js.map +1 -0
  37. package/dist/src/narrative/contract.d.ts +200 -0
  38. package/dist/src/narrative/contract.d.ts.map +1 -0
  39. package/dist/src/narrative/contract.js +274 -0
  40. package/dist/src/narrative/contract.js.map +1 -0
  41. package/dist/src/narrative/github-bridge.d.ts +23 -0
  42. package/dist/src/narrative/github-bridge.d.ts.map +1 -0
  43. package/dist/src/narrative/github-bridge.js +320 -0
  44. package/dist/src/narrative/github-bridge.js.map +1 -0
  45. package/dist/src/narrative/graph-manager.d.ts +83 -2
  46. package/dist/src/narrative/graph-manager.d.ts.map +1 -1
  47. package/dist/src/narrative/graph-manager.js +444 -54
  48. package/dist/src/narrative/graph-manager.js.map +1 -1
  49. package/dist/src/narrative/index.d.ts +24 -3
  50. package/dist/src/narrative/index.d.ts.map +1 -1
  51. package/dist/src/narrative/index.js +36 -15
  52. package/dist/src/narrative/index.js.map +1 -1
  53. package/dist/src/narrative/jsonl-preservation.d.ts +26 -0
  54. package/dist/src/narrative/jsonl-preservation.d.ts.map +1 -0
  55. package/dist/src/narrative/jsonl-preservation.js +293 -0
  56. package/dist/src/narrative/jsonl-preservation.js.map +1 -0
  57. package/dist/src/narrative/jsonl-records.d.ts +31 -0
  58. package/dist/src/narrative/jsonl-records.d.ts.map +1 -0
  59. package/dist/src/narrative/jsonl-records.js +69 -0
  60. package/dist/src/narrative/jsonl-records.js.map +1 -0
  61. package/dist/src/narrative/tool-definitions.d.ts +2 -1
  62. package/dist/src/narrative/tool-definitions.d.ts.map +1 -1
  63. package/dist/src/narrative/tool-definitions.js +134 -1
  64. package/dist/src/narrative/tool-definitions.js.map +1 -1
  65. package/dist/src/narrative/tool-handlers.d.ts.map +1 -1
  66. package/dist/src/narrative/tool-handlers.js +203 -20
  67. package/dist/src/narrative/tool-handlers.js.map +1 -1
  68. package/dist/src/narrative/types.d.ts +1 -1
  69. package/dist/src/narrative/types.d.ts.map +1 -1
  70. package/dist/src/narrative/validation.d.ts +13 -2
  71. package/dist/src/narrative/validation.d.ts.map +1 -1
  72. package/dist/src/narrative/validation.js +19 -4
  73. package/dist/src/narrative/validation.js.map +1 -1
  74. package/dist/src/redis.d.ts +10 -5
  75. package/dist/src/redis.d.ts.map +1 -1
  76. package/dist/src/redis.js +94 -8
  77. package/dist/src/redis.js.map +1 -1
  78. package/dist/src/types.d.ts +189 -0
  79. package/dist/src/types.d.ts.map +1 -1
  80. package/dist/src/types.js.map +1 -1
  81. package/llms-full.txt +73 -7
  82. package/llms.txt +5 -3
  83. package/package.json +11 -2
  84. package/rispecs/03-langfuse-module.spec.md +2 -1
@@ -5,57 +5,82 @@
5
5
  * Implements structural tension charts, narrative beats, MMOT evaluation,
6
6
  * and general-purpose entity/relation CRUD over a JSONL file.
7
7
  */
8
- import { promises as fs } from 'fs';
8
+ import { readJsonlMemoryFile, writeJsonlMemoryFile } from './jsonl-preservation.js';
9
+ import { assertNoUnparsedCallSyntax } from './argument-hygiene.js';
10
+ import { createGithubProjectFieldProjection, } from './github-bridge.js';
11
+ /**
12
+ * Parse a GitHub issue reference written as `owner/repo#number`.
13
+ *
14
+ * The full path is required. A bare `#42` is rejected: charts travel between
15
+ * repositories, and a bare number silently resolves against whichever repo the
16
+ * reader happens to be in — which is how an issue from one project ends up
17
+ * cited as another project's.
18
+ */
19
+ export function parseGithubIssueSpec(spec) {
20
+ const match = /^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)#(\d+)$/.exec(spec.trim());
21
+ if (!match) {
22
+ throw new Error(`Invalid GitHub issue reference: "${spec}". Use the full path 'owner/repo#number' (e.g. jgwill/coaiajs#1). A bare '#number' is not accepted — it resolves against the wrong repository as soon as the chart is read somewhere else.`);
23
+ }
24
+ const [, owner, repo, number] = match;
25
+ return {
26
+ owner: owner,
27
+ repo: repo,
28
+ number: Number(number),
29
+ url: `https://github.com/${owner}/${repo}/issues/${number}`,
30
+ };
31
+ }
9
32
  export class KnowledgeGraphManager {
10
33
  memoryFilePath;
11
34
  constructor(memoryFilePath) {
35
+ /**
36
+ * Refuse a path that still carries an unexpanded shell variable.
37
+ *
38
+ * Paid for 2026-08-11 in the coaia-narrative lineage. A seat booted with its
39
+ * memory-path variable unset, so its `.mcp.json` handed the process the
40
+ * placeholder verbatim. The server started happily and wrote a live chart into
41
+ * a file literally NAMED `${VAR}` in whatever directory it was launched from.
42
+ * Nothing failed. The charts were simply somewhere nobody would look.
43
+ *
44
+ * The sibling failure is worse and is why this is fatal rather than a warning:
45
+ * had the variable been set to a path that does not exist, this would have
46
+ * started CLEAN and EMPTY, and the caller would have reported its whole store
47
+ * lost. A store is the one input where "start anyway" is never the kind answer.
48
+ *
49
+ * `${` cannot appear in a legitimate path here: `$` is legal in a filename, but
50
+ * a caller writing `${...}` is quoting a shell it expected to have run.
51
+ */
52
+ if (memoryFilePath.includes('${')) {
53
+ throw new Error(`Refusing to open memory store: the path contains an unexpanded shell variable.\n` +
54
+ ` got: ${memoryFilePath}\n` +
55
+ ` The variable was not set in the environment that launched this process, so this ` +
56
+ `would create a file with that literal name and write your charts into it where ` +
57
+ `nothing will find them.\n` +
58
+ ` Fix the environment (or pass a real path) and start again — no store was touched.`);
59
+ }
12
60
  this.memoryFilePath = memoryFilePath;
13
61
  }
14
62
  // ─── JSONL Storage ──────────────────────────────────────────────────
63
+ /**
64
+ * Read the store through the preserving parser, which keeps every line's raw
65
+ * record on the graph so a later write can round-trip fields this package does
66
+ * not model. See jsonl-preservation.ts.
67
+ */
15
68
  async loadGraph() {
16
- try {
17
- const data = await fs.readFile(this.memoryFilePath, 'utf-8');
18
- const lines = data.split('\n').filter(line => line.trim() !== '');
19
- return lines.reduce((graph, line) => {
20
- const item = JSON.parse(line);
21
- if (item.type === 'entity')
22
- graph.entities.push(item);
23
- if (item.type === 'relation')
24
- graph.relations.push(item);
25
- // Support narrative_beat entities stored in legacy format
26
- if (item.type === 'narrative_beat') {
27
- const narrativeBeat = {
28
- name: item.name,
29
- entityType: 'narrative_beat',
30
- observations: item.observations || [],
31
- metadata: {
32
- ...item.metadata,
33
- narrative: item.narrative,
34
- relationalAlignment: item.relational_alignment,
35
- fourDirections: item.four_directions,
36
- },
37
- };
38
- graph.entities.push(narrativeBeat);
39
- }
40
- return graph;
41
- }, { entities: [], relations: [] });
42
- }
43
- catch (error) {
44
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
45
- return { entities: [], relations: [] };
46
- }
47
- throw error;
48
- }
69
+ return readJsonlMemoryFile(this.memoryFilePath);
49
70
  }
71
+ /**
72
+ * Write the store atomically and without loss. The previous implementation
73
+ * re-serialised only the fields this package knows, so any consumer's metadata
74
+ * on a record was dropped the first time coaiajs touched it.
75
+ */
50
76
  async saveGraph(graph) {
51
- const lines = [
52
- ...graph.entities.map(e => JSON.stringify({ type: 'entity', ...e })),
53
- ...graph.relations.map(r => JSON.stringify({ type: 'relation', ...r })),
54
- ];
55
- await fs.writeFile(this.memoryFilePath, lines.join('\n'));
77
+ await writeJsonlMemoryFile(this.memoryFilePath, graph);
56
78
  }
57
79
  // ─── Knowledge Graph CRUD ───────────────────────────────────────────
58
80
  async createEntities(entities) {
81
+ // Every chart body eventually lands here. Check before loading the graph, so a
82
+ // refusal leaves the store exactly as it was.
83
+ entities.forEach((entity, index) => assertNoUnparsedCallSyntax(entity.observations, `entities[${index}].observations`));
59
84
  const graph = await this.loadGraph();
60
85
  const newEntities = entities.filter(e => !graph.entities.some(existing => existing.name === e.name));
61
86
  graph.entities.push(...newEntities);
@@ -72,6 +97,9 @@ export class KnowledgeGraphManager {
72
97
  return newRelations;
73
98
  }
74
99
  async addObservations(observations) {
100
+ // All of them, before any of them — a batch where one body is malformed is a
101
+ // malformed call, and half of it must not land.
102
+ observations.forEach((o, index) => assertNoUnparsedCallSyntax(o.contents, `observations[${index}].contents`));
75
103
  const graph = await this.loadGraph();
76
104
  const results = observations.map(o => {
77
105
  const entity = graph.entities.find(e => e.name === o.entityName);
@@ -111,6 +139,20 @@ export class KnowledgeGraphManager {
111
139
  async readGraph() {
112
140
  return this.loadGraph();
113
141
  }
142
+ /**
143
+ * Project a chart (or any entity) into the GitHub Project field shape the
144
+ * coaia-github bridge writes. Returns null when nothing by that name or chartId
145
+ * is in the store.
146
+ */
147
+ async getGithubProjectFieldProjection(entityNameOrChartId) {
148
+ const graph = await this.loadGraph();
149
+ const entity = graph.entities.find(e => e.name === entityNameOrChartId) ??
150
+ graph.entities.find(e => e.entityType === 'structural_tension_chart' &&
151
+ e.metadata?.chartId === entityNameOrChartId);
152
+ if (!entity)
153
+ return null;
154
+ return createGithubProjectFieldProjection(entity, graph);
155
+ }
114
156
  async searchNodes(query) {
115
157
  const graph = await this.loadGraph();
116
158
  const q = query.toLowerCase();
@@ -155,10 +197,39 @@ export class KnowledgeGraphManager {
155
197
  return this.getChartDetails(telescopedOutcomeEntity.metadata.chartId);
156
198
  }
157
199
  // ─── Structural Tension Charts ──────────────────────────────────────
158
- async createStructuralTensionChart(desiredOutcome, currentReality, dueDate, actionSteps, elementsOfPerformance) {
159
- // Creative orientation validation
200
+ async createStructuralTensionChart(desiredOutcome, currentReality, dueDate, actionSteps, elementsOfPerformance, githubIssue) {
201
+ // A malformed call is diagnosed before its content is judged — there is no
202
+ // point coaching the creative orientation of a fragment of XML.
203
+ assertNoUnparsedCallSyntax(desiredOutcome, 'desiredOutcome');
204
+ assertNoUnparsedCallSyntax(currentReality, 'currentReality');
205
+ assertNoUnparsedCallSyntax(actionSteps, 'actionSteps');
206
+ // Creative orientation validation.
207
+ //
208
+ // MATCHED ON WORD BOUNDARIES, NOT SUBSTRINGS, and the difference is not
209
+ // cosmetic. This filter used `.includes()`, so it read the letters of these
210
+ // words wherever they fell inside a longer one:
211
+ //
212
+ // "a fixed ladder of named rungs" -> "fix" -> refused
213
+ // "carry one to completion or resolve it" -> "solve" -> refused
214
+ // "unstoppable", "prevention", "removal", "avoidance", "dissolve",
215
+ // "stopgap", "reducer", "solvent", "prefix", "suffix", "affix"…
216
+ //
217
+ // Both examples above are real refusals measured in the coaia-narrative
218
+ // lineage (2026-07-31 and 2026-08-01) on action steps already written in
219
+ // creative orientation. The author rephrased around the error both times
220
+ // without asking why, which is the quiet cost: a guard that fires on innocent
221
+ // text teaches the caller to edit for the checker rather than for the reader.
222
+ //
223
+ // Sharper still, the organisation's own guidance prescribes the very word this
224
+ // filter rejected: "GOOD EXAMPLE: Each action step resolve tension between
225
+ // current and desired states." So the check refused the phrasing its own
226
+ // doctrine recommends.
227
+ //
228
+ // The word list is unchanged — a chart whose desired outcome genuinely says
229
+ // "eliminate" or "reduce" should still be met with the teaching below. Only
230
+ // the matching is corrected.
160
231
  const problemSolvingWords = ['fix', 'solve', 'eliminate', 'prevent', 'stop', 'avoid', 'reduce', 'remove'];
161
- const detectedProblemWords = problemSolvingWords.filter(word => desiredOutcome.toLowerCase().includes(word));
232
+ const detectedProblemWords = problemSolvingWords.filter(word => new RegExp(`\\b${word}\\b`, 'i').test(desiredOutcome));
162
233
  if (detectedProblemWords.length > 0) {
163
234
  throw new Error(`🌊 CREATIVE ORIENTATION REQUIRED\n\n` +
164
235
  `Desired Outcome: "${desiredOutcome}"\n\n` +
@@ -193,6 +264,7 @@ export class KnowledgeGraphManager {
193
264
  }
194
265
  const chartId = `chart_${Date.now()}`;
195
266
  const timestamp = new Date().toISOString();
267
+ const issueRef = githubIssue ? parseGithubIssueSpec(githubIssue) : undefined;
196
268
  const entities = [
197
269
  {
198
270
  name: `${chartId}_chart`,
@@ -207,6 +279,7 @@ export class KnowledgeGraphManager {
207
279
  ...(elementsOfPerformance && elementsOfPerformance.length > 0
208
280
  ? { elementsOfPerformance }
209
281
  : {}),
282
+ ...(issueRef ? { github: { issue: issueRef } } : {}),
210
283
  },
211
284
  },
212
285
  {
@@ -358,15 +431,42 @@ export class KnowledgeGraphManager {
358
431
  async getChartProgress(chartId, preloadedGraph) {
359
432
  const graph = preloadedGraph ?? (await this.loadGraph());
360
433
  const actionSteps = graph.entities.filter(e => e.entityType === 'action_step' && e.metadata?.chartId === chartId);
361
- const completedActions = actionSteps.filter(e => e.metadata?.completionStatus === true).length;
362
- const totalActions = actionSteps.length;
434
+ // A chart holds its work in two shapes: action_step entities on the chart
435
+ // itself, and telescoped child charts. Counting only the first makes a chart
436
+ // whose steps were telescoped report 0/0 while holding real work.
437
+ const childCharts = graph.entities.filter(e => e.entityType === 'structural_tension_chart' &&
438
+ e.metadata?.parentChart === chartId);
439
+ // A child chart telescoped out of one of this chart's own steps is that same
440
+ // result seen closer up — it is counted through the step, not a second time.
441
+ const stepNames = new Set(actionSteps.map(e => e.name));
442
+ const telescopedChildren = childCharts.filter(c => !(c.metadata?.parentActionStep && stepNames.has(c.metadata.parentActionStep)));
443
+ const units = [
444
+ ...actionSteps.map(e => ({
445
+ name: e.name,
446
+ complete: e.metadata?.completionStatus === true,
447
+ dueDate: e.metadata?.dueDate,
448
+ })),
449
+ ...telescopedChildren.map(c => {
450
+ const childId = c.metadata?.chartId || c.name.replace('_chart', '');
451
+ // a child chart is completed through its desired outcome — that is the
452
+ // entity mark_action_complete writes to when a telescoped result is achieved
453
+ const outcome = graph.entities.find(e => e.name === `${childId}_desired_outcome` && e.entityType === 'desired_outcome');
454
+ return {
455
+ name: `${childId}_desired_outcome`,
456
+ complete: outcome?.metadata?.completionStatus === true,
457
+ dueDate: c.metadata?.dueDate,
458
+ };
459
+ }),
460
+ ];
461
+ const completedActions = units.filter(u => u.complete).length;
462
+ const totalActions = units.length;
363
463
  const progress = totalActions > 0 ? completedActions / totalActions : 0;
364
- // Next incomplete action step by earliest due date
365
- const incompleteActions = actionSteps
366
- .filter(e => e.metadata?.completionStatus !== true)
464
+ // Next incomplete unit by earliest due date
465
+ const incompleteActions = units
466
+ .filter(u => !u.complete)
367
467
  .sort((a, b) => {
368
- const dateA = new Date(a.metadata?.dueDate || '').getTime();
369
- const dateB = new Date(b.metadata?.dueDate || '').getTime();
468
+ const dateA = new Date(a.dueDate || '').getTime();
469
+ const dateB = new Date(b.dueDate || '').getTime();
370
470
  return dateA - dateB;
371
471
  });
372
472
  const chart = graph.entities.find(e => e.name === `${chartId}_chart`);
@@ -388,6 +488,18 @@ export class KnowledgeGraphManager {
388
488
  const progress = await this.getChartProgress(chartId, graph);
389
489
  const desiredOutcome = graph.entities.find(e => e.name === `${chartId}_desired_outcome` &&
390
490
  e.entityType === 'desired_outcome');
491
+ // A chart holds its work in two shapes: action_step entities that live on
492
+ // the chart itself, and telescoped child charts. Callers rendering a
493
+ // hierarchy from parentChart alone see only the second and report the
494
+ // first as absent.
495
+ const actionSteps = graph.entities
496
+ .filter(e => e.entityType === 'action_step' && e.metadata?.chartId === chartId)
497
+ .map(e => ({
498
+ name: e.name,
499
+ title: e.observations[0] || 'Untitled action step',
500
+ complete: Boolean(e.metadata?.completionStatus),
501
+ dueDate: e.metadata?.dueDate,
502
+ }));
391
503
  return {
392
504
  chartId,
393
505
  desiredOutcome: desiredOutcome?.observations[0] || 'Unknown outcome',
@@ -397,6 +509,8 @@ export class KnowledgeGraphManager {
397
509
  totalActions: progress.totalActions,
398
510
  level: chart.metadata?.level || 0,
399
511
  parentChart: chart.metadata?.parentChart,
512
+ parentActionStep: chart.metadata?.parentActionStep,
513
+ actionSteps,
400
514
  };
401
515
  }));
402
516
  return chartSummaries.sort((a, b) => {
@@ -409,6 +523,7 @@ export class KnowledgeGraphManager {
409
523
  }
410
524
  // ─── Update Action Progress ─────────────────────────────────────────
411
525
  async updateActionProgress(actionStepName, progressObservation, updateCurrentReality) {
526
+ assertNoUnparsedCallSyntax(progressObservation, 'progressObservation');
412
527
  const graph = await this.loadGraph();
413
528
  const actionStep = graph.entities.find(e => e.name === actionStepName &&
414
529
  (e.entityType === 'action_step' || e.entityType === 'desired_outcome'));
@@ -439,6 +554,7 @@ export class KnowledgeGraphManager {
439
554
  }
440
555
  // ─── Update Current Reality ─────────────────────────────────────────
441
556
  async updateCurrentReality(chartId, newObservations) {
557
+ assertNoUnparsedCallSyntax(newObservations, 'newObservations');
442
558
  const graph = await this.loadGraph();
443
559
  const currentReality = graph.entities.find(e => e.name === `${chartId}_current_reality` &&
444
560
  e.entityType === 'current_reality');
@@ -453,7 +569,27 @@ export class KnowledgeGraphManager {
453
569
  await this.saveGraph(graph);
454
570
  }
455
571
  // ─── Update Desired Outcome ─────────────────────────────────────────
572
+ /**
573
+ * Record on an existing chart the GitHub issue it was written from.
574
+ * Writes metadata.github.issue, which EntityMetadata already carried and no
575
+ * tool could reach.
576
+ */
577
+ async linkChartToGithubIssue(chartId, githubIssue) {
578
+ const graph = await this.loadGraph();
579
+ const chartEntity = graph.entities.find(e => e.name === `${chartId}_chart` && e.entityType === 'structural_tension_chart');
580
+ if (!chartEntity) {
581
+ throw new Error(`Chart ${chartId} not found`);
582
+ }
583
+ const issue = parseGithubIssueSpec(githubIssue);
584
+ const timestamp = new Date().toISOString();
585
+ chartEntity.metadata = chartEntity.metadata || {};
586
+ chartEntity.metadata.github = { ...(chartEntity.metadata.github || {}), issue };
587
+ chartEntity.metadata.updatedAt = timestamp;
588
+ await this.saveGraph(graph);
589
+ return { chartId, issue };
590
+ }
456
591
  async updateDesiredOutcome(chartId, newDesiredOutcome) {
592
+ assertNoUnparsedCallSyntax(newDesiredOutcome, 'newDesiredOutcome');
457
593
  const graph = await this.loadGraph();
458
594
  const desiredOutcomeEntity = graph.entities.find(e => e.name === `${chartId}_desired_outcome` &&
459
595
  e.entityType === 'desired_outcome');
@@ -466,8 +602,119 @@ export class KnowledgeGraphManager {
466
602
  }
467
603
  await this.saveGraph(graph);
468
604
  }
605
+ // ─── Chart Due Date ─────────────────────────────────────────────────
606
+ /**
607
+ * Move the date a chart is due.
608
+ *
609
+ * `updateDesiredOutcome` and `updateCurrentReality` could already reach both
610
+ * halves of the tension; the date the tension resolves by could only be changed
611
+ * by hand-editing the JSONL — on a store several MCP instances write with no
612
+ * lock, which is how a hand-edit becomes a lost write.
613
+ *
614
+ * The chart and its desired outcome were created carrying the same date and are
615
+ * moved together. Action step dates are left alone unless `redistributeActionSteps`
616
+ * is asked for; the count still standing past the new date is reported either way,
617
+ * so the caller sees what the move left behind.
618
+ */
619
+ async updateChartDueDate(chartId, newDueDate, redistributeActionSteps = false) {
620
+ const parsed = Date.parse(newDueDate);
621
+ if (Number.isNaN(parsed)) {
622
+ throw new Error(`Invalid due date: "${newDueDate}". Use an ISO date string, e.g. 2026-09-30T12:00:00Z`);
623
+ }
624
+ const normalizedDueDate = new Date(parsed).toISOString();
625
+ const graph = await this.loadGraph();
626
+ const chartEntity = graph.entities.find(e => e.name === `${chartId}_chart` && e.entityType === 'structural_tension_chart');
627
+ if (!chartEntity) {
628
+ throw new Error(`Chart ${chartId} not found`);
629
+ }
630
+ const timestamp = new Date().toISOString();
631
+ chartEntity.metadata = chartEntity.metadata || {};
632
+ const previousDueDate = typeof chartEntity.metadata.dueDate === 'string' ? chartEntity.metadata.dueDate : undefined;
633
+ chartEntity.metadata.dueDate = normalizedDueDate;
634
+ chartEntity.metadata.updatedAt = timestamp;
635
+ const desiredOutcomeEntity = graph.entities.find(e => e.name === `${chartId}_desired_outcome` && e.entityType === 'desired_outcome');
636
+ if (desiredOutcomeEntity) {
637
+ desiredOutcomeEntity.metadata = desiredOutcomeEntity.metadata || {};
638
+ desiredOutcomeEntity.metadata.dueDate = normalizedDueDate;
639
+ desiredOutcomeEntity.metadata.updatedAt = timestamp;
640
+ }
641
+ // The date a chart carried is part of its history. Recording the move on the
642
+ // chart itself keeps it readable later without touching current reality, which
643
+ // belongs to the work rather than to the schedule.
644
+ chartEntity.observations.push(`Due date changed from ${previousDueDate || 'unset'} to ${normalizedDueDate} on ${timestamp}`);
645
+ const units = this.collectChartWorkUnits(graph, chartId);
646
+ const openUnits = units.filter(u => !u.complete);
647
+ let actionStepsRescheduled = 0;
648
+ if (redistributeActionSteps && openUnits.length > 0) {
649
+ openUnits.sort((a, b) => {
650
+ const dateA = new Date(a.dueDate || normalizedDueDate).getTime();
651
+ const dateB = new Date(b.dueDate || normalizedDueDate).getTime();
652
+ return dateA - dateB;
653
+ });
654
+ const dates = this.distributeActionStepDates(new Date(timestamp), new Date(normalizedDueDate), openUnits.length);
655
+ openUnits.forEach((unit, index) => {
656
+ const moved = dates[index].toISOString();
657
+ unit.entities.forEach(entity => {
658
+ entity.metadata = entity.metadata || {};
659
+ entity.metadata.dueDate = moved;
660
+ entity.metadata.updatedAt = timestamp;
661
+ });
662
+ unit.dueDate = moved;
663
+ });
664
+ actionStepsRescheduled = openUnits.length;
665
+ }
666
+ const actionStepsPastDueDate = openUnits.filter(u => u.dueDate ? new Date(u.dueDate).getTime() > parsed : false).length;
667
+ await this.saveGraph(graph);
668
+ return {
669
+ chartId,
670
+ previousDueDate,
671
+ newDueDate: normalizedDueDate,
672
+ actionStepsRescheduled,
673
+ actionStepsPastDueDate,
674
+ };
675
+ }
676
+ /**
677
+ * A chart's work in the two shapes it takes: action_step entities on the chart
678
+ * itself, and telescoped child charts. A child telescoped out of one of the
679
+ * chart's own steps is that same result seen closer up — it travels with the
680
+ * step rather than counting as a second unit.
681
+ */
682
+ collectChartWorkUnits(graph, chartId) {
683
+ const ownSteps = graph.entities.filter(e => e.entityType === 'action_step' && e.metadata?.chartId === chartId);
684
+ const childCharts = graph.entities.filter(e => e.entityType === 'structural_tension_chart' && e.metadata?.parentChart === chartId);
685
+ const outcomeOf = (chart) => {
686
+ const childId = chart.metadata?.chartId || chart.name.replace('_chart', '');
687
+ return graph.entities.find(e => e.name === `${childId}_desired_outcome` && e.entityType === 'desired_outcome');
688
+ };
689
+ const stepNames = new Set(ownSteps.map(e => e.name));
690
+ const units = ownSteps.map(step => {
691
+ const grown = childCharts.filter(c => c.metadata?.parentActionStep === step.name);
692
+ const entities = [
693
+ step,
694
+ ...grown.flatMap(c => [c, outcomeOf(c)].filter((e) => Boolean(e))),
695
+ ];
696
+ return {
697
+ entities,
698
+ complete: step.metadata?.completionStatus === true,
699
+ dueDate: typeof step.metadata?.dueDate === 'string' ? step.metadata.dueDate : undefined,
700
+ };
701
+ });
702
+ childCharts
703
+ .filter(c => !(c.metadata?.parentActionStep && stepNames.has(c.metadata.parentActionStep)))
704
+ .forEach(chart => {
705
+ const outcome = outcomeOf(chart);
706
+ units.push({
707
+ entities: [chart, outcome].filter((e) => Boolean(e)),
708
+ complete: outcome?.metadata?.completionStatus === true,
709
+ dueDate: typeof chart.metadata?.dueDate === 'string' ? chart.metadata.dueDate : undefined,
710
+ });
711
+ });
712
+ return units;
713
+ }
469
714
  // ─── MMOT Evaluation ────────────────────────────────────────────────
470
715
  async performMmotEvaluation(chartId, phase = 'full', assessment, direction, correctiveActions, updateReality = true) {
716
+ assertNoUnparsedCallSyntax(assessment, 'assessment');
717
+ assertNoUnparsedCallSyntax(correctiveActions, 'correctiveActions');
471
718
  const graph = await this.loadGraph();
472
719
  const chartEntity = graph.entities.find(e => e.entityType === 'structural_tension_chart' &&
473
720
  e.metadata?.chartId === chartId);
@@ -536,11 +783,17 @@ export class KnowledgeGraphManager {
536
783
  const guidance = phaseGuidance[phase] || phaseGuidance.full || phaseGuidance.acknowledge;
537
784
  let evaluationStored = false;
538
785
  let beatEmitted = false;
539
- // Store evaluation observations
540
- if (assessment && updateReality) {
541
- const evalObservation = `[MMOT ${phase}${directionLabel}] ${assessment}`;
542
- if (currentReality) {
543
- currentReality.observations.push(evalObservation);
786
+ // Store evaluation observations.
787
+ //
788
+ // updateReality governs ONE record — the append into current reality — which is
789
+ // exactly what the tool declares it to mean ("whether to write evaluation
790
+ // observations into current reality"). The chart's own mmotEvaluations[] trail is
791
+ // a separate record and is written whenever an assessment was made. Gating both on
792
+ // one flag meant a caller who only asked to leave current reality alone lost the
793
+ // evaluation entirely, while still being handed a full phase response.
794
+ if (assessment) {
795
+ if (updateReality && currentReality) {
796
+ currentReality.observations.push(`[MMOT ${phase}${directionLabel}] ${assessment}`);
544
797
  if (currentReality.metadata) {
545
798
  currentReality.metadata.updatedAt = timestamp;
546
799
  }
@@ -711,6 +964,143 @@ export class KnowledgeGraphManager {
711
964
  return beats;
712
965
  }
713
966
  // ─── Add Action Step (telescoped chart) ─────────────────────────────
967
+ // ─── Wampum Belt Sequencing ─────────────────────────────────────────
968
+ // Runs in parallel with linear narrative beats: a grid of beads, each holding a
969
+ // mnemonic and its readings, optionally tied to a chart or beat by ceremony.
970
+ async createWampumBelt(title, purpose, rows = 1, cols = 1) {
971
+ if (!Number.isInteger(rows) || rows <= 0 || !Number.isInteger(cols) || cols <= 0) {
972
+ throw new Error(`rows and cols must be positive integers (received rows=${rows}, cols=${cols})`);
973
+ }
974
+ const beltId = `belt_${Date.now()}`;
975
+ const timestamp = new Date().toISOString();
976
+ const beltMetadata = {
977
+ beltId,
978
+ title,
979
+ purpose,
980
+ rows,
981
+ cols,
982
+ beads: [],
983
+ createdAt: timestamp,
984
+ updatedAt: timestamp,
985
+ };
986
+ const entity = {
987
+ name: `${beltId}_belt`,
988
+ entityType: 'wampum_belt',
989
+ observations: [`Wampum Belt created: ${title}`, `Purpose: ${purpose}`],
990
+ metadata: {
991
+ beltId,
992
+ createdAt: timestamp,
993
+ updatedAt: timestamp,
994
+ wampumBelt: beltMetadata,
995
+ },
996
+ };
997
+ await this.createEntities([entity]);
998
+ return { beltId, entity };
999
+ }
1000
+ async addWampumBead(beltId, mnemonic, color, position, reading, relationalReadings, ceremonyLink, observations = []) {
1001
+ const graph = await this.loadGraph();
1002
+ const beltEntity = graph.entities.find(e => e.entityType === 'wampum_belt' && e.metadata?.beltId === beltId);
1003
+ if (!beltEntity?.metadata?.wampumBelt) {
1004
+ throw new Error(`Wampum Belt not found: ${beltId}`);
1005
+ }
1006
+ const beltMeta = beltEntity.metadata.wampumBelt;
1007
+ if (!Number.isInteger(position.row) ||
1008
+ !Number.isInteger(position.col) ||
1009
+ position.row < 0 ||
1010
+ position.col < 0 ||
1011
+ position.row >= beltMeta.rows ||
1012
+ position.col >= beltMeta.cols) {
1013
+ throw new Error(`Position (${position.row},${position.col}) out of bounds for belt ${beltId} (${beltMeta.rows}x${beltMeta.cols})`);
1014
+ }
1015
+ const conflict = beltMeta.beads.find(b => b.position.row === position.row && b.position.col === position.col);
1016
+ if (conflict) {
1017
+ throw new Error(`Position (${position.row},${position.col}) already occupied by bead "${conflict.mnemonic}"`);
1018
+ }
1019
+ const timestamp = new Date().toISOString();
1020
+ const bead = {
1021
+ id: `bead_${beltId}_${position.row}_${position.col}`,
1022
+ mnemonic,
1023
+ color,
1024
+ position,
1025
+ reading,
1026
+ ...(relationalReadings ? { relationalReadings } : {}),
1027
+ ...(ceremonyLink ? { ceremonyLink } : {}),
1028
+ observations,
1029
+ createdAt: timestamp,
1030
+ };
1031
+ beltMeta.beads.push(bead);
1032
+ beltMeta.updatedAt = timestamp;
1033
+ beltEntity.metadata.updatedAt = timestamp;
1034
+ // Ceremony edges are subject to the BEAD, not the belt.
1035
+ //
1036
+ // A relation's identity in the store is (from, to, relationType) — see
1037
+ // jsonl-preservation.ts. With the belt as subject, a second bead linking the
1038
+ // same chart under a different ceremonyType collided on that triple: the
1039
+ // existence check below matched, the push was skipped, and the bead kept a
1040
+ // ceremonyType the graph had no edge for. Silent discard — the bead's own
1041
+ // record disagreed with the graph, and nothing said so.
1042
+ //
1043
+ // bead.id is `bead_${beltId}_${row}_${col}` and a position can be written only
1044
+ // once, so (bead, target, relationType) is unique by construction and every
1045
+ // ceremony link survives with its own ceremonyType.
1046
+ if (ceremonyLink?.chartId) {
1047
+ const from = bead.id;
1048
+ const to = `${ceremonyLink.chartId}_chart`;
1049
+ const exists = graph.relations.some(r => r.from === from && r.to === to && r.relationType === 'wampum_holds_accountable');
1050
+ if (!exists) {
1051
+ graph.relations.push({
1052
+ from,
1053
+ to,
1054
+ relationType: 'wampum_holds_accountable',
1055
+ metadata: { createdAt: timestamp, context: ceremonyLink.ceremonyType, beltId },
1056
+ });
1057
+ }
1058
+ }
1059
+ if (ceremonyLink?.beatName) {
1060
+ const from = bead.id;
1061
+ const to = ceremonyLink.beatName;
1062
+ const exists = graph.relations.some(r => r.from === from && r.to === to && r.relationType === 'wampum_witnesses');
1063
+ if (!exists) {
1064
+ graph.relations.push({
1065
+ from,
1066
+ to,
1067
+ relationType: 'wampum_witnesses',
1068
+ metadata: { createdAt: timestamp, beltId },
1069
+ });
1070
+ }
1071
+ }
1072
+ await this.saveGraph(graph);
1073
+ return { bead };
1074
+ }
1075
+ async readWampumBelt(beltId, position) {
1076
+ const graph = await this.loadGraph();
1077
+ const beltEntity = graph.entities.find(e => e.entityType === 'wampum_belt' && e.metadata?.beltId === beltId);
1078
+ if (!beltEntity?.metadata?.wampumBelt) {
1079
+ throw new Error(`Wampum Belt not found: ${beltId}`);
1080
+ }
1081
+ const belt = beltEntity.metadata.wampumBelt;
1082
+ if (!position) {
1083
+ return { belt };
1084
+ }
1085
+ if (!Number.isInteger(position.row) ||
1086
+ !Number.isInteger(position.col) ||
1087
+ position.row < 0 ||
1088
+ position.col < 0 ||
1089
+ position.row >= belt.rows ||
1090
+ position.col >= belt.cols) {
1091
+ throw new Error(`Position (${position.row},${position.col}) out of bounds for belt ${beltId} (${belt.rows}x${belt.cols})`);
1092
+ }
1093
+ const bead = belt.beads.find(b => b.position.row === position.row && b.position.col === position.col);
1094
+ if (!bead) {
1095
+ return { belt };
1096
+ }
1097
+ const colLabel = position.col === 0 ? 'left' : position.col === belt.cols - 1 ? 'right' : 'center';
1098
+ const positionalReading = bead.relationalReadings?.[`col:${position.col}`] ??
1099
+ bead.relationalReadings?.[`row:${position.row}`] ??
1100
+ bead.relationalReadings?.[colLabel] ??
1101
+ bead.reading;
1102
+ return { belt, bead, positionalReading };
1103
+ }
714
1104
  async addActionStep(parentChartId, actionStepTitle, dueDate, currentReality, performanceElements) {
715
1105
  const graph = await this.loadGraph();
716
1106
  const parentChart = graph.entities.find(e => e.entityType === 'structural_tension_chart' &&