timeline-truth 0.1.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/LICENSE +21 -0
- package/README.md +155 -0
- package/docs/MCP-SETUP.md +61 -0
- package/docs/RELEASE.md +47 -0
- package/examples/expected-output/jira-export.json +23 -0
- package/examples/expected-output/launch-checklist.json +19 -0
- package/examples/expected-output/prd-snippet.json +19 -0
- package/examples/expected-output/status-update.json +27 -0
- package/examples/jira-export.csv +5 -0
- package/examples/launch-checklist.md +6 -0
- package/examples/prd-snippet.md +6 -0
- package/examples/status-update.md +6 -0
- package/package.json +45 -0
- package/src/mcp-server.js +40 -0
- package/src/mcp-tools.js +136 -0
- package/src/timeline.js +512 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hilmi Muktitama
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Timeline Truth
|
|
2
|
+
|
|
3
|
+
Status: v0.2 adoption release in progress. MIT licensed. Requires Node.js
|
|
4
|
+
22 or newer.
|
|
5
|
+
|
|
6
|
+
Timeline Truth is a local MCP server for AI-agent TPM workflows: paste PRD/Jira/status notes,
|
|
7
|
+
CSV exports, launch checklists, or rough planning text; get timeline JSON,
|
|
8
|
+
validation gaps, assumptions, and Mermaid/Markdown renders.
|
|
9
|
+
|
|
10
|
+
It is intentionally narrow. Timeline Truth does not invent missing dates,
|
|
11
|
+
owners, or dependencies. It preserves `source_refs` and makes planning
|
|
12
|
+
uncertainty visible so humans can review the timeline instead of trusting a
|
|
13
|
+
confident rewrite.
|
|
14
|
+
|
|
15
|
+
## First Use
|
|
16
|
+
|
|
17
|
+
Use it when your planning input looks like this:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
Discovery: 2026-06-01 to 2026-06-05 owner PM status planned
|
|
21
|
+
API contract: starts 2026-06-06 duration 4d owner Platform depends on Discovery
|
|
22
|
+
Checkout QA: owner QA depends on API contract
|
|
23
|
+
Launch decision milestone on 2026-06-17 owner PM
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Ask your agent:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
Use the timeline-truth MCP server. Call create_timeline with these notes as a
|
|
30
|
+
single source. Then summarize the timeline, list gaps and assumptions, and show
|
|
31
|
+
the mermaid_gantt output. Do not infer missing dates or owners.
|
|
32
|
+
```
|
|
33
|
+
|
|
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.
|
|
36
|
+
|
|
37
|
+
## Why This Exists
|
|
38
|
+
|
|
39
|
+
Most timeline tools assume the plan is already structured. Real planning inputs
|
|
40
|
+
usually are not. They are PRD snippets, Jira notes, launch checklists, weekly
|
|
41
|
+
status updates, CSV exports, and Slack summaries.
|
|
42
|
+
|
|
43
|
+
Timeline Truth focuses on the handoff from messy planning material to a
|
|
44
|
+
reviewable timeline:
|
|
45
|
+
|
|
46
|
+
- preserve `source_refs` so every item can point back to evidence
|
|
47
|
+
- flag missing dates, owners, and dependency problems instead of guessing
|
|
48
|
+
- render portable Mermaid and Markdown artifacts
|
|
49
|
+
- stay small enough to run inside local agent workflows
|
|
50
|
+
|
|
51
|
+
## Why not just ask ChatGPT or Mermaid?
|
|
52
|
+
|
|
53
|
+
ChatGPT can draft a timeline, and Mermaid can render one. Timeline Truth does a
|
|
54
|
+
smaller job: it gives the agent a deterministic compiler/validator so the output
|
|
55
|
+
keeps evidence, gaps, assumptions, and repeatable render formats.
|
|
56
|
+
|
|
57
|
+
That matters when a TPM, PM, or engineering lead needs to review what is known,
|
|
58
|
+
what is missing, and where each timeline item came from.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
Local checkout:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install
|
|
66
|
+
node src/mcp-server.js
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Npm package config, after the npm package is published:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"timeline-truth": {
|
|
75
|
+
"command": "npx",
|
|
76
|
+
"args": ["-y", "--package=timeline-truth", "timeline-truth-mcp"]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
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).
|
|
85
|
+
|
|
86
|
+
## MCP Tools
|
|
87
|
+
|
|
88
|
+
- `create_timeline`: compile source content into timeline JSON plus Mermaid
|
|
89
|
+
outputs.
|
|
90
|
+
- `validate_timeline`: report missing dates, owners, unknown dependencies,
|
|
91
|
+
circular dependencies, and impossible sequencing.
|
|
92
|
+
- `render_timeline`: render a normalized timeline as `mermaid_gantt`,
|
|
93
|
+
`mermaid_timeline`, or `markdown`.
|
|
94
|
+
- `refine_timeline`: apply edits while preserving evidence (`source_refs`) and
|
|
95
|
+
assumptions.
|
|
96
|
+
|
|
97
|
+
## Examples
|
|
98
|
+
|
|
99
|
+
Realistic fixtures live in [examples](examples):
|
|
100
|
+
|
|
101
|
+
- [PRD snippet](examples/prd-snippet.md)
|
|
102
|
+
- [Jira CSV export](examples/jira-export.csv)
|
|
103
|
+
- [Launch checklist](examples/launch-checklist.md)
|
|
104
|
+
- [Status update](examples/status-update.md)
|
|
105
|
+
|
|
106
|
+
Each example has a compact expected-output JSON file and is covered by tests.
|
|
107
|
+
|
|
108
|
+
## Current limitations
|
|
109
|
+
|
|
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
|
|
113
|
+
nested documents are not fully parsed.
|
|
114
|
+
- CSV and JSON are more reliable than free-form notes when exact fields matter.
|
|
115
|
+
- There are no Jira, Confluence, Slack, or hosted imports in this release.
|
|
116
|
+
- The server validates dependencies by item title, not by external issue keys.
|
|
117
|
+
|
|
118
|
+
## Project Boundaries
|
|
119
|
+
|
|
120
|
+
Timeline Truth is not a project management system, scheduling optimizer, or
|
|
121
|
+
visual planning app. It is a compiler and validator for timeline artifacts.
|
|
122
|
+
|
|
123
|
+
Good fits:
|
|
124
|
+
|
|
125
|
+
- turning rough planning notes into a reviewable timeline
|
|
126
|
+
- finding missing dates, owners, and dependency issues
|
|
127
|
+
- generating Mermaid timelines for docs and status reports
|
|
128
|
+
- preserving evidence during AI-assisted planning
|
|
129
|
+
|
|
130
|
+
Poor fits:
|
|
131
|
+
|
|
132
|
+
- capacity planning
|
|
133
|
+
- drag-and-drop timeline editing
|
|
134
|
+
- automatic schedule generation from vague goals
|
|
135
|
+
- replacing Jira, Asana, Smartsheet, or similar systems of record
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npm install
|
|
141
|
+
npm test
|
|
142
|
+
npm run check
|
|
143
|
+
npm pack --dry-run
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
See [docs/RELEASE.md](docs/RELEASE.md) before publishing.
|
|
147
|
+
|
|
148
|
+
## Contributing
|
|
149
|
+
|
|
150
|
+
Contributions are welcome when they keep the project narrow and evidence-first.
|
|
151
|
+
Before adding features, check [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# MCP Setup
|
|
2
|
+
|
|
3
|
+
Timeline Truth runs as a local stdio MCP server. It does not need network
|
|
4
|
+
access, credentials, or hosted storage.
|
|
5
|
+
|
|
6
|
+
## Local Checkout
|
|
7
|
+
|
|
8
|
+
Use this while evaluating the repo or contributing changes:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"timeline-truth": {
|
|
14
|
+
"command": "node",
|
|
15
|
+
"args": ["C:/path/to/timeline-truth/src/mcp-server.js"]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
From the checkout, install dependencies once:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Npm Package
|
|
28
|
+
|
|
29
|
+
Use this after the `timeline-truth` npm package is published:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"timeline-truth": {
|
|
35
|
+
"command": "npx",
|
|
36
|
+
"args": ["-y", "--package=timeline-truth", "timeline-truth-mcp"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
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.
|
|
44
|
+
|
|
45
|
+
## Agent Prompt
|
|
46
|
+
|
|
47
|
+
Paste planning notes into your agent with this instruction:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
Use the timeline-truth MCP server. Call create_timeline with these notes as a
|
|
51
|
+
single source. Then summarize the timeline, list gaps and assumptions, and show
|
|
52
|
+
the mermaid_gantt output. Do not infer missing dates or owners; use the gaps
|
|
53
|
+
from the tool as follow-up questions.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Useful follow-up calls:
|
|
57
|
+
|
|
58
|
+
- `validate_timeline` after manual edits or agent refinements.
|
|
59
|
+
- `render_timeline` when you need only Mermaid or Markdown output.
|
|
60
|
+
- `refine_timeline` when a human answers a gap and you need to preserve
|
|
61
|
+
existing `source_refs`.
|
package/docs/RELEASE.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Release Checklist
|
|
2
|
+
|
|
3
|
+
Use this checklist before publishing a public version of `timeline-truth`.
|
|
4
|
+
|
|
5
|
+
## Verify Package Name
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm view timeline-truth version
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Expected before first publish: npm returns `404 Not Found`.
|
|
12
|
+
|
|
13
|
+
Expected after publish: npm returns the latest published version.
|
|
14
|
+
|
|
15
|
+
## Verify Local Quality
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm test
|
|
19
|
+
npm run check
|
|
20
|
+
npm pack --dry-run
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Confirm the pack output includes:
|
|
24
|
+
|
|
25
|
+
- `src/`
|
|
26
|
+
- `docs/`
|
|
27
|
+
- `examples/`
|
|
28
|
+
- `README.md`
|
|
29
|
+
- `LICENSE`
|
|
30
|
+
|
|
31
|
+
## Publish
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm publish --access public
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
After publishing, rerun:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm view timeline-truth version
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then test the documented MCP package command:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx -y --package=timeline-truth timeline-truth-mcp
|
|
47
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sourceId": "jira-export",
|
|
3
|
+
"itemTitles": [
|
|
4
|
+
"Discovery",
|
|
5
|
+
"Backend API",
|
|
6
|
+
"Beta milestone",
|
|
7
|
+
"Data migration"
|
|
8
|
+
],
|
|
9
|
+
"gaps": [
|
|
10
|
+
{
|
|
11
|
+
"itemTitle": "Backend API",
|
|
12
|
+
"field": "end"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"itemTitle": "Data migration",
|
|
16
|
+
"field": "start"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"itemTitle": "Data migration",
|
|
20
|
+
"field": "end"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sourceId": "launch-checklist",
|
|
3
|
+
"itemTitles": [
|
|
4
|
+
"Scope review",
|
|
5
|
+
"Production readiness",
|
|
6
|
+
"Legal approval",
|
|
7
|
+
"Go live"
|
|
8
|
+
],
|
|
9
|
+
"gaps": [
|
|
10
|
+
{
|
|
11
|
+
"itemTitle": "Legal approval",
|
|
12
|
+
"field": "start"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"itemTitle": "Legal approval",
|
|
16
|
+
"field": "end"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sourceId": "prd-snippet",
|
|
3
|
+
"itemTitles": [
|
|
4
|
+
"Discovery",
|
|
5
|
+
"API contract",
|
|
6
|
+
"Checkout QA",
|
|
7
|
+
"Launch decision"
|
|
8
|
+
],
|
|
9
|
+
"gaps": [
|
|
10
|
+
{
|
|
11
|
+
"itemTitle": "Checkout QA",
|
|
12
|
+
"field": "start"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"itemTitle": "Checkout QA",
|
|
16
|
+
"field": "end"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sourceId": "status-update",
|
|
3
|
+
"itemTitles": [
|
|
4
|
+
"Mobile beta",
|
|
5
|
+
"Partner review",
|
|
6
|
+
"Rollout",
|
|
7
|
+
"Analytics setup"
|
|
8
|
+
],
|
|
9
|
+
"gaps": [
|
|
10
|
+
{
|
|
11
|
+
"itemTitle": "Partner review",
|
|
12
|
+
"field": "owner"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"itemTitle": "Rollout",
|
|
16
|
+
"field": "start"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"itemTitle": "Rollout",
|
|
20
|
+
"field": "end"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"itemTitle": "Analytics setup",
|
|
24
|
+
"field": "owner"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Launch Checklist
|
|
2
|
+
|
|
3
|
+
- [x] Scope review: 2026-08-01 to 2026-08-02 owner TPM status done
|
|
4
|
+
- [ ] Production readiness: starts 2026-08-05 duration 3d owner SRE status planned depends on Scope review
|
|
5
|
+
- [ ] Legal approval owner Legal status planned depends on Scope review
|
|
6
|
+
- [ ] Go live milestone on 2026-08-12 owner PM status planned depends on Production readiness, Legal approval
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Payments Launch PRD Timeline
|
|
2
|
+
|
|
3
|
+
Discovery: 2026-06-01 to 2026-06-05 owner PM status planned
|
|
4
|
+
API contract: starts 2026-06-06 duration 4d owner Platform status planned depends on Discovery
|
|
5
|
+
Checkout QA: owner QA status planned depends on API contract
|
|
6
|
+
Launch decision milestone on 2026-06-17 owner PM status planned depends on Checkout QA
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Weekly Status
|
|
2
|
+
|
|
3
|
+
Mobile beta: starts 2026-09-02 duration 5d owner Mobile status active
|
|
4
|
+
Partner review milestone on 2026-09-09 status planned
|
|
5
|
+
Rollout owner TPM status planned depends on Mobile beta, Partner review
|
|
6
|
+
Analytics setup: 2026-09-03 to 2026-09-06 status planned
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "timeline-truth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open-source MCP server for compiling messy project planning inputs into evidence-preserving timelines.",
|
|
5
|
+
"author": "hilmimuktitama",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/hilmimuktitama/timeline-truth.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/hilmimuktitama/timeline-truth/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/hilmimuktitama/timeline-truth#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"timeline-truth-mcp": "src/mcp-server.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"docs",
|
|
22
|
+
"examples",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"mcp",
|
|
28
|
+
"model-context-protocol",
|
|
29
|
+
"timeline",
|
|
30
|
+
"project-planning",
|
|
31
|
+
"mermaid",
|
|
32
|
+
"tpm",
|
|
33
|
+
"program-management"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test",
|
|
37
|
+
"check": "node scripts/check-syntax.js"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.17.5"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
|
|
6
|
+
import { callTimelineTool, listTimelineTools } from "./mcp-tools.js";
|
|
7
|
+
|
|
8
|
+
const server = new Server(
|
|
9
|
+
{
|
|
10
|
+
name: "timeline-truth",
|
|
11
|
+
version: "0.1.0"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
capabilities: {
|
|
15
|
+
tools: {}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
21
|
+
tools: listTimelineTools()
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
|
+
try {
|
|
26
|
+
return callTimelineTool(request.params.name, request.params.arguments ?? {});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return {
|
|
29
|
+
isError: true,
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: error instanceof Error ? error.message : String(error)
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await server.connect(new StdioServerTransport());
|
package/src/mcp-tools.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createTimeline, refineTimeline, renderTimeline, validateTimeline } from "./timeline.js";
|
|
2
|
+
|
|
3
|
+
const SOURCE_SCHEMA = {
|
|
4
|
+
type: "object",
|
|
5
|
+
required: ["content"],
|
|
6
|
+
additionalProperties: true,
|
|
7
|
+
properties: {
|
|
8
|
+
id: { type: "string", description: "Stable source identifier used in source_refs." },
|
|
9
|
+
type: { type: "string", enum: ["text", "markdown", "csv", "json"], default: "text" },
|
|
10
|
+
content: {
|
|
11
|
+
description: "Pasted text/file content. JSON sources may pass a JSON string or object.",
|
|
12
|
+
oneOf: [{ type: "string" }, { type: "object" }, { type: "array" }]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const TIMELINE_SCHEMA = {
|
|
18
|
+
type: "object",
|
|
19
|
+
required: ["items"],
|
|
20
|
+
additionalProperties: true,
|
|
21
|
+
properties: {
|
|
22
|
+
items: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
23
|
+
milestones: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
24
|
+
assumptions: { type: "array", items: { type: "string" } },
|
|
25
|
+
gaps: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
26
|
+
render: { type: "object", additionalProperties: true }
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function listTimelineTools() {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
name: "create_timeline",
|
|
34
|
+
description:
|
|
35
|
+
"Compile pasted project planning text, Markdown, CSV, or JSON into a normalized timeline with gaps, assumptions, and Mermaid renders.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
required: ["sources"],
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
properties: {
|
|
41
|
+
sources: {
|
|
42
|
+
type: "array",
|
|
43
|
+
minItems: 1,
|
|
44
|
+
items: SOURCE_SCHEMA
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "validate_timeline",
|
|
51
|
+
description:
|
|
52
|
+
"Validate a normalized timeline for missing dates, missing owners, unknown dependencies, circular dependencies, and impossible sequencing.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
required: ["timeline"],
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties: {
|
|
58
|
+
timeline: TIMELINE_SCHEMA
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "render_timeline",
|
|
64
|
+
description: "Render a normalized timeline as Mermaid Gantt, Mermaid timeline, or compact Markdown.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
required: ["timeline"],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
properties: {
|
|
70
|
+
timeline: TIMELINE_SCHEMA,
|
|
71
|
+
format: {
|
|
72
|
+
type: "string",
|
|
73
|
+
enum: ["mermaid_gantt", "mermaid_timeline", "markdown"],
|
|
74
|
+
default: "mermaid_gantt"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "refine_timeline",
|
|
81
|
+
description:
|
|
82
|
+
"Apply agent/user edits to an existing timeline while preserving source_refs and assumptions unless explicitly replaced.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: "object",
|
|
85
|
+
required: ["timeline", "updates"],
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
properties: {
|
|
88
|
+
timeline: TIMELINE_SCHEMA,
|
|
89
|
+
updates: {
|
|
90
|
+
type: "array",
|
|
91
|
+
items: {
|
|
92
|
+
type: "object",
|
|
93
|
+
required: ["set"],
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
properties: {
|
|
96
|
+
matchTitle: { type: "string" },
|
|
97
|
+
matchId: { type: "string" },
|
|
98
|
+
set: { type: "object", additionalProperties: true }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function callTimelineTool(name, args = {}) {
|
|
109
|
+
switch (name) {
|
|
110
|
+
case "create_timeline":
|
|
111
|
+
return jsonContent(createTimeline({ sources: args.sources }));
|
|
112
|
+
case "validate_timeline":
|
|
113
|
+
return jsonContent(validateTimeline(args.timeline));
|
|
114
|
+
case "render_timeline":
|
|
115
|
+
return textContent(renderTimeline(args.timeline, { format: args.format }));
|
|
116
|
+
case "refine_timeline":
|
|
117
|
+
return jsonContent(refineTimeline(args.timeline, { updates: args.updates }));
|
|
118
|
+
default:
|
|
119
|
+
throw new Error(`Unknown timeline tool: ${name}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function jsonContent(value) {
|
|
124
|
+
return textContent(JSON.stringify(value, null, 2));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function textContent(text) {
|
|
128
|
+
return {
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: "text",
|
|
132
|
+
text
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/timeline.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
const DATE_PATTERN = /\b\d{4}-\d{2}-\d{2}\b/g;
|
|
2
|
+
|
|
3
|
+
export function createTimeline(input = {}) {
|
|
4
|
+
const sources = Array.isArray(input.sources) ? input.sources : [];
|
|
5
|
+
const importedAssumptions = [];
|
|
6
|
+
const items = sources.flatMap((source, index) => parseSource(source, index, importedAssumptions));
|
|
7
|
+
const timeline = normalizeTimeline({
|
|
8
|
+
items,
|
|
9
|
+
assumptions: [
|
|
10
|
+
...importedAssumptions,
|
|
11
|
+
"No dates were inferred. Missing dates are reported as gaps for agent or user follow-up."
|
|
12
|
+
],
|
|
13
|
+
gaps: [],
|
|
14
|
+
render: {
|
|
15
|
+
audience: "TPM/PM",
|
|
16
|
+
defaultFormats: ["mermaid_gantt", "mermaid_timeline", "markdown"]
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
const validation = validateTimeline(timeline);
|
|
20
|
+
const validatedTimeline = {
|
|
21
|
+
...timeline,
|
|
22
|
+
gaps: validation.gaps,
|
|
23
|
+
issues: validation.issues
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
timeline: validatedTimeline,
|
|
28
|
+
assumptions: validatedTimeline.assumptions,
|
|
29
|
+
gaps: validatedTimeline.gaps,
|
|
30
|
+
issues: validation.issues,
|
|
31
|
+
renders: {
|
|
32
|
+
mermaid_gantt: renderTimeline(validatedTimeline, { format: "mermaid_gantt" }),
|
|
33
|
+
mermaid_timeline: renderTimeline(validatedTimeline, { format: "mermaid_timeline" }),
|
|
34
|
+
markdown: renderTimeline(validatedTimeline, { format: "markdown" })
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateTimeline(timeline = {}) {
|
|
40
|
+
const normalized = normalizeTimeline(timeline);
|
|
41
|
+
const gaps = [];
|
|
42
|
+
const issues = [];
|
|
43
|
+
|
|
44
|
+
for (const item of normalized.items) {
|
|
45
|
+
if (!item.start) {
|
|
46
|
+
gaps.push(makeGap(item, "start", "Missing start date. Ask for the planned start date instead of inferring it."));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!item.end && !item.duration && item.type !== "milestone") {
|
|
50
|
+
gaps.push(makeGap(item, "end", "Missing end date or duration for a non-milestone item."));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!item.owner) {
|
|
54
|
+
gaps.push(makeGap(item, "owner", "Missing accountable owner."));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (item.type === "milestone" && !item.owner) {
|
|
58
|
+
gaps.push(makeGap(item, "owner", "Milestone ownership is ambiguous."));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const item of normalized.items) {
|
|
63
|
+
for (const dependency of item.dependencies) {
|
|
64
|
+
if (!normalized.items.some((candidate) => candidate.title === dependency)) {
|
|
65
|
+
issues.push({
|
|
66
|
+
type: "unknown_dependency",
|
|
67
|
+
severity: "warning",
|
|
68
|
+
itemTitle: item.title,
|
|
69
|
+
dependency,
|
|
70
|
+
message: `Dependency "${dependency}" was not found in the timeline.`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cycles = findDependencyCycles(normalized.items);
|
|
77
|
+
for (const cycle of cycles) {
|
|
78
|
+
issues.push({
|
|
79
|
+
type: "circular_dependency",
|
|
80
|
+
severity: "error",
|
|
81
|
+
items: cycle,
|
|
82
|
+
message: `Circular dependency detected: ${cycle.join(" -> ")}.`
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const item of normalized.items) {
|
|
87
|
+
for (const dependencyTitle of item.dependencies) {
|
|
88
|
+
const dependency = normalized.items.find((candidate) => candidate.title === dependencyTitle);
|
|
89
|
+
if (dependency?.end && item.start && item.start < dependency.end) {
|
|
90
|
+
issues.push({
|
|
91
|
+
type: "impossible_sequence",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
itemTitle: item.title,
|
|
94
|
+
dependency: dependencyTitle,
|
|
95
|
+
message: `"${item.title}" starts before dependency "${dependencyTitle}" ends.`
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { gaps, issues };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function renderTimeline(timeline = {}, options = {}) {
|
|
105
|
+
const normalized = normalizeTimeline(timeline);
|
|
106
|
+
const format = options.format ?? "mermaid_gantt";
|
|
107
|
+
|
|
108
|
+
if (format === "mermaid_timeline") {
|
|
109
|
+
return renderMermaidTimeline(normalized);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (format === "markdown") {
|
|
113
|
+
return renderMarkdown(normalized);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return renderMermaidGantt(normalized);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function refineTimeline(timeline = {}, refinement = {}) {
|
|
120
|
+
const refined = normalizeTimeline(structuredCloneSafe(timeline));
|
|
121
|
+
const updates = Array.isArray(refinement.updates) ? refinement.updates : [];
|
|
122
|
+
|
|
123
|
+
for (const update of updates) {
|
|
124
|
+
const item = refined.items.find((candidate) => {
|
|
125
|
+
if (update.matchTitle) return candidate.title === update.matchTitle;
|
|
126
|
+
if (update.matchId) return candidate.id === update.matchId;
|
|
127
|
+
return false;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!item || !update.set || typeof update.set !== "object") continue;
|
|
131
|
+
|
|
132
|
+
const preservedSourceRefs = item.source_refs;
|
|
133
|
+
const mergedItem = { ...item, ...update.set };
|
|
134
|
+
Object.assign(item, normalizeItem(mergedItem, update.set.source_refs ?? preservedSourceRefs));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const validation = validateTimeline(refined);
|
|
138
|
+
return {
|
|
139
|
+
...normalizeTimeline(refined),
|
|
140
|
+
gaps: validation.gaps,
|
|
141
|
+
issues: validation.issues
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseSource(source, index, importedAssumptions) {
|
|
146
|
+
const normalizedSource = {
|
|
147
|
+
id: source?.id || `source-${index + 1}`,
|
|
148
|
+
type: source?.type || "text",
|
|
149
|
+
content: source?.content ?? ""
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (normalizedSource.type === "json") {
|
|
153
|
+
return parseJsonSource(normalizedSource, importedAssumptions);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (normalizedSource.type === "csv") {
|
|
157
|
+
return parseCsvSource(normalizedSource);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return parseTextSource(normalizedSource);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseJsonSource(source, importedAssumptions) {
|
|
164
|
+
const parsed = typeof source.content === "string" ? JSON.parse(source.content) : source.content;
|
|
165
|
+
const rawItems = Array.isArray(parsed) ? parsed : parsed.items ?? [];
|
|
166
|
+
|
|
167
|
+
if (Array.isArray(parsed.assumptions)) {
|
|
168
|
+
importedAssumptions.push(...parsed.assumptions.filter((assumption) => typeof assumption === "string"));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return rawItems.map((item, index) =>
|
|
172
|
+
normalizeItem(item, item.source_refs ?? [{ sourceId: source.id, line: index + 1 }])
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseCsvSource(source) {
|
|
177
|
+
const rows = parseCsv(source.content);
|
|
178
|
+
if (rows.length === 0) return [];
|
|
179
|
+
|
|
180
|
+
const headers = rows[0].map((header) => normalizeHeader(header));
|
|
181
|
+
return rows.slice(1).flatMap((row, index) => {
|
|
182
|
+
if (row.every((cell) => cell.trim() === "")) return [];
|
|
183
|
+
|
|
184
|
+
const record = {};
|
|
185
|
+
headers.forEach((header, headerIndex) => {
|
|
186
|
+
record[header] = row[headerIndex] ?? "";
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return normalizeItem(csvRecordToItem(record), [{ sourceId: source.id, line: index + 2 }]);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseTextSource(source) {
|
|
194
|
+
return String(source.content)
|
|
195
|
+
.split(/\r?\n/)
|
|
196
|
+
.map((line, index) => parseTextLine(line, source.id, index + 1))
|
|
197
|
+
.filter(Boolean);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseTextLine(line, sourceId, lineNumber) {
|
|
201
|
+
const trimmed = normalizePlanningLine(line);
|
|
202
|
+
if (!trimmed) return null;
|
|
203
|
+
|
|
204
|
+
const dates = [...trimmed.matchAll(DATE_PATTERN)].map((match) => match[0]);
|
|
205
|
+
const lower = trimmed.toLowerCase();
|
|
206
|
+
const type = lower.includes("milestone") ? "milestone" : "task";
|
|
207
|
+
const title = extractTitle(trimmed, type);
|
|
208
|
+
const owner = extractKeywordValue(trimmed, "owner");
|
|
209
|
+
const status = extractKeywordValue(trimmed, "status") || "planned";
|
|
210
|
+
const dependencies = extractDependencies(trimmed);
|
|
211
|
+
const duration = extractDuration(trimmed);
|
|
212
|
+
const item = {
|
|
213
|
+
title,
|
|
214
|
+
type,
|
|
215
|
+
start: dates[0],
|
|
216
|
+
end: dates[1],
|
|
217
|
+
duration,
|
|
218
|
+
owner,
|
|
219
|
+
status,
|
|
220
|
+
dependencies,
|
|
221
|
+
confidence: dates.length > 0 ? 0.75 : 0.45
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return normalizeItem(item, [{ sourceId, line: lineNumber, text: trimmed }]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizePlanningLine(line) {
|
|
228
|
+
const trimmed = String(line).trim();
|
|
229
|
+
if (/^#{1,6}\s+/.test(trimmed)) return "";
|
|
230
|
+
|
|
231
|
+
return trimmed
|
|
232
|
+
.replace(/^[-*]\s+\[[ xX]\]\s+/, "")
|
|
233
|
+
.replace(/^[-*]\s+/, "");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function extractTitle(line, type) {
|
|
237
|
+
const colonIndex = line.indexOf(":");
|
|
238
|
+
if (colonIndex > 0) return cleanTitle(line.slice(0, colonIndex));
|
|
239
|
+
|
|
240
|
+
const milestoneMatch = line.match(/^(.+?)\s+milestone\b/i);
|
|
241
|
+
if (milestoneMatch) return cleanTitle(milestoneMatch[1]);
|
|
242
|
+
|
|
243
|
+
const dateMatch = line.match(DATE_PATTERN);
|
|
244
|
+
if (dateMatch?.index && dateMatch.index > 0) return cleanTitle(line.slice(0, dateMatch.index));
|
|
245
|
+
|
|
246
|
+
const ownerIndex = line.search(/\sowner\b/i);
|
|
247
|
+
if (ownerIndex > 0) return cleanTitle(line.slice(0, ownerIndex));
|
|
248
|
+
|
|
249
|
+
return cleanTitle(type === "milestone" ? line.replace(/\bmilestone\b/gi, "") : line);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cleanTitle(title) {
|
|
253
|
+
return title
|
|
254
|
+
.replace(/\b(starts?|start|from|on|by|duration|depends on|owner|status)\b.*$/i, "")
|
|
255
|
+
.trim()
|
|
256
|
+
.replace(/[.:-]+$/g, "")
|
|
257
|
+
.trim();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function extractKeywordValue(line, keyword) {
|
|
261
|
+
const pattern = new RegExp(`\\b${keyword}\\s+([^,;]+?)(?=\\s+(?:status|owner|depends? on|duration|starts?|from|on)\\b|$)`, "i");
|
|
262
|
+
const match = line.match(pattern);
|
|
263
|
+
return match ? match[1].trim() : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function extractDependencies(line) {
|
|
267
|
+
const match = line.match(/\bdepends? on\s+(.+?)(?=\s+(?:owner|status|duration|starts?|from|on)\b|$)/i);
|
|
268
|
+
if (!match) return [];
|
|
269
|
+
|
|
270
|
+
return match[1]
|
|
271
|
+
.split(/[|,;]/)
|
|
272
|
+
.map((dependency) => dependency.trim())
|
|
273
|
+
.filter(Boolean);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function extractDuration(line) {
|
|
277
|
+
const match = line.match(/\bduration\s+(\d+\s*[dwmy])\b/i);
|
|
278
|
+
return match ? match[1].replace(/\s+/g, "") : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function csvRecordToItem(record) {
|
|
282
|
+
return {
|
|
283
|
+
title: record.title || record.name || record.task || record.milestone,
|
|
284
|
+
type: record.type,
|
|
285
|
+
start: record.start || record.start_date,
|
|
286
|
+
end: record.end || record.end_date,
|
|
287
|
+
duration: record.duration,
|
|
288
|
+
owner: record.owner || record.assignee,
|
|
289
|
+
status: record.status,
|
|
290
|
+
dependencies: record.dependencies || record.depends_on,
|
|
291
|
+
confidence: record.confidence ? Number(record.confidence) : undefined
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizeTimeline(timeline = {}) {
|
|
296
|
+
const items = Array.isArray(timeline.items)
|
|
297
|
+
? timeline.items.map((item) => normalizeItem(item, item.source_refs))
|
|
298
|
+
: [];
|
|
299
|
+
const milestones = items.filter((item) => item.type === "milestone");
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
items,
|
|
303
|
+
milestones,
|
|
304
|
+
assumptions: Array.isArray(timeline.assumptions) ? [...timeline.assumptions] : [],
|
|
305
|
+
gaps: Array.isArray(timeline.gaps) ? [...timeline.gaps] : [],
|
|
306
|
+
render: timeline.render && typeof timeline.render === "object" ? { ...timeline.render } : {}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function normalizeItem(item = {}, sourceRefs = []) {
|
|
311
|
+
const dependencies = Array.isArray(item.dependencies)
|
|
312
|
+
? item.dependencies
|
|
313
|
+
: typeof item.dependencies === "string"
|
|
314
|
+
? item.dependencies.split(/[|,;]/)
|
|
315
|
+
: [];
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
id: item.id || slugify(item.title || "untitled"),
|
|
319
|
+
title: String(item.title || "Untitled").trim(),
|
|
320
|
+
type: item.type === "milestone" ? "milestone" : "task",
|
|
321
|
+
start: blankToUndefined(item.start),
|
|
322
|
+
end: blankToUndefined(item.end),
|
|
323
|
+
duration: blankToUndefined(item.duration),
|
|
324
|
+
owner: blankToUndefined(item.owner),
|
|
325
|
+
status: blankToUndefined(item.status) || "unknown",
|
|
326
|
+
dependencies: dependencies.map((dependency) => String(dependency).trim()).filter(Boolean),
|
|
327
|
+
confidence: typeof item.confidence === "number" ? item.confidence : 0.6,
|
|
328
|
+
source_refs: normalizeSourceRefs(sourceRefs)
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeSourceRefs(sourceRefs) {
|
|
333
|
+
return Array.isArray(sourceRefs)
|
|
334
|
+
? sourceRefs.map((sourceRef) => ({ ...sourceRef }))
|
|
335
|
+
: [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function makeGap(item, field, question) {
|
|
339
|
+
return {
|
|
340
|
+
itemTitle: item.title,
|
|
341
|
+
field,
|
|
342
|
+
question,
|
|
343
|
+
source_refs: item.source_refs
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findDependencyCycles(items) {
|
|
348
|
+
const byTitle = new Map(items.map((item) => [item.title, item]));
|
|
349
|
+
const visiting = new Set();
|
|
350
|
+
const visited = new Set();
|
|
351
|
+
const stack = [];
|
|
352
|
+
const cycles = [];
|
|
353
|
+
|
|
354
|
+
function visit(title) {
|
|
355
|
+
if (visiting.has(title)) {
|
|
356
|
+
const cycleStart = stack.indexOf(title);
|
|
357
|
+
cycles.push([...stack.slice(cycleStart), title]);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (visited.has(title)) return;
|
|
362
|
+
|
|
363
|
+
const item = byTitle.get(title);
|
|
364
|
+
if (!item) return;
|
|
365
|
+
|
|
366
|
+
visiting.add(title);
|
|
367
|
+
stack.push(title);
|
|
368
|
+
|
|
369
|
+
for (const dependency of item.dependencies) {
|
|
370
|
+
visit(dependency);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
stack.pop();
|
|
374
|
+
visiting.delete(title);
|
|
375
|
+
visited.add(title);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const item of items) {
|
|
379
|
+
visit(item.title);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return dedupeCycles(cycles);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function dedupeCycles(cycles) {
|
|
386
|
+
const seen = new Set();
|
|
387
|
+
return cycles.filter((cycle) => {
|
|
388
|
+
const key = [...new Set(cycle)].sort().join("|");
|
|
389
|
+
if (seen.has(key)) return false;
|
|
390
|
+
seen.add(key);
|
|
391
|
+
return true;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renderMermaidGantt(timeline) {
|
|
396
|
+
const lines = ["gantt", " title Project Timeline", " dateFormat YYYY-MM-DD", " axisFormat %b %d", " section Plan"];
|
|
397
|
+
|
|
398
|
+
for (const item of timeline.items) {
|
|
399
|
+
const label = escapeMermaidText(`${item.title}${item.owner ? ` (${item.owner})` : ""}`);
|
|
400
|
+
if (item.type === "milestone" && item.start) {
|
|
401
|
+
lines.push(` ${label} :milestone, ${item.start}, 0d`);
|
|
402
|
+
} else if (item.start && item.end) {
|
|
403
|
+
lines.push(` ${label} :${item.status || "unknown"}, ${item.start}, ${item.end}`);
|
|
404
|
+
} else if (item.start && item.duration) {
|
|
405
|
+
lines.push(` ${label} :${item.status || "unknown"}, ${item.start}, ${item.duration}`);
|
|
406
|
+
} else {
|
|
407
|
+
lines.push(` %% ${label} omitted from chart: missing defensible date or duration`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return `${lines.join("\n")}\n`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function renderMermaidTimeline(timeline) {
|
|
415
|
+
const lines = ["timeline", " title Project Timeline"];
|
|
416
|
+
|
|
417
|
+
for (const item of timeline.items) {
|
|
418
|
+
if (!item.start) {
|
|
419
|
+
lines.push(` ${escapeMermaidText("Unscheduled")} : ${escapeMermaidText(item.title)}`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
lines.push(` ${item.start} : ${escapeMermaidText(item.title)}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return `${lines.join("\n")}\n`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function renderMarkdown(timeline) {
|
|
430
|
+
const lines = ["## Timeline", ""];
|
|
431
|
+
|
|
432
|
+
for (const item of timeline.items) {
|
|
433
|
+
const window = item.start ? `${item.start}${item.end ? ` to ${item.end}` : item.duration ? ` for ${item.duration}` : ""}` : "date needed";
|
|
434
|
+
lines.push(`- **${item.title}** (${item.type}, ${item.status}) - ${window}${item.owner ? ` - owner: ${item.owner}` : ""}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (timeline.gaps.length > 0) {
|
|
438
|
+
lines.push("", "## Gaps");
|
|
439
|
+
for (const gap of timeline.gaps) {
|
|
440
|
+
lines.push(`- ${gap.itemTitle}: ${gap.field} - ${gap.question}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (timeline.assumptions.length > 0) {
|
|
445
|
+
lines.push("", "## Assumptions");
|
|
446
|
+
for (const assumption of timeline.assumptions) {
|
|
447
|
+
lines.push(`- ${assumption}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return `${lines.join("\n")}\n`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function parseCsv(content) {
|
|
455
|
+
const rows = [];
|
|
456
|
+
let row = [];
|
|
457
|
+
let cell = "";
|
|
458
|
+
let inQuotes = false;
|
|
459
|
+
|
|
460
|
+
for (let index = 0; index < String(content).length; index += 1) {
|
|
461
|
+
const character = String(content)[index];
|
|
462
|
+
const next = String(content)[index + 1];
|
|
463
|
+
|
|
464
|
+
if (character === '"' && inQuotes && next === '"') {
|
|
465
|
+
cell += '"';
|
|
466
|
+
index += 1;
|
|
467
|
+
} else if (character === '"') {
|
|
468
|
+
inQuotes = !inQuotes;
|
|
469
|
+
} else if (character === "," && !inQuotes) {
|
|
470
|
+
row.push(cell.trim());
|
|
471
|
+
cell = "";
|
|
472
|
+
} else if ((character === "\n" || character === "\r") && !inQuotes) {
|
|
473
|
+
if (character === "\r" && next === "\n") index += 1;
|
|
474
|
+
row.push(cell.trim());
|
|
475
|
+
rows.push(row);
|
|
476
|
+
row = [];
|
|
477
|
+
cell = "";
|
|
478
|
+
} else {
|
|
479
|
+
cell += character;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
row.push(cell.trim());
|
|
484
|
+
rows.push(row);
|
|
485
|
+
return rows.filter((cells) => cells.some((value) => value !== ""));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function normalizeHeader(header) {
|
|
489
|
+
return String(header).trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function slugify(value) {
|
|
493
|
+
return String(value)
|
|
494
|
+
.toLowerCase()
|
|
495
|
+
.trim()
|
|
496
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
497
|
+
.replace(/^-|-$/g, "");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function blankToUndefined(value) {
|
|
501
|
+
if (value === undefined || value === null) return undefined;
|
|
502
|
+
const normalized = String(value).trim();
|
|
503
|
+
return normalized === "" ? undefined : normalized;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function escapeMermaidText(value) {
|
|
507
|
+
return String(value).replace(/[:#;]/g, "-").trim();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function structuredCloneSafe(value) {
|
|
511
|
+
return JSON.parse(JSON.stringify(value));
|
|
512
|
+
}
|