timeline-truth 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # Timeline Truth
2
2
 
3
- Status: v0.2 adoption release in progress. MIT licensed. Requires Node.js
4
- 22 or newer.
3
+ Status: v0.1.0 public release. MIT licensed. Requires Node.js 22 or newer.
5
4
 
6
5
  Timeline Truth is a local MCP server for AI-agent TPM workflows: paste PRD/Jira/status notes,
7
6
  CSV exports, launch checklists, or rough planning text; get timeline JSON,
@@ -34,6 +33,32 @@ the mermaid_gantt output. Do not infer missing dates or owners.
34
33
  The server returns normalized items, gaps such as missing start/end dates, the
35
34
  default assumption that dates were not inferred, and portable Mermaid output.
36
35
 
36
+ For larger Markdown notes, `create_timeline` can parse only selected headings
37
+ and ignore the rest:
38
+
39
+ ```json
40
+ {
41
+ "sources": [
42
+ {
43
+ "id": "program-note",
44
+ "type": "markdown",
45
+ "path": "docs/program.md",
46
+ "content": "..."
47
+ }
48
+ ],
49
+ "markdown": {
50
+ "sections": ["Timeline", "Follow-Ups"],
51
+ "ignoreFrontmatter": true
52
+ }
53
+ }
54
+ ```
55
+
56
+ Markdown tables under those headings are parsed into items. Fuzzy targets such
57
+ as `W3-W4 May 2026` are preserved as `time_window`/`date_text` and flagged with
58
+ an `exact_date` gap instead of being converted into invented dates. The response
59
+ also includes `noise_report.ignored` counts for skipped frontmatter, prose, and
60
+ table rows without target dates.
61
+
37
62
  ## Why This Exists
38
63
 
39
64
  Most timeline tools assume the plan is already structured. Real planning inputs
@@ -66,7 +91,7 @@ npm install
66
91
  node src/mcp-server.js
67
92
  ```
68
93
 
69
- Npm package config, after the npm package is published:
94
+ Npm package config:
70
95
 
71
96
  ```json
72
97
  {
@@ -79,9 +104,19 @@ Npm package config, after the npm package is published:
79
104
  }
80
105
  ```
81
106
 
82
- Important: the npm package install path works only after `timeline-truth` is
83
- published to npm. Before the npm package is published, use the local checkout
84
- config in [docs/MCP-SETUP.md](docs/MCP-SETUP.md).
107
+ Optional global install:
108
+
109
+ ```bash
110
+ npm install -g timeline-truth
111
+ timeline-truth-mcp
112
+ ```
113
+
114
+ If your global npm bin directory is on `PATH`, you can also configure the MCP
115
+ server with `"command": "timeline-truth-mcp"`. The `npx --package` config above
116
+ is the most portable option because it does not depend on global shell setup.
117
+
118
+ For local development, use the checkout config in
119
+ [docs/MCP-SETUP.md](docs/MCP-SETUP.md).
85
120
 
86
121
  ## MCP Tools
87
122
 
@@ -107,9 +142,9 @@ Each example has a compact expected-output JSON file and is covered by tests.
107
142
 
108
143
  ## Current limitations
109
144
 
110
- - Text and Markdown parsing is heuristic. It works best when each planning item
111
- is on its own line.
112
- - Markdown headings are ignored and task-list markers are stripped, but rich
145
+ - Text parsing is heuristic. It works best when each planning item is on its own
146
+ line.
147
+ - Markdown parsing supports heading filters and simple pipe tables, but rich
113
148
  nested documents are not fully parsed.
114
149
  - CSV and JSON are more reliable than free-form notes when exact fields matter.
115
150
  - There are no Jira, Confluence, Slack, or hosted imports in this release.
package/docs/MCP-SETUP.md CHANGED
@@ -26,7 +26,7 @@ npm install
26
26
 
27
27
  ## Npm Package
28
28
 
29
- Use this after the `timeline-truth` npm package is published:
29
+ Use the published `timeline-truth` package:
30
30
 
31
31
  ```json
32
32
  {
@@ -39,8 +39,32 @@ Use this after the `timeline-truth` npm package is published:
39
39
  }
40
40
  ```
41
41
 
42
- Before publish, `npx --package=timeline-truth timeline-truth-mcp` will fail
43
- because the package is not yet available from the public registry.
42
+ This is the recommended package config because it does not require a global npm
43
+ install or depend on the user's global npm bin directory being on `PATH`.
44
+
45
+ Optional global install:
46
+
47
+ ```bash
48
+ npm install -g timeline-truth
49
+ timeline-truth-mcp
50
+ ```
51
+
52
+ With the global package installed and available on `PATH`, the MCP config can
53
+ call the package binary directly:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "timeline-truth": {
59
+ "command": "timeline-truth-mcp",
60
+ "args": []
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ If you need to test local changes before publishing a new version, use the local
67
+ checkout config above.
44
68
 
45
69
  ## Agent Prompt
46
70
 
package/docs/RELEASE.md CHANGED
@@ -2,15 +2,22 @@
2
2
 
3
3
  Use this checklist before publishing a public version of `timeline-truth`.
4
4
 
5
+ ## Current Release Notes
6
+
7
+ - Markdown ingestion now ignores frontmatter by default, parses configured
8
+ headings, supports simple pipe tables, preserves fuzzy time windows, enriches
9
+ source refs, and reports ignored content through `noise_report`.
10
+ - This release intentionally does not include CLI file mode, TPM validation
11
+ profiles, or dependency matching by external IDs.
12
+
5
13
  ## Verify Package Name
6
14
 
7
15
  ```bash
8
16
  npm view timeline-truth version
9
17
  ```
10
18
 
11
- Expected before first publish: npm returns `404 Not Found`.
12
-
13
- Expected after publish: npm returns the latest published version.
19
+ Expected result: npm returns the latest published version. Before publishing a
20
+ new release, confirm whether the version in `package.json` needs to be bumped.
14
21
 
15
22
  ## Verify Local Quality
16
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "timeline-truth",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Open-source MCP server for compiling messy project planning inputs into evidence-preserving timelines.",
5
5
  "author": "hilmimuktitama",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "files": [
20
20
  "src",
21
- "docs",
21
+ "docs/*.md",
22
22
  "examples",
23
23
  "README.md",
24
24
  "LICENSE"
package/src/mcp-tools.js CHANGED
@@ -6,6 +6,7 @@ const SOURCE_SCHEMA = {
6
6
  additionalProperties: true,
7
7
  properties: {
8
8
  id: { type: "string", description: "Stable source identifier used in source_refs." },
9
+ path: { type: "string", description: "Optional file path to preserve in source_refs." },
9
10
  type: { type: "string", enum: ["text", "markdown", "csv", "json"], default: "text" },
10
11
  content: {
11
12
  description: "Pasted text/file content. JSON sources may pass a JSON string or object.",
@@ -42,6 +43,23 @@ export function listTimelineTools() {
42
43
  type: "array",
43
44
  minItems: 1,
44
45
  items: SOURCE_SCHEMA
46
+ },
47
+ markdown: {
48
+ type: "object",
49
+ additionalProperties: false,
50
+ properties: {
51
+ sections: {
52
+ type: "array",
53
+ items: { type: "string" },
54
+ description:
55
+ "Markdown headings to parse. Defaults to Timeline, Milestones, Next, Risks And Blockers, and Follow-Ups."
56
+ },
57
+ ignoreFrontmatter: {
58
+ type: "boolean",
59
+ default: true,
60
+ description: "Ignore YAML frontmatter before parsing Markdown content."
61
+ }
62
+ }
45
63
  }
46
64
  }
47
65
  }
@@ -108,7 +126,7 @@ export function listTimelineTools() {
108
126
  export function callTimelineTool(name, args = {}) {
109
127
  switch (name) {
110
128
  case "create_timeline":
111
- return jsonContent(createTimeline({ sources: args.sources }));
129
+ return jsonContent(createTimeline({ sources: args.sources, markdown: args.markdown }));
112
130
  case "validate_timeline":
113
131
  return jsonContent(validateTimeline(args.timeline));
114
132
  case "render_timeline":
package/src/timeline.js CHANGED
@@ -1,9 +1,13 @@
1
1
  const DATE_PATTERN = /\b\d{4}-\d{2}-\d{2}\b/g;
2
+ const DEFAULT_MARKDOWN_SECTIONS = ["Timeline", "Milestones", "Next", "Risks And Blockers", "Follow-Ups"];
2
3
 
3
4
  export function createTimeline(input = {}) {
4
5
  const sources = Array.isArray(input.sources) ? input.sources : [];
5
6
  const importedAssumptions = [];
6
- const items = sources.flatMap((source, index) => parseSource(source, index, importedAssumptions));
7
+ const noiseReport = createNoiseReport();
8
+ const items = sources.flatMap((source, index) =>
9
+ parseSource(source, index, importedAssumptions, input, noiseReport)
10
+ );
7
11
  const timeline = normalizeTimeline({
8
12
  items,
9
13
  assumptions: [
@@ -28,6 +32,7 @@ export function createTimeline(input = {}) {
28
32
  assumptions: validatedTimeline.assumptions,
29
33
  gaps: validatedTimeline.gaps,
30
34
  issues: validation.issues,
35
+ noise_report: noiseReport,
31
36
  renders: {
32
37
  mermaid_gantt: renderTimeline(validatedTimeline, { format: "mermaid_gantt" }),
33
38
  mermaid_timeline: renderTimeline(validatedTimeline, { format: "mermaid_timeline" }),
@@ -42,11 +47,15 @@ export function validateTimeline(timeline = {}) {
42
47
  const issues = [];
43
48
 
44
49
  for (const item of normalized.items) {
45
- if (!item.start) {
50
+ if (item.exact_date_needed) {
51
+ gaps.push(makeGap(item, "exact_date", "Exact date needed before rendering this fuzzy time window."));
52
+ }
53
+
54
+ if (!item.start && !item.time_window) {
46
55
  gaps.push(makeGap(item, "start", "Missing start date. Ask for the planned start date instead of inferring it."));
47
56
  }
48
57
 
49
- if (!item.end && !item.duration && item.type !== "milestone") {
58
+ if (!item.end && !item.duration && !item.time_window && item.type !== "milestone") {
50
59
  gaps.push(makeGap(item, "end", "Missing end date or duration for a non-milestone item."));
51
60
  }
52
61
 
@@ -142,11 +151,12 @@ export function refineTimeline(timeline = {}, refinement = {}) {
142
151
  };
143
152
  }
144
153
 
145
- function parseSource(source, index, importedAssumptions) {
154
+ function parseSource(source, index, importedAssumptions, input, noiseReport) {
146
155
  const normalizedSource = {
147
156
  id: source?.id || `source-${index + 1}`,
148
157
  type: source?.type || "text",
149
- content: source?.content ?? ""
158
+ content: source?.content ?? "",
159
+ path: source?.path || source?.file_path || source?.filePath
150
160
  };
151
161
 
152
162
  if (normalizedSource.type === "json") {
@@ -157,6 +167,10 @@ function parseSource(source, index, importedAssumptions) {
157
167
  return parseCsvSource(normalizedSource);
158
168
  }
159
169
 
170
+ if (normalizedSource.type === "markdown") {
171
+ return parseMarkdownSource(normalizedSource, input?.markdown, noiseReport);
172
+ }
173
+
160
174
  return parseTextSource(normalizedSource);
161
175
  }
162
176
 
@@ -197,6 +211,85 @@ function parseTextSource(source) {
197
211
  .filter(Boolean);
198
212
  }
199
213
 
214
+ function parseMarkdownSource(source, markdownOptions = {}, noiseReport = createNoiseReport()) {
215
+ const lines = String(source.content).split(/\r?\n/);
216
+ const allowedHeadings = getAllowedMarkdownHeadings(markdownOptions);
217
+ const hasAllowedHeadings = markdownHasAllowedHeadings(lines, allowedHeadings);
218
+ const items = [];
219
+ let currentHeading;
220
+ let inFrontmatter = markdownOptions?.ignoreFrontmatter === false ? false : lines[0]?.trim() === "---";
221
+
222
+ for (let index = 0; index < lines.length; index += 1) {
223
+ const line = lines[index];
224
+ const trimmed = line.trim();
225
+
226
+ if (inFrontmatter) {
227
+ noiseReport.ignored.frontmatter_lines += 1;
228
+ if (index > 0 && trimmed === "---") inFrontmatter = false;
229
+ continue;
230
+ }
231
+
232
+ const heading = parseMarkdownHeading(trimmed);
233
+ if (heading) {
234
+ currentHeading = heading;
235
+ continue;
236
+ }
237
+
238
+ if (!trimmed) continue;
239
+
240
+ const inAllowedSection = !hasAllowedHeadings || allowedHeadings.has(normalizeHeading(currentHeading));
241
+ if (!inAllowedSection) {
242
+ noiseReport.ignored.prose_lines += 1;
243
+ continue;
244
+ }
245
+
246
+ if (isMarkdownTableLine(trimmed)) {
247
+ const table = parseMarkdownTable(lines, index);
248
+ if (!table) {
249
+ noiseReport.ignored.prose_lines += 1;
250
+ continue;
251
+ }
252
+
253
+ for (const row of table.rows) {
254
+ const item = markdownRecordToItem(row.record, currentHeading);
255
+ if (!item) {
256
+ noiseReport.ignored.table_rows_without_dates += row.hasDate ? 0 : 1;
257
+ continue;
258
+ }
259
+
260
+ if (!row.hasDate) noiseReport.ignored.table_rows_without_dates += 1;
261
+ items.push(
262
+ normalizeItem(item, [
263
+ compactObject({
264
+ sourceId: source.id,
265
+ path: source.path,
266
+ heading: currentHeading,
267
+ tableRow: row.tableRow,
268
+ line: row.line,
269
+ text: row.text
270
+ })
271
+ ])
272
+ );
273
+ }
274
+
275
+ index = table.endIndex;
276
+ continue;
277
+ }
278
+
279
+ const parsedLine = parseTextLine(line, source.id, index + 1);
280
+ if (parsedLine) {
281
+ parsedLine.source_refs = parsedLine.source_refs.map((sourceRef) =>
282
+ compactObject({ ...sourceRef, path: source.path, heading: currentHeading })
283
+ );
284
+ items.push(parsedLine);
285
+ } else {
286
+ noiseReport.ignored.prose_lines += 1;
287
+ }
288
+ }
289
+
290
+ return items;
291
+ }
292
+
200
293
  function parseTextLine(line, sourceId, lineNumber) {
201
294
  const trimmed = normalizePlanningLine(line);
202
295
  if (!trimmed) return null;
@@ -292,6 +385,31 @@ function csvRecordToItem(record) {
292
385
  };
293
386
  }
294
387
 
388
+ function markdownRecordToItem(record, heading) {
389
+ const title =
390
+ valueFromRecord(record, ["item", "follow_up", "follow-up", "followup", "task", "milestone", "risk", "blocker"]) ||
391
+ valueFromRecord(record, ["title", "name"]);
392
+ if (!title) return null;
393
+
394
+ const target = valueFromRecord(record, ["target", "date", "when", "time_window", "window"]);
395
+ const dates = extractExactDates(target);
396
+ const fuzzyTarget = target && dates.length === 0 ? target : undefined;
397
+
398
+ return {
399
+ title,
400
+ type: normalizeHeading(heading) === "milestones" || record.type === "milestone" ? "milestone" : "task",
401
+ start: dates[0],
402
+ end: dates[1],
403
+ owner: valueFromRecord(record, ["owner", "assignee"]),
404
+ status: valueFromRecord(record, ["status"]) || "planned",
405
+ dependencies: valueFromRecord(record, ["dependencies", "depends_on", "depends on"]),
406
+ time_window: fuzzyTarget,
407
+ date_text: fuzzyTarget,
408
+ exact_date_needed: Boolean(fuzzyTarget),
409
+ confidence: target ? 0.7 : 0.55
410
+ };
411
+ }
412
+
295
413
  function normalizeTimeline(timeline = {}) {
296
414
  const items = Array.isArray(timeline.items)
297
415
  ? timeline.items.map((item) => normalizeItem(item, item.source_refs))
@@ -321,6 +439,9 @@ function normalizeItem(item = {}, sourceRefs = []) {
321
439
  start: blankToUndefined(item.start),
322
440
  end: blankToUndefined(item.end),
323
441
  duration: blankToUndefined(item.duration),
442
+ time_window: blankToUndefined(item.time_window),
443
+ date_text: blankToUndefined(item.date_text),
444
+ exact_date_needed: Boolean(item.exact_date_needed),
324
445
  owner: blankToUndefined(item.owner),
325
446
  status: blankToUndefined(item.status) || "unknown",
326
447
  dependencies: dependencies.map((dependency) => String(dependency).trim()).filter(Boolean),
@@ -503,6 +624,115 @@ function blankToUndefined(value) {
503
624
  return normalized === "" ? undefined : normalized;
504
625
  }
505
626
 
627
+ function createNoiseReport() {
628
+ return {
629
+ ignored: {
630
+ frontmatter_lines: 0,
631
+ prose_lines: 0,
632
+ table_rows_without_dates: 0
633
+ }
634
+ };
635
+ }
636
+
637
+ function getAllowedMarkdownHeadings(options = {}) {
638
+ const sections = Array.isArray(options?.sections) && options.sections.length > 0
639
+ ? options.sections
640
+ : DEFAULT_MARKDOWN_SECTIONS;
641
+ return new Set(sections.map((section) => normalizeHeading(section)));
642
+ }
643
+
644
+ function markdownHasAllowedHeadings(lines, allowedHeadings) {
645
+ return lines.some((line) => {
646
+ const heading = parseMarkdownHeading(line.trim());
647
+ return heading && allowedHeadings.has(normalizeHeading(heading));
648
+ });
649
+ }
650
+
651
+ function parseMarkdownHeading(line) {
652
+ const match = line.match(/^#{1,6}\s+(.+?)\s*#*$/);
653
+ return match ? match[1].trim() : undefined;
654
+ }
655
+
656
+ function normalizeHeading(value) {
657
+ return String(value || "")
658
+ .trim()
659
+ .toLowerCase()
660
+ .replace(/&/g, "and")
661
+ .replace(/[^a-z0-9]+/g, " ")
662
+ .trim();
663
+ }
664
+
665
+ function isMarkdownTableLine(line) {
666
+ return /^\|.*\|\s*$/.test(line);
667
+ }
668
+
669
+ function parseMarkdownTable(lines, startIndex) {
670
+ const header = parseMarkdownTableCells(lines[startIndex]);
671
+ const separator = parseMarkdownTableCells(lines[startIndex + 1]);
672
+ if (header.length === 0 || !isMarkdownSeparatorRow(separator)) return null;
673
+
674
+ const headers = header.map((cell) => normalizeHeader(cell));
675
+ const rows = [];
676
+ let index = startIndex + 2;
677
+ let tableRow = 1;
678
+
679
+ while (index < lines.length && isMarkdownTableLine(lines[index].trim())) {
680
+ const cells = parseMarkdownTableCells(lines[index]);
681
+ const record = {};
682
+ headers.forEach((column, columnIndex) => {
683
+ record[column] = cells[columnIndex] ?? "";
684
+ });
685
+
686
+ const target = valueFromRecord(record, ["target", "date", "when", "time_window", "window"]);
687
+ rows.push({
688
+ record,
689
+ tableRow,
690
+ line: index + 1,
691
+ text: lines[index].trim(),
692
+ hasDate: extractExactDates(target).length > 0 || Boolean(blankToUndefined(target))
693
+ });
694
+ tableRow += 1;
695
+ index += 1;
696
+ }
697
+
698
+ return {
699
+ rows,
700
+ endIndex: index - 1
701
+ };
702
+ }
703
+
704
+ function parseMarkdownTableCells(line = "") {
705
+ const trimmed = String(line).trim();
706
+ if (!isMarkdownTableLine(trimmed)) return [];
707
+ return trimmed
708
+ .replace(/^\|/, "")
709
+ .replace(/\|$/, "")
710
+ .split("|")
711
+ .map((cell) => cell.trim());
712
+ }
713
+
714
+ function isMarkdownSeparatorRow(cells) {
715
+ return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
716
+ }
717
+
718
+ function valueFromRecord(record, names) {
719
+ for (const name of names) {
720
+ const normalized = normalizeHeader(name);
721
+ const value = blankToUndefined(record[normalized]);
722
+ if (value) return value;
723
+ }
724
+
725
+ return undefined;
726
+ }
727
+
728
+ function extractExactDates(value) {
729
+ return [...String(value || "").matchAll(DATE_PATTERN)].map((match) => match[0]);
730
+ }
731
+
732
+ function compactObject(value) {
733
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
734
+ }
735
+
506
736
  function escapeMermaidText(value) {
507
737
  return String(value).replace(/[:#;]/g, "-").trim();
508
738
  }