timeline-truth 0.1.0 → 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,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.2.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,
@@ -31,8 +30,44 @@ single source. Then summarize the timeline, list gaps and assumptions, and show
31
30
  the mermaid_gantt output. Do not infer missing dates or owners.
32
31
  ```
33
32
 
34
- The server returns normalized items, gaps such as missing start/end dates, the
35
- 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.
45
+
46
+ For larger Markdown notes, `create_timeline` can parse only selected headings
47
+ and ignore the rest:
48
+
49
+ ```json
50
+ {
51
+ "sources": [
52
+ {
53
+ "id": "program-note",
54
+ "type": "markdown",
55
+ "path": "docs/program.md",
56
+ "content": "..."
57
+ }
58
+ ],
59
+ "markdown": {
60
+ "sections": ["Timeline", "Follow-Ups"],
61
+ "ignoreFrontmatter": true
62
+ }
63
+ }
64
+ ```
65
+
66
+ Markdown tables under those headings are parsed into items. Fuzzy targets such
67
+ as `W3-W4 May 2026` are preserved as `time_window`/`date_text` and flagged with
68
+ an `exact_date` gap instead of being converted into invented dates. The response
69
+ also includes `noise_report.ignored` counts for skipped frontmatter, prose, and
70
+ table rows without target dates.
36
71
 
37
72
  ## Why This Exists
38
73
 
@@ -66,7 +101,7 @@ npm install
66
101
  node src/mcp-server.js
67
102
  ```
68
103
 
69
- Npm package config, after the npm package is published:
104
+ Npm package config:
70
105
 
71
106
  ```json
72
107
  {
@@ -79,9 +114,20 @@ Npm package config, after the npm package is published:
79
114
  }
80
115
  ```
81
116
 
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).
117
+ Optional global install:
118
+
119
+ ```bash
120
+ npm install -g timeline-truth
121
+ timeline-truth examples/launch-checklist.md --format review
122
+ timeline-truth-mcp
123
+ ```
124
+
125
+ If your global npm bin directory is on `PATH`, you can also configure the MCP
126
+ server with `"command": "timeline-truth-mcp"`. The `npx --package` config above
127
+ is the most portable option because it does not depend on global shell setup.
128
+
129
+ For local development, use the checkout config in
130
+ [docs/MCP-SETUP.md](docs/MCP-SETUP.md).
85
131
 
86
132
  ## MCP Tools
87
133
 
@@ -90,7 +136,7 @@ config in [docs/MCP-SETUP.md](docs/MCP-SETUP.md).
90
136
  - `validate_timeline`: report missing dates, owners, unknown dependencies,
91
137
  circular dependencies, and impossible sequencing.
92
138
  - `render_timeline`: render a normalized timeline as `mermaid_gantt`,
93
- `mermaid_timeline`, or `markdown`.
139
+ `mermaid_timeline`, `markdown`, or `review_report`.
94
140
  - `refine_timeline`: apply edits while preserving evidence (`source_refs`) and
95
141
  assumptions.
96
142
 
@@ -107,9 +153,9 @@ Each example has a compact expected-output JSON file and is covered by tests.
107
153
 
108
154
  ## Current limitations
109
155
 
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
156
+ - Text parsing is heuristic. It works best when each planning item is on its own
157
+ line.
158
+ - Markdown parsing supports heading filters and simple pipe tables, but rich
113
159
  nested documents are not fully parsed.
114
160
  - CSV and JSON are more reliable than free-form notes when exact fields matter.
115
161
  - 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,33 @@ 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 examples/launch-checklist.md --format review
50
+ timeline-truth-mcp
51
+ ```
52
+
53
+ With the global package installed and available on `PATH`, the MCP config can
54
+ call the package binary directly:
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "timeline-truth": {
60
+ "command": "timeline-truth-mcp",
61
+ "args": []
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ If you need to test local changes before publishing a new version, use the local
68
+ checkout config above.
44
69
 
45
70
  ## Agent Prompt
46
71
 
@@ -57,5 +82,7 @@ Useful follow-up calls:
57
82
 
58
83
  - `validate_timeline` after manual edits or agent refinements.
59
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.
60
87
  - `refine_timeline` when a human answers a gap and you need to preserve
61
88
  existing `source_refs`.
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.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,11 +14,12 @@
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": [
20
21
  "src",
21
- "docs",
22
+ "docs/*.md",
22
23
  "examples",
23
24
  "README.md",
24
25
  "LICENSE"
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
@@ -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
  }
@@ -61,7 +79,7 @@ export function listTimelineTools() {
61
79
  },
62
80
  {
63
81
  name: "render_timeline",
64
- 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.",
65
83
  inputSchema: {
66
84
  type: "object",
67
85
  required: ["timeline"],
@@ -70,7 +88,7 @@ export function listTimelineTools() {
70
88
  timeline: TIMELINE_SCHEMA,
71
89
  format: {
72
90
  type: "string",
73
- enum: ["mermaid_gantt", "mermaid_timeline", "markdown"],
91
+ enum: ["mermaid_gantt", "mermaid_timeline", "markdown", "review_report"],
74
92
  default: "mermaid_gantt"
75
93
  }
76
94
  }
@@ -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: [
@@ -13,7 +17,7 @@ export function createTimeline(input = {}) {
13
17
  gaps: [],
14
18
  render: {
15
19
  audience: "TPM/PM",
16
- defaultFormats: ["mermaid_gantt", "mermaid_timeline", "markdown"]
20
+ defaultFormats: ["mermaid_gantt", "mermaid_timeline", "markdown", "review_report"]
17
21
  }
18
22
  });
19
23
  const validation = validateTimeline(timeline);
@@ -22,16 +26,20 @@ export function createTimeline(input = {}) {
22
26
  gaps: validation.gaps,
23
27
  issues: validation.issues
24
28
  };
29
+ const followups = buildFollowups(validatedTimeline);
25
30
 
26
31
  return {
27
32
  timeline: validatedTimeline,
28
33
  assumptions: validatedTimeline.assumptions,
29
34
  gaps: validatedTimeline.gaps,
30
35
  issues: validation.issues,
36
+ followups,
37
+ noise_report: noiseReport,
31
38
  renders: {
32
39
  mermaid_gantt: renderTimeline(validatedTimeline, { format: "mermaid_gantt" }),
33
40
  mermaid_timeline: renderTimeline(validatedTimeline, { format: "mermaid_timeline" }),
34
- markdown: renderTimeline(validatedTimeline, { format: "markdown" })
41
+ markdown: renderTimeline(validatedTimeline, { format: "markdown" }),
42
+ review_report: renderTimeline(validatedTimeline, { format: "review_report" })
35
43
  }
36
44
  };
37
45
  }
@@ -42,11 +50,15 @@ export function validateTimeline(timeline = {}) {
42
50
  const issues = [];
43
51
 
44
52
  for (const item of normalized.items) {
45
- if (!item.start) {
53
+ if (item.exact_date_needed) {
54
+ gaps.push(makeGap(item, "exact_date", "Exact date needed before rendering this fuzzy time window."));
55
+ }
56
+
57
+ if (!item.start && !item.time_window) {
46
58
  gaps.push(makeGap(item, "start", "Missing start date. Ask for the planned start date instead of inferring it."));
47
59
  }
48
60
 
49
- if (!item.end && !item.duration && item.type !== "milestone") {
61
+ if (!item.end && !item.duration && !item.time_window && item.type !== "milestone") {
50
62
  gaps.push(makeGap(item, "end", "Missing end date or duration for a non-milestone item."));
51
63
  }
52
64
 
@@ -62,11 +74,13 @@ export function validateTimeline(timeline = {}) {
62
74
  for (const item of normalized.items) {
63
75
  for (const dependency of item.dependencies) {
64
76
  if (!normalized.items.some((candidate) => candidate.title === dependency)) {
77
+ const suggestions = suggestDependencyTitles(normalized.items, dependency);
65
78
  issues.push({
66
79
  type: "unknown_dependency",
67
80
  severity: "warning",
68
81
  itemTitle: item.title,
69
82
  dependency,
83
+ suggestions,
70
84
  message: `Dependency "${dependency}" was not found in the timeline.`
71
85
  });
72
86
  }
@@ -113,6 +127,10 @@ export function renderTimeline(timeline = {}, options = {}) {
113
127
  return renderMarkdown(normalized);
114
128
  }
115
129
 
130
+ if (format === "review_report") {
131
+ return renderReviewReport(normalized);
132
+ }
133
+
116
134
  return renderMermaidGantt(normalized);
117
135
  }
118
136
 
@@ -142,11 +160,12 @@ export function refineTimeline(timeline = {}, refinement = {}) {
142
160
  };
143
161
  }
144
162
 
145
- function parseSource(source, index, importedAssumptions) {
163
+ function parseSource(source, index, importedAssumptions, input, noiseReport) {
146
164
  const normalizedSource = {
147
165
  id: source?.id || `source-${index + 1}`,
148
166
  type: source?.type || "text",
149
- content: source?.content ?? ""
167
+ content: source?.content ?? "",
168
+ path: source?.path || source?.file_path || source?.filePath
150
169
  };
151
170
 
152
171
  if (normalizedSource.type === "json") {
@@ -157,11 +176,21 @@ function parseSource(source, index, importedAssumptions) {
157
176
  return parseCsvSource(normalizedSource);
158
177
  }
159
178
 
179
+ if (normalizedSource.type === "markdown") {
180
+ return parseMarkdownSource(normalizedSource, input?.markdown, noiseReport);
181
+ }
182
+
160
183
  return parseTextSource(normalizedSource);
161
184
  }
162
185
 
163
186
  function parseJsonSource(source, importedAssumptions) {
164
- 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
+ }
165
194
  const rawItems = Array.isArray(parsed) ? parsed : parsed.items ?? [];
166
195
 
167
196
  if (Array.isArray(parsed.assumptions)) {
@@ -197,6 +226,85 @@ function parseTextSource(source) {
197
226
  .filter(Boolean);
198
227
  }
199
228
 
229
+ function parseMarkdownSource(source, markdownOptions = {}, noiseReport = createNoiseReport()) {
230
+ const lines = String(source.content).split(/\r?\n/);
231
+ const allowedHeadings = getAllowedMarkdownHeadings(markdownOptions);
232
+ const hasAllowedHeadings = markdownHasAllowedHeadings(lines, allowedHeadings);
233
+ const items = [];
234
+ let currentHeading;
235
+ let inFrontmatter = markdownOptions?.ignoreFrontmatter === false ? false : lines[0]?.trim() === "---";
236
+
237
+ for (let index = 0; index < lines.length; index += 1) {
238
+ const line = lines[index];
239
+ const trimmed = line.trim();
240
+
241
+ if (inFrontmatter) {
242
+ noiseReport.ignored.frontmatter_lines += 1;
243
+ if (index > 0 && trimmed === "---") inFrontmatter = false;
244
+ continue;
245
+ }
246
+
247
+ const heading = parseMarkdownHeading(trimmed);
248
+ if (heading) {
249
+ currentHeading = heading;
250
+ continue;
251
+ }
252
+
253
+ if (!trimmed) continue;
254
+
255
+ const inAllowedSection = !hasAllowedHeadings || allowedHeadings.has(normalizeHeading(currentHeading));
256
+ if (!inAllowedSection) {
257
+ noiseReport.ignored.prose_lines += 1;
258
+ continue;
259
+ }
260
+
261
+ if (isMarkdownTableLine(trimmed)) {
262
+ const table = parseMarkdownTable(lines, index);
263
+ if (!table) {
264
+ noiseReport.ignored.prose_lines += 1;
265
+ continue;
266
+ }
267
+
268
+ for (const row of table.rows) {
269
+ const item = markdownRecordToItem(row.record, currentHeading);
270
+ if (!item) {
271
+ noiseReport.ignored.table_rows_without_dates += row.hasDate ? 0 : 1;
272
+ continue;
273
+ }
274
+
275
+ if (!row.hasDate) noiseReport.ignored.table_rows_without_dates += 1;
276
+ items.push(
277
+ normalizeItem(item, [
278
+ compactObject({
279
+ sourceId: source.id,
280
+ path: source.path,
281
+ heading: currentHeading,
282
+ tableRow: row.tableRow,
283
+ line: row.line,
284
+ text: row.text
285
+ })
286
+ ])
287
+ );
288
+ }
289
+
290
+ index = table.endIndex;
291
+ continue;
292
+ }
293
+
294
+ const parsedLine = parseTextLine(line, source.id, index + 1);
295
+ if (parsedLine) {
296
+ parsedLine.source_refs = parsedLine.source_refs.map((sourceRef) =>
297
+ compactObject({ ...sourceRef, path: source.path, heading: currentHeading })
298
+ );
299
+ items.push(parsedLine);
300
+ } else {
301
+ noiseReport.ignored.prose_lines += 1;
302
+ }
303
+ }
304
+
305
+ return items;
306
+ }
307
+
200
308
  function parseTextLine(line, sourceId, lineNumber) {
201
309
  const trimmed = normalizePlanningLine(line);
202
310
  if (!trimmed) return null;
@@ -218,7 +326,10 @@ function parseTextLine(line, sourceId, lineNumber) {
218
326
  owner,
219
327
  status,
220
328
  dependencies,
221
- 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."
222
333
  };
223
334
 
224
335
  return normalizeItem(item, [{ sourceId, line: lineNumber, text: trimmed }]);
@@ -292,6 +403,36 @@ function csvRecordToItem(record) {
292
403
  };
293
404
  }
294
405
 
406
+ function markdownRecordToItem(record, heading) {
407
+ const title =
408
+ valueFromRecord(record, ["item", "follow_up", "follow-up", "followup", "task", "milestone", "risk", "blocker"]) ||
409
+ valueFromRecord(record, ["title", "name"]);
410
+ if (!title) return null;
411
+
412
+ const target = valueFromRecord(record, ["target", "date", "when", "time_window", "window"]);
413
+ const dates = extractExactDates(target);
414
+ const fuzzyTarget = target && dates.length === 0 ? target : undefined;
415
+
416
+ return {
417
+ title,
418
+ type: normalizeHeading(heading) === "milestones" || record.type === "milestone" ? "milestone" : "task",
419
+ start: dates[0],
420
+ end: dates[1],
421
+ owner: valueFromRecord(record, ["owner", "assignee"]),
422
+ status: valueFromRecord(record, ["status"]) || "planned",
423
+ dependencies: valueFromRecord(record, ["dependencies", "depends_on", "depends on"]),
424
+ time_window: fuzzyTarget,
425
+ date_text: fuzzyTarget,
426
+ exact_date_needed: Boolean(fuzzyTarget),
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."
433
+ };
434
+ }
435
+
295
436
  function normalizeTimeline(timeline = {}) {
296
437
  const items = Array.isArray(timeline.items)
297
438
  ? timeline.items.map((item) => normalizeItem(item, item.source_refs))
@@ -303,6 +444,7 @@ function normalizeTimeline(timeline = {}) {
303
444
  milestones,
304
445
  assumptions: Array.isArray(timeline.assumptions) ? [...timeline.assumptions] : [],
305
446
  gaps: Array.isArray(timeline.gaps) ? [...timeline.gaps] : [],
447
+ issues: Array.isArray(timeline.issues) ? [...timeline.issues] : [],
306
448
  render: timeline.render && typeof timeline.render === "object" ? { ...timeline.render } : {}
307
449
  };
308
450
  }
@@ -321,10 +463,16 @@ function normalizeItem(item = {}, sourceRefs = []) {
321
463
  start: blankToUndefined(item.start),
322
464
  end: blankToUndefined(item.end),
323
465
  duration: blankToUndefined(item.duration),
466
+ time_window: blankToUndefined(item.time_window),
467
+ date_text: blankToUndefined(item.date_text),
468
+ exact_date_needed: Boolean(item.exact_date_needed),
324
469
  owner: blankToUndefined(item.owner),
325
470
  status: blankToUndefined(item.status) || "unknown",
326
471
  dependencies: dependencies.map((dependency) => String(dependency).trim()).filter(Boolean),
327
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),
328
476
  source_refs: normalizeSourceRefs(sourceRefs)
329
477
  };
330
478
  }
@@ -392,6 +540,24 @@ function dedupeCycles(cycles) {
392
540
  });
393
541
  }
394
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
+
395
561
  function renderMermaidGantt(timeline) {
396
562
  const lines = ["gantt", " title Project Timeline", " dateFormat YYYY-MM-DD", " axisFormat %b %d", " section Plan"];
397
563
 
@@ -451,6 +617,107 @@ function renderMarkdown(timeline) {
451
617
  return `${lines.join("\n")}\n`;
452
618
  }
453
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
+
454
721
  function parseCsv(content) {
455
722
  const rows = [];
456
723
  let row = [];
@@ -503,6 +770,115 @@ function blankToUndefined(value) {
503
770
  return normalized === "" ? undefined : normalized;
504
771
  }
505
772
 
773
+ function createNoiseReport() {
774
+ return {
775
+ ignored: {
776
+ frontmatter_lines: 0,
777
+ prose_lines: 0,
778
+ table_rows_without_dates: 0
779
+ }
780
+ };
781
+ }
782
+
783
+ function getAllowedMarkdownHeadings(options = {}) {
784
+ const sections = Array.isArray(options?.sections) && options.sections.length > 0
785
+ ? options.sections
786
+ : DEFAULT_MARKDOWN_SECTIONS;
787
+ return new Set(sections.map((section) => normalizeHeading(section)));
788
+ }
789
+
790
+ function markdownHasAllowedHeadings(lines, allowedHeadings) {
791
+ return lines.some((line) => {
792
+ const heading = parseMarkdownHeading(line.trim());
793
+ return heading && allowedHeadings.has(normalizeHeading(heading));
794
+ });
795
+ }
796
+
797
+ function parseMarkdownHeading(line) {
798
+ const match = line.match(/^#{1,6}\s+(.+?)\s*#*$/);
799
+ return match ? match[1].trim() : undefined;
800
+ }
801
+
802
+ function normalizeHeading(value) {
803
+ return String(value || "")
804
+ .trim()
805
+ .toLowerCase()
806
+ .replace(/&/g, "and")
807
+ .replace(/[^a-z0-9]+/g, " ")
808
+ .trim();
809
+ }
810
+
811
+ function isMarkdownTableLine(line) {
812
+ return /^\|.*\|\s*$/.test(line);
813
+ }
814
+
815
+ function parseMarkdownTable(lines, startIndex) {
816
+ const header = parseMarkdownTableCells(lines[startIndex]);
817
+ const separator = parseMarkdownTableCells(lines[startIndex + 1]);
818
+ if (header.length === 0 || !isMarkdownSeparatorRow(separator)) return null;
819
+
820
+ const headers = header.map((cell) => normalizeHeader(cell));
821
+ const rows = [];
822
+ let index = startIndex + 2;
823
+ let tableRow = 1;
824
+
825
+ while (index < lines.length && isMarkdownTableLine(lines[index].trim())) {
826
+ const cells = parseMarkdownTableCells(lines[index]);
827
+ const record = {};
828
+ headers.forEach((column, columnIndex) => {
829
+ record[column] = cells[columnIndex] ?? "";
830
+ });
831
+
832
+ const target = valueFromRecord(record, ["target", "date", "when", "time_window", "window"]);
833
+ rows.push({
834
+ record,
835
+ tableRow,
836
+ line: index + 1,
837
+ text: lines[index].trim(),
838
+ hasDate: extractExactDates(target).length > 0 || Boolean(blankToUndefined(target))
839
+ });
840
+ tableRow += 1;
841
+ index += 1;
842
+ }
843
+
844
+ return {
845
+ rows,
846
+ endIndex: index - 1
847
+ };
848
+ }
849
+
850
+ function parseMarkdownTableCells(line = "") {
851
+ const trimmed = String(line).trim();
852
+ if (!isMarkdownTableLine(trimmed)) return [];
853
+ return trimmed
854
+ .replace(/^\|/, "")
855
+ .replace(/\|$/, "")
856
+ .split("|")
857
+ .map((cell) => cell.trim());
858
+ }
859
+
860
+ function isMarkdownSeparatorRow(cells) {
861
+ return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
862
+ }
863
+
864
+ function valueFromRecord(record, names) {
865
+ for (const name of names) {
866
+ const normalized = normalizeHeader(name);
867
+ const value = blankToUndefined(record[normalized]);
868
+ if (value) return value;
869
+ }
870
+
871
+ return undefined;
872
+ }
873
+
874
+ function extractExactDates(value) {
875
+ return [...String(value || "").matchAll(DATE_PATTERN)].map((match) => match[0]);
876
+ }
877
+
878
+ function compactObject(value) {
879
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
880
+ }
881
+
506
882
  function escapeMermaidText(value) {
507
883
  return String(value).replace(/[:#;]/g, "-").trim();
508
884
  }