timeline-truth 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Timeline Truth
2
2
 
3
- Status: v0.1.0 public release. MIT licensed. Requires Node.js 22 or newer.
3
+ Status: v0.2.0 public release. MIT licensed. Requires Node.js 22 or newer.
4
4
 
5
5
  Timeline Truth is a local MCP server for AI-agent TPM workflows: paste PRD/Jira/status notes,
6
6
  CSV exports, launch checklists, or rough planning text; get timeline JSON,
@@ -30,8 +30,18 @@ single source. Then summarize the timeline, list gaps and assumptions, and show
30
30
  the mermaid_gantt output. Do not infer missing dates or owners.
31
31
  ```
32
32
 
33
- The server returns normalized items, gaps such as missing start/end dates, the
34
- default assumption that dates were not inferred, and portable Mermaid output.
33
+ The server returns normalized items, confidence reasons, grouped follow-up
34
+ questions, gaps such as missing start/end dates, the default assumption that
35
+ dates were not inferred, and portable Mermaid output.
36
+
37
+ For a quick local smoke test without configuring an MCP client, run the CLI:
38
+
39
+ ```bash
40
+ timeline-truth examples/launch-checklist.md --format review
41
+ ```
42
+
43
+ The CLI reads from stdin when no file is provided, and can print `json`,
44
+ `markdown`, `mermaid_gantt`, `mermaid_timeline`, or `review` output.
35
45
 
36
46
  For larger Markdown notes, `create_timeline` can parse only selected headings
37
47
  and ignore the rest:
@@ -108,6 +118,7 @@ Optional global install:
108
118
 
109
119
  ```bash
110
120
  npm install -g timeline-truth
121
+ timeline-truth examples/launch-checklist.md --format review
111
122
  timeline-truth-mcp
112
123
  ```
113
124
 
@@ -125,7 +136,7 @@ For local development, use the checkout config in
125
136
  - `validate_timeline`: report missing dates, owners, unknown dependencies,
126
137
  circular dependencies, and impossible sequencing.
127
138
  - `render_timeline`: render a normalized timeline as `mermaid_gantt`,
128
- `mermaid_timeline`, or `markdown`.
139
+ `mermaid_timeline`, `markdown`, or `review_report`.
129
140
  - `refine_timeline`: apply edits while preserving evidence (`source_refs`) and
130
141
  assumptions.
131
142
 
package/docs/MCP-SETUP.md CHANGED
@@ -46,6 +46,7 @@ Optional global install:
46
46
 
47
47
  ```bash
48
48
  npm install -g timeline-truth
49
+ timeline-truth examples/launch-checklist.md --format review
49
50
  timeline-truth-mcp
50
51
  ```
51
52
 
@@ -81,5 +82,7 @@ Useful follow-up calls:
81
82
 
82
83
  - `validate_timeline` after manual edits or agent refinements.
83
84
  - `render_timeline` when you need only Mermaid or Markdown output.
85
+ - `render_timeline` with `review_report` when you need a paste-ready review
86
+ summary with confidence reasons and grouped follow-up questions.
84
87
  - `refine_timeline` when a human answers a gap and you need to preserve
85
88
  existing `source_refs`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "timeline-truth",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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",
@@ -14,6 +14,7 @@
14
14
  "homepage": "https://github.com/hilmimuktitama/timeline-truth#readme",
15
15
  "type": "module",
16
16
  "bin": {
17
+ "timeline-truth": "src/cli.js",
17
18
  "timeline-truth-mcp": "src/mcp-server.js"
18
19
  },
19
20
  "files": [
package/src/cli.js ADDED
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { basename, extname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { createTimeline } from "./timeline.js";
7
+
8
+ const FORMAT_ALIASES = {
9
+ json: "json",
10
+ markdown: "markdown",
11
+ md: "markdown",
12
+ mermaid: "mermaid_gantt",
13
+ mermaid_gantt: "mermaid_gantt",
14
+ gantt: "mermaid_gantt",
15
+ mermaid_timeline: "mermaid_timeline",
16
+ timeline: "mermaid_timeline",
17
+ review: "review_report",
18
+ review_report: "review_report"
19
+ };
20
+
21
+ export function parseCliArgs(argv = []) {
22
+ const options = {
23
+ inputPath: "-",
24
+ sourceType: "text",
25
+ format: "json"
26
+ };
27
+
28
+ for (let index = 0; index < argv.length; index += 1) {
29
+ const arg = argv[index];
30
+ if (arg === "--type" || arg === "--source-type") {
31
+ options.sourceType = requireValue(argv, index, arg);
32
+ index += 1;
33
+ } else if (arg === "--format") {
34
+ options.format = requireValue(argv, index, arg);
35
+ index += 1;
36
+ } else if (arg === "--help" || arg === "-h") {
37
+ options.help = true;
38
+ } else if (arg.startsWith("--")) {
39
+ throw new Error(`Unknown option: ${arg}`);
40
+ } else {
41
+ options.inputPath = arg;
42
+ }
43
+ }
44
+
45
+ if (options.inputPath !== "-" && options.sourceType === "text") {
46
+ options.sourceType = inferSourceType(options.inputPath);
47
+ }
48
+
49
+ return options;
50
+ }
51
+
52
+ export function runTimelineCli({ argv = process.argv.slice(2), stdin, cwd = process.cwd() } = {}) {
53
+ const options = parseCliArgs(argv);
54
+ if (options.help) return usage();
55
+
56
+ const content = options.inputPath === "-"
57
+ ? stdin ?? readFileSync(0, "utf8")
58
+ : readFileSync(resolve(cwd, options.inputPath), "utf8");
59
+ const result = createTimeline({
60
+ sources: [
61
+ {
62
+ id: options.inputPath === "-" ? "stdin" : basename(options.inputPath),
63
+ type: options.sourceType,
64
+ path: options.inputPath === "-" ? undefined : options.inputPath,
65
+ content
66
+ }
67
+ ]
68
+ });
69
+
70
+ return formatCliResult(result, options.format);
71
+ }
72
+
73
+ export function formatCliResult(result, format = "json") {
74
+ const normalizedFormat = normalizeFormat(format);
75
+ if (normalizedFormat === "json") return JSON.stringify(result, null, 2);
76
+ if (normalizedFormat === "markdown") return result.renders.markdown.trimEnd();
77
+ if (normalizedFormat === "review_report") return result.renders.review_report.trimEnd();
78
+ if (normalizedFormat === "mermaid_timeline") return result.renders.mermaid_timeline.trimEnd();
79
+ return result.renders.mermaid_gantt.trimEnd();
80
+ }
81
+
82
+ function normalizeFormat(format) {
83
+ const normalized = String(format || "json").toLowerCase();
84
+ const mapped = FORMAT_ALIASES[normalized];
85
+ if (!mapped) {
86
+ throw new Error(`Unsupported format "${format}". Use json, markdown, mermaid_gantt, mermaid_timeline, or review.`);
87
+ }
88
+ return mapped;
89
+ }
90
+
91
+ function inferSourceType(inputPath) {
92
+ const extension = extname(inputPath).toLowerCase();
93
+ if (extension === ".md" || extension === ".markdown") return "markdown";
94
+ if (extension === ".csv") return "csv";
95
+ if (extension === ".json") return "json";
96
+ return "text";
97
+ }
98
+
99
+ function requireValue(argv, index, option) {
100
+ const value = argv[index + 1];
101
+ if (!value || value.startsWith("--")) {
102
+ throw new Error(`Missing value for ${option}.`);
103
+ }
104
+ return value;
105
+ }
106
+
107
+ function usage() {
108
+ return [
109
+ "Usage: timeline-truth [file] [--type text|markdown|csv|json] [--format json|markdown|mermaid_gantt|mermaid_timeline|review]",
110
+ "",
111
+ "Reads stdin when no file is provided."
112
+ ].join("\n");
113
+ }
114
+
115
+ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
116
+
117
+ if (isDirectRun) {
118
+ try {
119
+ process.stdout.write(`${runTimelineCli()}\n`);
120
+ } catch (error) {
121
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
122
+ process.exitCode = 1;
123
+ }
124
+ }
package/src/mcp-server.js CHANGED
@@ -8,7 +8,7 @@ import { callTimelineTool, listTimelineTools } from "./mcp-tools.js";
8
8
  const server = new Server(
9
9
  {
10
10
  name: "timeline-truth",
11
- version: "0.1.0"
11
+ version: "0.2.0"
12
12
  },
13
13
  {
14
14
  capabilities: {
package/src/mcp-tools.js CHANGED
@@ -79,7 +79,7 @@ export function listTimelineTools() {
79
79
  },
80
80
  {
81
81
  name: "render_timeline",
82
- description: "Render a normalized timeline as Mermaid Gantt, Mermaid timeline, or compact Markdown.",
82
+ description: "Render a normalized timeline as Mermaid Gantt, Mermaid timeline, compact Markdown, or a review report.",
83
83
  inputSchema: {
84
84
  type: "object",
85
85
  required: ["timeline"],
@@ -88,7 +88,7 @@ export function listTimelineTools() {
88
88
  timeline: TIMELINE_SCHEMA,
89
89
  format: {
90
90
  type: "string",
91
- enum: ["mermaid_gantt", "mermaid_timeline", "markdown"],
91
+ enum: ["mermaid_gantt", "mermaid_timeline", "markdown", "review_report"],
92
92
  default: "mermaid_gantt"
93
93
  }
94
94
  }
package/src/timeline.js CHANGED
@@ -17,7 +17,7 @@ export function createTimeline(input = {}) {
17
17
  gaps: [],
18
18
  render: {
19
19
  audience: "TPM/PM",
20
- defaultFormats: ["mermaid_gantt", "mermaid_timeline", "markdown"]
20
+ defaultFormats: ["mermaid_gantt", "mermaid_timeline", "markdown", "review_report"]
21
21
  }
22
22
  });
23
23
  const validation = validateTimeline(timeline);
@@ -26,17 +26,20 @@ export function createTimeline(input = {}) {
26
26
  gaps: validation.gaps,
27
27
  issues: validation.issues
28
28
  };
29
+ const followups = buildFollowups(validatedTimeline);
29
30
 
30
31
  return {
31
32
  timeline: validatedTimeline,
32
33
  assumptions: validatedTimeline.assumptions,
33
34
  gaps: validatedTimeline.gaps,
34
35
  issues: validation.issues,
36
+ followups,
35
37
  noise_report: noiseReport,
36
38
  renders: {
37
39
  mermaid_gantt: renderTimeline(validatedTimeline, { format: "mermaid_gantt" }),
38
40
  mermaid_timeline: renderTimeline(validatedTimeline, { format: "mermaid_timeline" }),
39
- markdown: renderTimeline(validatedTimeline, { format: "markdown" })
41
+ markdown: renderTimeline(validatedTimeline, { format: "markdown" }),
42
+ review_report: renderTimeline(validatedTimeline, { format: "review_report" })
40
43
  }
41
44
  };
42
45
  }
@@ -71,11 +74,13 @@ export function validateTimeline(timeline = {}) {
71
74
  for (const item of normalized.items) {
72
75
  for (const dependency of item.dependencies) {
73
76
  if (!normalized.items.some((candidate) => candidate.title === dependency)) {
77
+ const suggestions = suggestDependencyTitles(normalized.items, dependency);
74
78
  issues.push({
75
79
  type: "unknown_dependency",
76
80
  severity: "warning",
77
81
  itemTitle: item.title,
78
82
  dependency,
83
+ suggestions,
79
84
  message: `Dependency "${dependency}" was not found in the timeline.`
80
85
  });
81
86
  }
@@ -122,6 +127,10 @@ export function renderTimeline(timeline = {}, options = {}) {
122
127
  return renderMarkdown(normalized);
123
128
  }
124
129
 
130
+ if (format === "review_report") {
131
+ return renderReviewReport(normalized);
132
+ }
133
+
125
134
  return renderMermaidGantt(normalized);
126
135
  }
127
136
 
@@ -175,7 +184,13 @@ function parseSource(source, index, importedAssumptions, input, noiseReport) {
175
184
  }
176
185
 
177
186
  function parseJsonSource(source, importedAssumptions) {
178
- const parsed = typeof source.content === "string" ? JSON.parse(source.content) : source.content;
187
+ let parsed;
188
+ try {
189
+ parsed = typeof source.content === "string" ? JSON.parse(source.content) : source.content;
190
+ } catch (error) {
191
+ const detail = error instanceof Error ? error.message : String(error);
192
+ throw new Error(`Unable to parse JSON source "${source.id}": ${detail}`);
193
+ }
179
194
  const rawItems = Array.isArray(parsed) ? parsed : parsed.items ?? [];
180
195
 
181
196
  if (Array.isArray(parsed.assumptions)) {
@@ -311,7 +326,10 @@ function parseTextLine(line, sourceId, lineNumber) {
311
326
  owner,
312
327
  status,
313
328
  dependencies,
314
- confidence: dates.length > 0 ? 0.75 : 0.45
329
+ confidence: dates.length > 0 ? 0.75 : 0.45,
330
+ confidence_reason: dates.length > 0
331
+ ? "Exact date evidence found in source text."
332
+ : "No exact dates found; timeline placement needs human follow-up."
315
333
  };
316
334
 
317
335
  return normalizeItem(item, [{ sourceId, line: lineNumber, text: trimmed }]);
@@ -406,7 +424,12 @@ function markdownRecordToItem(record, heading) {
406
424
  time_window: fuzzyTarget,
407
425
  date_text: fuzzyTarget,
408
426
  exact_date_needed: Boolean(fuzzyTarget),
409
- confidence: target ? 0.7 : 0.55
427
+ confidence: target ? 0.7 : 0.55,
428
+ confidence_reason: fuzzyTarget
429
+ ? "Fuzzy date text was preserved for human review."
430
+ : target
431
+ ? "Exact target date evidence found in Markdown table."
432
+ : "No target date found in Markdown table row."
410
433
  };
411
434
  }
412
435
 
@@ -421,6 +444,7 @@ function normalizeTimeline(timeline = {}) {
421
444
  milestones,
422
445
  assumptions: Array.isArray(timeline.assumptions) ? [...timeline.assumptions] : [],
423
446
  gaps: Array.isArray(timeline.gaps) ? [...timeline.gaps] : [],
447
+ issues: Array.isArray(timeline.issues) ? [...timeline.issues] : [],
424
448
  render: timeline.render && typeof timeline.render === "object" ? { ...timeline.render } : {}
425
449
  };
426
450
  }
@@ -446,6 +470,9 @@ function normalizeItem(item = {}, sourceRefs = []) {
446
470
  status: blankToUndefined(item.status) || "unknown",
447
471
  dependencies: dependencies.map((dependency) => String(dependency).trim()).filter(Boolean),
448
472
  confidence: typeof item.confidence === "number" ? item.confidence : 0.6,
473
+ confidence_reason: typeof item.confidence_reason === "string"
474
+ ? item.confidence_reason
475
+ : deriveConfidenceReason(item),
449
476
  source_refs: normalizeSourceRefs(sourceRefs)
450
477
  };
451
478
  }
@@ -513,6 +540,24 @@ function dedupeCycles(cycles) {
513
540
  });
514
541
  }
515
542
 
543
+ function suggestDependencyTitles(items, dependency) {
544
+ const dependencyKey = normalizeDependencyKey(dependency);
545
+ if (!dependencyKey) return [];
546
+
547
+ return items
548
+ .filter((item) => {
549
+ const titleKey = normalizeDependencyKey(item.title);
550
+ const idKey = normalizeDependencyKey(item.id);
551
+ return titleKey === dependencyKey || idKey === dependencyKey;
552
+ })
553
+ .map((item) => item.title)
554
+ .slice(0, 3);
555
+ }
556
+
557
+ function normalizeDependencyKey(value) {
558
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
559
+ }
560
+
516
561
  function renderMermaidGantt(timeline) {
517
562
  const lines = ["gantt", " title Project Timeline", " dateFormat YYYY-MM-DD", " axisFormat %b %d", " section Plan"];
518
563
 
@@ -572,6 +617,107 @@ function renderMarkdown(timeline) {
572
617
  return `${lines.join("\n")}\n`;
573
618
  }
574
619
 
620
+ function renderReviewReport(timeline) {
621
+ const followups = buildFollowups(timeline);
622
+ const lines = ["## Timeline Review", "", "### Items"];
623
+
624
+ for (const item of timeline.items) {
625
+ const window = item.start ? `${item.start}${item.end ? ` to ${item.end}` : item.duration ? ` for ${item.duration}` : ""}` : item.time_window || "date needed";
626
+ lines.push(`- **${item.title}** (${item.type}, ${item.status}) - ${window}${item.owner ? ` - owner: ${item.owner}` : ""}`);
627
+ lines.push(` - Confidence: ${item.confidence} - ${item.confidence_reason}`);
628
+ if (item.source_refs.length > 0) {
629
+ lines.push(` - Source: ${formatSourceRef(item.source_refs[0])}`);
630
+ }
631
+ }
632
+
633
+ if (timeline.gaps.length > 0) {
634
+ lines.push("", "### Follow-Up Questions");
635
+ for (const followup of followups.all) {
636
+ lines.push(`- ${followup.itemTitle}${followup.owner ? ` (${followup.owner})` : ""}: ${followup.question}`);
637
+ }
638
+ }
639
+
640
+ if (timeline.issues.length > 0) {
641
+ lines.push("", "### Issues");
642
+ for (const issue of timeline.issues) {
643
+ const suggestions = issue.suggestions?.length ? ` Suggestions: ${issue.suggestions.join(", ")}.` : "";
644
+ lines.push(`- ${issue.severity}: ${issue.message}${suggestions}`);
645
+ }
646
+ }
647
+
648
+ if (timeline.assumptions.length > 0) {
649
+ lines.push("", "### Assumptions");
650
+ for (const assumption of timeline.assumptions) {
651
+ lines.push(`- ${assumption}`);
652
+ }
653
+ }
654
+
655
+ return `${lines.join("\n")}\n`;
656
+ }
657
+
658
+ function buildFollowups(timeline) {
659
+ const byTitle = new Map(timeline.items.map((item) => [item.title, item]));
660
+ const gapFollowups = timeline.gaps.map((gap) => {
661
+ const item = byTitle.get(gap.itemTitle);
662
+ return {
663
+ itemTitle: gap.itemTitle,
664
+ field: gap.field,
665
+ owner: item?.owner,
666
+ question: gap.question,
667
+ source_refs: gap.source_refs
668
+ };
669
+ });
670
+ const dependencyFollowups = timeline.issues
671
+ .filter((issue) => issue.type === "unknown_dependency")
672
+ .map((issue) => {
673
+ const item = byTitle.get(issue.itemTitle);
674
+ const suggestions = issue.suggestions?.length
675
+ ? ` Did you mean ${issue.suggestions.join(", ")}?`
676
+ : "";
677
+ return {
678
+ itemTitle: issue.itemTitle,
679
+ field: "dependency",
680
+ dependency: issue.dependency,
681
+ owner: item?.owner,
682
+ question: `Confirm the dependency "${issue.dependency}" or add it to the timeline.${suggestions}`,
683
+ source_refs: item?.source_refs ?? []
684
+ };
685
+ });
686
+ const all = [...gapFollowups, ...dependencyFollowups];
687
+ const dateFollowups = all.filter((followup) => ["start", "end", "exact_date"].includes(followup.field));
688
+
689
+ return {
690
+ all,
691
+ by_field: groupBy(all, (followup) => followup.field),
692
+ by_owner: groupBy(all, (followup) => followup.owner || "Unassigned"),
693
+ by_date: groupBy(dateFollowups, (followup) => followup.field),
694
+ by_dependency: groupBy(dependencyFollowups, (followup) => followup.dependency)
695
+ };
696
+ }
697
+
698
+ function groupBy(values, keyFn) {
699
+ return values.reduce((groups, value) => {
700
+ const key = keyFn(value);
701
+ groups[key] = groups[key] || [];
702
+ groups[key].push(value);
703
+ return groups;
704
+ }, {});
705
+ }
706
+
707
+ function deriveConfidenceReason(item) {
708
+ if (item.time_window || item.date_text) return "Fuzzy date text was preserved for human review.";
709
+ if (item.start || item.end || item.duration) return "Structured date evidence was supplied.";
710
+ return "No date evidence was supplied.";
711
+ }
712
+
713
+ function formatSourceRef(sourceRef) {
714
+ const parts = [sourceRef.sourceId];
715
+ if (sourceRef.path) parts.push(sourceRef.path);
716
+ if (sourceRef.heading) parts.push(`heading "${sourceRef.heading}"`);
717
+ if (sourceRef.line) parts.push(`line ${sourceRef.line}`);
718
+ return parts.filter(Boolean).join(", ");
719
+ }
720
+
575
721
  function parseCsv(content) {
576
722
  const rows = [];
577
723
  let row = [];