@picoai/tickets 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tickets/spec/AGENTS_EXAMPLE.md +5 -4
- package/.tickets/spec/TICKETS.md +295 -358
- package/.tickets/spec/profile/defaults.yml +29 -0
- package/.tickets/spec/version/20260311-tickets-spec.md +38 -0
- package/.tickets/spec/version/20260317-tickets-spec.md +82 -0
- package/README.md +122 -147
- package/package.json +4 -1
- package/release-history.json +19 -0
- package/src/cli.js +462 -137
- package/src/lib/claims.js +66 -0
- package/src/lib/config.js +162 -0
- package/src/lib/constants.js +8 -2
- package/src/lib/listing.js +21 -84
- package/src/lib/planning.js +355 -0
- package/src/lib/projections.js +70 -0
- package/src/lib/repair.js +75 -1
- package/src/lib/util.js +5 -1
- package/src/lib/validation.js +216 -0
- package/src/release-status.js +141 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CLAIM_ACTION_VALUES } from "./constants.js";
|
|
5
|
+
import { readJsonl } from "./util.js";
|
|
6
|
+
|
|
7
|
+
function claimEventsForLog(logPath) {
|
|
8
|
+
return readJsonl(logPath)
|
|
9
|
+
.filter((entry) => entry.event_type === "claim" && entry.claim && typeof entry.claim === "object")
|
|
10
|
+
.map((entry) => ({ ...entry, log_path: logPath }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function loadClaimEvents(logsDir) {
|
|
14
|
+
if (!fs.existsSync(logsDir)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return fs
|
|
19
|
+
.readdirSync(logsDir, { withFileTypes: true })
|
|
20
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
21
|
+
.map((entry) => path.join(logsDir, entry.name))
|
|
22
|
+
.sort((a, b) => a.localeCompare(b))
|
|
23
|
+
.flatMap((logPath) => claimEventsForLog(logPath))
|
|
24
|
+
.sort((a, b) => `${a.ts ?? ""}:${a.run_started ?? ""}`.localeCompare(`${b.ts ?? ""}:${b.run_started ?? ""}`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function deriveActiveClaim(entries, now = new Date()) {
|
|
28
|
+
let active = null;
|
|
29
|
+
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const claim = entry.claim;
|
|
32
|
+
if (!claim || !CLAIM_ACTION_VALUES.includes(claim.action)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (claim.action === "release") {
|
|
37
|
+
if (active && (!claim.claim_id || claim.claim_id === active.claim_id)) {
|
|
38
|
+
active = null;
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
active = {
|
|
44
|
+
claim_id: claim.claim_id,
|
|
45
|
+
action: claim.action,
|
|
46
|
+
holder_id: claim.holder_id,
|
|
47
|
+
holder_type: claim.holder_type,
|
|
48
|
+
reason: claim.reason ?? "",
|
|
49
|
+
ttl_minutes: claim.ttl_minutes,
|
|
50
|
+
expires_at: claim.expires_at,
|
|
51
|
+
supersedes_claim_id: claim.supersedes_claim_id ?? null,
|
|
52
|
+
ts: entry.ts,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!active) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const expiresAt = active.expires_at ? Date.parse(active.expires_at) : Number.NaN;
|
|
61
|
+
if (!Number.isNaN(expiresAt) && expiresAt <= now.getTime()) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return active;
|
|
66
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import yaml from "yaml";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_CLAIM_TTL_MINUTES,
|
|
8
|
+
PLANNING_NODE_TYPES,
|
|
9
|
+
WORKFLOW_MODE_VALUES,
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
import { packageRoot, repoRoot, ticketsDir } from "./util.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_PROFILE_RELATIVE_PATH = path.join(".tickets", "spec", "profile", "defaults.yml");
|
|
14
|
+
|
|
15
|
+
function isObject(value) {
|
|
16
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function deepMerge(base, override) {
|
|
20
|
+
if (!isObject(base) || !isObject(override)) {
|
|
21
|
+
return override;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const merged = { ...base };
|
|
25
|
+
for (const [key, value] of Object.entries(override)) {
|
|
26
|
+
if (isObject(value) && isObject(base[key])) {
|
|
27
|
+
merged[key] = deepMerge(base[key], value);
|
|
28
|
+
} else {
|
|
29
|
+
merged[key] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return merged;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function defaultProfilePath() {
|
|
36
|
+
return path.join(packageRoot(), DEFAULT_PROFILE_RELATIVE_PATH);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function repoConfigPath(root = repoRoot()) {
|
|
40
|
+
return path.join(root, ".tickets", "config.yml");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadDefaultProfile() {
|
|
44
|
+
return yaml.parse(fs.readFileSync(defaultProfilePath(), "utf8")) ?? {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadRepoConfig(root = repoRoot()) {
|
|
48
|
+
const configPath = repoConfigPath(root);
|
|
49
|
+
if (!fs.existsSync(configPath)) {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
const parsed = yaml.parse(fs.readFileSync(configPath, "utf8")) ?? {};
|
|
53
|
+
if (!isObject(parsed)) {
|
|
54
|
+
throw new Error("Repo config must be a mapping");
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadWorkflowProfile(root = repoRoot()) {
|
|
60
|
+
return deepMerge(loadDefaultProfile(), loadRepoConfig(root));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildInitialRepoConfig(profile = loadDefaultProfile()) {
|
|
64
|
+
return {
|
|
65
|
+
workflow: {
|
|
66
|
+
mode: profile.workflow?.mode ?? "auto",
|
|
67
|
+
},
|
|
68
|
+
defaults: {
|
|
69
|
+
planning: {
|
|
70
|
+
node_type: profile.defaults?.planning?.node_type ?? "work",
|
|
71
|
+
},
|
|
72
|
+
claims: {
|
|
73
|
+
ttl_minutes: profile.defaults?.claims?.ttl_minutes ?? DEFAULT_CLAIM_TTL_MINUTES,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
semantics: {
|
|
77
|
+
terms: profile.semantics?.terms ?? {},
|
|
78
|
+
},
|
|
79
|
+
views: profile.views ?? {},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderRepoConfig(profile = loadDefaultProfile()) {
|
|
84
|
+
const body = yaml.stringify(buildInitialRepoConfig(profile)).trimEnd();
|
|
85
|
+
return [
|
|
86
|
+
"# Repo-local @picoai/tickets overrides",
|
|
87
|
+
"# This file is authoritative for local semantic mapping and defaults.",
|
|
88
|
+
"# Safe to customize. `init --apply` will not overwrite it.",
|
|
89
|
+
"",
|
|
90
|
+
body,
|
|
91
|
+
"",
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateRepoConfig(root = repoRoot()) {
|
|
96
|
+
const issues = [];
|
|
97
|
+
const configPath = repoConfigPath(root);
|
|
98
|
+
|
|
99
|
+
if (!fs.existsSync(configPath)) {
|
|
100
|
+
return issues;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let config;
|
|
104
|
+
try {
|
|
105
|
+
config = loadRepoConfig(root);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
issues.push({
|
|
108
|
+
severity: "error",
|
|
109
|
+
code: "CONFIG_INVALID",
|
|
110
|
+
message: String(error.message ?? error),
|
|
111
|
+
config_path: configPath,
|
|
112
|
+
});
|
|
113
|
+
return issues;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const mode = config.workflow?.mode;
|
|
117
|
+
if (mode !== undefined && !WORKFLOW_MODE_VALUES.includes(mode)) {
|
|
118
|
+
issues.push({
|
|
119
|
+
severity: "error",
|
|
120
|
+
code: "CONFIG_WORKFLOW_MODE_INVALID",
|
|
121
|
+
message: `workflow.mode must be one of ${WORKFLOW_MODE_VALUES.join(", ")}`,
|
|
122
|
+
config_path: configPath,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const nodeType = config.defaults?.planning?.node_type;
|
|
127
|
+
if (nodeType !== undefined && !PLANNING_NODE_TYPES.includes(nodeType)) {
|
|
128
|
+
issues.push({
|
|
129
|
+
severity: "error",
|
|
130
|
+
code: "CONFIG_DEFAULT_NODE_TYPE_INVALID",
|
|
131
|
+
message: `defaults.planning.node_type must be one of ${PLANNING_NODE_TYPES.join(", ")}`,
|
|
132
|
+
config_path: configPath,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ttlMinutes = config.defaults?.claims?.ttl_minutes;
|
|
137
|
+
if (ttlMinutes !== undefined && (!Number.isInteger(ttlMinutes) || ttlMinutes <= 0)) {
|
|
138
|
+
issues.push({
|
|
139
|
+
severity: "error",
|
|
140
|
+
code: "CONFIG_CLAIM_TTL_INVALID",
|
|
141
|
+
message: "defaults.claims.ttl_minutes must be a positive integer",
|
|
142
|
+
config_path: configPath,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const terms = config.semantics?.terms;
|
|
147
|
+
if (terms !== undefined && !isObject(terms)) {
|
|
148
|
+
issues.push({
|
|
149
|
+
severity: "error",
|
|
150
|
+
code: "CONFIG_TERMS_INVALID",
|
|
151
|
+
message: "semantics.terms must be a mapping",
|
|
152
|
+
config_path: configPath,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return issues;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function ensureTicketsRoot(root = repoRoot()) {
|
|
160
|
+
fs.mkdirSync(ticketsDir(), { recursive: true });
|
|
161
|
+
fs.mkdirSync(path.dirname(repoConfigPath(root)), { recursive: true });
|
|
162
|
+
}
|
package/src/lib/constants.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export const BASE_DIR = ".tickets/spec";
|
|
2
|
-
export const FORMAT_VERSION =
|
|
3
|
-
export const FORMAT_VERSION_URL = "version/
|
|
2
|
+
export const FORMAT_VERSION = 3;
|
|
3
|
+
export const FORMAT_VERSION_URL = "version/20260317-tickets-spec.md";
|
|
4
4
|
|
|
5
5
|
export const STATUS_VALUES = ["todo", "doing", "blocked", "done", "canceled"];
|
|
6
6
|
export const PRIORITY_VALUES = ["low", "medium", "high", "critical"];
|
|
7
7
|
export const ASSIGNMENT_MODE_VALUES = ["human_only", "agent_only", "mixed"];
|
|
8
|
+
export const PLANNING_NODE_TYPES = ["work", "group", "checkpoint"];
|
|
9
|
+
export const RESOLUTION_VALUES = ["completed", "merged", "dropped"];
|
|
10
|
+
export const CLAIM_ACTION_VALUES = ["acquire", "renew", "release", "override"];
|
|
11
|
+
export const WORKFLOW_MODE_VALUES = ["auto", "doc_first", "skill_first"];
|
|
12
|
+
export const GRAPH_VIEW_VALUES = ["dependency", "sequence", "portfolio", "all"];
|
|
13
|
+
export const DEFAULT_CLAIM_TTL_MINUTES = 60;
|
package/src/lib/listing.js
CHANGED
|
@@ -1,85 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
status: frontMatter.status ?? "",
|
|
23
|
-
priority: frontMatter.priority ?? "",
|
|
24
|
-
owner: frontMatter.assignment?.owner,
|
|
25
|
-
mode: frontMatter.assignment?.mode,
|
|
26
|
-
last_updated: lastUpdated(path.dirname(ticketPath)),
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return rows;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function passesFilters(frontMatter, filters) {
|
|
34
|
-
if (filters.status && frontMatter.status !== filters.status) {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
if (filters.priority && frontMatter.priority !== filters.priority) {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
if (filters.mode && frontMatter.assignment?.mode !== filters.mode) {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
if (filters.owner && frontMatter.assignment?.owner !== filters.owner) {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
if (filters.label) {
|
|
47
|
-
const labels = Array.isArray(frontMatter.labels) ? frontMatter.labels : [];
|
|
48
|
-
if (!labels.includes(filters.label)) {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
if (filters.text) {
|
|
53
|
-
const text = String(filters.text).toLowerCase();
|
|
54
|
-
const haystack = `${frontMatter.title ?? ""}\n${frontMatter.description ?? ""}`.toLowerCase();
|
|
55
|
-
if (!haystack.includes(text)) {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return true;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function lastUpdated(ticketDir) {
|
|
63
|
-
const logsDir = path.join(ticketDir, "logs");
|
|
64
|
-
let latest = "";
|
|
65
|
-
|
|
66
|
-
if (!fs.existsSync(logsDir)) {
|
|
67
|
-
return latest;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const logFiles = fs
|
|
71
|
-
.readdirSync(logsDir, { withFileTypes: true })
|
|
72
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
73
|
-
.map((entry) => path.join(logsDir, entry.name));
|
|
74
|
-
|
|
75
|
-
for (const logFile of logFiles) {
|
|
76
|
-
for (const entry of readJsonl(logFile)) {
|
|
77
|
-
const ts = entry.ts;
|
|
78
|
-
if (typeof ts === "string" && (latest === "" || ts > latest)) {
|
|
79
|
-
latest = ts;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return latest;
|
|
1
|
+
import { listPlanningRows } from "./planning.js";
|
|
2
|
+
|
|
3
|
+
export function listTickets(filters, options = {}) {
|
|
4
|
+
return listPlanningRows(filters, options).map((row) => ({
|
|
5
|
+
id: row.id,
|
|
6
|
+
title: row.title,
|
|
7
|
+
status: row.status,
|
|
8
|
+
priority: row.priority ?? "",
|
|
9
|
+
owner: row.owner,
|
|
10
|
+
mode: row.mode,
|
|
11
|
+
node_type: row.planning.node_type,
|
|
12
|
+
lane: row.planning.lane,
|
|
13
|
+
rank: row.planning.rank,
|
|
14
|
+
horizon: row.planning.horizon,
|
|
15
|
+
group_ids: row.planning.group_ids,
|
|
16
|
+
precedes: row.planning.precedes,
|
|
17
|
+
resolution: row.resolution,
|
|
18
|
+
ready: row.ready,
|
|
19
|
+
active_claim: row.active_claim,
|
|
20
|
+
last_updated: row.last_updated,
|
|
21
|
+
}));
|
|
85
22
|
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { loadWorkflowProfile } from "./config.js";
|
|
5
|
+
import { deriveActiveClaim, loadClaimEvents } from "./claims.js";
|
|
6
|
+
import { collectTicketPaths } from "./validation.js";
|
|
7
|
+
import { loadTicket, readJsonl } from "./util.js";
|
|
8
|
+
|
|
9
|
+
const TERMINAL_STATUSES = new Set(["done", "canceled"]);
|
|
10
|
+
|
|
11
|
+
function isTerminal(status) {
|
|
12
|
+
return TERMINAL_STATUSES.has(status);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function lastUpdated(ticketDir) {
|
|
16
|
+
const logsDir = path.join(ticketDir, "logs");
|
|
17
|
+
let latest = "";
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(logsDir)) {
|
|
20
|
+
return latest;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const logFiles = fs
|
|
24
|
+
.readdirSync(logsDir, { withFileTypes: true })
|
|
25
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
26
|
+
.map((entry) => path.join(logsDir, entry.name));
|
|
27
|
+
|
|
28
|
+
for (const logFile of logFiles) {
|
|
29
|
+
for (const entry of readJsonl(logFile)) {
|
|
30
|
+
const ts = entry.ts;
|
|
31
|
+
if (typeof ts === "string" && (latest === "" || ts > latest)) {
|
|
32
|
+
latest = ts;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return latest;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizePlanning(frontMatter, profile) {
|
|
41
|
+
const planning = frontMatter.planning ?? {};
|
|
42
|
+
return {
|
|
43
|
+
node_type: planning.node_type ?? profile.defaults?.planning?.node_type ?? "work",
|
|
44
|
+
group_ids: Array.isArray(planning.group_ids) ? planning.group_ids : [],
|
|
45
|
+
lane: typeof planning.lane === "string" && planning.lane.trim() ? planning.lane.trim() : null,
|
|
46
|
+
rank: Number.isInteger(planning.rank) ? planning.rank : null,
|
|
47
|
+
horizon: typeof planning.horizon === "string" && planning.horizon.trim() ? planning.horizon.trim() : null,
|
|
48
|
+
precedes: Array.isArray(planning.precedes) ? planning.precedes : [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectGroupLeaves(groupId, membersByGroup, nodesById, seen = new Set(), leaves = new Set()) {
|
|
53
|
+
if (seen.has(groupId)) {
|
|
54
|
+
return leaves;
|
|
55
|
+
}
|
|
56
|
+
seen.add(groupId);
|
|
57
|
+
|
|
58
|
+
for (const childId of membersByGroup.get(groupId) ?? []) {
|
|
59
|
+
const child = nodesById.get(childId);
|
|
60
|
+
if (!child) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (child.planning.node_type === "work") {
|
|
64
|
+
leaves.add(child.id);
|
|
65
|
+
} else {
|
|
66
|
+
collectGroupLeaves(child.id, membersByGroup, nodesById, seen, leaves);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return leaves;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function computeRollup(row, membersByGroup, nodesById) {
|
|
74
|
+
if (!["group", "checkpoint"].includes(row.planning.node_type)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const leafIds = [...collectGroupLeaves(row.id, membersByGroup, nodesById)];
|
|
79
|
+
const leafRows = leafIds.map((id) => nodesById.get(id)).filter(Boolean);
|
|
80
|
+
const merged = leafRows.filter((leaf) => leaf.resolution === "merged").length;
|
|
81
|
+
const dropped = leafRows.filter((leaf) => leaf.resolution === "dropped").length;
|
|
82
|
+
const activeLeafRows = leafRows.filter((leaf) => !["merged", "dropped"].includes(leaf.resolution ?? ""));
|
|
83
|
+
const doneCompleted = activeLeafRows.filter(
|
|
84
|
+
(leaf) => leaf.resolution === "completed" || (leaf.status === "done" && !leaf.resolution),
|
|
85
|
+
).length;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
total_leaf: leafRows.length,
|
|
89
|
+
active_leaf: activeLeafRows.length,
|
|
90
|
+
todo: activeLeafRows.filter((leaf) => leaf.status === "todo").length,
|
|
91
|
+
doing: activeLeafRows.filter((leaf) => leaf.status === "doing").length,
|
|
92
|
+
blocked: activeLeafRows.filter((leaf) => leaf.status === "blocked").length,
|
|
93
|
+
done_completed: doneCompleted,
|
|
94
|
+
merged,
|
|
95
|
+
dropped,
|
|
96
|
+
percent_complete: activeLeafRows.length === 0 ? 0 : doneCompleted / activeLeafRows.length,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function passesFilters(row, filters) {
|
|
101
|
+
if (filters.status && row.status !== filters.status) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (filters.priority && row.priority !== filters.priority) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (filters.mode && row.mode !== filters.mode) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (filters.owner && row.owner !== filters.owner) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (filters.label && !row.labels.includes(filters.label)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (filters.text) {
|
|
117
|
+
const needle = String(filters.text).toLowerCase();
|
|
118
|
+
const haystack = `${row.title}\n${row.body}`.toLowerCase();
|
|
119
|
+
if (!haystack.includes(needle)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (filters.nodeType && row.planning.node_type !== filters.nodeType) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (filters.group && !row.planning.group_ids.includes(filters.group)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (filters.lane && row.planning.lane !== filters.lane) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (filters.horizon && row.planning.horizon !== filters.horizon) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (filters.claimed && !row.active_claim) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (filters.claimedBy && row.active_claim?.holder_id !== filters.claimedBy) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (filters.ready && !row.ready) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildPlanningSnapshot(options = {}) {
|
|
148
|
+
const profile = options.profile ?? loadWorkflowProfile();
|
|
149
|
+
const paths = collectTicketPaths(null);
|
|
150
|
+
const rows = [];
|
|
151
|
+
|
|
152
|
+
for (const ticketPath of paths) {
|
|
153
|
+
try {
|
|
154
|
+
const [frontMatter, body] = loadTicket(ticketPath);
|
|
155
|
+
const ticketDir = path.dirname(ticketPath);
|
|
156
|
+
const activeClaim = deriveActiveClaim(loadClaimEvents(path.join(ticketDir, "logs")));
|
|
157
|
+
|
|
158
|
+
rows.push({
|
|
159
|
+
id: frontMatter.id ?? "",
|
|
160
|
+
title: frontMatter.title ?? "",
|
|
161
|
+
status: frontMatter.status ?? "",
|
|
162
|
+
priority: frontMatter.priority ?? "",
|
|
163
|
+
owner: frontMatter.assignment?.owner ?? null,
|
|
164
|
+
mode: frontMatter.assignment?.mode ?? null,
|
|
165
|
+
labels: Array.isArray(frontMatter.labels) ? frontMatter.labels.filter((label) => typeof label === "string") : [],
|
|
166
|
+
body: body ?? "",
|
|
167
|
+
path: ticketPath,
|
|
168
|
+
dependencies: Array.isArray(frontMatter.dependencies) ? frontMatter.dependencies : [],
|
|
169
|
+
blocks: Array.isArray(frontMatter.blocks) ? frontMatter.blocks : [],
|
|
170
|
+
related: Array.isArray(frontMatter.related) ? frontMatter.related : [],
|
|
171
|
+
planning: normalizePlanning(frontMatter, profile),
|
|
172
|
+
resolution: frontMatter.resolution ?? null,
|
|
173
|
+
active_claim: activeClaim,
|
|
174
|
+
last_updated: lastUpdated(ticketDir),
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
// Invalid tickets are surfaced by `validate`; views skip them.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const nodesById = new Map(rows.map((row) => [row.id, row]));
|
|
182
|
+
const predecessorsById = new Map();
|
|
183
|
+
const membersByGroup = new Map();
|
|
184
|
+
|
|
185
|
+
for (const row of rows) {
|
|
186
|
+
for (const successorId of row.planning.precedes) {
|
|
187
|
+
if (!predecessorsById.has(successorId)) {
|
|
188
|
+
predecessorsById.set(successorId, []);
|
|
189
|
+
}
|
|
190
|
+
predecessorsById.get(successorId).push(row.id);
|
|
191
|
+
}
|
|
192
|
+
for (const groupId of row.planning.group_ids) {
|
|
193
|
+
if (!membersByGroup.has(groupId)) {
|
|
194
|
+
membersByGroup.set(groupId, []);
|
|
195
|
+
}
|
|
196
|
+
membersByGroup.get(groupId).push(row.id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
const depsSatisfied = row.dependencies.every((id) => {
|
|
202
|
+
const dependency = nodesById.get(id);
|
|
203
|
+
return dependency ? isTerminal(dependency.status) : false;
|
|
204
|
+
});
|
|
205
|
+
const predecessorsSatisfied = (predecessorsById.get(row.id) ?? []).every((id) => {
|
|
206
|
+
const predecessor = nodesById.get(id);
|
|
207
|
+
return predecessor ? isTerminal(predecessor.status) : false;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
row.ready =
|
|
211
|
+
row.planning.node_type === "work" &&
|
|
212
|
+
!isTerminal(row.status) &&
|
|
213
|
+
row.mode !== "human_only" &&
|
|
214
|
+
depsSatisfied &&
|
|
215
|
+
predecessorsSatisfied;
|
|
216
|
+
row.rollup = computeRollup(row, membersByGroup, nodesById);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
profile,
|
|
221
|
+
rows,
|
|
222
|
+
nodesById,
|
|
223
|
+
predecessorsById,
|
|
224
|
+
membersByGroup,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function listPlanningRows(filters = {}, options = {}) {
|
|
229
|
+
const snapshot = buildPlanningSnapshot(options);
|
|
230
|
+
return snapshot.rows.filter((row) => passesFilters(row, filters));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function buildGraphData(snapshot, options = {}) {
|
|
234
|
+
const view = options.view ?? "dependency";
|
|
235
|
+
const includeRelated = options.includeRelated ?? false;
|
|
236
|
+
const rootId = options.ticket ?? null;
|
|
237
|
+
const edgeMap = new Map();
|
|
238
|
+
const nodes = new Map(snapshot.rows.map((row) => [row.id, row]));
|
|
239
|
+
|
|
240
|
+
function addEdge(type, from, to) {
|
|
241
|
+
const key = `${type}:${from}:${to}`;
|
|
242
|
+
if (!edgeMap.has(key)) {
|
|
243
|
+
edgeMap.set(key, { type, from, to });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const row of snapshot.rows) {
|
|
248
|
+
if (["dependency", "all"].includes(view)) {
|
|
249
|
+
for (const dependency of row.dependencies) {
|
|
250
|
+
addEdge("dependency", dependency, row.id);
|
|
251
|
+
}
|
|
252
|
+
for (const blocked of row.blocks) {
|
|
253
|
+
addEdge("blocks", row.id, blocked);
|
|
254
|
+
}
|
|
255
|
+
if (includeRelated) {
|
|
256
|
+
for (const related of row.related) {
|
|
257
|
+
addEdge("related", row.id, related);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (["sequence", "portfolio", "all"].includes(view)) {
|
|
263
|
+
for (const successor of row.planning.precedes) {
|
|
264
|
+
addEdge("precedes", row.id, successor);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (["portfolio", "all"].includes(view)) {
|
|
269
|
+
for (const groupId of row.planning.group_ids) {
|
|
270
|
+
addEdge("contains", groupId, row.id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let nodeIds = new Set(nodes.keys());
|
|
276
|
+
if (rootId) {
|
|
277
|
+
const queue = [rootId];
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
while (queue.length > 0) {
|
|
280
|
+
const current = queue.shift();
|
|
281
|
+
if (!current || seen.has(current)) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
seen.add(current);
|
|
285
|
+
for (const edge of edgeMap.values()) {
|
|
286
|
+
if (edge.from === current && !seen.has(edge.to)) {
|
|
287
|
+
queue.push(edge.to);
|
|
288
|
+
}
|
|
289
|
+
if (edge.to === current && !seen.has(edge.from)) {
|
|
290
|
+
queue.push(edge.from);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
nodeIds = seen;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const filteredEdges = [...edgeMap.values()].filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to));
|
|
298
|
+
const filteredNodes = [...nodeIds].map((id) => nodes.get(id)).filter(Boolean);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
root_id: rootId,
|
|
302
|
+
nodes: filteredNodes,
|
|
303
|
+
edges: filteredEdges,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function buildPlanSummary(options = {}) {
|
|
308
|
+
const snapshot = buildPlanningSnapshot(options);
|
|
309
|
+
let rows = snapshot.rows;
|
|
310
|
+
|
|
311
|
+
if (options.group) {
|
|
312
|
+
rows = rows.filter((row) => row.id === options.group || row.planning.group_ids.includes(options.group));
|
|
313
|
+
}
|
|
314
|
+
if (options.horizon) {
|
|
315
|
+
rows = rows.filter((row) => row.planning.horizon === options.horizon);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const groups = rows.filter((row) => ["group", "checkpoint"].includes(row.planning.node_type));
|
|
319
|
+
const ready = rows
|
|
320
|
+
.filter((row) => row.ready)
|
|
321
|
+
.sort((a, b) => {
|
|
322
|
+
const lane = String(a.planning.lane ?? "").localeCompare(String(b.planning.lane ?? ""));
|
|
323
|
+
if (lane !== 0) {
|
|
324
|
+
return lane;
|
|
325
|
+
}
|
|
326
|
+
const rankA = a.planning.rank ?? Number.MAX_SAFE_INTEGER;
|
|
327
|
+
const rankB = b.planning.rank ?? Number.MAX_SAFE_INTEGER;
|
|
328
|
+
if (rankA !== rankB) {
|
|
329
|
+
return rankA - rankB;
|
|
330
|
+
}
|
|
331
|
+
return a.title.localeCompare(b.title);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
generated_at: new Date().toISOString(),
|
|
336
|
+
workflow_mode: snapshot.profile.workflow?.mode ?? "auto",
|
|
337
|
+
semantics: snapshot.profile.semantics?.terms ?? {},
|
|
338
|
+
ready: ready.map((row) => ({
|
|
339
|
+
id: row.id,
|
|
340
|
+
title: row.title,
|
|
341
|
+
lane: row.planning.lane,
|
|
342
|
+
rank: row.planning.rank,
|
|
343
|
+
horizon: row.planning.horizon,
|
|
344
|
+
active_claim: row.active_claim,
|
|
345
|
+
})),
|
|
346
|
+
groups: groups.map((row) => ({
|
|
347
|
+
id: row.id,
|
|
348
|
+
title: row.title,
|
|
349
|
+
node_type: row.planning.node_type,
|
|
350
|
+
lane: row.planning.lane,
|
|
351
|
+
horizon: row.planning.horizon,
|
|
352
|
+
rollup: row.rollup,
|
|
353
|
+
})),
|
|
354
|
+
};
|
|
355
|
+
}
|