@hassangameryt/freeflow 0.6.0 → 0.7.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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +4 -5
  4. package/README.md +39 -55
  5. package/capabilities/cognitive-routing/SKILL.md +288 -130
  6. package/command-surface.json +0 -24
  7. package/hooks/freeflow-runtime-context.mjs +42 -477
  8. package/hooks/hooks.json +1 -26
  9. package/hooks/tests/check-runtime-context-hook.sh +108 -319
  10. package/package.json +4 -2
  11. package/pi-extension/dist/cognitive-routing/commands.js +3 -3
  12. package/pi-extension/dist/cognitive-routing/config.js +16 -20
  13. package/pi-extension/dist/cognitive-routing/controller.js +478 -48
  14. package/pi-extension/dist/cognitive-routing/history.js +12 -0
  15. package/pi-extension/dist/cognitive-routing/host.js +74 -12
  16. package/pi-extension/dist/cognitive-routing/pi-routing-session.js +414 -0
  17. package/pi-extension/dist/cognitive-routing/pi-session-control.js +320 -0
  18. package/pi-extension/dist/cognitive-routing/tool.js +1 -0
  19. package/pi-extension/dist/cognitive-routing/types.js +1 -1
  20. package/pi-extension/dist/index.js +236 -219
  21. package/pi-extension/dist/runtime/runtime-context.js +126 -286
  22. package/pi-extension/dist/settings/settings-ui.js +52 -336
  23. package/pi-extension/src/cognitive-routing/commands.ts +15 -5
  24. package/pi-extension/src/cognitive-routing/config.ts +16 -20
  25. package/pi-extension/src/cognitive-routing/controller.ts +561 -51
  26. package/pi-extension/src/cognitive-routing/history.ts +12 -0
  27. package/pi-extension/src/cognitive-routing/host.ts +110 -16
  28. package/pi-extension/src/cognitive-routing/pi-routing-session.ts +538 -0
  29. package/pi-extension/src/cognitive-routing/pi-session-control.ts +444 -0
  30. package/pi-extension/src/cognitive-routing/tool.ts +1 -0
  31. package/pi-extension/src/cognitive-routing/types.ts +1 -1
  32. package/pi-extension/src/index.ts +249 -239
  33. package/pi-extension/src/runtime/runtime-context.ts +129 -305
  34. package/pi-extension/src/settings/settings-ui.ts +53 -371
  35. package/pi-extension/tests/cognitive-routing/commands.test.js +1 -1
  36. package/pi-extension/tests/cognitive-routing/controller.test.js +69 -40
  37. package/pi-extension/tests/cognitive-routing/extension-lifecycle.test.js +128 -57
  38. package/pi-extension/tests/cognitive-routing/pi-normal-lifecycle.test.js +434 -0
  39. package/pi-extension/tests/cognitive-routing/pi-session-control.test.js +174 -0
  40. package/pi-extension/tests/cognitive-routing/pi-session-history.test.js +91 -0
  41. package/pi-extension/tests/cognitive-routing/profile-resolution.test.js +47 -4
  42. package/pi-extension/tests/integration/mode-free-core.test.js +218 -0
  43. package/pi-extension/tests/integration/pi-extension.test.js +430 -2478
  44. package/pi-extension/tests/integration/prompt-architecture.test.js +124 -79
  45. package/pi-extension/tests/integration/unaffected-regressions.test.js +411 -0
  46. package/runtime/prompts/cognitive-routing.md +5 -4
  47. package/runtime/prompts/conversation-history.md +1 -1
  48. package/runtime/prompts/core.md +77 -6
  49. package/runtime/prompts/interaction-contract.md +6 -2
  50. package/skills/action-selection/SKILL.md +93 -44
  51. package/skills/action-selection/references/trajectory-stalls.md +71 -0
  52. package/skills/bypass/SKILL.md +109 -39
  53. package/skills/commit-work/SKILL.md +94 -55
  54. package/skills/commit-work/references/git-state-edges.md +94 -0
  55. package/skills/commit-work/references/simple-push.md +92 -0
  56. package/skills/commit-work/references/staging-decisions.md +79 -46
  57. package/skills/decision-gate/SKILL.md +80 -22
  58. package/skills/design-for-depth/SKILL.md +208 -43
  59. package/skills/design-for-depth/references/design-pressure-signals.md +108 -240
  60. package/skills/design-for-depth/references/interface-design-loop.md +77 -97
  61. package/skills/design-for-depth/references/module-and-dependency-design.md +147 -0
  62. package/skills/design-for-depth/references/software-design-philosophy.md +127 -173
  63. package/skills/design-for-depth/references/state-and-failure-boundaries.md +136 -0
  64. package/skills/diagnose-failure/SKILL.md +198 -52
  65. package/skills/diagnose-failure/references/diagnostic-loop-catalog.md +19 -5
  66. package/skills/diagnose-failure/references/flaky-and-performance-diagnosis.md +13 -5
  67. package/skills/discuss/SKILL.md +118 -76
  68. package/skills/discuss/references/discussion-continuity.md +84 -32
  69. package/skills/evaluate-skill/SKILL.md +166 -52
  70. package/skills/evaluate-skill/references/evaluation-definition-schema.md +2 -2
  71. package/skills/evaluate-skill/references/evaluation-design.md +56 -30
  72. package/skills/evaluate-skill/references/execution-and-evidence.md +1 -1
  73. package/skills/evaluate-skill/references/review-and-revision.md +62 -21
  74. package/skills/execute-work/SKILL.md +98 -59
  75. package/skills/execute-work/references/code-practices.md +4 -4
  76. package/skills/execute-work/references/execute-work-edges.md +42 -22
  77. package/skills/review-artifact/SKILL.md +132 -71
  78. package/skills/review-artifact/references/adjudicate-artifact-review.md +101 -0
  79. package/skills/review-artifact/references/independent-artifact-reviewer-contract.md +52 -41
  80. package/skills/review-work/SKILL.md +115 -72
  81. package/skills/review-work/references/adjudicate-work-review.md +97 -0
  82. package/skills/review-work/references/independent-work-reviewer-contract.md +40 -35
  83. package/skills/review-work/references/security-risk-lens.md +17 -15
  84. package/skills/setup-freeflow/SKILL.md +14 -17
  85. package/skills/setup-freeflow/references/activation-contract.md +24 -30
  86. package/skills/setup-freeflow/references/host-setup.md +6 -7
  87. package/skills/simplify-code/SKILL.md +97 -39
  88. package/skills/simplify-code/references/simplification-patterns.md +1 -1
  89. package/skills/tdd/SKILL.md +108 -41
  90. package/skills/tdd/references/test-design.md +1 -1
  91. package/skills/track-work/SKILL.md +133 -300
  92. package/skills/track-work/references/working-record-format.md +515 -0
  93. package/skills/track-work/scripts/lib/document.mjs +713 -0
  94. package/skills/track-work/scripts/lib/format.mjs +166 -0
  95. package/skills/track-work/scripts/lib/store.mjs +233 -0
  96. package/skills/track-work/scripts/lib/transitions.mjs +790 -0
  97. package/skills/track-work/scripts/lib/views.mjs +27 -430
  98. package/skills/track-work/scripts/working-record.mjs +292 -98
  99. package/skills/verify-work/SKILL.md +46 -20
  100. package/skills/verify-work/references/browser-runtime-evidence.md +1 -1
  101. package/skills/verify-work/references/integration-evidence.md +1 -7
  102. package/skills/verify-work/references/performance-evidence.md +1 -1
  103. package/skills/workflow/SKILL.md +110 -80
  104. package/skills/workflow/references/domain-skill-composition.md +24 -18
  105. package/skills/write-skill/SKILL.md +218 -62
  106. package/skills/write-skill/references/activation-boundaries.md +63 -21
  107. package/skills/write-skill/references/agent-first-instructions.md +61 -27
  108. package/skills/write-skill/references/progressive-disclosure.md +91 -41
  109. package/skills/write-skill/references/skill-dependency-graphs.md +190 -0
  110. package/runtime/prompts/skills.md +0 -65
  111. package/skills/mode-contract/SKILL.md +0 -104
  112. package/skills/track-work/scripts/lib/codec.mjs +0 -570
  113. package/skills/track-work/scripts/lib/commands.mjs +0 -568
  114. package/skills/track-work/scripts/lib/contract.mjs +0 -303
  115. package/skills/track-work/scripts/lib/model.mjs +0 -506
  116. package/skills/track-work/scripts/lib/operations.mjs +0 -965
  117. package/skills/track-work/scripts/lib/record-store.mjs +0 -264
  118. package/skills/track-work/scripts/lib/result.mjs +0 -37
  119. package/skills/track-work/scripts/lib/rewrites.mjs +0 -740
  120. package/skills/track-work/scripts/lib/schema.mjs +0 -277
  121. package/skills/track-work/scripts/lib/source-inventory.mjs +0 -162
  122. package/skills/track-work/scripts/lib/workspace.mjs +0 -134
@@ -0,0 +1,713 @@
1
+ import {
2
+ ACTIVE_SLICE_FIELDS,
3
+ CHECKPOINT_TYPES,
4
+ CURRENT_SLICE_STATES,
5
+ DECISION_FIELDS,
6
+ DECISION_STATES,
7
+ FUTURE_CHECKPOINT_FIELDS,
8
+ FUTURE_CHECKPOINT_STATES,
9
+ HISTORICAL_CHECKPOINT_FIELDS,
10
+ HISTORICAL_CHECKPOINT_STATES,
11
+ HISTORICAL_SLICE_FIELDS,
12
+ HISTORICAL_SLICE_STATES,
13
+ HISTORY_HEADINGS,
14
+ PROPOSED_SLICE_FIELDS,
15
+ SCHEMA_VERSION,
16
+ SLICE_TYPES,
17
+ TASK_STATES,
18
+ TOP_LEVEL_SECTIONS,
19
+ joinLines,
20
+ nextId,
21
+ parseId,
22
+ splitLines,
23
+ } from "./format.mjs";
24
+
25
+ export class DocumentError extends Error {
26
+ constructor(code, message, details = {}) {
27
+ super(message);
28
+ this.name = "DocumentError";
29
+ this.code = code;
30
+ this.details = details;
31
+ }
32
+ }
33
+
34
+ function fail(code, message, details = {}) {
35
+ throw new DocumentError(code, message, details);
36
+ }
37
+
38
+ function lineHeading(line) {
39
+ const match = /^(#{1,6})[ \t]+(.+?)\s*$/.exec(line);
40
+ return match ? { level: match[1].length, title: match[2] } : null;
41
+ }
42
+
43
+ function isBlank(line) {
44
+ return line.trim() === "";
45
+ }
46
+
47
+ function isUtcTimestamp(value) {
48
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) && !Number.isNaN(Date.parse(value));
49
+ }
50
+
51
+ function fieldLine(line) {
52
+ const compact = /^- ([A-Za-z][A-Za-z -]*):(?:[ \t]*(.*))?$/.exec(line);
53
+ if (compact) return { name: compact[1], inline: compact[2] ?? "", style: "compact" };
54
+ const expanded = /^([A-Za-z][A-Za-z -]*):(?:[ \t]*(.*))?$/.exec(line);
55
+ return expanded ? { name: expanded[1], inline: expanded[2] ?? "", style: "expanded" } : null;
56
+ }
57
+
58
+ function nonBlankLines(lines) {
59
+ return lines.filter((line) => !isBlank(line));
60
+ }
61
+
62
+ function hasMeaningfulContent(lines, start, end) {
63
+ return lines.slice(start, end).some((line) => line.replace(/^\s*[-*+]\s*/, "").trim() !== "");
64
+ }
65
+
66
+ function trimValueLines(lines) {
67
+ let start = 0;
68
+ let end = lines.length;
69
+ while (start < end && isBlank(lines[start])) start += 1;
70
+ while (end > start && isBlank(lines[end - 1])) end -= 1;
71
+ return lines.slice(start, end);
72
+ }
73
+
74
+ function valueText(field) {
75
+ if (!field) return "";
76
+ return trimValueLines(field.valueLines).join("\n").trim();
77
+ }
78
+
79
+ function scalarValue(block, name) {
80
+ return valueText(block.fields.get(name));
81
+ }
82
+
83
+ function requiredValue(block, name) {
84
+ const value = scalarValue(block, name);
85
+ if (!value) fail("missing-field", `Missing or empty field ${name} in ${block.path}`);
86
+ return value;
87
+ }
88
+
89
+ function fieldNames(allowed) {
90
+ return [...allowed].sort().join(", ");
91
+ }
92
+
93
+ function detectFieldStyle(lines, start, end) {
94
+ for (let index = start; index < end; index += 1) {
95
+ if (isBlank(lines[index])) continue;
96
+ const foundField = fieldLine(lines[index]);
97
+ return foundField?.style ?? "expanded";
98
+ }
99
+ return "expanded";
100
+ }
101
+
102
+ function parseFields(lines, start, end, allowed, path, allowedNestedHeadings = new Set()) {
103
+ const fields = new Map();
104
+ const style = detectFieldStyle(lines, start, end);
105
+ let current = null;
106
+ let inNestedBlock = false;
107
+ for (let index = start; index < end; index += 1) {
108
+ const line = lines[index];
109
+ const foundHeading = lineHeading(line);
110
+ if (foundHeading) {
111
+ if (foundHeading.level <= 4) fail("malformed-structure", `Unexpected heading in ${path}: ${line}`);
112
+ if (!allowedNestedHeadings.has(foundHeading.title))
113
+ fail("unknown-heading", `Unknown nested heading ${foundHeading.title} in ${path}`);
114
+ current = null;
115
+ inNestedBlock = true;
116
+ continue;
117
+ }
118
+ if (inNestedBlock) continue;
119
+ const foundField = fieldLine(line);
120
+ if (foundField && foundField.style !== style) {
121
+ if (style === "expanded" && current && foundField.style === "compact") {
122
+ current.valueLines.push(line);
123
+ continue;
124
+ }
125
+ fail("mixed-field-style", `Mixed structured-field styles in ${path}: ${line}`);
126
+ }
127
+ if (foundField) {
128
+ if (!allowed.has(foundField.name))
129
+ fail("unknown-field", `Unknown field ${foundField.name} in ${path}; allowed fields: ${fieldNames(allowed)}`);
130
+ if (fields.has(foundField.name)) fail("duplicate-field", `Duplicate field ${foundField.name} in ${path}`);
131
+ current = {
132
+ name: foundField.name,
133
+ start: index,
134
+ valueLines: foundField.inline === "" ? [] : [foundField.inline],
135
+ };
136
+ fields.set(foundField.name, current);
137
+ continue;
138
+ }
139
+ if (current) {
140
+ if (style === "compact" && !isBlank(line)) {
141
+ if (!line.startsWith(" "))
142
+ fail("malformed-field", `Compact field values must be indented in ${path}: ${line}`);
143
+ current.valueLines.push(line.slice(2));
144
+ } else {
145
+ current.valueLines.push(line);
146
+ }
147
+ } else if (!isBlank(line) && style === "compact") {
148
+ fail("unowned-content", `Unowned content in ${path}: ${line}`);
149
+ } else if (!isBlank(line)) {
150
+ fail("unowned-content", `Unowned content in ${path}: ${line}`);
151
+ }
152
+ }
153
+ return { fields, style };
154
+ }
155
+
156
+ function parseBlock(lines, start, end, kind, title, id, allowed, path) {
157
+ const nested =
158
+ path === "Current Slice"
159
+ ? new Set(["Material updates"])
160
+ : path.startsWith("History/")
161
+ ? new Set(["Corrections"])
162
+ : new Set();
163
+ const parsed = parseFields(lines, start + 1, end, allowed, path, nested);
164
+ return { kind, title, id: id ?? null, start, end, fields: parsed.fields, fieldStyle: parsed.style, path };
165
+ }
166
+
167
+ function findHeadings(lines, start, end, level) {
168
+ const result = [];
169
+ for (let index = start; index < end; index += 1) {
170
+ const found = lineHeading(lines[index]);
171
+ if (found?.level === level) result.push({ index, title: found.title });
172
+ }
173
+ return result;
174
+ }
175
+
176
+ function blockEnd(lines, headingIndex, sectionEnd, level) {
177
+ for (let index = headingIndex + 1; index < sectionEnd; index += 1) {
178
+ const found = lineHeading(lines[index]);
179
+ if (found && found.level <= level) return index;
180
+ }
181
+ return sectionEnd;
182
+ }
183
+
184
+ function parseHeader(lines, firstSectionIndex) {
185
+ if (!/^# Working Record:\s*.+$/.test(lines[0] ?? ""))
186
+ fail("malformed-header", "The record must begin with '# Working Record: <task name>'");
187
+ const name = lines[0].slice("# Working Record:".length).trim();
188
+ const schemaLines = [];
189
+ const stateLines = [];
190
+ const updatedLines = [];
191
+ for (let index = 1; index < firstSectionIndex; index += 1) {
192
+ if (isBlank(lines[index])) continue;
193
+ const schema = /^Schema:\s*(\d+)\s*$/.exec(lines[index]);
194
+ if (schema) {
195
+ schemaLines.push({ index, value: Number(schema[1]) });
196
+ continue;
197
+ }
198
+ const state = /^State:\s*(\S+)\s*$/.exec(lines[index]);
199
+ if (state) {
200
+ stateLines.push({ index, value: state[1] });
201
+ continue;
202
+ }
203
+ const updated = /^Last updated:\s*(\S+)\s*$/.exec(lines[index]);
204
+ if (updated) {
205
+ updatedLines.push({ index, value: updated[1] });
206
+ continue;
207
+ }
208
+ fail("unknown-header-field", `Unknown header content: ${lines[index]}`);
209
+ }
210
+ if (schemaLines.length !== 1) fail("missing-field", "Header must contain exactly one Schema field");
211
+ if (stateLines.length !== 1) fail("missing-field", "Header must contain exactly one State field");
212
+ if (updatedLines.length !== 1) fail("missing-field", "Header must contain exactly one Last updated field");
213
+ if (!isUtcTimestamp(updatedLines[0].value))
214
+ fail("invalid-timestamp", `Invalid Last updated value: ${updatedLines[0].value}`);
215
+ if (schemaLines[0].value !== SCHEMA_VERSION)
216
+ fail("unsupported-schema", `Expected Schema ${SCHEMA_VERSION}, found Schema ${schemaLines[0].value}`);
217
+ if (!TASK_STATES.has(stateLines[0].value)) fail("invalid-state", `Invalid task State: ${stateLines[0].value}`);
218
+ return {
219
+ name,
220
+ schema: schemaLines[0].value,
221
+ state: stateLines[0].value,
222
+ stateLine: stateLines[0].index,
223
+ lastUpdated: updatedLines[0].value,
224
+ lastUpdatedLine: updatedLines[0].index,
225
+ };
226
+ }
227
+
228
+ function parseTopLevelSections(lines) {
229
+ const headings = [];
230
+ for (let index = 0; index < lines.length; index += 1) {
231
+ const found = lineHeading(lines[index]);
232
+ if (found?.level === 2) {
233
+ headings.push({ index, title: found.title });
234
+ if (found.title === "Notes") break;
235
+ }
236
+ }
237
+ if (headings.length !== TOP_LEVEL_SECTIONS.length)
238
+ fail("invalid-sections", `Expected exactly ${TOP_LEVEL_SECTIONS.length} top-level sections`);
239
+ for (let index = 0; index < TOP_LEVEL_SECTIONS.length; index += 1) {
240
+ if (headings[index].title !== TOP_LEVEL_SECTIONS[index])
241
+ fail(
242
+ "invalid-sections",
243
+ `Expected top-level section ${TOP_LEVEL_SECTIONS[index]}, found ${headings[index].title}`,
244
+ );
245
+ }
246
+ return Object.fromEntries(
247
+ headings.map((item, index) => [
248
+ item.title,
249
+ { headingIndex: item.index, start: item.index + 1, end: headings[index + 1]?.index ?? lines.length },
250
+ ]),
251
+ );
252
+ }
253
+
254
+ function parseContext(lines, section) {
255
+ const headings = findHeadings(lines, section.start, section.end, 3);
256
+ if (headings.length !== 7)
257
+ fail("invalid-context", "Current Context must contain exactly the seven canonical headings");
258
+ const statements = new Map();
259
+ for (let index = 0; index < headings.length; index += 1) {
260
+ const expected = [
261
+ "Goal",
262
+ "What defines this task",
263
+ "Settled",
264
+ "Tentative",
265
+ "Open",
266
+ "Current direction",
267
+ "Boundaries",
268
+ ][index];
269
+ if (headings[index].title !== expected) fail("invalid-context", `Expected Current Context heading ${expected}`);
270
+ const end = headings[index + 1]?.index ?? section.end;
271
+ statements.set(expected, { headingIndex: headings[index].index, start: headings[index].index + 1, end });
272
+ }
273
+ return { ...section, headings: statements };
274
+ }
275
+
276
+ function parseCurrentWork(lines, section) {
277
+ const headings = findHeadings(lines, section.start, section.end, 3);
278
+ if (headings.length !== 2 || headings[0].title !== "Current Slice" || headings[1].title !== "Next useful action")
279
+ fail("invalid-current-work", "Current Work must contain Current Slice followed by Next useful action");
280
+ const sliceContentStart = headings[0].index + 1;
281
+ const sliceContentEnd = headings[1].index;
282
+ const content = nonBlankLines(lines.slice(sliceContentStart, sliceContentEnd));
283
+ let currentSlice = null;
284
+ if (content.length === 1 && content[0] === "None") {
285
+ currentSlice = null;
286
+ } else {
287
+ const entries = findHeadings(lines, sliceContentStart, sliceContentEnd, 4);
288
+ if (entries.length !== 1)
289
+ fail("invalid-current-slice", "Current Slice must contain exactly one S-NNN block or None");
290
+ const found = /^S-(\d{3,})\s+—\s+(.+)$/.exec(entries[0].title);
291
+ if (!found) fail("invalid-current-slice", `Invalid Current Slice heading: ${lines[entries[0].index]}`);
292
+ const end = blockEnd(lines, entries[0].index, sliceContentEnd, 4);
293
+ currentSlice = parseBlock(
294
+ lines,
295
+ entries[0].index,
296
+ end,
297
+ "slice",
298
+ found[2],
299
+ `S-${found[1]}`,
300
+ ACTIVE_SLICE_FIELDS,
301
+ "Current Slice",
302
+ );
303
+ }
304
+ return {
305
+ ...section,
306
+ sliceHeadingIndex: headings[0].index,
307
+ nextActionHeadingIndex: headings[1].index,
308
+ currentSlice,
309
+ nextAction: { start: headings[1].index + 1, end: section.end },
310
+ };
311
+ }
312
+
313
+ function parseFuture(lines, section) {
314
+ const headings = findHeadings(lines, section.start, section.end, 3);
315
+ const items = [];
316
+ for (const itemHeading of headings) {
317
+ let match = /^Slice\s+—\s+(.+)$/.exec(itemHeading.title);
318
+ let kind = "slice";
319
+ let id = null;
320
+ let title;
321
+ if (match) {
322
+ title = match[1];
323
+ } else {
324
+ match = /^Checkpoint(?:\s+(C-\d{3,}))?\s+—\s+(.+)$/.exec(itemHeading.title);
325
+ if (!match) fail("invalid-future-item", `Unknown Future Work heading: ${lines[itemHeading.index]}`);
326
+ kind = "checkpoint";
327
+ id = match[1] ?? null;
328
+ title = match[2];
329
+ }
330
+ const end = blockEnd(lines, itemHeading.index, section.end, 3);
331
+ const allowed = kind === "slice" ? PROPOSED_SLICE_FIELDS : FUTURE_CHECKPOINT_FIELDS;
332
+ items.push(parseBlock(lines, itemHeading.index, end, kind, title, id, allowed, `Future Work/${title}`));
333
+ }
334
+ return { ...section, items };
335
+ }
336
+
337
+ function parseHistorySubsection(lines, section, title, kind, idPattern, allowed) {
338
+ const heading = findHeadings(lines, section.start, section.end, 3).find((item) => item.title === title);
339
+ if (!heading) fail("invalid-history", `History is missing the ${title} subsection`);
340
+ const subsectionEnd = blockEnd(lines, heading.index, section.end, 3);
341
+ const entries = findHeadings(lines, heading.index + 1, subsectionEnd, 4);
342
+ const blocks = [];
343
+ for (const entry of entries) {
344
+ const match = idPattern.exec(entry.title);
345
+ if (!match) fail("invalid-history-entry", `Invalid ${title} heading: ${lines[entry.index]}`);
346
+ const end = blockEnd(lines, entry.index, subsectionEnd, 4);
347
+ blocks.push(parseBlock(lines, entry.index, end, kind, match[2], match[1], allowed, `History/${title}/${match[1]}`));
348
+ }
349
+ return { headingIndex: heading.index, start: heading.index + 1, end: subsectionEnd, blocks };
350
+ }
351
+
352
+ function parseHistory(lines, section) {
353
+ const headings = findHeadings(lines, section.start, section.end, 3);
354
+ if (
355
+ headings.length !== HISTORY_HEADINGS.length ||
356
+ headings.some((item, index) => item.title !== HISTORY_HEADINGS[index])
357
+ )
358
+ fail("invalid-history", "History must contain Decisions, Checkpoints, and Slices in that order");
359
+ return {
360
+ ...section,
361
+ decisions: parseHistorySubsection(
362
+ lines,
363
+ section,
364
+ "Decisions",
365
+ "decision",
366
+ /^(D-\d{3,})\s+—\s+(.+)$/,
367
+ DECISION_FIELDS,
368
+ ),
369
+ checkpoints: parseHistorySubsection(
370
+ lines,
371
+ section,
372
+ "Checkpoints",
373
+ "checkpoint",
374
+ /^(C-\d{3,})\s+—\s+(.+)$/,
375
+ HISTORICAL_CHECKPOINT_FIELDS,
376
+ ),
377
+ slices: parseHistorySubsection(
378
+ lines,
379
+ section,
380
+ "Slices",
381
+ "slice",
382
+ /^(S-\d{3,})\s+—\s+(.+)$/,
383
+ HISTORICAL_SLICE_FIELDS,
384
+ ),
385
+ };
386
+ }
387
+
388
+ function validateChoice(block, field, choices, required = true) {
389
+ const value = scalarValue(block, field);
390
+ if (!value && !required) return "";
391
+ if (!value) requiredValue(block, field);
392
+ if (!choices.has(value)) fail("invalid-state", `Invalid ${field} in ${block.path}: ${value}`);
393
+ return value;
394
+ }
395
+
396
+ function validateFields(block, required, conditional = {}) {
397
+ for (const field of required) requiredValue(block, field);
398
+ for (const [field, condition] of Object.entries(conditional)) {
399
+ if (condition(block)) requiredValue(block, field);
400
+ }
401
+ }
402
+
403
+ function allBlocks(document) {
404
+ return [
405
+ ...(document.current.currentSlice ? [document.current.currentSlice] : []),
406
+ ...document.future.items,
407
+ ...document.history.decisions.blocks,
408
+ ...document.history.checkpoints.blocks,
409
+ ...document.history.slices.blocks,
410
+ ];
411
+ }
412
+
413
+ function validateReferences(document) {
414
+ const currentSlice = document.current.currentSlice;
415
+ const historicalSlices = document.history.slices.blocks;
416
+ const sliceIds = new Set([...(currentSlice ? [currentSlice] : []), ...historicalSlices].map((block) => block.id));
417
+ const historicalSliceIds = new Set(historicalSlices.map((block) => block.id));
418
+ const futureSliceTitles = new Set(
419
+ document.future.items.filter((item) => item.kind === "slice").map((item) => item.title),
420
+ );
421
+ const checkpointIds = new Set([
422
+ ...document.future.items.filter((item) => item.kind === "checkpoint" && item.id).map((item) => item.id),
423
+ ...document.history.checkpoints.blocks.map((item) => item.id),
424
+ ]);
425
+ const decisionIds = new Set(document.history.decisions.blocks.map((item) => item.id));
426
+
427
+ for (const checkpoint of document.future.items.filter((item) => item.kind === "checkpoint")) {
428
+ const appliesTo = requiredValue(checkpoint, "Applies to");
429
+ if (!sliceIds.has(appliesTo) && !futureSliceTitles.has(appliesTo))
430
+ fail("invalid-reference", `Checkpoint ${checkpoint.title} applies to unknown Slice ${appliesTo}`);
431
+ }
432
+ for (const checkpoint of document.history.checkpoints.blocks) {
433
+ const appliesTo = requiredValue(checkpoint, "Applies to");
434
+ if (!sliceIds.has(appliesTo))
435
+ fail("invalid-reference", `Checkpoint ${checkpoint.id} applies to unknown Slice ${appliesTo}`);
436
+ if (scalarValue(checkpoint, "State") === "replaced") {
437
+ const replacement = requiredValue(checkpoint, "Replaced by");
438
+ if (!checkpointIds.has(replacement) || replacement === checkpoint.id)
439
+ fail("invalid-reference", `Invalid replacement Checkpoint ${replacement}`);
440
+ }
441
+ }
442
+ for (const decision of document.history.decisions.blocks) {
443
+ const state = scalarValue(decision, "State");
444
+ if (state === "superseded") {
445
+ const replacement = requiredValue(decision, "Superseded by");
446
+ if (!decisionIds.has(replacement) || replacement === decision.id)
447
+ fail("invalid-reference", `Invalid replacement Decision ${replacement}`);
448
+ }
449
+ }
450
+ if (currentSlice) {
451
+ const continues = scalarValue(currentSlice, "Reopened from");
452
+ if (continues && (!historicalSliceIds.has(continues) || continues === currentSlice.id))
453
+ fail("invalid-reference", `Current Slice continues unknown historical Slice ${continues}`);
454
+ }
455
+ for (const slice of historicalSlices) {
456
+ const continues = scalarValue(slice, "Reopened from");
457
+ if (continues && (!historicalSliceIds.has(continues) || continues === slice.id))
458
+ fail("invalid-reference", `Historical Slice ${slice.id} continues unknown historical Slice ${continues}`);
459
+ }
460
+ }
461
+
462
+ export function validateDocument(document) {
463
+ const { header, context, current, future, history } = document;
464
+ if (!header.name) fail("malformed-header", "Task name cannot be empty");
465
+ const goal = context.headings.get("Goal");
466
+ if (!hasMeaningfulContent(document.lines, goal.start, goal.end))
467
+ fail("missing-field", "Current Context Goal must contain meaningful content");
468
+ if (!hasMeaningfulContent(document.lines, current.nextAction.start, current.nextAction.end))
469
+ fail("missing-field", "Next useful action must contain meaningful content");
470
+ for (const block of allBlocks(document)) {
471
+ if (block.id && !parseId(block.id, block.kind)) fail("invalid-id", `Invalid ${block.kind} ID ${block.id}`);
472
+ }
473
+
474
+ const ids = new Set();
475
+ for (const block of allBlocks(document)) {
476
+ if (block.id) {
477
+ if (ids.has(block.id)) fail("duplicate-id", `Duplicate durable ID ${block.id}`);
478
+ ids.add(block.id);
479
+ }
480
+ }
481
+
482
+ for (const item of future.items) {
483
+ if (!item.title.trim()) fail("invalid-title", "Future Work item titles cannot be empty");
484
+ if (item.kind === "slice") {
485
+ validateFields(item, ["State", "Intended result"], {});
486
+ validateChoice(item, "State", new Set(["proposed"]));
487
+ const type = scalarValue(item, "Type");
488
+ if (type && !SLICE_TYPES.has(type)) fail("invalid-type", `Invalid Slice Type ${type}`);
489
+ } else {
490
+ validateFields(item, ["State"], {});
491
+ validateChoice(item, "State", FUTURE_CHECKPOINT_STATES);
492
+ validateChoice(item, "Type", CHECKPOINT_TYPES);
493
+ validateFields(item, ["Condition", "Applies to"]);
494
+ if (item.id && !parseId(item.id, "checkpoint")) fail("invalid-id", `Invalid Checkpoint ID ${item.id}`);
495
+ const checkpointState = scalarValue(item, "State");
496
+ if (checkpointState === "proposed" && item.id)
497
+ fail("invalid-future-item", "A proposed Checkpoint cannot have a durable ID");
498
+ if (checkpointState !== "proposed" && !item.id)
499
+ fail("invalid-future-item", "A pending or deferred Checkpoint requires a durable ID");
500
+ }
501
+ }
502
+
503
+ const futureTitles = new Set();
504
+ for (const item of future.items) {
505
+ if (futureTitles.has(item.title)) fail("duplicate-title", `Duplicate Future Work title: ${item.title}`);
506
+ futureTitles.add(item.title);
507
+ }
508
+
509
+ if (current.currentSlice) {
510
+ validateChoice(current.currentSlice, "State", CURRENT_SLICE_STATES);
511
+ validateFields(current.currentSlice, [
512
+ "Intended result",
513
+ "Authority source",
514
+ "Scope",
515
+ "Expected evidence",
516
+ "Stop condition",
517
+ "Starting state",
518
+ ]);
519
+ const type = scalarValue(current.currentSlice, "Type");
520
+ if (type && !SLICE_TYPES.has(type)) fail("invalid-type", `Invalid Current Slice Type ${type}`);
521
+ }
522
+
523
+ for (const decision of history.decisions.blocks) {
524
+ validateChoice(decision, "State", DECISION_STATES);
525
+ validateFields(decision, ["Decision", "Established by", "Rationale", "Consequences", "Revisit when"]);
526
+ const state = scalarValue(decision, "State");
527
+ if (state === "superseded") requiredValue(decision, "Superseded by");
528
+ if (state === "retired") requiredValue(decision, "Retired because");
529
+ }
530
+
531
+ for (const checkpoint of history.checkpoints.blocks) {
532
+ validateChoice(checkpoint, "State", HISTORICAL_CHECKPOINT_STATES);
533
+ validateChoice(checkpoint, "Type", CHECKPOINT_TYPES);
534
+ validateFields(checkpoint, ["Condition", "Applies to", "Result", "Task effect"]);
535
+ const state = scalarValue(checkpoint, "State");
536
+ if (state === "cancelled" || state === "replaced") requiredValue(checkpoint, "Reason");
537
+ if (state === "replaced") requiredValue(checkpoint, "Replaced by");
538
+ }
539
+
540
+ for (const slice of history.slices.blocks) {
541
+ validateChoice(slice, "State", HISTORICAL_SLICE_STATES);
542
+ validateFields(slice, ["Intended result", "Result", "Evidence and limits", "Task effect"]);
543
+ const state = scalarValue(slice, "State");
544
+ if (state === "blocked") requiredValue(slice, "Resume when");
545
+ if (state === "abandoned") requiredValue(slice, "Reason");
546
+ }
547
+
548
+ if (header.state === "paused" && current.currentSlice && scalarValue(current.currentSlice, "State") !== "paused")
549
+ fail("invalid-task-state", "A paused task must have a paused Current Slice");
550
+ if ((header.state === "completed" || header.state === "abandoned") && current.currentSlice)
551
+ fail("invalid-task-state", `A ${header.state} task cannot have a Current Slice`);
552
+ if (
553
+ (header.state === "completed" || header.state === "abandoned") &&
554
+ future.items.some(
555
+ (item) => item.kind === "checkpoint" && ["pending", "deferred"].includes(scalarValue(item, "State")),
556
+ )
557
+ )
558
+ fail("invalid-task-state", `A ${header.state} task cannot have a pending or deferred Checkpoint`);
559
+
560
+ validateReferences(document);
561
+ return document;
562
+ }
563
+
564
+ export function parseDocument(text, { validate = true } = {}) {
565
+ if (typeof text !== "string" || !text) fail("empty-record", "Record is empty");
566
+ const { lines, newline } = splitLines(text);
567
+ const topLevel = parseTopLevelSections(lines);
568
+ const header = parseHeader(lines, topLevel[TOP_LEVEL_SECTIONS[0]].headingIndex);
569
+ const context = parseContext(lines, topLevel["Current Context"]);
570
+ const current = parseCurrentWork(lines, topLevel["Current Work"]);
571
+ const future = parseFuture(lines, topLevel["Future Work"]);
572
+ const history = parseHistory(lines, topLevel.History);
573
+ const document = {
574
+ text,
575
+ lines,
576
+ newline,
577
+ header,
578
+ sections: topLevel,
579
+ context,
580
+ current,
581
+ future,
582
+ history,
583
+ notes: topLevel.Notes,
584
+ };
585
+ return validate ? validateDocument(document) : document;
586
+ }
587
+
588
+ export function parseFragment(text, allowed, path = "input") {
589
+ if (typeof text !== "string") fail("invalid-input", `Input for ${path} must be text`);
590
+ const { lines } = splitLines(text);
591
+ const parsed = parseFields(lines, 0, lines.length, allowed, path);
592
+ return { fields: parsed.fields, fieldStyle: parsed.style, lines };
593
+ }
594
+
595
+ export function fieldValue(block, name) {
596
+ return scalarValue(block, name);
597
+ }
598
+
599
+ export function fieldLines(block, name) {
600
+ return block.fields.has(name) ? trimValueLines(block.fields.get(name).valueLines) : null;
601
+ }
602
+
603
+ export function hasField(block, name) {
604
+ return block.fields.has(name) && Boolean(valueText(block.fields.get(name)));
605
+ }
606
+
607
+ export function formatField(name, linesOrValue, style = "expanded") {
608
+ const raw = Array.isArray(linesOrValue) ? linesOrValue : String(linesOrValue ?? "").split("\n");
609
+ const lines = trimValueLines(raw);
610
+ if (style === "compact") {
611
+ if (lines.length === 0) return [`- ${name}:`];
612
+ if (lines.length === 1) {
613
+ const value = lines[0].startsWith("- ") ? lines[0].slice(2) : lines[0];
614
+ return [`- ${name}: ${value}`];
615
+ }
616
+ return [`- ${name}:`, ...lines.map((line) => (line === "" ? "" : ` ${line}`))];
617
+ }
618
+ if (lines.length === 0) return [`${name}:`];
619
+ if (lines.length === 1 && !lines[0].startsWith("- ")) return [`${name}: ${lines[0]}`];
620
+ return [`${name}:`, ...lines];
621
+ }
622
+
623
+ export function renderBlock(blockHeading, orderedFields, values, style = "compact") {
624
+ const lines = [blockHeading];
625
+ let emittedField = false;
626
+ for (const field of orderedFields) {
627
+ if (!Object.hasOwn(values, field) || values[field] === undefined || values[field] === null) continue;
628
+ const value = Array.isArray(values[field]) ? values[field] : String(values[field]).split("\n");
629
+ if (value.length === 0 || (value.length === 1 && value[0] === "")) continue;
630
+ if (emittedField && style !== "compact") lines.push("");
631
+ lines.push(...formatField(field, value, style));
632
+ emittedField = true;
633
+ }
634
+ lines.push("");
635
+ return lines;
636
+ }
637
+
638
+ export function blockLines(document, block) {
639
+ return document.lines.slice(block.start, block.end);
640
+ }
641
+
642
+ export function applyLineReplacements(document, replacements) {
643
+ const sorted = [...replacements].sort((left, right) => right.start - left.start);
644
+ let previousStart = Number.POSITIVE_INFINITY;
645
+ for (const replacement of sorted) {
646
+ if (
647
+ !Number.isInteger(replacement.start) ||
648
+ !Number.isInteger(replacement.end) ||
649
+ replacement.start > replacement.end
650
+ )
651
+ fail("invalid-edit", "Invalid source range");
652
+ if (replacement.end > previousStart) fail("overlapping-edit", "Overlapping source edits are not supported");
653
+ previousStart = replacement.start;
654
+ const replacementLines = Array.isArray(replacement.lines)
655
+ ? replacement.lines
656
+ : String(replacement.text ?? "").split(/\r?\n/);
657
+ document.lines.splice(replacement.start, replacement.end - replacement.start, ...replacementLines);
658
+ }
659
+ return joinLines(document.lines, document.newline);
660
+ }
661
+
662
+ export function insertLines(document, index, lines) {
663
+ return applyLineReplacements(document, [{ start: index, end: index, lines }]);
664
+ }
665
+
666
+ export function replaceBlock(document, block, lines) {
667
+ return applyLineReplacements(document, [{ start: block.start, end: block.end, lines }]);
668
+ }
669
+
670
+ export function replaceFieldInBlock(document, block, fieldName, value) {
671
+ const field = block.fields.get(fieldName);
672
+ if (!field) fail("missing-field", `Missing field ${fieldName} in ${block.path}`);
673
+ const fieldEnd =
674
+ [...block.fields.values()]
675
+ .filter((candidate) => candidate.start > field.start)
676
+ .sort((left, right) => left.start - right.start)[0]?.start ?? block.end;
677
+ const style = block.fieldStyle ?? "expanded";
678
+ const lines = formatField(fieldName, value, style);
679
+ if (style !== "compact") lines.push("");
680
+ return applyLineReplacements(document, [{ start: field.start, end: fieldEnd, lines }]);
681
+ }
682
+
683
+ export function nextEntityId(document, kind) {
684
+ return nextId(
685
+ allBlocks(document)
686
+ .map((block) => block.id)
687
+ .filter(Boolean),
688
+ kind,
689
+ );
690
+ }
691
+
692
+ export function findFutureItem(document, title, kind = null) {
693
+ const matches = document.future.items.filter((item) => item.title === title && (!kind || item.kind === kind));
694
+ if (matches.length === 0) fail("missing-item", `No Future Work item matched: ${title}`);
695
+ if (matches.length > 1) fail("ambiguous-item", `Future Work title matched more than once: ${title}`);
696
+ return matches[0];
697
+ }
698
+
699
+ export function findEntity(document, id, kind = null) {
700
+ const candidates = allBlocks(document).filter((block) => block.id === id && (!kind || block.kind === kind));
701
+ if (candidates.length === 0) fail("missing-entity", `No entity matched ${id}`);
702
+ if (candidates.length > 1) fail("ambiguous-entity", `Entity ID matched more than once: ${id}`);
703
+ return candidates[0];
704
+ }
705
+
706
+ export function sectionContentLines(document, sectionName) {
707
+ const section = document.sections[sectionName];
708
+ return document.lines.slice(section.headingIndex, section.end);
709
+ }
710
+
711
+ export function activeDecisionBlocks(document) {
712
+ return document.history.decisions.blocks.filter((block) => fieldValue(block, "State") === "active");
713
+ }