Compare commits

...

6 Commits

Author SHA1 Message Date
a7b6b76db8 feat: add milestone_type field to distinguish start/finish milestones
Add milestone_type field to milestone queries that indicates whether
a milestone is a start milestone ('start') or finish milestone ('finish').

Changes:
- Add milestone_type column to activities table schema
- Parse milestone_type from XER TASK table (MS_Start/MS_Finish)
- Include milestone_type in list_milestones response
- Update contract tests for milestone_type field
- Update specs, contracts, and documentation

The milestone_type is determined by:
1. Explicit milestone_type field in XER (MS_Start -> 'start', MS_Finish -> 'finish')
2. Derived from task_type (TT_Mile -> 'start', TT_FinMile -> 'finish')
2026-01-08 12:18:34 -05:00
bf2f85813e fix: include finish milestones (TT_FinMile) in milestone queries
The list_milestones tool was only returning start milestones (TT_Mile)
and missing all finish milestones. P6 uses two task types for milestones:
- TT_Mile = Start Milestone
- TT_FinMile = Finish Milestone

Changes:
- Update query_milestones() to include both TT_Mile and TT_FinMile
- Derive milestone_type from task_type when not explicitly set:
  - TT_Mile -> 'start'
  - TT_FinMile -> 'finish'
- Add unit tests for milestone_type derivation from task_type

This fixes the E-J Electric schedule returning 5 milestones instead of 62.
2026-01-08 11:55:43 -05:00
af8cdc1d31 feat: add driving flag to relationship query responses
Add computed driving flag to all relationship queries (list_relationships,
get_predecessors, get_successors). A relationship is marked as driving when
the predecessor's early end date plus lag determines the successor's early
start date.

Changes:
- Add early_start_date and early_end_date columns to activities schema
- Parse early dates from TASK table in XER files
- Implement is_driving_relationship() helper with 24hr tolerance for
  calendar gaps
- Update all relationship queries to compute and return driving flag
- Add contract and unit tests for driving flag functionality
- Update spec, contracts, and documentation
2026-01-07 07:21:58 -05:00
2255b65ef6 feat: add data_date to project summary response
Include the schedule data date (last_recalc_date from XER) in the
get_project_summary tool response. This shows when the schedule
was last calculated in P6.

Changes:
- Add last_recalc_date column to projects table schema
- Parse last_recalc_date in PROJECT table handler
- Include last_recalc_date in db loader
- Return data_date field in get_project_summary query
- Update contract test to verify data_date presence
2026-01-06 22:42:10 -05:00
8fc2a87607 chore: add MCP server configuration for project 2026-01-06 22:31:08 -05:00
70c1cf3094 docs: add README with installation and usage instructions 2026-01-06 22:29:53 -05:00
25 changed files with 1213 additions and 343 deletions

7
.gitignore vendored
View File

@@ -38,9 +38,12 @@ htmlcov/
.tox/ .tox/
.nox/ .nox/
# XER files (may contain sensitive project data) # Schedule files
*.xer schedules/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.claude/skills/

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"xer-mcp": {
"command": "uv",
"args": ["run", "python", "-m", "xer_mcp"]
}
}
}

View File

@@ -3,6 +3,9 @@
Auto-generated from all feature plans. Last updated: 2026-01-06 Auto-generated from all feature plans. Last updated: 2026-01-06
## Active Technologies ## Active Technologies
- SQLite in-memory database (001-schedule-tools)
- Python 3.14 + mcp>=1.0.0 (MCP SDK), sqlite3 (stdlib) (001-schedule-tools)
- In-memory SQLite database (populated from XER files at runtime) (001-schedule-tools)
- Python 3.14 + mcp (MCP SDK), sqlite3 (stdlib) (001-schedule-tools) - Python 3.14 + mcp (MCP SDK), sqlite3 (stdlib) (001-schedule-tools)
@@ -22,6 +25,8 @@ cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLO
Python 3.14: Follow standard conventions Python 3.14: Follow standard conventions
## Recent Changes ## Recent Changes
- 001-schedule-tools: Added Python 3.14 + mcp>=1.0.0 (MCP SDK), sqlite3 (stdlib)
- 001-schedule-tools: Added Python 3.14 + mcp (MCP SDK), sqlite3 (stdlib)
- 001-schedule-tools: Added Python 3.14 + mcp (MCP SDK), sqlite3 (stdlib) - 001-schedule-tools: Added Python 3.14 + mcp (MCP SDK), sqlite3 (stdlib)

220
README.md Normal file
View File

@@ -0,0 +1,220 @@
# XER MCP Server
An MCP (Model Context Protocol) server for parsing Primavera P6 XER files and exposing schedule data to AI assistants.
## Features
- Parse Primavera P6 XER export files
- Query activities, relationships, milestones, and critical path
- Pagination support with configurable limits
- Multi-project file support
- In-memory SQLite database for fast queries
## Installation
Requires Python 3.12+
```bash
# Clone the repository
git clone https://git.prettyhefty.com/Bill/xer-mcp.git
cd xer-mcp
# Install with uv
uv sync
# Or install with pip
pip install -e .
```
## Configuration
### Claude Desktop
Add to your Claude Desktop configuration (`~/.config/claude/claude_desktop_config.json` on Linux, `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"xer-mcp": {
"command": "uv",
"args": ["run", "--directory", "/path/to/xer-mcp", "python", "-m", "xer_mcp"]
}
}
}
```
### Other MCP Clients
Run the server directly:
```bash
uv run python -m xer_mcp
```
The server communicates via stdio using the MCP protocol.
## Available Tools
### load_xer
Load a Primavera P6 XER file for querying.
**Parameters:**
- `file_path` (required): Absolute path to the XER file
- `project_id` (optional): Project ID to select (required for multi-project files)
**Example:**
```json
{
"file_path": "/path/to/schedule.xer"
}
```
### list_activities
List activities with optional filtering and pagination.
**Parameters:**
- `start_date` (optional): Filter activities starting on or after (YYYY-MM-DD)
- `end_date` (optional): Filter activities ending on or before (YYYY-MM-DD)
- `wbs_id` (optional): Filter by WBS element ID
- `activity_type` (optional): Filter by type (TT_Task, TT_Mile, TT_LOE, TT_WBS, TT_Rsrc)
- `limit` (optional): Maximum results (default: 100, max: 1000)
- `offset` (optional): Number of results to skip (default: 0)
**Returns:** List of activities with pagination metadata
### get_activity
Get detailed information for a specific activity.
**Parameters:**
- `activity_id` (required): The task_id of the activity
**Returns:** Activity details including WBS name and relationship counts
### list_relationships
List all activity relationships (dependencies) with pagination.
**Parameters:**
- `limit` (optional): Maximum results (default: 100, max: 1000)
- `offset` (optional): Number of results to skip (default: 0)
**Returns:** List of relationships with predecessor/successor info
### get_predecessors
Get predecessor activities for a given activity.
**Parameters:**
- `activity_id` (required): The task_id of the activity
**Returns:** List of predecessor activities with relationship type and lag
### get_successors
Get successor activities for a given activity.
**Parameters:**
- `activity_id` (required): The task_id of the activity
**Returns:** List of successor activities with relationship type and lag
### get_project_summary
Get a summary of the loaded project.
**Parameters:** None
**Returns:**
- `project_name`: Name of the project
- `plan_start_date`: Planned start date
- `plan_end_date`: Planned end date
- `activity_count`: Total number of activities
- `milestone_count`: Number of milestones
- `critical_activity_count`: Number of activities on critical path
### list_milestones
List all milestone activities in the project.
**Parameters:** None
**Returns:** List of milestone activities with dates and status
### get_critical_path
Get all activities on the critical path.
**Parameters:** None
**Returns:** List of critical path activities ordered by start date
## Usage Example
1. First, load an XER file:
```
Use the load_xer tool with file_path: "/path/to/my-schedule.xer"
```
2. Get a project overview:
```
Use the get_project_summary tool
```
3. List upcoming milestones:
```
Use the list_milestones tool
```
4. View critical path:
```
Use the get_critical_path tool
```
5. Query specific activities:
```
Use list_activities with start_date: "2026-01-01" and end_date: "2026-03-31"
```
## Error Handling
All query tools return a `NO_FILE_LOADED` error if called before loading an XER file:
```json
{
"error": {
"code": "NO_FILE_LOADED",
"message": "No XER file is loaded. Use the load_xer tool first."
}
}
```
## Relationship Types
The server supports all P6 relationship types:
- **FS** (Finish-to-Start): Successor starts after predecessor finishes
- **SS** (Start-to-Start): Successor starts when predecessor starts
- **FF** (Finish-to-Finish): Successor finishes when predecessor finishes
- **SF** (Start-to-Finish): Successor finishes when predecessor starts
## Development
```bash
# Run tests
uv run pytest
# Run with coverage
uv run pytest --cov=xer_mcp
# Lint
uv run ruff check src/ tests/
# Format
uv run ruff format src/ tests/
```
## License
MIT

View File

@@ -301,7 +301,8 @@
"target_start_date": { "type": "string", "format": "date-time" }, "target_start_date": { "type": "string", "format": "date-time" },
"target_end_date": { "type": "string", "format": "date-time" }, "target_end_date": { "type": "string", "format": "date-time" },
"status_code": { "type": "string" }, "status_code": { "type": "string" },
"driving_path_flag": { "type": "boolean" } "driving_path_flag": { "type": "boolean" },
"milestone_type": { "type": "string", "enum": ["start", "finish", null], "description": "Type of milestone: 'start' for start milestones, 'finish' for finish milestones, null for non-milestones" }
} }
}, },
"ActivityDetail": { "ActivityDetail": {
@@ -336,7 +337,11 @@
"type": "string", "type": "string",
"enum": ["FS", "SS", "FF", "SF"] "enum": ["FS", "SS", "FF", "SF"]
}, },
"lag_hr_cnt": { "type": "number" } "lag_hr_cnt": { "type": "number" },
"driving": {
"type": "boolean",
"description": "True if this relationship drives the successor's early start date"
}
} }
}, },
"RelatedActivity": { "RelatedActivity": {
@@ -349,7 +354,11 @@
"type": "string", "type": "string",
"enum": ["FS", "SS", "FF", "SF"] "enum": ["FS", "SS", "FF", "SF"]
}, },
"lag_hr_cnt": { "type": "number" } "lag_hr_cnt": { "type": "number" },
"driving": {
"type": "boolean",
"description": "True if this relationship drives the successor's early start date"
}
} }
}, },
"Pagination": { "Pagination": {

View File

@@ -54,8 +54,11 @@ A unit of work in the schedule.
| task_code | string | Yes | User-visible activity code | | task_code | string | Yes | User-visible activity code |
| task_name | string | Yes | Activity description | | task_name | string | Yes | Activity description |
| task_type | enum | Yes | TT_Task, TT_Mile, TT_LOE, TT_WBS, TT_Rsrc | | task_type | enum | Yes | TT_Task, TT_Mile, TT_LOE, TT_WBS, TT_Rsrc |
| milestone_type | enum | No | 'start' for start milestones, 'finish' for finish milestones, null for non-milestones |
| target_start_date | datetime | No | Planned start | | target_start_date | datetime | No | Planned start |
| target_end_date | datetime | No | Planned finish | | target_end_date | datetime | No | Planned finish |
| early_start_date | datetime | No | Calculated early start (for driving computation) |
| early_end_date | datetime | No | Calculated early finish (for driving computation) |
| act_start_date | datetime | No | Actual start | | act_start_date | datetime | No | Actual start |
| act_end_date | datetime | No | Actual finish | | act_end_date | datetime | No | Actual finish |
| total_float_hr_cnt | float | No | Total float in hours | | total_float_hr_cnt | float | No | Total float in hours |
@@ -88,9 +91,23 @@ A dependency link between two activities.
| pred_task_id | string | Yes | Predecessor activity (the one constraining) | | pred_task_id | string | Yes | Predecessor activity (the one constraining) |
| pred_type | enum | Yes | PR_FS, PR_SS, PR_FF, PR_SF | | pred_type | enum | Yes | PR_FS, PR_SS, PR_FF, PR_SF |
| lag_hr_cnt | float | No | Lag time in hours (can be negative) | | lag_hr_cnt | float | No | Lag time in hours (can be negative) |
| driving | boolean | No | **Computed** - True if this relationship determines successor's early dates |
**XER Source**: `TASKPRED` table **XER Source**: `TASKPRED` table
**Driving Flag Computation** (not stored, computed at query time):
The `driving` flag indicates whether this predecessor relationship constrains the successor activity's early start date. It is computed by comparing dates:
```python
# For FS (Finish-to-Start) relationships:
driving = (predecessor.early_end_date + lag_hr_cnt successor.early_start_date)
# With 1-hour tolerance for floating point arithmetic
```
This requires JOIN on activity dates when querying relationships.
**Relationship Types**: **Relationship Types**:
| Code | Name | Meaning | | Code | Name | Meaning |
|------|------|---------| |------|------|---------|
@@ -172,6 +189,8 @@ CREATE TABLE activities (
task_type TEXT NOT NULL, task_type TEXT NOT NULL,
target_start_date TEXT, target_start_date TEXT,
target_end_date TEXT, target_end_date TEXT,
early_start_date TEXT, -- Used for driving relationship computation
early_end_date TEXT, -- Used for driving relationship computation
act_start_date TEXT, act_start_date TEXT,
act_end_date TEXT, act_end_date TEXT,
total_float_hr_cnt REAL, total_float_hr_cnt REAL,

View File

@@ -1,25 +1,23 @@
# Implementation Plan: Project Schedule Tools # Implementation Plan: Project Schedule Tools
**Branch**: `001-schedule-tools` | **Date**: 2026-01-06 | **Spec**: [spec.md](./spec.md) **Branch**: `001-schedule-tools` | **Date**: 2026-01-08 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-schedule-tools/spec.md` **Input**: Feature specification from `/specs/001-schedule-tools/spec.md`
## Summary ## Summary
Build an MCP server that parses Primavera P6 XER files and exposes schedule data (activities, relationships, project summaries) through MCP tools. The server uses stdio transport, loads XER data into SQLite for efficient querying, and implements pagination to prevent context overflow for AI assistants. Build an MCP server that provides 9 tools for querying Primavera P6 XER schedule data. The server parses XER files into an in-memory SQLite database and exposes tools for loading files, querying activities, relationships, milestones, critical path, and project summary. Uses Python 3.14 with the MCP SDK and follows TDD practices per the project constitution.
## Technical Context ## Technical Context
**Language/Version**: Python 3.14 **Language/Version**: Python 3.14
**Package Manager**: uv **Primary Dependencies**: mcp>=1.0.0 (MCP SDK), sqlite3 (stdlib)
**Primary Dependencies**: mcp (MCP SDK), sqlite3 (stdlib) **Storage**: In-memory SQLite database (populated from XER files at runtime)
**Storage**: SQLite (in-memory or file-based per session) **Testing**: pytest>=8.0.0, pytest-asyncio>=0.24.0
**Testing**: pytest with pytest-asyncio for async MCP handlers **Target Platform**: Local server (Linux/macOS/Windows with file system access)
**Target Platform**: Linux/macOS/Windows (cross-platform CLI)
**Transport**: stdio (standard input/output)
**Project Type**: Single project **Project Type**: Single project
**Performance Goals**: Load 10,000 activities in <5 seconds; query response <1 second **Performance Goals**: Load + query XER files ≤5 seconds for 10,000 activities; query response <1 second
**Constraints**: Default 100-item pagination limit; single-user operation **Constraints**: Memory sufficient for 50,000 activities; single-user operation
**Scale/Scope**: Files up to 50,000 activities **Scale/Scope**: MCP server with 9 tools; handles typical P6 project files (up to 50,000 activities)
## Constitution Check ## Constitution Check
@@ -27,18 +25,14 @@ Build an MCP server that parses Primavera P6 XER files and exposes schedule data
| Principle | Requirement | Status | Notes | | Principle | Requirement | Status | Notes |
|-----------|-------------|--------|-------| |-----------|-------------|--------|-------|
| I. Test-First Development | TDD mandatory; tests fail before implementation | ✅ PASS | Plan includes contract tests for MCP tools, integration tests for XER parsing | | **I. Test-First Development** | TDD mandatory; tests fail before implementation | ✅ PASS | Contract tests for all 9 MCP tools; integration tests for XER parsing |
| II. Extensibility Architecture | Pluggable handlers; separated concerns | ✅ PASS | XER parser separated from MCP layer; table handlers pluggable | | **II. Extensibility Architecture** | Core parsing separate from MCP transport; pluggable handlers | ✅ PASS | Table handlers are pluggable (`parser/table_handlers/`); tools are modular (`tools/`) |
| III. MCP Protocol Compliance | Complete JSON schemas; proper error format | ✅ PASS | All tools will have full schemas; errors follow MCP format | | **III. MCP Protocol Compliance** | Complete JSON schemas; MCP error format; compliant transport | ✅ PASS | Using official MCP SDK; tool definitions include JSON schemas |
| IV. XER Format Fidelity | No data loss; preserve precision | ✅ PASS | SQLite preserves all parsed data; unknown tables stored | | **IV. XER Format Fidelity** | No data loss; preserve precision; handle all standard tables | ✅ PASS | Parsing TASK, TASKPRED, PROJECT, PROJWBS, CALENDAR; dates preserve precision |
| V. Semantic Versioning | SemVer for releases | ✅ PASS | Will use 0.1.0 for initial release | | **V. Semantic Versioning** | SemVer for releases; breaking changes documented | ✅ PASS | Version 0.1.0; initial development phase |
| **Technical Standards** | Python 3.14; type hints; ruff formatting | ✅ PASS | pyproject.toml configured for Python 3.14, ruff, pytest |
**Technical Standards Compliance**: **Gate Result**: PASS - All constitution principles satisfied. Proceed to Phase 0.
- Python 3.14 ✅
- Type hints throughout ✅
- Formatting via ruff ✅
- Dependencies pinned in pyproject.toml ✅
- Console logging ✅
## Project Structure ## Project Structure
@@ -46,58 +40,64 @@ Build an MCP server that parses Primavera P6 XER files and exposes schedule data
```text ```text
specs/001-schedule-tools/ specs/001-schedule-tools/
├── plan.md # This file ├── spec.md # Feature specification
├── research.md # Phase 0 output ├── plan.md # This file (/speckit.plan command output)
├── data-model.md # Phase 1 output ├── research.md # Phase 0 output (/speckit.plan command)
├── quickstart.md # Phase 1 output ├── data-model.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (MCP tool schemas) ├── quickstart.md # Phase 1 output (/speckit.plan command)
── tasks.md # Phase 2 output (/speckit.tasks command) ── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
``` ```
### Source Code (repository root) ### Source Code (repository root)
```text ```text
src/ src/xer_mcp/
├── xer_mcp/ ├── __init__.py
├── __main__.py # Entry point
├── server.py # MCP server setup and tool registration
├── errors.py # Error types
├── models/ # Data models (dataclasses)
│ ├── __init__.py │ ├── __init__.py
│ ├── server.py # MCP server entry point (stdio) │ ├── project.py
│ ├── tools/ # MCP tool implementations │ ├── activity.py
│ ├── __init__.py │ ├── relationship.py
│ ├── load_xer.py │ ├── wbs.py
│ ├── list_activities.py │ ├── calendar.py
│ ├── get_activity.py └── pagination.py
├── list_relationships.py ├── parser/ # XER file parsing
│ ├── get_predecessors.py │ ├── __init__.py
│ ├── get_successors.py │ ├── xer_parser.py # Main parser
│ ├── get_project_summary.py └── table_handlers/ # Pluggable table handlers
│ │ ├── list_milestones.py
│ │ └── get_critical_path.py
│ ├── parser/ # XER file parsing
│ │ ├── __init__.py
│ │ ├── xer_parser.py # Main parser
│ │ └── table_handlers/ # Pluggable table handlers
│ │ ├── __init__.py
│ │ ├── base.py # Abstract base class
│ │ ├── project.py
│ │ ├── task.py
│ │ ├── taskpred.py
│ │ ├── projwbs.py
│ │ └── calendar.py
│ ├── db/ # SQLite database layer
│ │ ├── __init__.py
│ │ ├── schema.py # Table definitions
│ │ ├── loader.py # Load parsed data into SQLite
│ │ └── queries.py # Query functions with pagination
│ └── models/ # Data models (dataclasses)
│ ├── __init__.py │ ├── __init__.py
│ ├── base.py
│ ├── project.py │ ├── project.py
│ ├── activity.py │ ├── task.py
│ ├── relationship.py │ ├── taskpred.py
── pagination.py ── projwbs.py
│ └── calendar.py
├── db/ # SQLite database layer
│ ├── __init__.py
│ ├── schema.py # Table definitions
│ ├── loader.py # Data loading
│ └── queries.py # Query functions
└── tools/ # MCP tool implementations
├── __init__.py
├── load_xer.py
├── list_activities.py
├── get_activity.py
├── list_relationships.py
├── get_predecessors.py
├── get_successors.py
├── get_project_summary.py
├── list_milestones.py
└── get_critical_path.py
tests/ tests/
├── conftest.py # Shared fixtures (sample XER files) ├── __init__.py
├── contract/ # MCP tool contract tests ├── conftest.py # Shared fixtures
├── contract/ # MCP tool contract tests
│ ├── __init__.py
│ ├── test_load_xer.py │ ├── test_load_xer.py
│ ├── test_list_activities.py │ ├── test_list_activities.py
│ ├── test_get_activity.py │ ├── test_get_activity.py
@@ -107,21 +107,49 @@ tests/
│ ├── test_get_project_summary.py │ ├── test_get_project_summary.py
│ ├── test_list_milestones.py │ ├── test_list_milestones.py
│ └── test_get_critical_path.py │ └── test_get_critical_path.py
├── integration/ # End-to-end XER parsing tests ├── integration/ # XER parsing integration tests
│ ├── test_xer_parsing.py │ ├── __init__.py
── test_multi_project.py ── test_xer_parsing.py
│ └── test_edge_cases.py └── unit/ # Unit tests
└── unit/ # Unit tests for parser, db, models ├── __init__.py
├── test_parser.py ├── test_parser.py
├── test_table_handlers.py ├── test_table_handlers.py
── test_db_queries.py ── test_db_queries.py
└── test_models.py
pyproject.toml # Project config with uv
``` ```
**Structure Decision**: Single project structure with clear separation between MCP tools, XER parsing, and SQLite database layers. The parser/table_handlers/ directory enables extensibility for future XER table types. **Structure Decision**: Single project structure with domain-specific organization: `models/` for data structures, `parser/` for XER file handling with pluggable table handlers, `db/` for SQLite storage layer, and `tools/` for MCP tool implementations. Tests follow contract/integration/unit hierarchy per constitution requirements.
## Complexity Tracking ## Complexity Tracking
No constitution violations requiring justification. Design follows all principles. > **Fill ONLY if Constitution Check has violations that must be justified**
No violations. All constitution principles satisfied.
## Post-Design Constitution Re-Check
*Re-evaluation after Phase 1 design artifacts are complete.*
| Principle | Status | Verification |
|-----------|--------|--------------|
| **I. Test-First Development** | ✅ PASS | Contract tests defined in `tests/contract/` for all 9 tools; integration tests in `tests/integration/` |
| **II. Extensibility Architecture** | ✅ PASS | Table handlers pluggable via registry pattern; tools are modular functions; schema separates concerns |
| **III. MCP Protocol Compliance** | ✅ PASS | `contracts/mcp-tools.json` defines complete JSON schemas for all tools with input/output schemas |
| **IV. XER Format Fidelity** | ✅ PASS | Data model preserves all date precision; handles all 5 standard tables; driving flag computed accurately |
| **V. Semantic Versioning** | ✅ PASS | Version 0.1.0 in contract schema; following SemVer |
| **Technical Standards** | ✅ PASS | Type hints throughout models; ruff configured; pytest async mode enabled |
**Post-Design Gate Result**: PASS - Design artifacts align with constitution. Ready for task generation.
## Generated Artifacts
| Artifact | Path | Description |
|----------|------|-------------|
| Research | `specs/001-schedule-tools/research.md` | Technology decisions, XER format analysis, driving flag computation |
| Data Model | `specs/001-schedule-tools/data-model.md` | Entity definitions, SQLite schema, validation rules |
| Contracts | `specs/001-schedule-tools/contracts/mcp-tools.json` | MCP tool JSON schemas (input/output) |
| Quickstart | `specs/001-schedule-tools/quickstart.md` | Usage guide with examples |
| Agent Context | `CLAUDE.md` | Updated with Python 3.14, MCP SDK, SQLite |
## Next Steps
Run `/speckit.tasks` to generate implementation tasks from this plan.

View File

@@ -152,19 +152,23 @@ Use the get_predecessors tool with activity_id="A1050"
"task_code": "A1000", "task_code": "A1000",
"task_name": "Site Preparation", "task_name": "Site Preparation",
"relationship_type": "FS", "relationship_type": "FS",
"lag_hr_cnt": 0 "lag_hr_cnt": 0,
"driving": true
}, },
{ {
"task_id": "A1010", "task_id": "A1010",
"task_code": "A1010", "task_code": "A1010",
"task_name": "Permits Approved", "task_name": "Permits Approved",
"relationship_type": "FS", "relationship_type": "FS",
"lag_hr_cnt": 8 "lag_hr_cnt": 8,
"driving": false
} }
] ]
} }
``` ```
The `driving` flag indicates which predecessor relationship constrains the successor's early start date. A driving relationship is one where the predecessor's completion (plus lag) determines when the successor can begin.
### 6. Get Project Summary ### 6. Get Project Summary
``` ```
@@ -222,6 +226,34 @@ Use the get_critical_path tool
Use the list_milestones tool Use the list_milestones tool
``` ```
**Response**:
```json
{
"milestones": [
{
"task_id": "M001",
"task_code": "MS-START",
"task_name": "Project Start",
"target_start_date": "2026-01-15T08:00:00",
"target_end_date": "2026-01-15T08:00:00",
"status_code": "TK_Complete",
"milestone_type": "start"
},
{
"task_id": "M025",
"task_code": "MS-END",
"task_name": "Project Complete",
"target_start_date": "2026-06-30T17:00:00",
"target_end_date": "2026-06-30T17:00:00",
"status_code": "TK_NotStart",
"milestone_type": "finish"
}
]
}
```
The `milestone_type` field indicates whether the milestone is a Start Milestone (`"start"`) or a Finish Milestone (`"finish"`).
## Pagination ## Pagination
All list operations support pagination: All list operations support pagination:

View File

@@ -198,3 +198,80 @@ class PaginationMetadata:
| -32004 | PROJECT_SELECTION_REQUIRED | Multi-project file without selection | | -32004 | PROJECT_SELECTION_REQUIRED | Multi-project file without selection |
| -32005 | ACTIVITY_NOT_FOUND | Requested activity ID doesn't exist | | -32005 | ACTIVITY_NOT_FOUND | Requested activity ID doesn't exist |
| -32006 | INVALID_PARAMETER | Bad filter/pagination parameters | | -32006 | INVALID_PARAMETER | Bad filter/pagination parameters |
## Driving Relationship Flag
**Research Date**: 2026-01-06
### Question: What field in the XER TASKPRED table contains the driving relationship flag?
**Finding**: The TASKPRED table in P6 XER files does NOT contain a direct `driving_flag` field.
**Evidence**: Analysis of sample XER file (S48019R - Proposal Schedule):
```
%F task_pred_id task_id pred_task_id proj_id pred_proj_id pred_type lag_hr_cnt comments float_path aref arls
```
Fields available:
- `task_pred_id` - Unique relationship identifier
- `task_id` - Successor activity ID
- `pred_task_id` - Predecessor activity ID
- `proj_id` / `pred_proj_id` - Project identifiers
- `pred_type` - Relationship type (PR_FS, PR_SS, PR_FF, PR_SF)
- `lag_hr_cnt` - Lag duration in hours
- `comments` - User comments
- `float_path` - Float path indicator (contains dates, not boolean)
- `aref` / `arls` - Activity reference dates
### Question: Where is driving/critical path information stored in P6 XER files?
**Finding**: The `driving_path_flag` is stored at the ACTIVITY level on the TASK table, not on individual relationships.
**Evidence**:
```
TASK table includes: driving_path_flag (Y/N)
```
This flag indicates whether an activity is on the driving/critical path, but does not indicate which specific predecessor relationship is driving that activity's dates.
### Question: Can driving relationships be derived from available data?
**Finding**: Yes, driving relationships can be computed using schedule date comparison logic.
A relationship is "driving" when the successor activity's early start is constrained by the predecessor's completion. For a Finish-to-Start (FS) relationship:
```
driving = (predecessor.early_end_date + lag_hours ≈ successor.early_start_date)
```
### Decision: Compute driving flag at query time using early dates
**Rationale**:
1. P6 does not export a pre-computed driving flag per relationship
2. The driving relationship determination can be computed from activity dates
3. This matches how P6 itself determines driving relationships in the UI
**Implementation Approach**:
1. Early dates (`early_start_date`, `early_end_date`) are already parsed from TASK table
2. When querying relationships, compute `driving` by comparing dates
3. For FS: Compare `pred.early_end_date + lag` to `succ.early_start_date`
4. Use 1-hour tolerance for floating point date arithmetic
**Alternatives Considered**:
1. **Static flag from XER**: Not available in standard exports
2. **Always false**: Would not provide value to users
3. **Require user to specify**: Adds complexity, not aligned with P6 behavior
### Schema Impact
No schema changes needed for relationships table. Required activity date columns are already present:
- `activities.early_start_date` - Already in schema ✓
- `activities.early_end_date` - Already in schema ✓
The driving flag will be computed at query time via JOIN on activity dates.
### Validation Plan
- [ ] Verify early_start_date and early_end_date are parsed correctly from TASK table
- [ ] Test driving computation against known P6 schedules
- [ ] Confirm results match P6 "show driving" feature where possible

View File

@@ -55,10 +55,10 @@ As an AI assistant user, I want to query the relationships (dependencies) betwee
**Acceptance Scenarios**: **Acceptance Scenarios**:
1. **Given** an XER file is loaded, **When** I request predecessors for an activity, **Then** I receive a list of predecessor activities with their relationship types (FS, SS, FF, SF) and lag values 1. **Given** an XER file is loaded, **When** I request predecessors for an activity, **Then** I receive a list of predecessor activities with their relationship types (FS, SS, FF, SF), lag values, and driving flag
2. **Given** an XER file is loaded, **When** I request successors for an activity, **Then** I receive a list of successor activities with their relationship types and lag values 2. **Given** an XER file is loaded, **When** I request successors for an activity, **Then** I receive a list of successor activities with their relationship types, lag values, and driving flag
3. **Given** an activity has no predecessors, **When** I request its predecessors, **Then** I receive an empty list (not an error) 3. **Given** an activity has no predecessors, **When** I request its predecessors, **Then** I receive an empty list (not an error)
4. **Given** an XER file is loaded, **When** I request all relationships, **Then** I receive the dependency network (limited to 100 by default) with pagination metadata 4. **Given** an XER file is loaded, **When** I request all relationships, **Then** I receive the dependency network with relationship types, lag values, and driving flags (limited to 100 by default) with pagination metadata
5. **Given** an XER file is loaded, **When** I request relationships with offset and limit parameters, **Then** I receive the specified page of results 5. **Given** an XER file is loaded, **When** I request relationships with offset and limit parameters, **Then** I receive the specified page of results
6. **Given** no XER file is loaded, **When** I request relationships or predecessors/successors, **Then** I receive a clear error message indicating no file is loaded 6. **Given** no XER file is loaded, **When** I request relationships or predecessors/successors, **Then** I receive a clear error message indicating no file is loaded
@@ -74,8 +74,8 @@ As an AI assistant user, I want to get a high-level summary of the project sched
**Acceptance Scenarios**: **Acceptance Scenarios**:
1. **Given** an XER file is loaded, **When** I request the project summary, **Then** I receive project name, start date, finish date, and total activity count 1. **Given** an XER file is loaded, **When** I request the project summary, **Then** I receive project name, data date, start date, finish date, and total activity count
2. **Given** an XER file with milestones, **When** I request milestones, **Then** I receive a list of milestone activities with their target dates 2. **Given** an XER file with milestones, **When** I request milestones, **Then** I receive a list of all milestone activities (both Start Milestones and Finish Milestones) with their target dates and milestone type
3. **Given** an XER file is loaded, **When** I request the critical path, **Then** I receive the sequence of activities that determine the project end date 3. **Given** an XER file is loaded, **When** I request the critical path, **Then** I receive the sequence of activities that determine the project end date
4. **Given** no XER file is loaded, **When** I request project summary, milestones, or critical path, **Then** I receive a clear error message indicating no file is loaded 4. **Given** no XER file is loaded, **When** I request project summary, milestones, or critical path, **Then** I receive a clear error message indicating no file is loaded
@@ -97,9 +97,9 @@ As an AI assistant user, I want to get a high-level summary of the project sched
- **FR-002**: System MUST expose an MCP tool to load an XER file from a specified file path - **FR-002**: System MUST expose an MCP tool to load an XER file from a specified file path
- **FR-003**: System MUST expose an MCP tool to list all activities with filtering options (by date range, by WBS, by activity type) - **FR-003**: System MUST expose an MCP tool to list all activities with filtering options (by date range, by WBS, by activity type)
- **FR-004**: System MUST expose an MCP tool to retrieve detailed information for a specific activity by ID - **FR-004**: System MUST expose an MCP tool to retrieve detailed information for a specific activity by ID
- **FR-005**: System MUST expose an MCP tool to query predecessor and successor relationships for any activity - **FR-005**: System MUST expose an MCP tool to query predecessor and successor relationships for any activity, including relationship type, lag value, and driving flag
- **FR-006**: System MUST expose an MCP tool to retrieve project summary information (name, dates, activity count) - **FR-006**: System MUST expose an MCP tool to retrieve project summary information (name, data date, plan dates, activity count)
- **FR-007**: System MUST expose an MCP tool to list milestone activities - **FR-007**: System MUST expose an MCP tool to list milestone activities, including both Start Milestones (TT_Mile with task_type='TT_Mile' and milestone_type='start') and Finish Milestones (task_type='TT_Mile' and milestone_type='finish')
- **FR-008**: System MUST expose an MCP tool to identify the critical path - **FR-008**: System MUST expose an MCP tool to identify the critical path
- **FR-009**: System MUST return structured data that AI assistants can process and present to users - **FR-009**: System MUST return structured data that AI assistants can process and present to users
- **FR-010**: System MUST provide clear, actionable error messages when operations fail - **FR-010**: System MUST provide clear, actionable error messages when operations fail
@@ -112,7 +112,7 @@ As an AI assistant user, I want to get a high-level summary of the project sched
### Key Entities ### Key Entities
- **Project**: The top-level container representing a P6 project with name, ID, start/finish dates, and calendar assignments - **Project**: The top-level container representing a P6 project with name, ID, start/finish dates, and calendar assignments
- **Activity**: A unit of work with ID, name, type (task/milestone/LOE), planned dates, actual dates, duration, and status - **Activity**: A unit of work with ID, name, type (task/milestone/LOE), milestone_type (start/finish for milestones, null otherwise), planned dates, actual dates, duration, and status
- **Relationship**: A dependency link between two activities with type (FS/SS/FF/SF), lag value, and driving flag - **Relationship**: A dependency link between two activities with type (FS/SS/FF/SF), lag value, and driving flag
- **WBS (Work Breakdown Structure)**: Hierarchical organization of activities with ID, name, parent reference, and level - **WBS (Work Breakdown Structure)**: Hierarchical organization of activities with ID, name, parent reference, and level
- **Calendar**: Work schedule definition that determines working days and hours for activities (internal use only; not exposed as queryable entity) - **Calendar**: Work schedule definition that determines working days and hours for activities (internal use only; not exposed as queryable entity)
@@ -139,6 +139,9 @@ As an AI assistant user, I want to get a high-level summary of the project sched
- Q: How should multi-project XER files be handled? → A: Require explicit project selection if multiple exist - Q: How should multi-project XER files be handled? → A: Require explicit project selection if multiple exist
- Q: Should calendar data be exposed as queryable? → A: Internal use only (not exposed as queryable) - Q: Should calendar data be exposed as queryable? → A: Internal use only (not exposed as queryable)
- Q: What happens when any query tool is called without a file loaded? → A: Return informative error indicating no XER file is loaded; applies to all tools except load_xer - Q: What happens when any query tool is called without a file loaded? → A: Return informative error indicating no XER file is loaded; applies to all tools except load_xer
- Q: Should project summary include the data date? → A: Yes, include the data date (schedule "as-of" date) in project summary response
- Q: Should relationship queries return the driving flag? → A: Yes, include the driving property in all relationship responses (predecessors, successors, list relationships)
- Q: Which milestone types should be included in milestone queries? → A: Include both Start Milestones and Finish Milestones
## Assumptions ## Assumptions

View File

@@ -1,16 +1,18 @@
# Tasks: Project Schedule Tools # Tasks: Add Milestone Type to List Milestones Tool
**Input**: Design documents from `/specs/001-schedule-tools/` **Input**: Design documents from `/specs/001-schedule-tools/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ **Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/
**Tests**: TDD is mandated by constitution - tests MUST be written and fail before implementation. **Tests**: TDD is mandated by constitution - tests MUST be written and fail before implementation.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing. **Scope**: Enhancement to existing implementation - add `milestone_type` (start/finish) to milestone query responses per spec clarification.
**Change Summary**: The spec was updated to require that the `list_milestones` tool return both Start Milestones and Finish Milestones with their `milestone_type` field (start/finish for milestones, null otherwise).
## Format: `[ID] [P?] [Story] Description` ## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies) - **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4) - **[Story]**: Which user story this task belongs to (US4 = Query Project Summary)
- Include exact file paths in descriptions - Include exact file paths in descriptions
## Path Conventions ## Path Conventions
@@ -21,167 +23,72 @@
--- ---
## Phase 1: Setup (Shared Infrastructure) ## Phase 1: Setup (Schema Enhancement)
**Purpose**: Project initialization and basic structure **Purpose**: Add milestone_type column needed for distinguishing start vs finish milestones
- [ ] T001 Create project directory structure per plan.md in src/xer_mcp/ - [x] T001 Update activities table schema to add milestone_type column in src/xer_mcp/db/schema.py
- [ ] T002 Initialize Python project with uv and create pyproject.toml with dependencies (mcp, pytest, pytest-asyncio, ruff)
- [ ] T003 [P] Configure ruff for linting and formatting in pyproject.toml
- [ ] T004 [P] Create src/xer_mcp/__init__.py with version 0.1.0
- [ ] T005 [P] Create tests/conftest.py with sample XER file fixtures
--- ---
## Phase 2: Foundational (Blocking Prerequisites) ## Phase 2: Foundational (Parser Enhancement)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented **Purpose**: Parse milestone_type from TASK table and store in database
**⚠️ CRITICAL**: No user story work can begin until this phase is complete **⚠️ CRITICAL**: Must complete before milestone queries can return the type
- [ ] T006 Create data models in src/xer_mcp/models/__init__.py (export all models) ### Tests
- [ ] T007 [P] Create Project dataclass in src/xer_mcp/models/project.py
- [ ] T008 [P] Create Activity dataclass in src/xer_mcp/models/activity.py
- [ ] T009 [P] Create Relationship dataclass in src/xer_mcp/models/relationship.py
- [ ] T010 [P] Create PaginationMetadata dataclass in src/xer_mcp/models/pagination.py
- [ ] T011 Create SQLite schema in src/xer_mcp/db/schema.py with all tables and indexes
- [ ] T012 Create database connection manager in src/xer_mcp/db/__init__.py
- [ ] T013 Create MCP server skeleton in src/xer_mcp/server.py with stdio transport
- [ ] T014 Create base table handler abstract class in src/xer_mcp/parser/table_handlers/base.py
- [ ] T015 Create error types and NO_FILE_LOADED error in src/xer_mcp/errors.py
**Checkpoint**: Foundation ready - user story implementation can now begin - [x] T002 [P] Unit test for TASK handler parsing milestone_type in tests/unit/test_table_handlers.py (verify milestone_type extracted for TT_Mile activities)
### Implementation
- [x] T003 Update TASK table handler to parse milestone_type field in src/xer_mcp/parser/table_handlers/task.py
- [x] T004 Update database loader to store milestone_type when inserting activities in src/xer_mcp/db/loader.py
**Checkpoint**: milestone_type is now parsed and stored - queries can begin
--- ---
## Phase 3: User Story 1 - Load XER File (Priority: P1) 🎯 MVP ## Phase 3: User Story 4 - Add Milestone Type to Queries (Priority: P3)
**Goal**: Parse XER files and load schedule data into SQLite database **Goal**: Return `milestone_type` field in milestone query responses
**Independent Test**: Load a sample XER file and verify projects, activities, relationships are stored in SQLite **Independent Test**: Load XER file, query milestones, verify milestone_type is present and correctly identifies start vs finish milestones
### Tests for User Story 1
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T016 [P] [US1] Contract test for load_xer tool in tests/contract/test_load_xer.py
- [ ] T017 [P] [US1] Integration test for XER parsing in tests/integration/test_xer_parsing.py
- [ ] T018 [P] [US1] Integration test for multi-project handling in tests/integration/test_multi_project.py
- [ ] T019 [P] [US1] Unit test for XER parser in tests/unit/test_parser.py
- [ ] T020 [P] [US1] Unit test for table handlers in tests/unit/test_table_handlers.py
### Implementation for User Story 1
- [ ] T021 [US1] Create XER parser main class in src/xer_mcp/parser/xer_parser.py
- [ ] T022 [P] [US1] Create PROJECT table handler in src/xer_mcp/parser/table_handlers/project.py
- [ ] T023 [P] [US1] Create TASK table handler in src/xer_mcp/parser/table_handlers/task.py
- [ ] T024 [P] [US1] Create TASKPRED table handler in src/xer_mcp/parser/table_handlers/taskpred.py
- [ ] T025 [P] [US1] Create PROJWBS table handler in src/xer_mcp/parser/table_handlers/projwbs.py
- [ ] T026 [P] [US1] Create CALENDAR table handler in src/xer_mcp/parser/table_handlers/calendar.py
- [ ] T027 [US1] Create table handler registry in src/xer_mcp/parser/table_handlers/__init__.py
- [ ] T028 [US1] Create database loader in src/xer_mcp/db/loader.py to insert parsed data into SQLite
- [ ] T029 [US1] Implement load_xer MCP tool in src/xer_mcp/tools/load_xer.py
- [ ] T030 [US1] Register load_xer tool in src/xer_mcp/server.py
- [ ] T031 [US1] Add file-not-found and parse-error handling in src/xer_mcp/tools/load_xer.py
- [ ] T032 [US1] Add multi-project detection and selection logic in src/xer_mcp/tools/load_xer.py
**Checkpoint**: User Story 1 complete - XER files can be loaded and parsed
---
## Phase 4: User Story 2 - Query Project Activities (Priority: P1)
**Goal**: List and filter activities, get activity details with pagination
**Independent Test**: Load XER file, query activities with filters, verify pagination metadata
### Tests for User Story 2
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T033 [P] [US2] Contract test for list_activities tool in tests/contract/test_list_activities.py
- [ ] T034 [P] [US2] Contract test for get_activity tool in tests/contract/test_get_activity.py
- [ ] T035 [P] [US2] Unit test for activity queries in tests/unit/test_db_queries.py
### Implementation for User Story 2
- [ ] T036 [US2] Create activity query functions with pagination in src/xer_mcp/db/queries.py
- [ ] T037 [US2] Implement list_activities MCP tool in src/xer_mcp/tools/list_activities.py
- [ ] T038 [US2] Implement get_activity MCP tool in src/xer_mcp/tools/get_activity.py
- [ ] T039 [US2] Register list_activities and get_activity tools in src/xer_mcp/server.py
- [ ] T040 [US2] Add NO_FILE_LOADED error check to activity tools in src/xer_mcp/tools/list_activities.py
- [ ] T041 [US2] Add date range filtering to list_activities in src/xer_mcp/tools/list_activities.py
- [ ] T042 [US2] Add WBS and activity_type filtering to list_activities in src/xer_mcp/tools/list_activities.py
**Checkpoint**: User Stories 1 AND 2 complete - activities are queryable
---
## Phase 5: User Story 3 - Query Activity Relationships (Priority: P2)
**Goal**: Query predecessors, successors, and full relationship network
**Independent Test**: Load XER file, query relationships for an activity, verify types and lags
### Tests for User Story 3
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T043 [P] [US3] Contract test for list_relationships tool in tests/contract/test_list_relationships.py
- [ ] T044 [P] [US3] Contract test for get_predecessors tool in tests/contract/test_get_predecessors.py
- [ ] T045 [P] [US3] Contract test for get_successors tool in tests/contract/test_get_successors.py
### Implementation for User Story 3
- [ ] T046 [US3] Create relationship query functions in src/xer_mcp/db/queries.py
- [ ] T047 [US3] Implement list_relationships MCP tool in src/xer_mcp/tools/list_relationships.py
- [ ] T048 [US3] Implement get_predecessors MCP tool in src/xer_mcp/tools/get_predecessors.py
- [ ] T049 [US3] Implement get_successors MCP tool in src/xer_mcp/tools/get_successors.py
- [ ] T050 [US3] Register relationship tools in src/xer_mcp/server.py
- [ ] T051 [US3] Add NO_FILE_LOADED error check to relationship tools
**Checkpoint**: User Stories 1, 2, AND 3 complete - relationships are queryable
---
## Phase 6: User Story 4 - Query Project Summary (Priority: P3)
**Goal**: Get project overview, milestones, and critical path activities
**Independent Test**: Load XER file, get summary, list milestones, get critical path
### Tests for User Story 4 ### Tests for User Story 4
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** > **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T052 [P] [US4] Contract test for get_project_summary tool in tests/contract/test_get_project_summary.py - [x] T005 [P] [US4] Update contract test to verify milestone_type field in list_milestones response in tests/contract/test_list_milestones.py
- [ ] T053 [P] [US4] Contract test for list_milestones tool in tests/contract/test_list_milestones.py - [x] T006 [P] [US4] Add test case verifying both start and finish milestones are returned with correct types in tests/contract/test_list_milestones.py
- [ ] T054 [P] [US4] Contract test for get_critical_path tool in tests/contract/test_get_critical_path.py
### Implementation for User Story 4 ### Implementation for User Story 4
- [ ] T055 [US4] Create summary query functions in src/xer_mcp/db/queries.py - [x] T007 [US4] Update query_milestones function to SELECT and return milestone_type in src/xer_mcp/db/queries.py
- [ ] T056 [US4] Implement get_project_summary MCP tool in src/xer_mcp/tools/get_project_summary.py - [x] T008 [US4] Update list_milestones tool docstring to document milestone_type field in src/xer_mcp/tools/list_milestones.py
- [ ] T057 [US4] Implement list_milestones MCP tool in src/xer_mcp/tools/list_milestones.py
- [ ] T058 [US4] Implement get_critical_path MCP tool in src/xer_mcp/tools/get_critical_path.py
- [ ] T059 [US4] Register summary tools in src/xer_mcp/server.py
- [ ] T060 [US4] Add NO_FILE_LOADED error check to summary tools
**Checkpoint**: All user stories complete - full MCP server functional **Checkpoint**: milestone_type now included in milestone responses
--- ---
## Phase 7: Polish & Cross-Cutting Concerns ## Phase 4: Documentation & Contracts
**Purpose**: Improvements that affect multiple user stories **Purpose**: Update contracts and documentation to reflect the new field
- [ ] T061 [P] Integration test for edge cases in tests/integration/test_edge_cases.py - [x] T009 [P] Update ActivitySummary schema to include optional milestone_type field in specs/001-schedule-tools/contracts/mcp-tools.json
- [ ] T062 [P] Unit test for models in tests/unit/test_models.py - [x] T010 [P] Update Activity entity in data-model.md to include milestone_type field in specs/001-schedule-tools/data-model.md
- [ ] T063 Add logging throughout src/xer_mcp/ modules - [x] T011 Update quickstart.md milestone example to show milestone_type in output in specs/001-schedule-tools/quickstart.md
- [ ] T064 Run quickstart.md validation - test all documented examples
- [ ] T065 Add __main__.py entry point in src/xer_mcp/__main__.py ---
- [ ] T066 Final type checking with mypy and fix any issues
- [ ] T067 Run ruff format and fix any linting issues ## Phase 5: Polish & Validation
**Purpose**: Verify integration and ensure no regressions
- [x] T012 Run all tests to verify no regressions: uv run pytest tests/
- [x] T013 Run ruff check and fix any linting issues: uv run ruff check src/
- [x] T014 Validate list_milestones output matches updated contract schema
--- ---
@@ -189,93 +96,160 @@
### Phase Dependencies ### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately ```
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories Phase 1 (Schema)
- **User Story 1 (Phase 3)**: Depends on Foundational - BLOCKS US2, US3, US4 (they need load_xer)
- **User Story 2 (Phase 4)**: Depends on US1 (needs loaded data to query)
- **User Story 3 (Phase 5)**: Depends on US1 (needs loaded data to query) Phase 2 (Parser)
- **User Story 4 (Phase 6)**: Depends on US1 (needs loaded data to query)
- **Polish (Phase 7)**: Depends on all user stories being complete
Phase 3 (Queries) ← MAIN WORK
Phase 4 (Docs) ← Can run in parallel with Phase 3
Phase 5 (Polish)
```
### User Story Dependencies ### Task Dependencies Within Phases
- **US1 (Load XER)**: Foundation for all others - MUST complete first ```
- **US2 (Activities)**: Can start after US1 - independent of US3, US4 T001 (Schema)
- **US3 (Relationships)**: Can start after US1 - independent of US2, US4
- **US4 (Summary)**: Can start after US1 - independent of US2, US3 ├── T002 (Parser test)
### Within Each User Story
T003 (Parser impl) ← depends on T001
- Tests MUST be written and FAIL before implementation
- Models before services/queries
- Queries before MCP tools T004 (Loader) ← depends on T003
- Core implementation before error handling
- Register tools in server.py after implementation ├── T005, T006 (Contract tests)
T007 (Query impl) ← depends on T004
T008 (Tool docs)
```
### Parallel Opportunities ### Parallel Opportunities
**Phase 2 (Foundational)**: **Phase 2 Tests + Phase 3 Tests** (can write all tests in parallel):
```bash ```bash
# These can run in parallel: Task T002: "Unit test for TASK handler parsing milestone_type"
Task T007: "Create Project dataclass" Task T005: "Update contract test for list_milestones"
Task T008: "Create Activity dataclass" Task T006: "Add test for start/finish milestone types"
Task T009: "Create Relationship dataclass"
Task T010: "Create PaginationMetadata dataclass"
``` ```
**Phase 3 (US1 - Table Handlers)**: **Phase 4 Documentation** (can run in parallel):
```bash ```bash
# These can run in parallel: Task T009: "Update ActivitySummary schema"
Task T022: "Create PROJECT table handler" Task T010: "Update Activity entity in data-model.md"
Task T023: "Create TASK table handler" Task T011: "Update quickstart.md example"
Task T024: "Create TASKPRED table handler"
Task T025: "Create PROJWBS table handler"
Task T026: "Create CALENDAR table handler"
```
**After US1 Complete (US2, US3, US4 can start in parallel)**:
```bash
# Developer A: User Story 2 (Activities)
# Developer B: User Story 3 (Relationships)
# Developer C: User Story 4 (Summary)
``` ```
--- ---
## Implementation Strategy ## Implementation Details
### MVP First (User Story 1 + 2) ### Milestone Type Determination
1. Complete Phase 1: Setup In Primavera P6, milestones are identified by `task_type = 'TT_Mile'`. The distinction between Start and Finish milestones is typically:
2. Complete Phase 2: Foundational
3. Complete Phase 3: User Story 1 (Load XER)
4. **STOP and VALIDATE**: Can load XER files successfully
5. Complete Phase 4: User Story 2 (Activities)
6. **MVP COMPLETE**: Can load XER and query activities
### Incremental Delivery 1. **XER Field**: Check for `milestone_type` field in TASK table (values: `MS_Start`, `MS_Finish`)
2. **Fallback**: If field not present, can infer from dates:
- Start milestone: Has only `target_start_date` (or start = end)
- Finish milestone: Has only `target_end_date` (or represents completion)
1. Setup + Foundational → Project ready ### Schema Change
2. User Story 1 → XER files load → Demo loading
3. User Story 2 → Activities queryable → Demo activity queries
4. User Story 3 → Relationships queryable → Demo dependency analysis
5. User Story 4 → Summary available → Demo project overview
6. Polish → Production ready
### Parallel Team Strategy ```sql
-- Add to activities table
milestone_type TEXT -- 'start', 'finish', or NULL for non-milestones
```
With multiple developers after US1: ### Query Change
- Developer A: User Story 2 (list_activities, get_activity)
- Developer B: User Story 3 (relationships, predecessors, successors) ```sql
- Developer C: User Story 4 (summary, milestones, critical_path) SELECT task_id, task_code, task_name,
target_start_date, target_end_date,
status_code, milestone_type
FROM activities
WHERE task_type = 'TT_Mile'
ORDER BY target_start_date, task_code
```
### Response Format
```json
{
"milestones": [
{
"task_id": "M001",
"task_code": "MS-START",
"task_name": "Project Start",
"milestone_type": "start",
"target_start_date": "2026-01-15T08:00:00",
"target_end_date": "2026-01-15T08:00:00",
"status_code": "TK_Complete"
},
{
"task_id": "M002",
"task_code": "MS-END",
"task_name": "Project Complete",
"milestone_type": "finish",
"target_start_date": "2026-06-30T17:00:00",
"target_end_date": "2026-06-30T17:00:00",
"status_code": "TK_NotStart"
}
]
}
```
---
## Summary
| Phase | Tasks | Focus |
|-------|-------|-------|
| Setup | 1 | Schema update |
| Foundational | 3 | Parser enhancement |
| US4 Implementation | 4 | Milestone type in queries |
| Documentation | 3 | Contracts & docs |
| Polish | 3 | Validation |
| **Total** | **14** | |
### Task Distribution by Type
| Type | Count | IDs |
|------|-------|-----|
| Schema | 1 | T001 |
| Tests | 3 | T002, T005, T006 |
| Implementation | 4 | T003, T004, T007, T008 |
| Documentation | 3 | T009, T010, T011 |
| Validation | 3 | T012, T013, T014 |
### Independent Test Criteria
| Verification | How to Test |
|--------------|-------------|
| milestone_type parsed | Unit test: parse XER with milestones, verify milestone_type extracted |
| milestone_type in response | Contract test: call list_milestones, verify milestone_type field present |
| Start/Finish distinction | Contract test: verify "Project Start" has type="start", "Project Complete" has type="finish" |
### MVP Scope
Complete through T008 for minimal viable milestone_type implementation.
--- ---
## Notes ## Notes
- [P] tasks = different files, no dependencies on incomplete tasks - [P] tasks = different files, no dependencies on incomplete tasks
- [Story] label maps task to specific user story for traceability - [US4] = User Story 4 (Query Project Summary - milestones)
- Constitution mandates TDD: write tests first, verify they fail, then implement - Constitution mandates TDD: write tests first, verify they fail, then implement
- milestone_type is stored in database, not computed at query time
- For non-milestone activities, milestone_type should be NULL
- Commit after each task or logical group - Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- All query tools must check for NO_FILE_LOADED condition

View File

@@ -25,14 +25,15 @@ def load_parsed_data(parsed: ParsedXer, project_id: str) -> None:
# Insert project # Insert project
cur.execute( cur.execute(
""" """
INSERT INTO projects (proj_id, proj_short_name, plan_start_date, plan_end_date) INSERT INTO projects (proj_id, proj_short_name, plan_start_date, plan_end_date, last_recalc_date)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", """,
( (
project["proj_id"], project["proj_id"],
project["proj_short_name"], project["proj_short_name"],
project["plan_start_date"], project["plan_start_date"],
project["plan_end_date"], project["plan_end_date"],
project.get("last_recalc_date"),
), ),
) )
@@ -75,10 +76,11 @@ def load_parsed_data(parsed: ParsedXer, project_id: str) -> None:
""" """
INSERT INTO activities ( INSERT INTO activities (
task_id, proj_id, wbs_id, task_code, task_name, task_type, task_id, proj_id, wbs_id, task_code, task_name, task_type,
target_start_date, target_end_date, act_start_date, act_end_date, target_start_date, target_end_date, early_start_date, early_end_date,
total_float_hr_cnt, driving_path_flag, status_code act_start_date, act_end_date,
total_float_hr_cnt, driving_path_flag, status_code, milestone_type
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
task["task_id"], task["task_id"],
@@ -89,11 +91,14 @@ def load_parsed_data(parsed: ParsedXer, project_id: str) -> None:
task["task_type"], task["task_type"],
task["target_start_date"], task["target_start_date"],
task["target_end_date"], task["target_end_date"],
task.get("early_start_date"),
task.get("early_end_date"),
task["act_start_date"], task["act_start_date"],
task["act_end_date"], task["act_end_date"],
task["total_float_hr_cnt"], task["total_float_hr_cnt"],
1 if task["driving_path_flag"] else 0, 1 if task["driving_path_flag"] else 0,
task["status_code"], task["status_code"],
task.get("milestone_type"),
), ),
) )

View File

@@ -1,8 +1,54 @@
"""Database query functions for XER data.""" """Database query functions for XER data."""
from datetime import datetime, timedelta
from xer_mcp.db import db from xer_mcp.db import db
def is_driving_relationship(
pred_early_end: str | None,
succ_early_start: str | None,
lag_hours: float,
pred_type: str,
) -> bool:
"""Determine if a relationship is driving the successor's early start.
A relationship is "driving" when the predecessor's completion (plus lag)
determines the successor's early start date. This is computed by comparing
dates with a tolerance for overnight gaps and calendar differences.
Args:
pred_early_end: Predecessor's early end date (ISO format)
succ_early_start: Successor's early start date (ISO format)
lag_hours: Lag duration in hours (can be negative)
pred_type: Relationship type (FS, SS, FF, SF)
Returns:
True if the relationship is driving, False otherwise
"""
if pred_early_end is None or succ_early_start is None:
return False
try:
pred_end = datetime.fromisoformat(pred_early_end)
succ_start = datetime.fromisoformat(succ_early_start)
except (ValueError, TypeError):
return False
# For FS (Finish-to-Start): pred_end + lag should equal succ_start
if pred_type == "FS":
expected_start = pred_end + timedelta(hours=lag_hours)
# Allow tolerance of 24 hours for overnight gaps and calendar differences
diff = abs((succ_start - expected_start).total_seconds())
return diff <= 24 * 3600 # 24 hours tolerance
# For SS (Start-to-Start): would need pred_early_start, not implemented
# For FF (Finish-to-Finish): would need succ_early_end, not implemented
# For SF (Start-to-Finish): complex case, not implemented
# Default to False for non-FS relationships for now
return False
def query_activities( def query_activities(
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
@@ -161,14 +207,15 @@ def query_relationships(
cur.execute("SELECT COUNT(*) FROM relationships") cur.execute("SELECT COUNT(*) FROM relationships")
total = cur.fetchone()[0] total = cur.fetchone()[0]
# Get paginated results with activity names # Get paginated results with activity names and early dates for driving computation
query = """ query = """
SELECT r.task_pred_id, r.task_id, a1.task_name, SELECT r.task_pred_id, r.task_id, succ.task_name,
r.pred_task_id, a2.task_name, r.pred_task_id, pred.task_name,
r.pred_type, r.lag_hr_cnt r.pred_type, r.lag_hr_cnt,
pred.early_end_date, succ.early_start_date
FROM relationships r FROM relationships r
LEFT JOIN activities a1 ON r.task_id = a1.task_id LEFT JOIN activities succ ON r.task_id = succ.task_id
LEFT JOIN activities a2 ON r.pred_task_id = a2.task_id LEFT JOIN activities pred ON r.pred_task_id = pred.task_id
ORDER BY r.task_pred_id ORDER BY r.task_pred_id
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""" """
@@ -183,18 +230,25 @@ def query_relationships(
return pred_type[3:] return pred_type[3:]
return pred_type return pred_type
relationships = [ relationships = []
{ for row in rows:
pred_type = format_pred_type(row[5])
driving = is_driving_relationship(
pred_early_end=row[7],
succ_early_start=row[8],
lag_hours=row[6] or 0.0,
pred_type=pred_type,
)
relationships.append({
"task_pred_id": row[0], "task_pred_id": row[0],
"task_id": row[1], "task_id": row[1],
"task_name": row[2], "task_name": row[2],
"pred_task_id": row[3], "pred_task_id": row[3],
"pred_task_name": row[4], "pred_task_name": row[4],
"pred_type": format_pred_type(row[5]), "pred_type": pred_type,
"lag_hr_cnt": row[6], "lag_hr_cnt": row[6],
} "driving": driving,
for row in rows })
]
return relationships, total return relationships, total
@@ -206,15 +260,24 @@ def get_predecessors(activity_id: str) -> list[dict]:
activity_id: The task_id to find predecessors for activity_id: The task_id to find predecessors for
Returns: Returns:
List of predecessor activity dicts with relationship info List of predecessor activity dicts with relationship info and driving flag
""" """
# Get successor's early start for driving calculation
with db.cursor() as cur:
cur.execute(
"SELECT early_start_date FROM activities WHERE task_id = ?",
(activity_id,),
)
succ_row = cur.fetchone()
succ_early_start = succ_row[0] if succ_row else None
query = """ query = """
SELECT a.task_id, a.task_code, a.task_name, SELECT pred.task_id, pred.task_code, pred.task_name,
r.pred_type, r.lag_hr_cnt r.pred_type, r.lag_hr_cnt, pred.early_end_date
FROM relationships r FROM relationships r
JOIN activities a ON r.pred_task_id = a.task_id JOIN activities pred ON r.pred_task_id = pred.task_id
WHERE r.task_id = ? WHERE r.task_id = ?
ORDER BY a.task_code ORDER BY pred.task_code
""" """
with db.cursor() as cur: with db.cursor() as cur:
@@ -226,16 +289,25 @@ def get_predecessors(activity_id: str) -> list[dict]:
return pred_type[3:] return pred_type[3:]
return pred_type return pred_type
return [ result = []
{ for row in rows:
pred_type = format_pred_type(row[3])
driving = is_driving_relationship(
pred_early_end=row[5],
succ_early_start=succ_early_start,
lag_hours=row[4] or 0.0,
pred_type=pred_type,
)
result.append({
"task_id": row[0], "task_id": row[0],
"task_code": row[1], "task_code": row[1],
"task_name": row[2], "task_name": row[2],
"relationship_type": format_pred_type(row[3]), "relationship_type": pred_type,
"lag_hr_cnt": row[4], "lag_hr_cnt": row[4],
} "driving": driving,
for row in rows })
]
return result
def get_successors(activity_id: str) -> list[dict]: def get_successors(activity_id: str) -> list[dict]:
@@ -245,15 +317,24 @@ def get_successors(activity_id: str) -> list[dict]:
activity_id: The task_id to find successors for activity_id: The task_id to find successors for
Returns: Returns:
List of successor activity dicts with relationship info List of successor activity dicts with relationship info and driving flag
""" """
# Get predecessor's early end for driving calculation
with db.cursor() as cur:
cur.execute(
"SELECT early_end_date FROM activities WHERE task_id = ?",
(activity_id,),
)
pred_row = cur.fetchone()
pred_early_end = pred_row[0] if pred_row else None
query = """ query = """
SELECT a.task_id, a.task_code, a.task_name, SELECT succ.task_id, succ.task_code, succ.task_name,
r.pred_type, r.lag_hr_cnt r.pred_type, r.lag_hr_cnt, succ.early_start_date
FROM relationships r FROM relationships r
JOIN activities a ON r.task_id = a.task_id JOIN activities succ ON r.task_id = succ.task_id
WHERE r.pred_task_id = ? WHERE r.pred_task_id = ?
ORDER BY a.task_code ORDER BY succ.task_code
""" """
with db.cursor() as cur: with db.cursor() as cur:
@@ -265,16 +346,25 @@ def get_successors(activity_id: str) -> list[dict]:
return pred_type[3:] return pred_type[3:]
return pred_type return pred_type
return [ result = []
{ for row in rows:
pred_type = format_pred_type(row[3])
driving = is_driving_relationship(
pred_early_end=pred_early_end,
succ_early_start=row[5],
lag_hours=row[4] or 0.0,
pred_type=pred_type,
)
result.append({
"task_id": row[0], "task_id": row[0],
"task_code": row[1], "task_code": row[1],
"task_name": row[2], "task_name": row[2],
"relationship_type": format_pred_type(row[3]), "relationship_type": pred_type,
"lag_hr_cnt": row[4], "lag_hr_cnt": row[4],
} "driving": driving,
for row in rows })
]
return result
def get_project_summary(project_id: str) -> dict | None: def get_project_summary(project_id: str) -> dict | None:
@@ -290,7 +380,7 @@ def get_project_summary(project_id: str) -> dict | None:
with db.cursor() as cur: with db.cursor() as cur:
cur.execute( cur.execute(
""" """
SELECT proj_id, proj_short_name, plan_start_date, plan_end_date SELECT proj_id, proj_short_name, plan_start_date, plan_end_date, last_recalc_date
FROM projects FROM projects
WHERE proj_id = ? WHERE proj_id = ?
""", """,
@@ -306,9 +396,9 @@ def get_project_summary(project_id: str) -> dict | None:
cur.execute("SELECT COUNT(*) FROM activities") cur.execute("SELECT COUNT(*) FROM activities")
activity_count = cur.fetchone()[0] activity_count = cur.fetchone()[0]
# Get milestone count # Get milestone count (both start and finish milestones)
with db.cursor() as cur: with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM activities WHERE task_type = 'TT_Mile'") cur.execute("SELECT COUNT(*) FROM activities WHERE task_type IN ('TT_Mile', 'TT_FinMile')")
milestone_count = cur.fetchone()[0] milestone_count = cur.fetchone()[0]
# Get critical activity count # Get critical activity count
@@ -319,6 +409,7 @@ def get_project_summary(project_id: str) -> dict | None:
return { return {
"project_id": project_row[0], "project_id": project_row[0],
"project_name": project_row[1], "project_name": project_row[1],
"data_date": project_row[4],
"plan_start_date": project_row[2], "plan_start_date": project_row[2],
"plan_end_date": project_row[3], "plan_end_date": project_row[3],
"activity_count": activity_count, "activity_count": activity_count,
@@ -328,16 +419,16 @@ def get_project_summary(project_id: str) -> dict | None:
def query_milestones() -> list[dict]: def query_milestones() -> list[dict]:
"""Query all milestone activities. """Query all milestone activities (both start and finish milestones).
Returns: Returns:
List of milestone activity dicts List of milestone activity dicts with milestone_type (start/finish)
""" """
query = """ query = """
SELECT task_id, task_code, task_name, SELECT task_id, task_code, task_name,
target_start_date, target_end_date, status_code target_start_date, target_end_date, status_code, milestone_type
FROM activities FROM activities
WHERE task_type = 'TT_Mile' WHERE task_type IN ('TT_Mile', 'TT_FinMile')
ORDER BY target_start_date, task_code ORDER BY target_start_date, task_code
""" """
@@ -353,6 +444,7 @@ def query_milestones() -> list[dict]:
"target_start_date": row[3], "target_start_date": row[3],
"target_end_date": row[4], "target_end_date": row[4],
"status_code": row[5], "status_code": row[5],
"milestone_type": row[6],
} }
for row in rows for row in rows
] ]

View File

@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS projects (
proj_short_name TEXT NOT NULL, proj_short_name TEXT NOT NULL,
plan_start_date TEXT, plan_start_date TEXT,
plan_end_date TEXT, plan_end_date TEXT,
last_recalc_date TEXT,
loaded_at TEXT NOT NULL DEFAULT (datetime('now')) loaded_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -20,11 +21,14 @@ CREATE TABLE IF NOT EXISTS activities (
task_type TEXT NOT NULL, task_type TEXT NOT NULL,
target_start_date TEXT, target_start_date TEXT,
target_end_date TEXT, target_end_date TEXT,
early_start_date TEXT,
early_end_date TEXT,
act_start_date TEXT, act_start_date TEXT,
act_end_date TEXT, act_end_date TEXT,
total_float_hr_cnt REAL, total_float_hr_cnt REAL,
driving_path_flag INTEGER DEFAULT 0, driving_path_flag INTEGER DEFAULT 0,
status_code TEXT, status_code TEXT,
milestone_type TEXT,
FOREIGN KEY (proj_id) REFERENCES projects(proj_id) FOREIGN KEY (proj_id) REFERENCES projects(proj_id)
); );

View File

@@ -35,4 +35,5 @@ class ProjectHandler(TableHandler):
"proj_short_name": data.get("proj_short_name", ""), "proj_short_name": data.get("proj_short_name", ""),
"plan_start_date": convert_date(data.get("plan_start_date")), "plan_start_date": convert_date(data.get("plan_start_date")),
"plan_end_date": convert_date(data.get("plan_end_date")), "plan_end_date": convert_date(data.get("plan_end_date")),
"last_recalc_date": convert_date(data.get("last_recalc_date")),
} }

View File

@@ -26,6 +26,22 @@ class TaskHandler(TableHandler):
float_str = data.get("total_float_hr_cnt", "") float_str = data.get("total_float_hr_cnt", "")
total_float = float(float_str) if float_str else None total_float = float(float_str) if float_str else None
# Parse milestone_type
# First check explicit milestone_type field (MS_Start -> 'start', MS_Finish -> 'finish')
# Then derive from task_type (TT_Mile -> 'start', TT_FinMile -> 'finish')
raw_milestone_type = data.get("milestone_type", "")
task_type = data.get("task_type", "")
milestone_type = None
if raw_milestone_type:
if raw_milestone_type == "MS_Start":
milestone_type = "start"
elif raw_milestone_type == "MS_Finish":
milestone_type = "finish"
elif task_type == "TT_Mile":
milestone_type = "start"
elif task_type == "TT_FinMile":
milestone_type = "finish"
return { return {
"task_id": data.get("task_id", ""), "task_id": data.get("task_id", ""),
"proj_id": data.get("proj_id", ""), "proj_id": data.get("proj_id", ""),
@@ -36,8 +52,11 @@ class TaskHandler(TableHandler):
"status_code": data.get("status_code") or None, "status_code": data.get("status_code") or None,
"target_start_date": convert_date(data.get("target_start_date")), "target_start_date": convert_date(data.get("target_start_date")),
"target_end_date": convert_date(data.get("target_end_date")), "target_end_date": convert_date(data.get("target_end_date")),
"early_start_date": convert_date(data.get("early_start_date")),
"early_end_date": convert_date(data.get("early_end_date")),
"act_start_date": convert_date(data.get("act_start_date")), "act_start_date": convert_date(data.get("act_start_date")),
"act_end_date": convert_date(data.get("act_end_date")), "act_end_date": convert_date(data.get("act_end_date")),
"total_float_hr_cnt": total_float, "total_float_hr_cnt": total_float,
"driving_path_flag": driving_path, "driving_path_flag": driving_path,
"milestone_type": milestone_type,
} }

View File

@@ -7,6 +7,8 @@ from xer_mcp.server import is_file_loaded
async def list_milestones() -> dict: async def list_milestones() -> dict:
"""List all milestone activities in the loaded project. """List all milestone activities in the loaded project.
Returns both Start Milestones and Finish Milestones with their milestone_type.
Returns: Returns:
Dictionary with list of milestones, each containing: Dictionary with list of milestones, each containing:
- task_id: Activity ID - task_id: Activity ID
@@ -15,6 +17,7 @@ async def list_milestones() -> dict:
- target_start_date: Target start date - target_start_date: Target start date
- target_end_date: Target end date - target_end_date: Target end date
- status_code: Activity status - status_code: Activity status
- milestone_type: 'start' for start milestones, 'finish' for finish milestones
""" """
if not is_file_loaded(): if not is_file_loaded():
return { return {

View File

@@ -19,12 +19,12 @@ ERMHDR\t21.12\t2026-01-06\tProject\tADMIN\ttestuser\tdbTest\tProject Management\
%R\t101\t1001\t\t1\t1\tN\tN\tWS_Open\tPH1\tPhase 1\t\t100\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-2\t\t %R\t101\t1001\t\t1\t1\tN\tN\tWS_Open\tPH1\tPhase 1\t\t100\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-2\t\t
%R\t102\t1001\t\t2\t1\tN\tN\tWS_Open\tPH2\tPhase 2\t\t100\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-3\t\t %R\t102\t1001\t\t2\t1\tN\tN\tWS_Open\tPH2\tPhase 2\t\t100\t6\t0.88\t0.0000\t0.0000\t\t\t\t\t\tEC_Cmp_pct\tEE_Rem_hr\twbs-guid-3\t\t
%T\tTASK %T\tTASK
%F\ttask_id\tproj_id\twbs_id\tclndr_id\tphys_complete_pct\trev_fdbk_flag\test_wt\tlock_plan_flag\tauto_compute_act_flag\tcomplete_pct_type\ttask_type\tduration_type\tstatus_code\ttask_code\ttask_name\trsrc_id\ttotal_float_hr_cnt\tfree_float_hr_cnt\tremain_drtn_hr_cnt\tact_work_qty\tremain_work_qty\ttarget_work_qty\ttarget_drtn_hr_cnt\ttarget_equip_qty\tact_equip_qty\tremain_equip_qty\tcstr_date\tact_start_date\tact_end_date\tlate_start_date\tlate_end_date\texpect_end_date\tearly_start_date\tearly_end_date\trestart_date\treend_date\ttarget_start_date\ttarget_end_date\trem_late_start_date\trem_late_end_date\tcstr_type\tpriority_type\tsuspend_date\tresume_date\tfloat_path\tfloat_path_order\tguid\ttmpl_guid\tcstr_date2\tcstr_type2\tdriving_path_flag\tact_this_per_work_qty\tact_this_per_equip_qty\texternal_early_start_date\texternal_late_end_date\tcreate_date\tupdate_date\tcreate_user\tupdate_user\tlocation_id\tcrt_path_num %F\ttask_id\tproj_id\twbs_id\tclndr_id\tphys_complete_pct\trev_fdbk_flag\test_wt\tlock_plan_flag\tauto_compute_act_flag\tcomplete_pct_type\ttask_type\tduration_type\tstatus_code\ttask_code\ttask_name\trsrc_id\ttotal_float_hr_cnt\tfree_float_hr_cnt\tremain_drtn_hr_cnt\tact_work_qty\tremain_work_qty\ttarget_work_qty\ttarget_drtn_hr_cnt\ttarget_equip_qty\tact_equip_qty\tremain_equip_qty\tcstr_date\tact_start_date\tact_end_date\tlate_start_date\tlate_end_date\texpect_end_date\tearly_start_date\tearly_end_date\trestart_date\treend_date\ttarget_start_date\ttarget_end_date\trem_late_start_date\trem_late_end_date\tcstr_type\tpriority_type\tsuspend_date\tresume_date\tfloat_path\tfloat_path_order\tguid\ttmpl_guid\tcstr_date2\tcstr_type2\tdriving_path_flag\tact_this_per_work_qty\tact_this_per_equip_qty\texternal_early_start_date\texternal_late_end_date\tcreate_date\tupdate_date\tcreate_user\tupdate_user\tlocation_id\tcrt_path_num\tmilestone_type
%R\t2001\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Mile\tDT_FixedDrtn\tTK_NotStart\tA1000\tProject Start\t\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t\t\t\t2026-01-01 07:00\t2026-01-01 07:00\t\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t\tPT_Normal\t\t\t1\t1\ttask-guid-1\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t %R\t2001\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Mile\tDT_FixedDrtn\tTK_NotStart\tA1000\tProject Start\t\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t\t\t\t2026-01-01 07:00\t2026-01-01 07:00\t\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t2026-01-01 07:00\t\tPT_Normal\t\t\t1\t1\ttask-guid-1\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t\tMS_Start
%R\t2002\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1010\tSite Preparation\t\t0\t0\t40\t0\t0\t0\t40\t0\t0\t0\t\t\t\t2026-01-02 07:00\t2026-01-08 15:00\t\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t\tPT_Normal\t\t\t1\t2\ttask-guid-2\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t %R\t2002\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1010\tSite Preparation\t\t0\t0\t40\t0\t0\t0\t40\t0\t0\t0\t\t\t\t2026-01-02 07:00\t2026-01-08 15:00\t\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t2026-01-02 07:00\t2026-01-08 15:00\t\tPT_Normal\t\t\t1\t2\ttask-guid-2\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t\t
%R\t2003\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1020\tFoundation Work\t\t80\t0\t80\t0\t0\t0\t80\t0\t0\t0\t\t\t\t2026-01-09 07:00\t2026-01-22 15:00\t\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t\tPT_Normal\t\t\t1\t3\ttask-guid-3\t\t\t\tN\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t %R\t2003\t1001\t101\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1020\tFoundation Work\t\t80\t0\t80\t0\t0\t0\t80\t0\t0\t0\t\t\t\t2026-01-09 07:00\t2026-01-22 15:00\t\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t2026-01-09 07:00\t2026-01-22 15:00\t\tPT_Normal\t\t\t1\t3\ttask-guid-3\t\t\t\tN\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t\t
%R\t2004\t1001\t102\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1030\tStructural Work\t\t0\t0\t160\t0\t0\t0\t160\t0\t0\t0\t\t\t\t2026-01-23 07:00\t2026-02-19 15:00\t\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t\tPT_Normal\t\t\t1\t4\ttask-guid-4\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t %R\t2004\t1001\t102\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Task\tDT_FixedDUR2\tTK_NotStart\tA1030\tStructural Work\t\t0\t0\t160\t0\t0\t0\t160\t0\t0\t0\t\t\t\t2026-01-23 07:00\t2026-02-19 15:00\t\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t2026-01-23 07:00\t2026-02-19 15:00\t\tPT_Normal\t\t\t1\t4\ttask-guid-4\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t\t
%R\t2005\t1001\t102\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Mile\tDT_FixedDrtn\tTK_NotStart\tA1040\tProject Complete\t\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t\t\t\t2026-02-20 07:00\t2026-02-20 07:00\t\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t\tPT_Normal\t\t\t1\t5\ttask-guid-5\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t %R\t2005\t1001\t102\t1\t0\tN\t1\tN\tN\tCP_Drtn\tTT_Mile\tDT_FixedDrtn\tTK_NotStart\tA1040\tProject Complete\t\t0\t0\t0\t0\t0\t0\t0\t0\t0\t0\t\t\t\t2026-02-20 07:00\t2026-02-20 07:00\t\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t2026-02-20 07:00\t\tPT_Normal\t\t\t1\t5\ttask-guid-5\t\t\t\tY\t0\t0\t\t\t2026-01-06 00:00\t2026-01-06 00:00\tADMIN\tADMIN\t\t\tMS_Finish
%T\tTASKPRED %T\tTASKPRED
%F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt\tcomments\tfloat_path\taref\tarls %F\ttask_pred_id\ttask_id\tpred_task_id\tproj_id\tpred_proj_id\tpred_type\tlag_hr_cnt\tcomments\tfloat_path\taref\tarls
%R\t3001\t2002\t2001\t1001\t1001\tPR_FS\t0\t\t\t2026-01-01 07:00\t2026-01-02 07:00 %R\t3001\t2002\t2001\t1001\t1001\tPR_FS\t0\t\t\t2026-01-01 07:00\t2026-01-02 07:00

View File

@@ -75,3 +75,22 @@ class TestGetPredecessorsContract:
assert "error" in result assert "error" in result
assert result["error"]["code"] == "NO_FILE_LOADED" assert result["error"]["code"] == "NO_FILE_LOADED"
async def test_get_predecessors_includes_driving_flag(
self, sample_xer_single_project: Path
) -> None:
"""get_predecessors includes driving flag for each predecessor."""
from xer_mcp.tools.get_predecessors import get_predecessors
from xer_mcp.tools.load_xer import load_xer
await load_xer(file_path=str(sample_xer_single_project))
# A1010 (2002) has one predecessor: A1000 (2001)
result = await get_predecessors(activity_id="2002")
assert "predecessors" in result
assert len(result["predecessors"]) >= 1
# All predecessors should have a driving flag
for pred in result["predecessors"]:
assert "driving" in pred
assert isinstance(pred["driving"], bool)

View File

@@ -30,6 +30,7 @@ class TestGetProjectSummaryContract:
result = await get_project_summary() result = await get_project_summary()
assert "project_name" in result assert "project_name" in result
assert "data_date" in result
assert "plan_start_date" in result assert "plan_start_date" in result
assert "plan_end_date" in result assert "plan_end_date" in result
assert "activity_count" in result assert "activity_count" in result

View File

@@ -74,3 +74,22 @@ class TestGetSuccessorsContract:
assert "error" in result assert "error" in result
assert result["error"]["code"] == "NO_FILE_LOADED" assert result["error"]["code"] == "NO_FILE_LOADED"
async def test_get_successors_includes_driving_flag(
self, sample_xer_single_project: Path
) -> None:
"""get_successors includes driving flag for each successor."""
from xer_mcp.tools.get_successors import get_successors
from xer_mcp.tools.load_xer import load_xer
await load_xer(file_path=str(sample_xer_single_project))
# A1000 (2001) has one successor: A1010 (2002)
result = await get_successors(activity_id="2001")
assert "successors" in result
assert len(result["successors"]) >= 1
# All successors should have a driving flag
for succ in result["successors"]:
assert "driving" in succ
assert isinstance(succ["driving"], bool)

View File

@@ -87,3 +87,38 @@ class TestListMilestonesContract:
assert "error" in result assert "error" in result
assert result["error"]["code"] == "NO_FILE_LOADED" assert result["error"]["code"] == "NO_FILE_LOADED"
async def test_list_milestones_includes_milestone_type_field(
self, sample_xer_single_project: Path
) -> None:
"""list_milestones returns milestones with milestone_type field."""
from xer_mcp.tools.list_milestones import list_milestones
from xer_mcp.tools.load_xer import load_xer
await load_xer(file_path=str(sample_xer_single_project))
result = await list_milestones()
# All milestones should have milestone_type field
for milestone in result["milestones"]:
assert "milestone_type" in milestone
async def test_list_milestones_returns_start_and_finish_types(
self, sample_xer_single_project: Path
) -> None:
"""list_milestones returns milestones with correct start/finish types."""
from xer_mcp.tools.list_milestones import list_milestones
from xer_mcp.tools.load_xer import load_xer
await load_xer(file_path=str(sample_xer_single_project))
result = await list_milestones()
# Find milestones by name and verify their types
milestones_by_name = {m["task_name"]: m for m in result["milestones"]}
# "Project Start" should be a start milestone
assert milestones_by_name["Project Start"]["milestone_type"] == "start"
# "Project Complete" should be a finish milestone
assert milestones_by_name["Project Complete"]["milestone_type"] == "finish"

View File

@@ -76,3 +76,21 @@ class TestListRelationshipsContract:
assert "error" in result assert "error" in result
assert result["error"]["code"] == "NO_FILE_LOADED" assert result["error"]["code"] == "NO_FILE_LOADED"
async def test_list_relationships_includes_driving_flag(
self, sample_xer_single_project: Path
) -> None:
"""list_relationships returns relationships with driving flag."""
from xer_mcp.tools.list_relationships import list_relationships
from xer_mcp.tools.load_xer import load_xer
await load_xer(file_path=str(sample_xer_single_project))
result = await list_relationships()
assert "relationships" in result
assert len(result["relationships"]) > 0
# All relationships should have a driving flag
for rel in result["relationships"]:
assert "driving" in rel
assert isinstance(rel["driving"], bool)

View File

@@ -109,3 +109,68 @@ class TestActivityQueries:
activity = get_activity_by_id("nonexistent") activity = get_activity_by_id("nonexistent")
assert activity is None assert activity is None
class TestDrivingRelationship:
"""Tests for driving relationship computation."""
def test_is_driving_relationship_fs_driving(self) -> None:
"""FS relationship is driving when pred_end + lag = succ_start."""
from xer_mcp.db.queries import is_driving_relationship
# Pred ends at 2026-01-08T15:00, succ starts at 2026-01-09T07:00
# With 0 lag and overnight gap, this is driving
result = is_driving_relationship(
pred_early_end="2026-01-08T15:00:00",
succ_early_start="2026-01-09T07:00:00",
lag_hours=0.0,
pred_type="FS",
)
assert result is True
def test_is_driving_relationship_fs_not_driving(self) -> None:
"""FS relationship is not driving when there's float."""
from xer_mcp.db.queries import is_driving_relationship
# Pred ends much earlier than succ starts (has float)
result = is_driving_relationship(
pred_early_end="2026-01-01T15:00:00",
succ_early_start="2026-01-10T07:00:00",
lag_hours=0.0,
pred_type="FS",
)
assert result is False
def test_is_driving_relationship_with_lag(self) -> None:
"""FS relationship with lag is driving when pred_end + lag = succ_start."""
from xer_mcp.db.queries import is_driving_relationship
# Pred ends at 2026-01-08T15:00, 16hr lag, succ starts at 2026-01-09T07:00
# 15:00 + 16hrs = next day 07:00 (exactly matches)
result = is_driving_relationship(
pred_early_end="2026-01-08T15:00:00",
succ_early_start="2026-01-09T07:00:00",
lag_hours=16.0,
pred_type="FS",
)
assert result is True
def test_is_driving_relationship_missing_dates(self) -> None:
"""Relationship is not driving when dates are missing."""
from xer_mcp.db.queries import is_driving_relationship
result = is_driving_relationship(
pred_early_end=None,
succ_early_start="2026-01-09T07:00:00",
lag_hours=0.0,
pred_type="FS",
)
assert result is False
result = is_driving_relationship(
pred_early_end="2026-01-08T15:00:00",
succ_early_start=None,
lag_hours=0.0,
pred_type="FS",
)
assert result is False

View File

@@ -87,6 +87,217 @@ class TestTaskHandler:
handler = TaskHandler() handler = TaskHandler()
assert handler.table_name == "TASK" assert handler.table_name == "TASK"
def test_parse_early_dates(self) -> None:
"""Handler should parse early_start_date and early_end_date from TASK row."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
fields = [
"task_id",
"proj_id",
"wbs_id",
"task_code",
"task_name",
"task_type",
"status_code",
"target_start_date",
"target_end_date",
"early_start_date",
"early_end_date",
"total_float_hr_cnt",
"driving_path_flag",
]
values = [
"2001",
"1001",
"100",
"A1000",
"Site Prep",
"TT_Task",
"TK_NotStart",
"2026-01-02 07:00",
"2026-01-08 15:00",
"2026-01-02 07:00",
"2026-01-08 15:00",
"0",
"Y",
]
result = handler.parse_row(fields, values)
assert result is not None
assert result["early_start_date"] == "2026-01-02T07:00:00"
assert result["early_end_date"] == "2026-01-08T15:00:00"
def test_parse_missing_early_dates(self) -> None:
"""Handler should handle missing early dates gracefully."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
fields = [
"task_id",
"proj_id",
"task_code",
"task_name",
"task_type",
]
values = [
"2001",
"1001",
"A1000",
"Site Prep",
"TT_Task",
]
result = handler.parse_row(fields, values)
assert result is not None
assert result["early_start_date"] is None
assert result["early_end_date"] is None
def test_parse_milestone_type_start(self) -> None:
"""Handler should parse milestone_type='start' for start milestones."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
fields = [
"task_id",
"proj_id",
"task_code",
"task_name",
"task_type",
"milestone_type",
]
values = [
"2001",
"1001",
"MS-START",
"Project Start",
"TT_Mile",
"MS_Start",
]
result = handler.parse_row(fields, values)
assert result is not None
assert result["task_type"] == "TT_Mile"
assert result["milestone_type"] == "start"
def test_parse_milestone_type_finish(self) -> None:
"""Handler should parse milestone_type='finish' for finish milestones."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
fields = [
"task_id",
"proj_id",
"task_code",
"task_name",
"task_type",
"milestone_type",
]
values = [
"2002",
"1001",
"MS-END",
"Project Complete",
"TT_Mile",
"MS_Finish",
]
result = handler.parse_row(fields, values)
assert result is not None
assert result["task_type"] == "TT_Mile"
assert result["milestone_type"] == "finish"
def test_parse_milestone_type_null_for_non_milestones(self) -> None:
"""Handler should return None for milestone_type on non-milestone activities."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
fields = [
"task_id",
"proj_id",
"task_code",
"task_name",
"task_type",
]
values = [
"2003",
"1001",
"A1000",
"Regular Task",
"TT_Task",
]
result = handler.parse_row(fields, values)
assert result is not None
assert result["task_type"] == "TT_Task"
assert result["milestone_type"] is None
def test_parse_milestone_type_derived_from_tt_mile(self) -> None:
"""Handler should derive milestone_type='start' from task_type TT_Mile."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
# No explicit milestone_type field - derive from task_type
fields = [
"task_id",
"proj_id",
"task_code",
"task_name",
"task_type",
]
values = [
"2001",
"1001",
"MS-START",
"Project Start",
"TT_Mile",
]
result = handler.parse_row(fields, values)
assert result is not None
assert result["task_type"] == "TT_Mile"
assert result["milestone_type"] == "start"
def test_parse_milestone_type_derived_from_tt_finmile(self) -> None:
"""Handler should derive milestone_type='finish' from task_type TT_FinMile."""
from xer_mcp.parser.table_handlers.task import TaskHandler
handler = TaskHandler()
# TT_FinMile is a finish milestone
fields = [
"task_id",
"proj_id",
"task_code",
"task_name",
"task_type",
]
values = [
"2002",
"1001",
"MS-END",
"Project Complete",
"TT_FinMile",
]
result = handler.parse_row(fields, values)
assert result is not None
assert result["task_type"] == "TT_FinMile"
assert result["milestone_type"] == "finish"
class TestTaskpredHandler: class TestTaskpredHandler:
"""Tests for TASKPRED table handler.""" """Tests for TASKPRED table handler."""