@kitsy/coop-core 0.0.1 → 2.0.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/dist/chunk-UK4JN4TZ.js +951 -0
- package/dist/index.cjs +3866 -240
- package/dist/index.d.cts +832 -14
- package/dist/index.d.ts +832 -14
- package/dist/index.js +2886 -245
- package/dist/planning/monte-carlo-worker.cjs +670 -0
- package/dist/planning/monte-carlo-worker.d.cts +2 -0
- package/dist/planning/monte-carlo-worker.d.ts +2 -0
- package/dist/planning/monte-carlo-worker.js +14 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allocate,
|
|
3
|
+
allocate_ai,
|
|
4
|
+
allocate_ai_tokens,
|
|
5
|
+
build_capacity_ledger,
|
|
6
|
+
check_wip,
|
|
7
|
+
create_seeded_rng,
|
|
8
|
+
effective_weekly_hours,
|
|
9
|
+
effort_or_default,
|
|
10
|
+
get_remaining_tokens,
|
|
11
|
+
monte_carlo_forecast,
|
|
12
|
+
pert_hours,
|
|
13
|
+
pert_stddev,
|
|
14
|
+
run_monte_carlo_chunk,
|
|
15
|
+
sample_pert_beta,
|
|
16
|
+
sample_task_hours,
|
|
17
|
+
simulate_schedule,
|
|
18
|
+
task_effort_hours
|
|
19
|
+
} from "./chunk-UK4JN4TZ.js";
|
|
20
|
+
|
|
21
|
+
// src/graph/external.ts
|
|
22
|
+
var EXTERNAL_PREFIX = "external:";
|
|
23
|
+
function is_external_dependency(value) {
|
|
24
|
+
return value.trim().toLowerCase().startsWith(EXTERNAL_PREFIX);
|
|
25
|
+
}
|
|
26
|
+
function parse_external_dependency(value) {
|
|
27
|
+
const normalized = value.trim();
|
|
28
|
+
if (!is_external_dependency(normalized)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const rest = normalized.slice(EXTERNAL_PREFIX.length);
|
|
32
|
+
const separator = rest.indexOf("/");
|
|
33
|
+
if (separator <= 0 || separator === rest.length - 1) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const repo = rest.slice(0, separator).trim();
|
|
37
|
+
const taskId = rest.slice(separator + 1).trim();
|
|
38
|
+
if (!repo || !taskId) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
raw: normalized,
|
|
43
|
+
repo,
|
|
44
|
+
task_id: taskId
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function external_dependencies_for_task(graph, taskId) {
|
|
48
|
+
return [...graph.external_dependencies?.get(taskId) ?? []];
|
|
49
|
+
}
|
|
50
|
+
function joinUrl(baseUrl, pathName) {
|
|
51
|
+
return `${baseUrl.replace(/\/+$/g, "")}${pathName.startsWith("/") ? pathName : `/${pathName}`}`;
|
|
52
|
+
}
|
|
53
|
+
function resolvedStatus(taskStatus) {
|
|
54
|
+
if (!taskStatus) return "unknown";
|
|
55
|
+
if (taskStatus === "done" || taskStatus === "canceled") return "resolved";
|
|
56
|
+
return "blocked";
|
|
57
|
+
}
|
|
58
|
+
async function resolve_external_dependencies(graph, options) {
|
|
59
|
+
const fetchImpl = options.fetch_impl ?? fetch;
|
|
60
|
+
const resolutions = /* @__PURE__ */ new Map();
|
|
61
|
+
const externalMap = graph.external_dependencies ?? /* @__PURE__ */ new Map();
|
|
62
|
+
for (const [taskId, deps] of externalMap.entries()) {
|
|
63
|
+
const results = [];
|
|
64
|
+
for (const dep of deps) {
|
|
65
|
+
const remote = options.remotes[dep.repo];
|
|
66
|
+
if (!remote?.base_url) {
|
|
67
|
+
results.push({
|
|
68
|
+
dependency: dep,
|
|
69
|
+
status: "unknown",
|
|
70
|
+
warning: `No API base URL configured for external repo '${dep.repo}'.`
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetchImpl(joinUrl(remote.base_url, `/api/tasks/${encodeURIComponent(dep.task_id)}`));
|
|
76
|
+
if (response.status === 404) {
|
|
77
|
+
results.push({
|
|
78
|
+
dependency: dep,
|
|
79
|
+
status: "missing",
|
|
80
|
+
warning: `External task '${dep.raw}' was not found at ${remote.base_url}.`
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
results.push({
|
|
86
|
+
dependency: dep,
|
|
87
|
+
status: "unreachable",
|
|
88
|
+
warning: `External task '${dep.raw}' returned HTTP ${response.status}.`
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const payload = await response.json();
|
|
93
|
+
const taskRecord = payload && typeof payload === "object" && payload.task && typeof payload.task === "object" ? payload.task : payload;
|
|
94
|
+
const taskStatus = typeof taskRecord.status === "string" ? taskRecord.status : null;
|
|
95
|
+
results.push({
|
|
96
|
+
dependency: dep,
|
|
97
|
+
status: resolvedStatus(taskStatus),
|
|
98
|
+
task_status: taskStatus,
|
|
99
|
+
warning: resolvedStatus(taskStatus) === "resolved" ? void 0 : `External task '${dep.raw}' is ${taskStatus ?? "unknown"}.`
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
results.push({
|
|
103
|
+
dependency: dep,
|
|
104
|
+
status: "unreachable",
|
|
105
|
+
warning: `External task '${dep.raw}' could not be reached.`
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
resolutions.set(taskId, results);
|
|
110
|
+
}
|
|
111
|
+
return resolutions;
|
|
112
|
+
}
|
|
113
|
+
|
|
1
114
|
// src/graph/dag.ts
|
|
2
115
|
function existingDeps(graph, nodeId) {
|
|
3
116
|
const deps = graph.forward.get(nodeId) ?? /* @__PURE__ */ new Set();
|
|
@@ -118,13 +231,25 @@ function build_graph(tasks, context = {}) {
|
|
|
118
231
|
}
|
|
119
232
|
const forward = /* @__PURE__ */ new Map();
|
|
120
233
|
const reverse = /* @__PURE__ */ new Map();
|
|
234
|
+
const externalDependencies = /* @__PURE__ */ new Map();
|
|
121
235
|
for (const nodeId of nodes.keys()) {
|
|
122
236
|
forward.set(nodeId, /* @__PURE__ */ new Set());
|
|
123
237
|
reverse.set(nodeId, /* @__PURE__ */ new Set());
|
|
238
|
+
externalDependencies.set(nodeId, []);
|
|
124
239
|
}
|
|
125
240
|
for (const task of tasks) {
|
|
126
|
-
const deps = new Set(
|
|
241
|
+
const deps = /* @__PURE__ */ new Set();
|
|
242
|
+
const externalDeps = [];
|
|
243
|
+
for (const depId of task.depends_on ?? []) {
|
|
244
|
+
const external = parse_external_dependency(depId);
|
|
245
|
+
if (external) {
|
|
246
|
+
externalDeps.push(external);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
deps.add(depId);
|
|
250
|
+
}
|
|
127
251
|
forward.set(task.id, deps);
|
|
252
|
+
externalDependencies.set(task.id, externalDeps);
|
|
128
253
|
for (const depId of deps) {
|
|
129
254
|
if (!nodes.has(depId)) continue;
|
|
130
255
|
reverse.get(depId)?.add(task.id);
|
|
@@ -137,16 +262,266 @@ function build_graph(tasks, context = {}) {
|
|
|
137
262
|
topological_order: [],
|
|
138
263
|
tracks: context.tracks ? new Map(context.tracks) : /* @__PURE__ */ new Map(),
|
|
139
264
|
resources: context.resources ? new Map(context.resources) : /* @__PURE__ */ new Map(),
|
|
140
|
-
deliveries: context.deliveries ? new Map(context.deliveries) : /* @__PURE__ */ new Map()
|
|
265
|
+
deliveries: context.deliveries ? new Map(context.deliveries) : /* @__PURE__ */ new Map(),
|
|
266
|
+
external_dependencies: externalDependencies
|
|
141
267
|
};
|
|
142
268
|
graph.topological_order = topological_sort(graph);
|
|
143
269
|
return graph;
|
|
144
270
|
}
|
|
145
271
|
|
|
146
|
-
// src/graph/
|
|
272
|
+
// src/graph/subgraph.ts
|
|
273
|
+
function extract_subgraph(graph, taskIds) {
|
|
274
|
+
const selected = new Set(taskIds.filter((taskId) => graph.nodes.has(taskId)));
|
|
275
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
276
|
+
for (const taskId of selected) {
|
|
277
|
+
const task = graph.nodes.get(taskId);
|
|
278
|
+
if (task) nodes.set(taskId, task);
|
|
279
|
+
}
|
|
280
|
+
const forward = /* @__PURE__ */ new Map();
|
|
281
|
+
const reverse = /* @__PURE__ */ new Map();
|
|
282
|
+
for (const taskId of selected) {
|
|
283
|
+
forward.set(taskId, /* @__PURE__ */ new Set());
|
|
284
|
+
reverse.set(taskId, /* @__PURE__ */ new Set());
|
|
285
|
+
}
|
|
286
|
+
for (const taskId of selected) {
|
|
287
|
+
const deps = graph.forward.get(taskId) ?? /* @__PURE__ */ new Set();
|
|
288
|
+
const internalDeps = new Set(Array.from(deps).filter((depId) => selected.has(depId)));
|
|
289
|
+
forward.set(taskId, internalDeps);
|
|
290
|
+
for (const depId of internalDeps) {
|
|
291
|
+
reverse.get(depId)?.add(taskId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const subgraph = {
|
|
295
|
+
nodes,
|
|
296
|
+
forward,
|
|
297
|
+
reverse,
|
|
298
|
+
topological_order: [],
|
|
299
|
+
tracks: new Map(graph.tracks),
|
|
300
|
+
resources: new Map(graph.resources),
|
|
301
|
+
deliveries: new Map(graph.deliveries),
|
|
302
|
+
external_dependencies: new Map(
|
|
303
|
+
Array.from(selected).map((taskId) => [taskId, external_dependencies_for_task(graph, taskId)])
|
|
304
|
+
)
|
|
305
|
+
};
|
|
306
|
+
subgraph.topological_order = topological_sort(subgraph);
|
|
307
|
+
return subgraph;
|
|
308
|
+
}
|
|
309
|
+
function find_external_dependencies(subgraph, fullGraph) {
|
|
310
|
+
const external = /* @__PURE__ */ new Set();
|
|
311
|
+
for (const taskId of subgraph.nodes.keys()) {
|
|
312
|
+
const deps = fullGraph.forward.get(taskId) ?? /* @__PURE__ */ new Set();
|
|
313
|
+
for (const depId of deps) {
|
|
314
|
+
if (!subgraph.nodes.has(depId) && fullGraph.nodes.has(depId)) {
|
|
315
|
+
external.add(depId);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const dep of external_dependencies_for_task(fullGraph, taskId)) {
|
|
319
|
+
external.add(dep.raw);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return Array.from(external).sort((a, b) => a.localeCompare(b));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/graph/critical-path.ts
|
|
326
|
+
var DEFAULT_CONFIG = {
|
|
327
|
+
version: 2,
|
|
328
|
+
project: {
|
|
329
|
+
name: "COOP",
|
|
330
|
+
id: "coop"
|
|
331
|
+
},
|
|
332
|
+
defaults: {
|
|
333
|
+
task: {
|
|
334
|
+
complexity: "medium"
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
var EPSILON = 1e-9;
|
|
339
|
+
function durationHours(task) {
|
|
340
|
+
return effort_or_default(task, DEFAULT_CONFIG);
|
|
341
|
+
}
|
|
342
|
+
function rootsOf(graph) {
|
|
343
|
+
const roots = [];
|
|
344
|
+
for (const id of graph.nodes.keys()) {
|
|
345
|
+
if ((graph.forward.get(id)?.size ?? 0) === 0) {
|
|
346
|
+
roots.push(id);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return roots;
|
|
350
|
+
}
|
|
351
|
+
function leavesOf(graph) {
|
|
352
|
+
const leaves = [];
|
|
353
|
+
for (const id of graph.nodes.keys()) {
|
|
354
|
+
if ((graph.reverse.get(id)?.size ?? 0) === 0) {
|
|
355
|
+
leaves.push(id);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return leaves;
|
|
359
|
+
}
|
|
360
|
+
function compute_critical_path(delivery, graph) {
|
|
361
|
+
const include = new Set(delivery.scope.include ?? []);
|
|
362
|
+
for (const excluded of delivery.scope.exclude ?? []) {
|
|
363
|
+
include.delete(excluded);
|
|
364
|
+
}
|
|
365
|
+
const subgraph = extract_subgraph(graph, [...include]);
|
|
366
|
+
if (subgraph.nodes.size === 0) {
|
|
367
|
+
return {
|
|
368
|
+
critical_path: [],
|
|
369
|
+
project_duration_hours: 0,
|
|
370
|
+
per_task: []
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const roots = new Set(rootsOf(subgraph));
|
|
374
|
+
const leaves = new Set(leavesOf(subgraph));
|
|
375
|
+
const es = /* @__PURE__ */ new Map();
|
|
376
|
+
const ef = /* @__PURE__ */ new Map();
|
|
377
|
+
const ls = /* @__PURE__ */ new Map();
|
|
378
|
+
const lf = /* @__PURE__ */ new Map();
|
|
379
|
+
const durations = /* @__PURE__ */ new Map();
|
|
380
|
+
for (const taskId of subgraph.topological_order) {
|
|
381
|
+
const task = subgraph.nodes.get(taskId);
|
|
382
|
+
if (!task) continue;
|
|
383
|
+
const duration = durationHours(task);
|
|
384
|
+
durations.set(taskId, duration);
|
|
385
|
+
let earliestStart = 0;
|
|
386
|
+
if (!roots.has(taskId)) {
|
|
387
|
+
const deps = [...subgraph.forward.get(taskId) ?? /* @__PURE__ */ new Set()];
|
|
388
|
+
earliestStart = deps.length === 0 ? 0 : Math.max(...deps.map((depId) => ef.get(depId) ?? 0));
|
|
389
|
+
}
|
|
390
|
+
es.set(taskId, earliestStart);
|
|
391
|
+
ef.set(taskId, earliestStart + duration);
|
|
392
|
+
}
|
|
393
|
+
const project_duration_hours = Math.max(...[...leaves].map((id) => ef.get(id) ?? 0));
|
|
394
|
+
const reverseTopo = [...subgraph.topological_order].reverse();
|
|
395
|
+
for (const taskId of reverseTopo) {
|
|
396
|
+
const duration = durations.get(taskId) ?? 0;
|
|
397
|
+
let latestFinish = project_duration_hours;
|
|
398
|
+
if (!leaves.has(taskId)) {
|
|
399
|
+
const successors = [...subgraph.reverse.get(taskId) ?? /* @__PURE__ */ new Set()];
|
|
400
|
+
latestFinish = successors.length === 0 ? project_duration_hours : Math.min(...successors.map((id) => ls.get(id) ?? project_duration_hours));
|
|
401
|
+
}
|
|
402
|
+
lf.set(taskId, latestFinish);
|
|
403
|
+
ls.set(taskId, latestFinish - duration);
|
|
404
|
+
}
|
|
405
|
+
const per_task = [];
|
|
406
|
+
for (const id of subgraph.nodes.keys()) {
|
|
407
|
+
const ES = es.get(id) ?? 0;
|
|
408
|
+
const EF = ef.get(id) ?? 0;
|
|
409
|
+
const LS = ls.get(id) ?? 0;
|
|
410
|
+
const LF = lf.get(id) ?? 0;
|
|
411
|
+
const slack = LS - ES;
|
|
412
|
+
per_task.push({
|
|
413
|
+
id,
|
|
414
|
+
ES,
|
|
415
|
+
EF,
|
|
416
|
+
LS,
|
|
417
|
+
LF,
|
|
418
|
+
slack,
|
|
419
|
+
on_critical_path: Math.abs(slack) <= EPSILON
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
per_task.sort((a, b) => {
|
|
423
|
+
if (a.ES !== b.ES) return a.ES - b.ES;
|
|
424
|
+
return a.id.localeCompare(b.id);
|
|
425
|
+
});
|
|
426
|
+
const critical_path = per_task.filter((task) => task.on_critical_path).map((task) => task.id);
|
|
427
|
+
return {
|
|
428
|
+
critical_path,
|
|
429
|
+
project_duration_hours,
|
|
430
|
+
per_task
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/index/index-manager.ts
|
|
147
435
|
import fs5 from "fs";
|
|
148
436
|
import path from "path";
|
|
149
437
|
|
|
438
|
+
// src/planning/readiness.ts
|
|
439
|
+
function isResolvedDependencyStatus(status) {
|
|
440
|
+
return status === "done" || status === "canceled";
|
|
441
|
+
}
|
|
442
|
+
function compute_readiness(task, graph) {
|
|
443
|
+
if (task.status === "done" || task.status === "canceled") {
|
|
444
|
+
return "done";
|
|
445
|
+
}
|
|
446
|
+
if (task.status === "in_review") {
|
|
447
|
+
return "waiting_review";
|
|
448
|
+
}
|
|
449
|
+
if (task.status === "in_progress") {
|
|
450
|
+
return "in_progress";
|
|
451
|
+
}
|
|
452
|
+
for (const depId of task.depends_on ?? []) {
|
|
453
|
+
const dep = graph.nodes.get(depId);
|
|
454
|
+
if (!dep || !isResolvedDependencyStatus(dep.status)) {
|
|
455
|
+
return "blocked";
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return "ready";
|
|
459
|
+
}
|
|
460
|
+
function compute_all_readiness(graph) {
|
|
461
|
+
const readiness = /* @__PURE__ */ new Map();
|
|
462
|
+
for (const [taskId, task] of graph.nodes.entries()) {
|
|
463
|
+
readiness.set(taskId, compute_readiness(task, graph));
|
|
464
|
+
}
|
|
465
|
+
return readiness;
|
|
466
|
+
}
|
|
467
|
+
function partition_by_readiness(graph) {
|
|
468
|
+
const partitions = {
|
|
469
|
+
ready: [],
|
|
470
|
+
blocked: [],
|
|
471
|
+
in_progress: [],
|
|
472
|
+
waiting_review: [],
|
|
473
|
+
done: []
|
|
474
|
+
};
|
|
475
|
+
for (const task of graph.nodes.values()) {
|
|
476
|
+
const state = compute_readiness(task, graph);
|
|
477
|
+
partitions[state].push(task);
|
|
478
|
+
}
|
|
479
|
+
return partitions;
|
|
480
|
+
}
|
|
481
|
+
function compute_readiness_with_corrections(graph) {
|
|
482
|
+
const readiness = /* @__PURE__ */ new Map();
|
|
483
|
+
const corrections = [];
|
|
484
|
+
const warnings = [];
|
|
485
|
+
const partitions = {
|
|
486
|
+
ready: [],
|
|
487
|
+
blocked: [],
|
|
488
|
+
in_progress: [],
|
|
489
|
+
waiting_review: [],
|
|
490
|
+
done: []
|
|
491
|
+
};
|
|
492
|
+
for (const [taskId, task] of graph.nodes.entries()) {
|
|
493
|
+
const state = compute_readiness(task, graph);
|
|
494
|
+
readiness.set(taskId, state);
|
|
495
|
+
partitions[state].push(task);
|
|
496
|
+
if (task.status === "blocked" && state === "ready") {
|
|
497
|
+
corrections.push({
|
|
498
|
+
type: "task.transitioned",
|
|
499
|
+
task_id: taskId,
|
|
500
|
+
from: "blocked",
|
|
501
|
+
to: "todo",
|
|
502
|
+
reason: "All dependencies are resolved."
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
if (task.status === "todo" && state === "blocked") {
|
|
506
|
+
const unresolved = (task.depends_on ?? []).filter((depId) => {
|
|
507
|
+
const depTask = graph.nodes.get(depId);
|
|
508
|
+
return !depTask || !isResolvedDependencyStatus(depTask.status);
|
|
509
|
+
});
|
|
510
|
+
warnings.push({
|
|
511
|
+
code: "todo_with_unresolved_dependencies",
|
|
512
|
+
task_id: taskId,
|
|
513
|
+
message: `Task is todo but has unresolved dependencies: ${unresolved.join(", ")}.`
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
readiness,
|
|
519
|
+
partitions,
|
|
520
|
+
corrections,
|
|
521
|
+
warnings
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
150
525
|
// src/parser/delivery-parser.ts
|
|
151
526
|
import fs3 from "fs";
|
|
152
527
|
|
|
@@ -314,6 +689,12 @@ function parseYamlFile(filePath) {
|
|
|
314
689
|
const content = fs2.readFileSync(filePath, "utf8");
|
|
315
690
|
return parseYamlContent(content, filePath);
|
|
316
691
|
}
|
|
692
|
+
function stringifyYamlContent(content) {
|
|
693
|
+
return YAML2.stringify(content);
|
|
694
|
+
}
|
|
695
|
+
function writeYamlFile(filePath, content) {
|
|
696
|
+
fs2.writeFileSync(filePath, stringifyYamlContent(content), "utf8");
|
|
697
|
+
}
|
|
317
698
|
|
|
318
699
|
// src/parser/delivery-parser.ts
|
|
319
700
|
function asStringArray(value) {
|
|
@@ -410,7 +791,7 @@ function parseTaskFile(filePath) {
|
|
|
410
791
|
return parseTaskContent(content, filePath);
|
|
411
792
|
}
|
|
412
793
|
|
|
413
|
-
// src/
|
|
794
|
+
// src/index/index-manager.ts
|
|
414
795
|
function walkFiles(dirPath, extensions) {
|
|
415
796
|
if (!fs5.existsSync(dirPath)) return [];
|
|
416
797
|
const out = [];
|
|
@@ -429,114 +810,455 @@ function walkFiles(dirPath, extensions) {
|
|
|
429
810
|
}
|
|
430
811
|
return out.sort((a, b) => a.localeCompare(b));
|
|
431
812
|
}
|
|
432
|
-
function
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
813
|
+
function toPosixPath(input) {
|
|
814
|
+
return input.replace(/\\/g, "/");
|
|
815
|
+
}
|
|
816
|
+
function fromRecordMap(value) {
|
|
817
|
+
return new Map(Object.entries(value));
|
|
818
|
+
}
|
|
819
|
+
function toRecordMap(value) {
|
|
820
|
+
return Object.fromEntries(value.entries());
|
|
821
|
+
}
|
|
822
|
+
function round2(value) {
|
|
823
|
+
return Number(value.toFixed(2));
|
|
824
|
+
}
|
|
825
|
+
function parseTaskCollection(files, baseDir) {
|
|
826
|
+
const tasks = /* @__PURE__ */ new Map();
|
|
827
|
+
const fileToId = {};
|
|
436
828
|
for (const filePath of files) {
|
|
437
|
-
const parsed = parseTaskFile(filePath);
|
|
438
|
-
if (
|
|
439
|
-
throw new Error(`Duplicate task id '${parsed.
|
|
829
|
+
const parsed = parseTaskFile(filePath).task;
|
|
830
|
+
if (tasks.has(parsed.id)) {
|
|
831
|
+
throw new Error(`Duplicate task id '${parsed.id}' found at ${filePath}.`);
|
|
440
832
|
}
|
|
441
|
-
|
|
442
|
-
|
|
833
|
+
tasks.set(parsed.id, parsed);
|
|
834
|
+
fileToId[toPosixPath(path.relative(baseDir, filePath))] = parsed.id;
|
|
443
835
|
}
|
|
444
|
-
return tasks;
|
|
836
|
+
return { tasks, fileToId };
|
|
445
837
|
}
|
|
446
|
-
function
|
|
447
|
-
const files = walkFiles(tracksDir, /* @__PURE__ */ new Set([".yml", ".yaml"]));
|
|
838
|
+
function parseTrackCollection(files, baseDir) {
|
|
448
839
|
const tracks = /* @__PURE__ */ new Map();
|
|
840
|
+
const fileToId = {};
|
|
449
841
|
for (const filePath of files) {
|
|
450
|
-
const
|
|
451
|
-
if (!
|
|
452
|
-
tracks.set(
|
|
842
|
+
const parsed = parseYamlFile(filePath);
|
|
843
|
+
if (!parsed.id) continue;
|
|
844
|
+
tracks.set(parsed.id, parsed);
|
|
845
|
+
fileToId[toPosixPath(path.relative(baseDir, filePath))] = parsed.id;
|
|
453
846
|
}
|
|
454
|
-
return tracks;
|
|
847
|
+
return { tracks, fileToId };
|
|
455
848
|
}
|
|
456
|
-
function
|
|
457
|
-
const files = walkFiles(resourcesDir, /* @__PURE__ */ new Set([".yml", ".yaml"]));
|
|
849
|
+
function parseResourceCollection(files, baseDir) {
|
|
458
850
|
const resources = /* @__PURE__ */ new Map();
|
|
851
|
+
const fileToId = {};
|
|
459
852
|
for (const filePath of files) {
|
|
460
|
-
const
|
|
461
|
-
if (!
|
|
462
|
-
resources.set(
|
|
853
|
+
const parsed = parseYamlFile(filePath);
|
|
854
|
+
if (!parsed.id) continue;
|
|
855
|
+
resources.set(parsed.id, parsed);
|
|
856
|
+
fileToId[toPosixPath(path.relative(baseDir, filePath))] = parsed.id;
|
|
463
857
|
}
|
|
464
|
-
return resources;
|
|
858
|
+
return { resources, fileToId };
|
|
465
859
|
}
|
|
466
|
-
function
|
|
467
|
-
const files = walkFiles(deliveriesDir, /* @__PURE__ */ new Set([".yml", ".yaml", ".md"]));
|
|
860
|
+
function parseDeliveryCollection(files, baseDir) {
|
|
468
861
|
const deliveries = /* @__PURE__ */ new Map();
|
|
862
|
+
const fileToId = {};
|
|
469
863
|
for (const filePath of files) {
|
|
470
|
-
const parsed = parseDeliveryFile(filePath);
|
|
471
|
-
deliveries.set(parsed.
|
|
864
|
+
const parsed = parseDeliveryFile(filePath).delivery;
|
|
865
|
+
deliveries.set(parsed.id, parsed);
|
|
866
|
+
fileToId[toPosixPath(path.relative(baseDir, filePath))] = parsed.id;
|
|
472
867
|
}
|
|
473
|
-
return deliveries;
|
|
474
|
-
}
|
|
475
|
-
function load_graph(coopDir) {
|
|
476
|
-
const tasks = loadTasks(path.join(coopDir, "tasks"));
|
|
477
|
-
const tracks = loadTracks(path.join(coopDir, "tracks"));
|
|
478
|
-
const resources = loadResources(path.join(coopDir, "resources"));
|
|
479
|
-
const deliveries = loadDeliveries(path.join(coopDir, "deliveries"));
|
|
480
|
-
return build_graph(tasks, {
|
|
481
|
-
tracks,
|
|
482
|
-
resources,
|
|
483
|
-
deliveries
|
|
484
|
-
});
|
|
868
|
+
return { deliveries, fileToId };
|
|
485
869
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
870
|
+
var IndexManager = class {
|
|
871
|
+
coopDir;
|
|
872
|
+
indexDir;
|
|
873
|
+
graphPath;
|
|
874
|
+
tasksPath;
|
|
875
|
+
capacityPath;
|
|
876
|
+
constructor(coopDir) {
|
|
877
|
+
this.coopDir = path.resolve(coopDir);
|
|
878
|
+
this.indexDir = path.join(this.coopDir, ".index");
|
|
879
|
+
this.graphPath = path.join(this.indexDir, "graph.json");
|
|
880
|
+
this.tasksPath = path.join(this.indexDir, "tasks.json");
|
|
881
|
+
this.capacityPath = path.join(this.indexDir, "capacity.json");
|
|
882
|
+
}
|
|
883
|
+
is_stale() {
|
|
884
|
+
return this.status().stale;
|
|
885
|
+
}
|
|
886
|
+
status() {
|
|
887
|
+
const scan = this.scanSources();
|
|
888
|
+
if (!fs5.existsSync(this.graphPath)) {
|
|
889
|
+
return {
|
|
890
|
+
exists: false,
|
|
891
|
+
stale: true,
|
|
892
|
+
graph_path: this.graphPath,
|
|
893
|
+
source_max_mtime_ms: scan.sourceMaxMtimeMs,
|
|
894
|
+
changed_files: Object.keys(scan.sourceFiles).sort((a, b) => a.localeCompare(b)),
|
|
895
|
+
reason: "graph index missing"
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
const indexed = this.readGraphIndexFile();
|
|
899
|
+
if (!indexed) {
|
|
900
|
+
return {
|
|
901
|
+
exists: true,
|
|
902
|
+
stale: true,
|
|
903
|
+
graph_path: this.graphPath,
|
|
904
|
+
source_max_mtime_ms: scan.sourceMaxMtimeMs,
|
|
905
|
+
changed_files: Object.keys(scan.sourceFiles).sort((a, b) => a.localeCompare(b)),
|
|
906
|
+
reason: "graph index unreadable"
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
const changed = /* @__PURE__ */ new Set();
|
|
910
|
+
for (const [relativePath, mtime] of Object.entries(scan.sourceFiles)) {
|
|
911
|
+
const indexedMtime = indexed.source_files[relativePath];
|
|
912
|
+
if (indexedMtime === void 0 || indexedMtime !== mtime) {
|
|
913
|
+
changed.add(relativePath);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
for (const relativePath of Object.keys(indexed.source_files)) {
|
|
917
|
+
if (scan.sourceFiles[relativePath] === void 0) {
|
|
918
|
+
changed.add(relativePath);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return {
|
|
922
|
+
exists: true,
|
|
923
|
+
stale: changed.size > 0 || indexed.version !== 1,
|
|
924
|
+
graph_path: this.graphPath,
|
|
925
|
+
source_max_mtime_ms: scan.sourceMaxMtimeMs,
|
|
926
|
+
changed_files: Array.from(changed).sort((a, b) => a.localeCompare(b)),
|
|
927
|
+
generated_at: indexed.generated_at,
|
|
928
|
+
reason: indexed.version === 1 ? void 0 : "index version mismatch"
|
|
929
|
+
};
|
|
500
930
|
}
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
931
|
+
load_indexed_graph() {
|
|
932
|
+
const indexed = this.readGraphIndexFile();
|
|
933
|
+
if (!indexed) return null;
|
|
934
|
+
try {
|
|
935
|
+
return this.deserializeGraph(indexed.graph);
|
|
936
|
+
} catch {
|
|
937
|
+
return null;
|
|
507
938
|
}
|
|
508
939
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
940
|
+
build_full_index() {
|
|
941
|
+
const scan = this.scanSources();
|
|
942
|
+
const taskData = parseTaskCollection(scan.taskFiles, this.coopDir);
|
|
943
|
+
const trackData = parseTrackCollection(scan.trackFiles, this.coopDir);
|
|
944
|
+
const resourceData = parseResourceCollection(scan.resourceFiles, this.coopDir);
|
|
945
|
+
const deliveryData = parseDeliveryCollection(scan.deliveryFiles, this.coopDir);
|
|
946
|
+
const graph = build_graph(Array.from(taskData.tasks.values()), {
|
|
947
|
+
tracks: trackData.tracks,
|
|
948
|
+
resources: resourceData.resources,
|
|
949
|
+
deliveries: deliveryData.deliveries
|
|
950
|
+
});
|
|
951
|
+
this.writeIndexFiles(
|
|
952
|
+
graph,
|
|
953
|
+
scan,
|
|
954
|
+
{
|
|
955
|
+
tasks: taskData.fileToId,
|
|
956
|
+
tracks: trackData.fileToId,
|
|
957
|
+
resources: resourceData.fileToId,
|
|
958
|
+
deliveries: deliveryData.fileToId
|
|
528
959
|
}
|
|
529
|
-
|
|
960
|
+
);
|
|
961
|
+
return graph;
|
|
530
962
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
963
|
+
update_incremental(changedFiles) {
|
|
964
|
+
const indexed = this.readGraphIndexFile();
|
|
965
|
+
if (!indexed) {
|
|
966
|
+
return this.build_full_index();
|
|
967
|
+
}
|
|
968
|
+
const scan = this.scanSources();
|
|
969
|
+
const changed = changedFiles.length > 0 ? changedFiles : this.status().changed_files;
|
|
970
|
+
const normalized = new Set(
|
|
971
|
+
changed.map((entry) => this.normalizeChangedFile(entry)).filter((entry) => Boolean(entry))
|
|
972
|
+
);
|
|
973
|
+
if (normalized.size === 0) {
|
|
974
|
+
const fromIndex = this.load_indexed_graph();
|
|
975
|
+
return fromIndex ?? this.build_full_index();
|
|
976
|
+
}
|
|
977
|
+
const taskMap = fromRecordMap(indexed.graph.nodes);
|
|
978
|
+
const trackMap = fromRecordMap(indexed.graph.tracks);
|
|
979
|
+
const resourceMap = fromRecordMap(indexed.graph.resources);
|
|
980
|
+
const deliveryMap = fromRecordMap(indexed.graph.deliveries);
|
|
981
|
+
const fileMaps = {
|
|
982
|
+
tasks: { ...indexed.files.tasks },
|
|
983
|
+
tracks: { ...indexed.files.tracks },
|
|
984
|
+
resources: { ...indexed.files.resources },
|
|
985
|
+
deliveries: { ...indexed.files.deliveries }
|
|
986
|
+
};
|
|
987
|
+
for (const relativePath of normalized) {
|
|
988
|
+
const kind = this.fileKind(relativePath);
|
|
989
|
+
if (!kind) {
|
|
990
|
+
return this.build_full_index();
|
|
991
|
+
}
|
|
992
|
+
const absolutePath = path.join(this.coopDir, relativePath);
|
|
993
|
+
const exists = fs5.existsSync(absolutePath);
|
|
994
|
+
if (!exists) {
|
|
995
|
+
const oldId = fileMaps[kind][relativePath];
|
|
996
|
+
if (!oldId) {
|
|
997
|
+
return this.build_full_index();
|
|
998
|
+
}
|
|
999
|
+
delete fileMaps[kind][relativePath];
|
|
1000
|
+
if (kind === "tasks") taskMap.delete(oldId);
|
|
1001
|
+
if (kind === "tracks") trackMap.delete(oldId);
|
|
1002
|
+
if (kind === "resources") resourceMap.delete(oldId);
|
|
1003
|
+
if (kind === "deliveries") deliveryMap.delete(oldId);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
try {
|
|
1007
|
+
if (kind === "tasks") {
|
|
1008
|
+
const oldId2 = fileMaps.tasks[relativePath];
|
|
1009
|
+
if (oldId2) taskMap.delete(oldId2);
|
|
1010
|
+
const parsed2 = parseTaskFile(absolutePath).task;
|
|
1011
|
+
taskMap.set(parsed2.id, parsed2);
|
|
1012
|
+
fileMaps.tasks[relativePath] = parsed2.id;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
if (kind === "tracks") {
|
|
1016
|
+
const oldId2 = fileMaps.tracks[relativePath];
|
|
1017
|
+
if (oldId2) trackMap.delete(oldId2);
|
|
1018
|
+
const parsed2 = parseYamlFile(absolutePath);
|
|
1019
|
+
if (parsed2.id) {
|
|
1020
|
+
trackMap.set(parsed2.id, parsed2);
|
|
1021
|
+
fileMaps.tracks[relativePath] = parsed2.id;
|
|
1022
|
+
}
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (kind === "resources") {
|
|
1026
|
+
const oldId2 = fileMaps.resources[relativePath];
|
|
1027
|
+
if (oldId2) resourceMap.delete(oldId2);
|
|
1028
|
+
const parsed2 = parseYamlFile(absolutePath);
|
|
1029
|
+
if (parsed2.id) {
|
|
1030
|
+
resourceMap.set(parsed2.id, parsed2);
|
|
1031
|
+
fileMaps.resources[relativePath] = parsed2.id;
|
|
1032
|
+
}
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const oldId = fileMaps.deliveries[relativePath];
|
|
1036
|
+
if (oldId) deliveryMap.delete(oldId);
|
|
1037
|
+
const parsed = parseDeliveryFile(absolutePath).delivery;
|
|
1038
|
+
deliveryMap.set(parsed.id, parsed);
|
|
1039
|
+
fileMaps.deliveries[relativePath] = parsed.id;
|
|
1040
|
+
} catch {
|
|
1041
|
+
return this.build_full_index();
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const graph = build_graph(Array.from(taskMap.values()), {
|
|
1045
|
+
tracks: trackMap,
|
|
1046
|
+
resources: resourceMap,
|
|
1047
|
+
deliveries: deliveryMap
|
|
1048
|
+
});
|
|
1049
|
+
this.writeIndexFiles(graph, scan, fileMaps);
|
|
1050
|
+
return graph;
|
|
1051
|
+
}
|
|
1052
|
+
ensureIndexDir() {
|
|
1053
|
+
fs5.mkdirSync(this.indexDir, { recursive: true });
|
|
1054
|
+
}
|
|
1055
|
+
writeIndexFiles(graph, scan, fileMaps) {
|
|
1056
|
+
this.ensureIndexDir();
|
|
1057
|
+
const graphIndex = {
|
|
1058
|
+
version: 1,
|
|
1059
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1060
|
+
source_max_mtime_ms: scan.sourceMaxMtimeMs,
|
|
1061
|
+
source_files: scan.sourceFiles,
|
|
1062
|
+
files: fileMaps,
|
|
1063
|
+
graph: this.serializeGraph(graph)
|
|
1064
|
+
};
|
|
1065
|
+
fs5.writeFileSync(this.graphPath, `${JSON.stringify(graphIndex, null, 2)}
|
|
1066
|
+
`, "utf8");
|
|
1067
|
+
fs5.writeFileSync(this.tasksPath, `${JSON.stringify(this.computeTaskIndexRows(graph), null, 2)}
|
|
1068
|
+
`, "utf8");
|
|
1069
|
+
fs5.writeFileSync(
|
|
1070
|
+
this.capacityPath,
|
|
1071
|
+
`${JSON.stringify(this.computeCapacitySnapshot(graph.resources), null, 2)}
|
|
1072
|
+
`,
|
|
1073
|
+
"utf8"
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
readGraphIndexFile() {
|
|
1077
|
+
if (!fs5.existsSync(this.graphPath)) return null;
|
|
1078
|
+
try {
|
|
1079
|
+
const parsed = JSON.parse(fs5.readFileSync(this.graphPath, "utf8"));
|
|
1080
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
1081
|
+
if (!parsed.graph || typeof parsed.graph !== "object") return null;
|
|
1082
|
+
if (!parsed.source_files || typeof parsed.source_files !== "object") return null;
|
|
1083
|
+
if (!parsed.files || typeof parsed.files !== "object") return null;
|
|
1084
|
+
return parsed;
|
|
1085
|
+
} catch {
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
serializeGraph(graph) {
|
|
1090
|
+
return {
|
|
1091
|
+
nodes: toRecordMap(graph.nodes),
|
|
1092
|
+
forward: Object.fromEntries(
|
|
1093
|
+
Array.from(graph.forward.entries()).map(([id, deps]) => [id, Array.from(deps).sort((a, b) => a.localeCompare(b))])
|
|
1094
|
+
),
|
|
1095
|
+
reverse: Object.fromEntries(
|
|
1096
|
+
Array.from(graph.reverse.entries()).map(([id, deps]) => [id, Array.from(deps).sort((a, b) => a.localeCompare(b))])
|
|
1097
|
+
),
|
|
1098
|
+
topological_order: [...graph.topological_order],
|
|
1099
|
+
tracks: toRecordMap(graph.tracks),
|
|
1100
|
+
resources: toRecordMap(graph.resources),
|
|
1101
|
+
deliveries: toRecordMap(graph.deliveries),
|
|
1102
|
+
external_dependencies: Object.fromEntries(
|
|
1103
|
+
Array.from(graph.external_dependencies?.entries() ?? []).map(([id, deps]) => [id, deps])
|
|
1104
|
+
)
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
deserializeGraph(data) {
|
|
1108
|
+
return {
|
|
1109
|
+
nodes: fromRecordMap(data.nodes ?? {}),
|
|
1110
|
+
forward: new Map(
|
|
1111
|
+
Object.entries(data.forward ?? {}).map(([id, deps]) => [id, new Set(deps ?? [])])
|
|
1112
|
+
),
|
|
1113
|
+
reverse: new Map(
|
|
1114
|
+
Object.entries(data.reverse ?? {}).map(([id, deps]) => [id, new Set(deps ?? [])])
|
|
1115
|
+
),
|
|
1116
|
+
topological_order: Array.isArray(data.topological_order) ? [...data.topological_order] : [],
|
|
1117
|
+
tracks: fromRecordMap(data.tracks ?? {}),
|
|
1118
|
+
resources: fromRecordMap(data.resources ?? {}),
|
|
1119
|
+
deliveries: fromRecordMap(data.deliveries ?? {}),
|
|
1120
|
+
external_dependencies: new Map(Object.entries(data.external_dependencies ?? {}))
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
scanSources() {
|
|
1124
|
+
const taskFiles = walkFiles(path.join(this.coopDir, "tasks"), /* @__PURE__ */ new Set([".md"]));
|
|
1125
|
+
const trackFiles = walkFiles(path.join(this.coopDir, "tracks"), /* @__PURE__ */ new Set([".yml", ".yaml"]));
|
|
1126
|
+
const resourceFiles = walkFiles(path.join(this.coopDir, "resources"), /* @__PURE__ */ new Set([".yml", ".yaml"]));
|
|
1127
|
+
const deliveryFiles = walkFiles(path.join(this.coopDir, "deliveries"), /* @__PURE__ */ new Set([".yml", ".yaml", ".md"]));
|
|
1128
|
+
const sourceFiles = {};
|
|
1129
|
+
let sourceMaxMtimeMs = 0;
|
|
1130
|
+
const allFiles = [...taskFiles, ...trackFiles, ...resourceFiles, ...deliveryFiles];
|
|
1131
|
+
for (const filePath of allFiles) {
|
|
1132
|
+
const stat = fs5.statSync(filePath);
|
|
1133
|
+
const relative = toPosixPath(path.relative(this.coopDir, filePath));
|
|
1134
|
+
sourceFiles[relative] = stat.mtimeMs;
|
|
1135
|
+
if (stat.mtimeMs > sourceMaxMtimeMs) {
|
|
1136
|
+
sourceMaxMtimeMs = stat.mtimeMs;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
taskFiles,
|
|
1141
|
+
trackFiles,
|
|
1142
|
+
resourceFiles,
|
|
1143
|
+
deliveryFiles,
|
|
1144
|
+
sourceFiles,
|
|
1145
|
+
sourceMaxMtimeMs
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
normalizeChangedFile(value) {
|
|
1149
|
+
if (!value || !value.trim()) return null;
|
|
1150
|
+
const normalized = path.normalize(value.trim());
|
|
1151
|
+
const absolute = path.isAbsolute(normalized) ? normalized : path.resolve(this.coopDir, normalized);
|
|
1152
|
+
const relative = path.relative(this.coopDir, absolute);
|
|
1153
|
+
if (relative.startsWith("..")) return null;
|
|
1154
|
+
return toPosixPath(relative);
|
|
1155
|
+
}
|
|
1156
|
+
fileKind(relativePath) {
|
|
1157
|
+
if (relativePath.startsWith("tasks/")) return "tasks";
|
|
1158
|
+
if (relativePath.startsWith("tracks/")) return "tracks";
|
|
1159
|
+
if (relativePath.startsWith("resources/")) return "resources";
|
|
1160
|
+
if (relativePath.startsWith("deliveries/")) return "deliveries";
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
computeTaskIndexRows(graph) {
|
|
1164
|
+
const depth = /* @__PURE__ */ new Map();
|
|
1165
|
+
for (const id of graph.topological_order) {
|
|
1166
|
+
const deps = Array.from(graph.forward.get(id) ?? /* @__PURE__ */ new Set()).filter((dep) => graph.nodes.has(dep));
|
|
1167
|
+
if (deps.length === 0) {
|
|
1168
|
+
depth.set(id, 0);
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
const maxDepDepth = Math.max(...deps.map((dep) => depth.get(dep) ?? 0));
|
|
1172
|
+
depth.set(id, maxDepDepth + 1);
|
|
1173
|
+
}
|
|
1174
|
+
const rows = {};
|
|
1175
|
+
for (const [id, task] of graph.nodes.entries()) {
|
|
1176
|
+
const estimate = task.estimate;
|
|
1177
|
+
let pertHours;
|
|
1178
|
+
let pertStddev;
|
|
1179
|
+
if (estimate && typeof estimate.optimistic_hours === "number" && typeof estimate.expected_hours === "number" && typeof estimate.pessimistic_hours === "number") {
|
|
1180
|
+
pertHours = round2((estimate.optimistic_hours + 4 * estimate.expected_hours + estimate.pessimistic_hours) / 6);
|
|
1181
|
+
pertStddev = round2((estimate.pessimistic_hours - estimate.optimistic_hours) / 6);
|
|
1182
|
+
}
|
|
1183
|
+
const blocks = Array.from(graph.reverse.get(id) ?? /* @__PURE__ */ new Set()).sort((a, b) => a.localeCompare(b));
|
|
1184
|
+
const row = {
|
|
1185
|
+
id,
|
|
1186
|
+
status: task.status,
|
|
1187
|
+
priority: task.priority ?? null,
|
|
1188
|
+
track: task.track ?? null,
|
|
1189
|
+
blocks,
|
|
1190
|
+
readiness: compute_readiness(task, graph),
|
|
1191
|
+
depth: depth.get(id) ?? 0
|
|
1192
|
+
};
|
|
1193
|
+
if (pertHours !== void 0) row.pert_hours = pertHours;
|
|
1194
|
+
if (pertStddev !== void 0) row.pert_stddev = pertStddev;
|
|
1195
|
+
rows[id] = row;
|
|
1196
|
+
}
|
|
1197
|
+
return rows;
|
|
1198
|
+
}
|
|
1199
|
+
computeCapacitySnapshot(resources) {
|
|
1200
|
+
const profiles = {};
|
|
1201
|
+
for (const [id, profile] of resources.entries()) {
|
|
1202
|
+
if (profile.type === "human") {
|
|
1203
|
+
const members = profile.members ?? [];
|
|
1204
|
+
const baseHours = members.reduce((sum, member) => sum + (member.hours_per_week ?? 0), 0);
|
|
1205
|
+
const meetings = profile.overhead?.meetings_percent ?? 0;
|
|
1206
|
+
const context = profile.overhead?.context_switch_percent ?? 0;
|
|
1207
|
+
const overheadPercent = meetings + context;
|
|
1208
|
+
const effectiveHours = baseHours * Math.max(0, 1 - overheadPercent / 100);
|
|
1209
|
+
profiles[id] = {
|
|
1210
|
+
id,
|
|
1211
|
+
type: profile.type,
|
|
1212
|
+
members: members.length,
|
|
1213
|
+
total_weekly_hours: round2(baseHours),
|
|
1214
|
+
effective_weekly_hours: round2(effectiveHours),
|
|
1215
|
+
overhead_percent: round2(overheadPercent)
|
|
1216
|
+
};
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
if (profile.type === "ai") {
|
|
1220
|
+
profiles[id] = {
|
|
1221
|
+
id,
|
|
1222
|
+
type: profile.type,
|
|
1223
|
+
agents: profile.agents.length,
|
|
1224
|
+
effective_capacity: profile.effective_capacity ?? null
|
|
1225
|
+
};
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
profiles[id] = {
|
|
1229
|
+
id,
|
|
1230
|
+
type: profile.type,
|
|
1231
|
+
nodes: profile.nodes.length,
|
|
1232
|
+
effective_capacity: profile.effective_capacity ?? null
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
return {
|
|
1236
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1237
|
+
profiles
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
// src/graph/loader.ts
|
|
1243
|
+
function load_graph(coopDir) {
|
|
1244
|
+
const index = new IndexManager(coopDir);
|
|
1245
|
+
const status = index.status();
|
|
1246
|
+
if (status.exists && !status.stale) {
|
|
1247
|
+
const cached = index.load_indexed_graph();
|
|
1248
|
+
if (cached) return cached;
|
|
1249
|
+
}
|
|
1250
|
+
if (!status.exists) {
|
|
1251
|
+
return index.build_full_index();
|
|
1252
|
+
}
|
|
1253
|
+
return index.update_incremental(status.changed_files);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// src/graph/validator.ts
|
|
1257
|
+
function error(invariant, message, task_ids) {
|
|
1258
|
+
return {
|
|
1259
|
+
level: "error",
|
|
1260
|
+
invariant,
|
|
1261
|
+
message,
|
|
540
1262
|
task_ids
|
|
541
1263
|
};
|
|
542
1264
|
}
|
|
@@ -594,10 +1316,17 @@ function checkTerminalConvergence(graph) {
|
|
|
594
1316
|
}
|
|
595
1317
|
return issues;
|
|
596
1318
|
}
|
|
597
|
-
function validate_graph(graph) {
|
|
1319
|
+
function validate_graph(graph, context = {}) {
|
|
598
1320
|
const issues = [];
|
|
599
1321
|
const cycle = detect_cycle(graph);
|
|
600
1322
|
if (cycle) {
|
|
1323
|
+
context.eventEmitter?.emit({
|
|
1324
|
+
type: "graph.cycle_detected",
|
|
1325
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1326
|
+
payload: {
|
|
1327
|
+
cycle
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
601
1330
|
issues.push(error("acyclicity", `Dependency cycle detected: ${cycle.join(" -> ")}.`, cycle));
|
|
602
1331
|
}
|
|
603
1332
|
for (const [taskId, deps] of graph.forward.entries()) {
|
|
@@ -635,8 +1364,540 @@ function validate_graph(graph) {
|
|
|
635
1364
|
return issues;
|
|
636
1365
|
}
|
|
637
1366
|
|
|
638
|
-
// src/
|
|
1367
|
+
// src/events/emitter.ts
|
|
1368
|
+
var CoopEventEmitter = class {
|
|
1369
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1370
|
+
on(eventType, handler) {
|
|
1371
|
+
const existing = this.handlers.get(eventType) ?? /* @__PURE__ */ new Set();
|
|
1372
|
+
existing.add(handler);
|
|
1373
|
+
this.handlers.set(eventType, existing);
|
|
1374
|
+
return () => {
|
|
1375
|
+
const bucket = this.handlers.get(eventType);
|
|
1376
|
+
if (!bucket) return;
|
|
1377
|
+
bucket.delete(handler);
|
|
1378
|
+
if (bucket.size === 0) {
|
|
1379
|
+
this.handlers.delete(eventType);
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
emit(event) {
|
|
1384
|
+
const listeners = this.handlers.get(event.type);
|
|
1385
|
+
if (!listeners || listeners.size === 0) return;
|
|
1386
|
+
for (const listener of listeners) {
|
|
1387
|
+
listener(event);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
|
|
1392
|
+
// src/events/events.ts
|
|
1393
|
+
var COOP_EVENT_TYPES = [
|
|
1394
|
+
"task.created",
|
|
1395
|
+
"task.transitioned",
|
|
1396
|
+
"task.assigned",
|
|
1397
|
+
"delivery.committed",
|
|
1398
|
+
"delivery.at_risk",
|
|
1399
|
+
"run.started",
|
|
1400
|
+
"run.completed",
|
|
1401
|
+
"run.failed",
|
|
1402
|
+
"graph.cycle_detected"
|
|
1403
|
+
];
|
|
1404
|
+
|
|
1405
|
+
// src/events/hook-runner.ts
|
|
1406
|
+
import { spawn } from "child_process";
|
|
1407
|
+
import path2 from "path";
|
|
1408
|
+
function commandFor(hookPath) {
|
|
1409
|
+
const ext = path2.extname(hookPath).toLowerCase();
|
|
1410
|
+
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
|
|
1411
|
+
return {
|
|
1412
|
+
command: process.execPath,
|
|
1413
|
+
args: [hookPath]
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
return {
|
|
1417
|
+
command: hookPath,
|
|
1418
|
+
args: []
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
function run_hook(hookPath, event, timeoutMs = 1e4) {
|
|
1422
|
+
return new Promise((resolve) => {
|
|
1423
|
+
const payload = JSON.stringify(event);
|
|
1424
|
+
const runner = commandFor(hookPath);
|
|
1425
|
+
const child = spawn(runner.command, runner.args, {
|
|
1426
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1427
|
+
shell: runner.command === hookPath,
|
|
1428
|
+
windowsHide: true
|
|
1429
|
+
});
|
|
1430
|
+
let stdout = "";
|
|
1431
|
+
let stderr = "";
|
|
1432
|
+
let settled = false;
|
|
1433
|
+
let timedOut = false;
|
|
1434
|
+
const settle = (result) => {
|
|
1435
|
+
if (settled) return;
|
|
1436
|
+
settled = true;
|
|
1437
|
+
resolve(result);
|
|
1438
|
+
};
|
|
1439
|
+
const timeout = setTimeout(() => {
|
|
1440
|
+
timedOut = true;
|
|
1441
|
+
child.kill("SIGTERM");
|
|
1442
|
+
settle({
|
|
1443
|
+
exitCode: null,
|
|
1444
|
+
stdout,
|
|
1445
|
+
stderr: `${stderr}${stderr ? "\n" : ""}Hook timed out after ${timeoutMs}ms.`,
|
|
1446
|
+
timedOut: true
|
|
1447
|
+
});
|
|
1448
|
+
}, timeoutMs);
|
|
1449
|
+
child.stdout.on("data", (chunk) => {
|
|
1450
|
+
stdout += chunk.toString();
|
|
1451
|
+
});
|
|
1452
|
+
child.stderr.on("data", (chunk) => {
|
|
1453
|
+
stderr += chunk.toString();
|
|
1454
|
+
});
|
|
1455
|
+
child.on("error", (error6) => {
|
|
1456
|
+
clearTimeout(timeout);
|
|
1457
|
+
settle({
|
|
1458
|
+
exitCode: -1,
|
|
1459
|
+
stdout,
|
|
1460
|
+
stderr: `${stderr}${stderr ? "\n" : ""}${error6.message}`,
|
|
1461
|
+
timedOut: false
|
|
1462
|
+
});
|
|
1463
|
+
});
|
|
1464
|
+
child.on("close", (code) => {
|
|
1465
|
+
clearTimeout(timeout);
|
|
1466
|
+
settle({
|
|
1467
|
+
exitCode: timedOut ? null : code,
|
|
1468
|
+
stdout,
|
|
1469
|
+
stderr,
|
|
1470
|
+
timedOut
|
|
1471
|
+
});
|
|
1472
|
+
});
|
|
1473
|
+
child.stdin.write(payload, "utf8");
|
|
1474
|
+
child.stdin.end();
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// src/auth/auth.ts
|
|
1479
|
+
var DEFAULT_AUTH_CONFIG = {
|
|
1480
|
+
enabled: false,
|
|
1481
|
+
roles: {
|
|
1482
|
+
admin: { permissions: ["all"] },
|
|
1483
|
+
lead: {
|
|
1484
|
+
permissions: ["create_task", "transition_task", "create_delivery", "plan_delivery", "assign_task"]
|
|
1485
|
+
},
|
|
1486
|
+
contributor: { permissions: ["transition_own_task", "comment"] },
|
|
1487
|
+
viewer: { permissions: ["read"] }
|
|
1488
|
+
},
|
|
1489
|
+
assignments: {},
|
|
1490
|
+
policies: [
|
|
1491
|
+
{ action: "delete_task", requires: ["admin"] },
|
|
1492
|
+
{ action: "commit_delivery", requires: ["admin", "lead"] },
|
|
1493
|
+
{ action: "override_blocked", requires: ["admin", "lead"] }
|
|
1494
|
+
]
|
|
1495
|
+
};
|
|
1496
|
+
function normalizeRole(value) {
|
|
1497
|
+
if (typeof value !== "string") return null;
|
|
1498
|
+
if (value === "admin" || value === "lead" || value === "contributor" || value === "viewer") return value;
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
function asPermissions(value) {
|
|
1502
|
+
if (!Array.isArray(value)) return [];
|
|
1503
|
+
return value.map((entry) => String(entry)).filter((entry) => {
|
|
1504
|
+
return entry === "all" || entry === "read" || entry === "comment" || entry === "create_task" || entry === "transition_task" || entry === "transition_own_task" || entry === "create_delivery" || entry === "plan_delivery" || entry === "assign_task" || entry === "delete_task" || entry === "commit_delivery" || entry === "override_blocked";
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
function normalizeUser(value) {
|
|
1508
|
+
return value.trim().toLowerCase();
|
|
1509
|
+
}
|
|
1510
|
+
function loadPolicies(raw) {
|
|
1511
|
+
if (!Array.isArray(raw)) return [...DEFAULT_AUTH_CONFIG.policies];
|
|
1512
|
+
const out = [];
|
|
1513
|
+
for (const entry of raw) {
|
|
1514
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1515
|
+
const record = entry;
|
|
1516
|
+
const action = typeof record.action === "string" ? record.action : "";
|
|
1517
|
+
if (action !== "delete_task" && action !== "commit_delivery" && action !== "override_blocked" && action !== "transition_task" && action !== "create_delivery") {
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
const requiresRaw = record.requires;
|
|
1521
|
+
const requiresList = Array.isArray(requiresRaw) ? requiresRaw : [requiresRaw];
|
|
1522
|
+
const requires = requiresList.map((role) => normalizeRole(role)).filter((role) => Boolean(role));
|
|
1523
|
+
if (requires.length === 0) continue;
|
|
1524
|
+
out.push({ action, requires });
|
|
1525
|
+
}
|
|
1526
|
+
return out.length > 0 ? out : [...DEFAULT_AUTH_CONFIG.policies];
|
|
1527
|
+
}
|
|
1528
|
+
function load_auth_config(config) {
|
|
1529
|
+
const source = config && typeof config === "object" ? config : {};
|
|
1530
|
+
const rawAuth = source.authorization && typeof source.authorization === "object" ? source.authorization : {};
|
|
1531
|
+
const enabled = Boolean(rawAuth.enabled ?? false);
|
|
1532
|
+
const rawRoles = rawAuth.roles && typeof rawAuth.roles === "object" ? rawAuth.roles : {};
|
|
1533
|
+
const roles = {
|
|
1534
|
+
admin: { permissions: [...DEFAULT_AUTH_CONFIG.roles.admin.permissions] },
|
|
1535
|
+
lead: { permissions: [...DEFAULT_AUTH_CONFIG.roles.lead.permissions] },
|
|
1536
|
+
contributor: { permissions: [...DEFAULT_AUTH_CONFIG.roles.contributor.permissions] },
|
|
1537
|
+
viewer: { permissions: [...DEFAULT_AUTH_CONFIG.roles.viewer.permissions] }
|
|
1538
|
+
};
|
|
1539
|
+
for (const role of Object.keys(roles)) {
|
|
1540
|
+
const roleConfig = rawRoles[role];
|
|
1541
|
+
if (!roleConfig || typeof roleConfig !== "object") continue;
|
|
1542
|
+
const permissions = asPermissions(roleConfig.permissions);
|
|
1543
|
+
if (permissions.length > 0) {
|
|
1544
|
+
roles[role].permissions = permissions;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
const rawAssignments = rawAuth.assignments && typeof rawAuth.assignments === "object" ? rawAuth.assignments : {};
|
|
1548
|
+
const assignments = {};
|
|
1549
|
+
for (const [user, roleRaw] of Object.entries(rawAssignments)) {
|
|
1550
|
+
const role = normalizeRole(roleRaw);
|
|
1551
|
+
if (!role) continue;
|
|
1552
|
+
assignments[normalizeUser(user)] = role;
|
|
1553
|
+
}
|
|
1554
|
+
const policies = loadPolicies(rawAuth.policies);
|
|
1555
|
+
return {
|
|
1556
|
+
enabled,
|
|
1557
|
+
roles,
|
|
1558
|
+
assignments,
|
|
1559
|
+
policies
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
function get_user_role(user, config) {
|
|
1563
|
+
const key = normalizeUser(user);
|
|
1564
|
+
return config.assignments[key] ?? "viewer";
|
|
1565
|
+
}
|
|
1566
|
+
function hasRolePolicyOverride(role, action, config) {
|
|
1567
|
+
const policy = config.policies.find((entry) => entry.action === action);
|
|
1568
|
+
if (!policy) return true;
|
|
1569
|
+
return policy.requires.includes(role);
|
|
1570
|
+
}
|
|
1571
|
+
function check_permission(user, action, context) {
|
|
1572
|
+
const { config } = context;
|
|
1573
|
+
if (!config.enabled) return true;
|
|
1574
|
+
const role = get_user_role(user, config);
|
|
1575
|
+
const rolePermissions = new Set(config.roles[role]?.permissions ?? []);
|
|
1576
|
+
let allowed = rolePermissions.has("all") || rolePermissions.has(action);
|
|
1577
|
+
if (!allowed && action === "transition_task" && rolePermissions.has("transition_own_task")) {
|
|
1578
|
+
const owner = context.taskOwner?.trim() ?? "";
|
|
1579
|
+
allowed = owner.length > 0 && normalizeUser(owner) === normalizeUser(user);
|
|
1580
|
+
}
|
|
1581
|
+
if (!allowed) return false;
|
|
1582
|
+
return hasRolePolicyOverride(role, action, config);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// src/plugins/plugin-loader.ts
|
|
639
1586
|
import fs6 from "fs";
|
|
1587
|
+
import path3 from "path";
|
|
1588
|
+
var EVENT_TYPES = new Set(COOP_EVENT_TYPES);
|
|
1589
|
+
var PLUGIN_ID_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/i;
|
|
1590
|
+
function pluginDir(coopDir) {
|
|
1591
|
+
return path3.join(coopDir, "plugins");
|
|
1592
|
+
}
|
|
1593
|
+
function listPluginFiles(coopDir) {
|
|
1594
|
+
const dir = pluginDir(coopDir);
|
|
1595
|
+
if (!fs6.existsSync(dir)) return [];
|
|
1596
|
+
const entries = fs6.readdirSync(dir, { withFileTypes: true });
|
|
1597
|
+
return entries.filter((entry) => entry.isFile() && /\.(yml|yaml)$/i.test(entry.name)).map((entry) => path3.join(dir, entry.name)).sort((a, b) => a.localeCompare(b));
|
|
1598
|
+
}
|
|
1599
|
+
function asString(value, field, source) {
|
|
1600
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
1601
|
+
throw new Error(`${source}: ${field} must be a non-empty string.`);
|
|
1602
|
+
}
|
|
1603
|
+
return value.trim();
|
|
1604
|
+
}
|
|
1605
|
+
function asStringMap(value, field, source) {
|
|
1606
|
+
if (value === void 0) return {};
|
|
1607
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1608
|
+
throw new Error(`${source}: ${field} must be a mapping of string values.`);
|
|
1609
|
+
}
|
|
1610
|
+
const out = {};
|
|
1611
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1612
|
+
if (typeof entry !== "string") {
|
|
1613
|
+
throw new Error(`${source}: ${field}.${key} must be a string.`);
|
|
1614
|
+
}
|
|
1615
|
+
out[key] = entry;
|
|
1616
|
+
}
|
|
1617
|
+
return out;
|
|
1618
|
+
}
|
|
1619
|
+
function parseAction(raw, source) {
|
|
1620
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1621
|
+
throw new Error(`${source}: trigger action must be a mapping object.`);
|
|
1622
|
+
}
|
|
1623
|
+
const record = raw;
|
|
1624
|
+
const actionType = asString(record.type, "action.type", source).toLowerCase();
|
|
1625
|
+
if (actionType === "webhook") {
|
|
1626
|
+
const url = asString(record.url, "action.url", source);
|
|
1627
|
+
const template = record.template === void 0 ? void 0 : asString(record.template, "action.template", source);
|
|
1628
|
+
const headers = asStringMap(record.headers, "action.headers", source);
|
|
1629
|
+
return { type: "webhook", url, template, headers };
|
|
1630
|
+
}
|
|
1631
|
+
if (actionType === "console") {
|
|
1632
|
+
const template = asString(record.template, "action.template", source);
|
|
1633
|
+
return { type: "console", template };
|
|
1634
|
+
}
|
|
1635
|
+
if (actionType === "github_pr") {
|
|
1636
|
+
const operation = asString(record.operation, "action.operation", source).toLowerCase();
|
|
1637
|
+
if (operation !== "create_or_update" && operation !== "merge") {
|
|
1638
|
+
throw new Error(`${source}: unsupported action.operation '${operation}' for github_pr.`);
|
|
1639
|
+
}
|
|
1640
|
+
const branch = record.branch === void 0 ? void 0 : asString(record.branch, "action.branch", source);
|
|
1641
|
+
const baseBranch = record.base_branch === void 0 ? void 0 : asString(record.base_branch, "action.base_branch", source);
|
|
1642
|
+
const draft = record.draft === void 0 ? void 0 : Boolean(record.draft);
|
|
1643
|
+
return {
|
|
1644
|
+
type: "github_pr",
|
|
1645
|
+
operation,
|
|
1646
|
+
branch,
|
|
1647
|
+
base_branch: baseBranch,
|
|
1648
|
+
draft
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
throw new Error(`${source}: unsupported action.type '${actionType}'.`);
|
|
1652
|
+
}
|
|
1653
|
+
function parseTrigger(raw, source, index) {
|
|
1654
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1655
|
+
throw new Error(`${source}: triggers[${index}] must be a mapping object.`);
|
|
1656
|
+
}
|
|
1657
|
+
const record = raw;
|
|
1658
|
+
const event = asString(record.event, `triggers[${index}].event`, source);
|
|
1659
|
+
if (!EVENT_TYPES.has(event)) {
|
|
1660
|
+
throw new Error(`${source}: unsupported event '${event}' in triggers[${index}].`);
|
|
1661
|
+
}
|
|
1662
|
+
const filterRaw = record.filter;
|
|
1663
|
+
if (filterRaw !== void 0 && (!filterRaw || typeof filterRaw !== "object" || Array.isArray(filterRaw))) {
|
|
1664
|
+
throw new Error(`${source}: triggers[${index}].filter must be a mapping object.`);
|
|
1665
|
+
}
|
|
1666
|
+
const action = parseAction(record.action, `${source} triggers[${index}]`);
|
|
1667
|
+
return {
|
|
1668
|
+
event,
|
|
1669
|
+
filter: filterRaw,
|
|
1670
|
+
action
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
function parseSecrets(value, source) {
|
|
1674
|
+
if (value === void 0) return [];
|
|
1675
|
+
if (!Array.isArray(value)) {
|
|
1676
|
+
throw new Error(`${source}: secrets must be a list of environment variable names.`);
|
|
1677
|
+
}
|
|
1678
|
+
return value.map((entry, index) => asString(entry, `secrets[${index}]`, source));
|
|
1679
|
+
}
|
|
1680
|
+
function parsePlugin(filePath) {
|
|
1681
|
+
const source = path3.relative(path3.dirname(path3.dirname(filePath)), filePath);
|
|
1682
|
+
const raw = parseYamlFile(filePath);
|
|
1683
|
+
const id = asString(raw.id, "id", source);
|
|
1684
|
+
if (!PLUGIN_ID_PATTERN.test(id)) {
|
|
1685
|
+
throw new Error(`${source}: id '${id}' contains invalid characters.`);
|
|
1686
|
+
}
|
|
1687
|
+
const name = asString(raw.name, "name", source);
|
|
1688
|
+
const version = asString(raw.version, "version", source);
|
|
1689
|
+
if (!Array.isArray(raw.triggers) || raw.triggers.length === 0) {
|
|
1690
|
+
throw new Error(`${source}: triggers must be a non-empty list.`);
|
|
1691
|
+
}
|
|
1692
|
+
const triggers = raw.triggers.map((entry, index) => parseTrigger(entry, source, index));
|
|
1693
|
+
const secrets = parseSecrets(raw.secrets, source);
|
|
1694
|
+
return {
|
|
1695
|
+
id,
|
|
1696
|
+
name,
|
|
1697
|
+
version,
|
|
1698
|
+
triggers,
|
|
1699
|
+
secrets,
|
|
1700
|
+
file_path: filePath
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
function load_plugins(coopDir) {
|
|
1704
|
+
const files = listPluginFiles(coopDir);
|
|
1705
|
+
const plugins = files.map((filePath) => parsePlugin(filePath));
|
|
1706
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1707
|
+
for (const plugin of plugins) {
|
|
1708
|
+
if (seen.has(plugin.id)) {
|
|
1709
|
+
throw new Error(`Duplicate plugin id '${plugin.id}'.`);
|
|
1710
|
+
}
|
|
1711
|
+
seen.add(plugin.id);
|
|
1712
|
+
}
|
|
1713
|
+
return plugins;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/plugins/plugin-runner.ts
|
|
1717
|
+
import fs7 from "fs";
|
|
1718
|
+
import path4 from "path";
|
|
1719
|
+
function isObject(value) {
|
|
1720
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1721
|
+
}
|
|
1722
|
+
function resolvePathValue(input, pathExpr) {
|
|
1723
|
+
const tokens = pathExpr.split(".").map((entry) => entry.trim()).filter(Boolean);
|
|
1724
|
+
if (tokens.length === 0) return void 0;
|
|
1725
|
+
let cursor = input;
|
|
1726
|
+
for (const token of tokens) {
|
|
1727
|
+
if (!isObject(cursor)) return void 0;
|
|
1728
|
+
cursor = cursor[token];
|
|
1729
|
+
}
|
|
1730
|
+
return cursor;
|
|
1731
|
+
}
|
|
1732
|
+
function renderTemplate(template, context) {
|
|
1733
|
+
return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, expr) => {
|
|
1734
|
+
const value = resolvePathValue(context, expr);
|
|
1735
|
+
if (value === null || value === void 0) return "";
|
|
1736
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1737
|
+
return String(value);
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
function resolveSecrets(template, env, plugin) {
|
|
1741
|
+
return template.replace(/\$\{([A-Z0-9_]+)\}/g, (_, secretName) => {
|
|
1742
|
+
if (!plugin.secrets.includes(secretName)) {
|
|
1743
|
+
throw new Error(`Plugin '${plugin.id}' references undeclared secret '${secretName}'.`);
|
|
1744
|
+
}
|
|
1745
|
+
const value = env[secretName];
|
|
1746
|
+
if (!value) {
|
|
1747
|
+
throw new Error(`Plugin '${plugin.id}' missing environment value for '${secretName}'.`);
|
|
1748
|
+
}
|
|
1749
|
+
return value;
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
function filterMatches(filter, eventView) {
|
|
1753
|
+
if (!filter) return true;
|
|
1754
|
+
for (const [key, expected] of Object.entries(filter)) {
|
|
1755
|
+
const actual = resolvePathValue(eventView, key);
|
|
1756
|
+
if (Array.isArray(expected)) {
|
|
1757
|
+
const allowed = expected.map((entry) => String(entry));
|
|
1758
|
+
if (!allowed.includes(String(actual))) return false;
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
if (String(actual) !== String(expected)) return false;
|
|
1762
|
+
}
|
|
1763
|
+
return true;
|
|
1764
|
+
}
|
|
1765
|
+
function eventTemplateView(event) {
|
|
1766
|
+
return {
|
|
1767
|
+
type: event.type,
|
|
1768
|
+
timestamp: event.timestamp,
|
|
1769
|
+
...event.payload
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
function buildContext(event, graph) {
|
|
1773
|
+
const context = {
|
|
1774
|
+
event: eventTemplateView(event)
|
|
1775
|
+
};
|
|
1776
|
+
if (graph) {
|
|
1777
|
+
const payload = event.payload;
|
|
1778
|
+
const taskId = typeof payload.task_id === "string" ? payload.task_id : void 0;
|
|
1779
|
+
const deliveryId = typeof payload.delivery_id === "string" ? payload.delivery_id : void 0;
|
|
1780
|
+
if (taskId) {
|
|
1781
|
+
const task = graph.nodes.get(taskId);
|
|
1782
|
+
if (task) {
|
|
1783
|
+
context.task = task;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
if (deliveryId) {
|
|
1787
|
+
const delivery = graph.deliveries.get(deliveryId);
|
|
1788
|
+
if (delivery) {
|
|
1789
|
+
context.delivery = delivery;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return context;
|
|
1794
|
+
}
|
|
1795
|
+
function appendRunLog(coopDir, record) {
|
|
1796
|
+
const runsDir = path4.join(coopDir, "runs");
|
|
1797
|
+
fs7.mkdirSync(runsDir, { recursive: true });
|
|
1798
|
+
const logPath = path4.join(runsDir, "plugin-events.log");
|
|
1799
|
+
fs7.appendFileSync(logPath, `${JSON.stringify(record)}
|
|
1800
|
+
`, "utf8");
|
|
1801
|
+
}
|
|
1802
|
+
async function executeWebhookAction(plugin, trigger, action, context, env, fetchImpl) {
|
|
1803
|
+
const resolvedUrl = resolveSecrets(action.url, env, plugin);
|
|
1804
|
+
const template = action.template ?? "{{ event.type }}";
|
|
1805
|
+
const body = renderTemplate(resolveSecrets(template, env, plugin), context);
|
|
1806
|
+
const headers = {
|
|
1807
|
+
"content-type": "text/plain; charset=utf-8"
|
|
1808
|
+
};
|
|
1809
|
+
for (const [key, value] of Object.entries(action.headers ?? {})) {
|
|
1810
|
+
headers[key] = resolveSecrets(value, env, plugin);
|
|
1811
|
+
}
|
|
1812
|
+
const response = await fetchImpl(resolvedUrl, {
|
|
1813
|
+
method: "POST",
|
|
1814
|
+
headers,
|
|
1815
|
+
body
|
|
1816
|
+
});
|
|
1817
|
+
return {
|
|
1818
|
+
statusCode: response.status,
|
|
1819
|
+
message: `${trigger.event} -> ${response.status}`
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
function executeConsoleAction(plugin, action, context, env) {
|
|
1823
|
+
const template = resolveSecrets(action.template, env, plugin);
|
|
1824
|
+
const message = renderTemplate(template, context);
|
|
1825
|
+
console.log(`[COOP][plugin:${plugin.id}] ${message}`);
|
|
1826
|
+
return { message };
|
|
1827
|
+
}
|
|
1828
|
+
async function executeCustomAction(plugin, trigger, action, context, env, actionHandlers) {
|
|
1829
|
+
const handler = actionHandlers[action.type];
|
|
1830
|
+
if (!handler) {
|
|
1831
|
+
throw new Error(`Plugin action '${action.type}' requires a registered action handler.`);
|
|
1832
|
+
}
|
|
1833
|
+
return handler(plugin, trigger, action, context, env);
|
|
1834
|
+
}
|
|
1835
|
+
async function run_plugins_for_event(coopDir, event, options = {}) {
|
|
1836
|
+
const env = options.env ?? process.env;
|
|
1837
|
+
const fetchImpl = options.fetch_impl ?? fetch;
|
|
1838
|
+
const graph = options.graph;
|
|
1839
|
+
const actionHandlers = options.action_handlers ?? {};
|
|
1840
|
+
const plugins = load_plugins(coopDir);
|
|
1841
|
+
const context = buildContext(event, graph);
|
|
1842
|
+
const eventView = context.event;
|
|
1843
|
+
const results = [];
|
|
1844
|
+
for (const plugin of plugins) {
|
|
1845
|
+
for (const trigger of plugin.triggers) {
|
|
1846
|
+
if (trigger.event !== event.type) continue;
|
|
1847
|
+
if (!filterMatches(trigger.filter, eventView)) continue;
|
|
1848
|
+
const base = {
|
|
1849
|
+
plugin_id: plugin.id,
|
|
1850
|
+
plugin_name: plugin.name,
|
|
1851
|
+
trigger_event: trigger.event,
|
|
1852
|
+
action_type: trigger.action.type,
|
|
1853
|
+
success: false,
|
|
1854
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1855
|
+
};
|
|
1856
|
+
try {
|
|
1857
|
+
if (trigger.action.type === "webhook") {
|
|
1858
|
+
const result = await executeWebhookAction(plugin, trigger, trigger.action, context, env, fetchImpl);
|
|
1859
|
+
base.success = true;
|
|
1860
|
+
base.status_code = result.statusCode;
|
|
1861
|
+
base.message = result.message;
|
|
1862
|
+
} else if (trigger.action.type === "console") {
|
|
1863
|
+
const result = executeConsoleAction(plugin, trigger.action, context, env);
|
|
1864
|
+
base.success = true;
|
|
1865
|
+
base.message = result.message;
|
|
1866
|
+
} else {
|
|
1867
|
+
const result = await executeCustomAction(plugin, trigger, trigger.action, context, env, actionHandlers);
|
|
1868
|
+
base.success = true;
|
|
1869
|
+
base.status_code = result.statusCode;
|
|
1870
|
+
base.message = result.message;
|
|
1871
|
+
}
|
|
1872
|
+
} catch (error6) {
|
|
1873
|
+
base.success = false;
|
|
1874
|
+
base.error = error6 instanceof Error ? error6.message : String(error6);
|
|
1875
|
+
}
|
|
1876
|
+
appendRunLog(coopDir, base);
|
|
1877
|
+
results.push(base);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return results;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// src/models/run.ts
|
|
1884
|
+
var RunStatus = {
|
|
1885
|
+
pending: "pending",
|
|
1886
|
+
running: "running",
|
|
1887
|
+
paused: "paused",
|
|
1888
|
+
completed: "completed",
|
|
1889
|
+
failed: "failed",
|
|
1890
|
+
canceled: "canceled"
|
|
1891
|
+
};
|
|
1892
|
+
var RunStepStatus = {
|
|
1893
|
+
completed: "completed",
|
|
1894
|
+
failed: "failed",
|
|
1895
|
+
paused: "paused",
|
|
1896
|
+
skipped: "skipped"
|
|
1897
|
+
};
|
|
1898
|
+
|
|
1899
|
+
// src/parser/idea-parser.ts
|
|
1900
|
+
import fs8 from "fs";
|
|
640
1901
|
function asStringArray2(value) {
|
|
641
1902
|
if (!Array.isArray(value)) {
|
|
642
1903
|
return [];
|
|
@@ -668,6 +1929,7 @@ function parseIdeaFromRaw(raw, source) {
|
|
|
668
1929
|
id,
|
|
669
1930
|
title,
|
|
670
1931
|
created,
|
|
1932
|
+
aliases: asStringArray2(raw.aliases),
|
|
671
1933
|
author,
|
|
672
1934
|
status,
|
|
673
1935
|
tags: asStringArray2(raw.tags),
|
|
@@ -684,12 +1946,12 @@ function parseIdeaContent(content, source = "<content>") {
|
|
|
684
1946
|
};
|
|
685
1947
|
}
|
|
686
1948
|
function parseIdeaFile(filePath) {
|
|
687
|
-
const content =
|
|
1949
|
+
const content = fs8.readFileSync(filePath, "utf8");
|
|
688
1950
|
return parseIdeaContent(content, filePath);
|
|
689
1951
|
}
|
|
690
1952
|
|
|
691
1953
|
// src/parser/task-writer.ts
|
|
692
|
-
import
|
|
1954
|
+
import fs9 from "fs";
|
|
693
1955
|
var TASK_FIELD_ORDER = [
|
|
694
1956
|
"id",
|
|
695
1957
|
"title",
|
|
@@ -697,6 +1959,7 @@ var TASK_FIELD_ORDER = [
|
|
|
697
1959
|
"status",
|
|
698
1960
|
"created",
|
|
699
1961
|
"updated",
|
|
1962
|
+
"aliases",
|
|
700
1963
|
"priority",
|
|
701
1964
|
"track",
|
|
702
1965
|
"assignee",
|
|
@@ -734,123 +1997,1098 @@ function buildOrderedFrontmatter(task, raw) {
|
|
|
734
1997
|
if (!raw) {
|
|
735
1998
|
return ordered;
|
|
736
1999
|
}
|
|
737
|
-
for (const key of Object.keys(raw)) {
|
|
738
|
-
if (ORDERED_TASK_FIELDS.has(key)) {
|
|
739
|
-
continue;
|
|
2000
|
+
for (const key of Object.keys(raw)) {
|
|
2001
|
+
if (ORDERED_TASK_FIELDS.has(key)) {
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
ordered[key] = raw[key];
|
|
2005
|
+
}
|
|
2006
|
+
return ordered;
|
|
2007
|
+
}
|
|
2008
|
+
function writeTask(task, options = {}) {
|
|
2009
|
+
const frontmatter = buildOrderedFrontmatter(task, options.raw);
|
|
2010
|
+
const output = stringifyFrontmatter(frontmatter, options.body ?? "");
|
|
2011
|
+
if (options.filePath) {
|
|
2012
|
+
const existedBeforeWrite = fs9.existsSync(options.filePath);
|
|
2013
|
+
fs9.writeFileSync(options.filePath, output, "utf8");
|
|
2014
|
+
if (!existedBeforeWrite) {
|
|
2015
|
+
options.eventEmitter?.emit({
|
|
2016
|
+
type: "task.created",
|
|
2017
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2018
|
+
payload: {
|
|
2019
|
+
task_id: task.id,
|
|
2020
|
+
title: task.title,
|
|
2021
|
+
task_type: task.type,
|
|
2022
|
+
file_path: options.filePath
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
return output;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// src/planning/feasibility.ts
|
|
2031
|
+
var DEFAULT_ESTIMATION_CONFIG = {
|
|
2032
|
+
version: 2,
|
|
2033
|
+
project: {
|
|
2034
|
+
name: "COOP",
|
|
2035
|
+
id: "coop"
|
|
2036
|
+
},
|
|
2037
|
+
defaults: {
|
|
2038
|
+
task: {
|
|
2039
|
+
complexity: "medium"
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
2043
|
+
function asDate(value) {
|
|
2044
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
2045
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
2046
|
+
if (Number.isNaN(parsed.valueOf())) {
|
|
2047
|
+
throw new Error(`Invalid date '${value}'.`);
|
|
2048
|
+
}
|
|
2049
|
+
return parsed;
|
|
2050
|
+
}
|
|
2051
|
+
function normalize_track(track) {
|
|
2052
|
+
return (track ?? "unassigned").trim().toLowerCase();
|
|
2053
|
+
}
|
|
2054
|
+
function isBusinessDay(date) {
|
|
2055
|
+
const day = date.getUTCDay();
|
|
2056
|
+
return day >= 1 && day <= 5;
|
|
2057
|
+
}
|
|
2058
|
+
function business_days(from, to) {
|
|
2059
|
+
const start = asDate(from);
|
|
2060
|
+
const end = asDate(to);
|
|
2061
|
+
if (start.getTime() === end.getTime()) return 0;
|
|
2062
|
+
const forward = start.getTime() < end.getTime();
|
|
2063
|
+
const low = forward ? start : end;
|
|
2064
|
+
const high = forward ? end : start;
|
|
2065
|
+
let count = 0;
|
|
2066
|
+
const cursor = new Date(low.getTime());
|
|
2067
|
+
while (cursor < high) {
|
|
2068
|
+
if (isBusinessDay(cursor)) count += 1;
|
|
2069
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
2070
|
+
}
|
|
2071
|
+
return forward ? count : -count;
|
|
2072
|
+
}
|
|
2073
|
+
function sumTaskEffort(tasks) {
|
|
2074
|
+
let total = 0;
|
|
2075
|
+
for (const task of tasks) {
|
|
2076
|
+
total += effort_or_default(task, DEFAULT_ESTIMATION_CONFIG);
|
|
2077
|
+
}
|
|
2078
|
+
return total;
|
|
2079
|
+
}
|
|
2080
|
+
function sumPert(tasks) {
|
|
2081
|
+
let mean2 = 0;
|
|
2082
|
+
let variance = 0;
|
|
2083
|
+
for (const task of tasks) {
|
|
2084
|
+
if (task.estimate) {
|
|
2085
|
+
mean2 += pert_hours(task.estimate);
|
|
2086
|
+
const sigma = pert_stddev(task.estimate);
|
|
2087
|
+
variance += sigma * sigma;
|
|
2088
|
+
} else {
|
|
2089
|
+
mean2 += effort_or_default(task, DEFAULT_ESTIMATION_CONFIG);
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
return {
|
|
2093
|
+
mean: mean2,
|
|
2094
|
+
stddev: Math.sqrt(variance)
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
function find_track_slots_hours(slots, track) {
|
|
2098
|
+
const direct = slots.get(track);
|
|
2099
|
+
if (direct) {
|
|
2100
|
+
let sum2 = 0;
|
|
2101
|
+
for (const value of direct.values()) sum2 += value;
|
|
2102
|
+
return sum2;
|
|
2103
|
+
}
|
|
2104
|
+
const fallback = slots.get("unassigned");
|
|
2105
|
+
if (!fallback) return 0;
|
|
2106
|
+
let sum = 0;
|
|
2107
|
+
for (const value of fallback.values()) sum += value;
|
|
2108
|
+
return sum;
|
|
2109
|
+
}
|
|
2110
|
+
function track_effort(tasks) {
|
|
2111
|
+
const out = /* @__PURE__ */ new Map();
|
|
2112
|
+
for (const task of tasks) {
|
|
2113
|
+
const track = normalize_track(task.track);
|
|
2114
|
+
out.set(track, (out.get(track) ?? 0) + effort_or_default(task, DEFAULT_ESTIMATION_CONFIG));
|
|
2115
|
+
}
|
|
2116
|
+
return out;
|
|
2117
|
+
}
|
|
2118
|
+
function analyze_feasibility(deliveryId, graph, today) {
|
|
2119
|
+
const delivery = graph.deliveries.get(deliveryId);
|
|
2120
|
+
if (!delivery) {
|
|
2121
|
+
throw new Error(`Delivery '${deliveryId}' not found.`);
|
|
2122
|
+
}
|
|
2123
|
+
const include = new Set(delivery.scope.include);
|
|
2124
|
+
for (const excluded of delivery.scope.exclude) {
|
|
2125
|
+
include.delete(excluded);
|
|
2126
|
+
}
|
|
2127
|
+
const scope_tasks = Array.from(include).map((id) => graph.nodes.get(id)).filter((task) => Boolean(task));
|
|
2128
|
+
const subgraph = extract_subgraph(
|
|
2129
|
+
graph,
|
|
2130
|
+
scope_tasks.map((task) => task.id)
|
|
2131
|
+
);
|
|
2132
|
+
const external_deps = find_external_dependencies(subgraph, graph);
|
|
2133
|
+
const done_tasks = scope_tasks.filter((task) => task.status === "done");
|
|
2134
|
+
const remaining_tasks = scope_tasks.filter(
|
|
2135
|
+
(task) => task.status !== "done" && task.status !== "canceled"
|
|
2136
|
+
);
|
|
2137
|
+
const total_effort = sumTaskEffort(remaining_tasks);
|
|
2138
|
+
const pert = sumPert(remaining_tasks);
|
|
2139
|
+
const cpm = compute_critical_path(delivery, graph);
|
|
2140
|
+
const ledger = build_capacity_ledger(delivery, graph.resources, today);
|
|
2141
|
+
const simulation = simulate_schedule(remaining_tasks, graph, delivery, today);
|
|
2142
|
+
const tracks = new Set(remaining_tasks.map((task) => normalize_track(task.track)));
|
|
2143
|
+
const capacity_by_track = {};
|
|
2144
|
+
for (const track of tracks) {
|
|
2145
|
+
capacity_by_track[track] = find_track_slots_hours(ledger.slots, track);
|
|
2146
|
+
}
|
|
2147
|
+
const total_capacity = Object.values(capacity_by_track).reduce((sum, value) => sum + value, 0);
|
|
2148
|
+
const budget_hours = delivery.budget.engineering_hours ?? Number.POSITIVE_INFINITY;
|
|
2149
|
+
const budget_cost = delivery.budget.cost_usd ?? Number.POSITIVE_INFINITY;
|
|
2150
|
+
const total_cost = remaining_tasks.reduce((sum, task) => sum + (task.resources?.cost_usd ?? 0), 0);
|
|
2151
|
+
let feasible = true;
|
|
2152
|
+
const risks = [];
|
|
2153
|
+
if (total_effort > budget_hours) {
|
|
2154
|
+
feasible = false;
|
|
2155
|
+
risks.push({
|
|
2156
|
+
type: "budget_exceeded",
|
|
2157
|
+
message: `Required ${total_effort.toFixed(1)}h exceeds budget ${budget_hours.toFixed(1)}h.`,
|
|
2158
|
+
overage_hours: total_effort - budget_hours
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
if (total_cost > budget_cost) {
|
|
2162
|
+
feasible = false;
|
|
2163
|
+
risks.push({
|
|
2164
|
+
type: "cost_budget_exceeded",
|
|
2165
|
+
message: `Required $${total_cost.toFixed(2)} exceeds budget $${budget_cost.toFixed(2)}.`,
|
|
2166
|
+
overage_cost_usd: total_cost - budget_cost
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
if (total_effort > total_capacity) {
|
|
2170
|
+
feasible = false;
|
|
2171
|
+
risks.push({
|
|
2172
|
+
type: "capacity_exceeded",
|
|
2173
|
+
message: `Required ${total_effort.toFixed(1)}h exceeds capacity ${total_capacity.toFixed(1)}h.`,
|
|
2174
|
+
overage_hours: total_effort - total_capacity
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
if (simulation.error) {
|
|
2178
|
+
feasible = false;
|
|
2179
|
+
risks.push({
|
|
2180
|
+
type: "schedule_overflow",
|
|
2181
|
+
message: `Simulation overflow while scheduling task ${simulation.error.task}.`,
|
|
2182
|
+
tasks: [simulation.error.task]
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
if (delivery.target_date && simulation.projected_completion && simulation.projected_completion > delivery.target_date) {
|
|
2186
|
+
feasible = false;
|
|
2187
|
+
risks.push({
|
|
2188
|
+
type: "schedule_exceeded",
|
|
2189
|
+
message: `Projected ${simulation.projected_completion} exceeds target ${delivery.target_date}.`,
|
|
2190
|
+
overage_days: business_days(delivery.target_date, simulation.projected_completion)
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
const effort_by_track = track_effort(remaining_tasks);
|
|
2194
|
+
for (const [track, effort] of effort_by_track.entries()) {
|
|
2195
|
+
const capacity = capacity_by_track[track] ?? 0;
|
|
2196
|
+
if (capacity <= 0) continue;
|
|
2197
|
+
const utilization = effort / capacity;
|
|
2198
|
+
if (utilization > 0.85) {
|
|
2199
|
+
risks.push({
|
|
2200
|
+
type: "high_utilization",
|
|
2201
|
+
message: `Track '${track}' at ${Math.round(utilization * 100)}% utilization.`,
|
|
2202
|
+
track,
|
|
2203
|
+
utilization
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
if (external_deps.length > 0) {
|
|
2208
|
+
risks.push({
|
|
2209
|
+
type: "external_dependencies",
|
|
2210
|
+
message: `${external_deps.length} tasks depend on work outside this delivery.`,
|
|
2211
|
+
tasks: external_deps
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
const buffer_days = delivery.target_date && simulation.projected_completion ? business_days(simulation.projected_completion, delivery.target_date) : null;
|
|
2215
|
+
return {
|
|
2216
|
+
delivery: deliveryId,
|
|
2217
|
+
status: feasible ? "FEASIBLE" : "NOT_FEASIBLE",
|
|
2218
|
+
risks,
|
|
2219
|
+
summary: {
|
|
2220
|
+
total_tasks: scope_tasks.length,
|
|
2221
|
+
completed: done_tasks.length,
|
|
2222
|
+
remaining: remaining_tasks.length,
|
|
2223
|
+
effort_hours: {
|
|
2224
|
+
required: total_effort,
|
|
2225
|
+
budget: budget_hours
|
|
2226
|
+
},
|
|
2227
|
+
capacity_hours: {
|
|
2228
|
+
available: total_capacity,
|
|
2229
|
+
by_track: capacity_by_track
|
|
2230
|
+
},
|
|
2231
|
+
pert,
|
|
2232
|
+
dates: {
|
|
2233
|
+
target: delivery.target_date,
|
|
2234
|
+
projected: simulation.projected_completion,
|
|
2235
|
+
buffer_days
|
|
2236
|
+
},
|
|
2237
|
+
critical_path: cpm.critical_path,
|
|
2238
|
+
critical_path_hours: cpm.project_duration_hours
|
|
2239
|
+
},
|
|
2240
|
+
simulation
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// src/planning/risk-detector.ts
|
|
2245
|
+
function toIsoTimestamp(now) {
|
|
2246
|
+
if (typeof now === "string") {
|
|
2247
|
+
if (now.includes("T")) return now;
|
|
2248
|
+
return `${now}T00:00:00.000Z`;
|
|
2249
|
+
}
|
|
2250
|
+
const date = now instanceof Date ? now : /* @__PURE__ */ new Date();
|
|
2251
|
+
return date.toISOString();
|
|
2252
|
+
}
|
|
2253
|
+
function detect_delivery_risks(delivery, graph, velocity, options = {}) {
|
|
2254
|
+
const today = options.today ?? /* @__PURE__ */ new Date();
|
|
2255
|
+
const feasibility = analyze_feasibility(delivery.id, graph, today);
|
|
2256
|
+
const criticalPath = compute_critical_path(delivery, graph);
|
|
2257
|
+
const risks = [];
|
|
2258
|
+
if (velocity.trend === "decelerating" && (feasibility.status === "NOT_FEASIBLE" || feasibility.summary.dates.target && feasibility.summary.dates.projected && feasibility.summary.dates.projected > feasibility.summary.dates.target || typeof feasibility.summary.dates.buffer_days === "number" && feasibility.summary.dates.buffer_days < 0)) {
|
|
2259
|
+
risks.push({
|
|
2260
|
+
type: "behind_schedule",
|
|
2261
|
+
message: `Delivery '${delivery.id}' is slipping while velocity is decelerating.`
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
for (const utilization of feasibility.simulation.utilization_by_track) {
|
|
2265
|
+
if (utilization.utilization >= 0.9) {
|
|
2266
|
+
risks.push({
|
|
2267
|
+
type: "capacity_crunch",
|
|
2268
|
+
message: `Track '${utilization.track}' is at ${Math.round(utilization.utilization * 100)}% utilization.`,
|
|
2269
|
+
track: utilization.track
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
const notStarted = criticalPath.critical_path.filter((taskId) => {
|
|
2274
|
+
const task = graph.nodes.get(taskId);
|
|
2275
|
+
return task?.status === "todo" || task?.status === "blocked";
|
|
2276
|
+
});
|
|
2277
|
+
if (notStarted.length > 0) {
|
|
2278
|
+
risks.push({
|
|
2279
|
+
type: "critical_path_not_started",
|
|
2280
|
+
message: `${notStarted.length} critical path tasks are not started.`,
|
|
2281
|
+
task_ids: notStarted
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
const highRiskCritical = criticalPath.critical_path.filter((taskId) => {
|
|
2285
|
+
const task = graph.nodes.get(taskId);
|
|
2286
|
+
return task?.risk?.level === "high" || task?.risk?.level === "critical";
|
|
2287
|
+
});
|
|
2288
|
+
if (highRiskCritical.length > 0) {
|
|
2289
|
+
risks.push({
|
|
2290
|
+
type: "high_risk_critical_path",
|
|
2291
|
+
message: `${highRiskCritical.length} high-risk tasks are on the critical path.`,
|
|
2292
|
+
task_ids: highRiskCritical
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
if (risks.length > 0) {
|
|
2296
|
+
options.eventEmitter?.emit({
|
|
2297
|
+
type: "delivery.at_risk",
|
|
2298
|
+
timestamp: toIsoTimestamp(today),
|
|
2299
|
+
payload: {
|
|
2300
|
+
delivery_id: delivery.id,
|
|
2301
|
+
status: feasibility.status,
|
|
2302
|
+
risks: risks.map((risk) => risk.message)
|
|
2303
|
+
}
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
return risks;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// src/planning/scorer.ts
|
|
2310
|
+
var DEFAULT_SCORE_WEIGHTS = {
|
|
2311
|
+
priority: {
|
|
2312
|
+
p0: 100,
|
|
2313
|
+
p1: 75,
|
|
2314
|
+
p2: 50,
|
|
2315
|
+
p3: 25
|
|
2316
|
+
},
|
|
2317
|
+
urgency: {
|
|
2318
|
+
days_7: 40,
|
|
2319
|
+
days_14: 25,
|
|
2320
|
+
days_28: 10
|
|
2321
|
+
},
|
|
2322
|
+
dependency_unlock: {
|
|
2323
|
+
gte_5: 30,
|
|
2324
|
+
gte_3: 20,
|
|
2325
|
+
gte_1: 10,
|
|
2326
|
+
transitive_cap: 15
|
|
2327
|
+
},
|
|
2328
|
+
critical_path: 35,
|
|
2329
|
+
determinism: {
|
|
2330
|
+
high: 30,
|
|
2331
|
+
medium: 15,
|
|
2332
|
+
low: 0,
|
|
2333
|
+
experimental: -10
|
|
2334
|
+
},
|
|
2335
|
+
executor_fit: {
|
|
2336
|
+
match: 15,
|
|
2337
|
+
mismatch: -15,
|
|
2338
|
+
ci_match: 10
|
|
2339
|
+
},
|
|
2340
|
+
type: {
|
|
2341
|
+
bug: 10,
|
|
2342
|
+
spike: 5,
|
|
2343
|
+
feature: 0,
|
|
2344
|
+
chore: 0,
|
|
2345
|
+
epic: 0
|
|
2346
|
+
},
|
|
2347
|
+
complexity_penalty: {
|
|
2348
|
+
trivial: 0,
|
|
2349
|
+
small: 0,
|
|
2350
|
+
medium: -10,
|
|
2351
|
+
large: -25,
|
|
2352
|
+
unknown: -40
|
|
2353
|
+
},
|
|
2354
|
+
risk_penalty: {
|
|
2355
|
+
low: 0,
|
|
2356
|
+
medium: -10,
|
|
2357
|
+
high: -25,
|
|
2358
|
+
critical: -40
|
|
2359
|
+
}
|
|
2360
|
+
};
|
|
2361
|
+
function asDate2(value) {
|
|
2362
|
+
if (value instanceof Date) {
|
|
2363
|
+
return new Date(value.getTime());
|
|
2364
|
+
}
|
|
2365
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
2366
|
+
if (Number.isNaN(parsed.valueOf())) {
|
|
2367
|
+
throw new Error(`Invalid date '${value}'.`);
|
|
2368
|
+
}
|
|
2369
|
+
return parsed;
|
|
2370
|
+
}
|
|
2371
|
+
function calendar_days(today, target) {
|
|
2372
|
+
const from = asDate2(today);
|
|
2373
|
+
const to = asDate2(target);
|
|
2374
|
+
const millis = to.getTime() - from.getTime();
|
|
2375
|
+
return Math.floor(millis / (1e3 * 60 * 60 * 24));
|
|
2376
|
+
}
|
|
2377
|
+
function toMap(deliveries) {
|
|
2378
|
+
if (!deliveries) return /* @__PURE__ */ new Map();
|
|
2379
|
+
if (deliveries instanceof Map) return deliveries;
|
|
2380
|
+
return new Map(deliveries.map((delivery) => [delivery.id, delivery]));
|
|
2381
|
+
}
|
|
2382
|
+
function isRecord(value) {
|
|
2383
|
+
return typeof value === "object" && value !== null;
|
|
2384
|
+
}
|
|
2385
|
+
function number_or_default(value, fallback) {
|
|
2386
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
2387
|
+
}
|
|
2388
|
+
function configured_weights(config) {
|
|
2389
|
+
const rawWeights = config?.scheduling?.weights;
|
|
2390
|
+
if (!isRecord(rawWeights)) {
|
|
2391
|
+
return DEFAULT_SCORE_WEIGHTS;
|
|
2392
|
+
}
|
|
2393
|
+
const priority = isRecord(rawWeights.priority) ? rawWeights.priority : {};
|
|
2394
|
+
const urgency = isRecord(rawWeights.urgency) ? rawWeights.urgency : {};
|
|
2395
|
+
const dependency_unlock = isRecord(rawWeights.dependency_unlock) ? rawWeights.dependency_unlock : {};
|
|
2396
|
+
const determinism = isRecord(rawWeights.determinism) ? rawWeights.determinism : {};
|
|
2397
|
+
const executor_fit = isRecord(rawWeights.executor_fit) ? rawWeights.executor_fit : {};
|
|
2398
|
+
const type = isRecord(rawWeights.type) ? rawWeights.type : {};
|
|
2399
|
+
const complexity_penalty2 = isRecord(rawWeights.complexity_penalty) ? rawWeights.complexity_penalty : {};
|
|
2400
|
+
const risk_penalty2 = isRecord(rawWeights.risk_penalty) ? rawWeights.risk_penalty : {};
|
|
2401
|
+
return {
|
|
2402
|
+
priority: {
|
|
2403
|
+
p0: number_or_default(priority.p0, DEFAULT_SCORE_WEIGHTS.priority.p0),
|
|
2404
|
+
p1: number_or_default(priority.p1, DEFAULT_SCORE_WEIGHTS.priority.p1),
|
|
2405
|
+
p2: number_or_default(priority.p2, DEFAULT_SCORE_WEIGHTS.priority.p2),
|
|
2406
|
+
p3: number_or_default(priority.p3, DEFAULT_SCORE_WEIGHTS.priority.p3)
|
|
2407
|
+
},
|
|
2408
|
+
urgency: {
|
|
2409
|
+
days_7: number_or_default(urgency.days_7, DEFAULT_SCORE_WEIGHTS.urgency.days_7),
|
|
2410
|
+
days_14: number_or_default(urgency.days_14, DEFAULT_SCORE_WEIGHTS.urgency.days_14),
|
|
2411
|
+
days_28: number_or_default(urgency.days_28, DEFAULT_SCORE_WEIGHTS.urgency.days_28)
|
|
2412
|
+
},
|
|
2413
|
+
dependency_unlock: {
|
|
2414
|
+
gte_5: number_or_default(dependency_unlock.gte_5, DEFAULT_SCORE_WEIGHTS.dependency_unlock.gte_5),
|
|
2415
|
+
gte_3: number_or_default(dependency_unlock.gte_3, DEFAULT_SCORE_WEIGHTS.dependency_unlock.gte_3),
|
|
2416
|
+
gte_1: number_or_default(dependency_unlock.gte_1, DEFAULT_SCORE_WEIGHTS.dependency_unlock.gte_1),
|
|
2417
|
+
transitive_cap: number_or_default(
|
|
2418
|
+
dependency_unlock.transitive_cap,
|
|
2419
|
+
DEFAULT_SCORE_WEIGHTS.dependency_unlock.transitive_cap
|
|
2420
|
+
)
|
|
2421
|
+
},
|
|
2422
|
+
critical_path: number_or_default(rawWeights.critical_path, DEFAULT_SCORE_WEIGHTS.critical_path),
|
|
2423
|
+
determinism: {
|
|
2424
|
+
high: number_or_default(determinism.high, DEFAULT_SCORE_WEIGHTS.determinism.high),
|
|
2425
|
+
medium: number_or_default(determinism.medium, DEFAULT_SCORE_WEIGHTS.determinism.medium),
|
|
2426
|
+
low: number_or_default(determinism.low, DEFAULT_SCORE_WEIGHTS.determinism.low),
|
|
2427
|
+
experimental: number_or_default(determinism.experimental, DEFAULT_SCORE_WEIGHTS.determinism.experimental)
|
|
2428
|
+
},
|
|
2429
|
+
executor_fit: {
|
|
2430
|
+
match: number_or_default(executor_fit.match, DEFAULT_SCORE_WEIGHTS.executor_fit.match),
|
|
2431
|
+
mismatch: number_or_default(executor_fit.mismatch, DEFAULT_SCORE_WEIGHTS.executor_fit.mismatch),
|
|
2432
|
+
ci_match: number_or_default(executor_fit.ci_match, DEFAULT_SCORE_WEIGHTS.executor_fit.ci_match)
|
|
2433
|
+
},
|
|
2434
|
+
type: {
|
|
2435
|
+
bug: number_or_default(type.bug, DEFAULT_SCORE_WEIGHTS.type.bug),
|
|
2436
|
+
spike: number_or_default(type.spike, DEFAULT_SCORE_WEIGHTS.type.spike),
|
|
2437
|
+
feature: number_or_default(type.feature, DEFAULT_SCORE_WEIGHTS.type.feature),
|
|
2438
|
+
chore: number_or_default(type.chore, DEFAULT_SCORE_WEIGHTS.type.chore),
|
|
2439
|
+
epic: number_or_default(type.epic, DEFAULT_SCORE_WEIGHTS.type.epic)
|
|
2440
|
+
},
|
|
2441
|
+
complexity_penalty: {
|
|
2442
|
+
trivial: number_or_default(complexity_penalty2.trivial, DEFAULT_SCORE_WEIGHTS.complexity_penalty.trivial),
|
|
2443
|
+
small: number_or_default(complexity_penalty2.small, DEFAULT_SCORE_WEIGHTS.complexity_penalty.small),
|
|
2444
|
+
medium: number_or_default(complexity_penalty2.medium, DEFAULT_SCORE_WEIGHTS.complexity_penalty.medium),
|
|
2445
|
+
large: number_or_default(complexity_penalty2.large, DEFAULT_SCORE_WEIGHTS.complexity_penalty.large),
|
|
2446
|
+
unknown: number_or_default(complexity_penalty2.unknown, DEFAULT_SCORE_WEIGHTS.complexity_penalty.unknown)
|
|
2447
|
+
},
|
|
2448
|
+
risk_penalty: {
|
|
2449
|
+
low: number_or_default(risk_penalty2.low, DEFAULT_SCORE_WEIGHTS.risk_penalty.low),
|
|
2450
|
+
medium: number_or_default(risk_penalty2.medium, DEFAULT_SCORE_WEIGHTS.risk_penalty.medium),
|
|
2451
|
+
high: number_or_default(risk_penalty2.high, DEFAULT_SCORE_WEIGHTS.risk_penalty.high),
|
|
2452
|
+
critical: number_or_default(risk_penalty2.critical, DEFAULT_SCORE_WEIGHTS.risk_penalty.critical)
|
|
2453
|
+
}
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
function unresolved_dependencies(task, graph) {
|
|
2457
|
+
return (task.depends_on ?? []).filter((depId) => {
|
|
2458
|
+
const dep = graph.nodes.get(depId);
|
|
2459
|
+
return !dep || dep.status !== "done" && dep.status !== "canceled";
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
function priority_weight(task, config) {
|
|
2463
|
+
const weights = configured_weights(config);
|
|
2464
|
+
const priority = task.priority ?? "p2";
|
|
2465
|
+
return weights.priority[priority];
|
|
2466
|
+
}
|
|
2467
|
+
function urgency_weight(task, deliveries, today, config) {
|
|
2468
|
+
if (!task.delivery) return 0;
|
|
2469
|
+
const delivery = toMap(deliveries).get(task.delivery);
|
|
2470
|
+
if (!delivery?.target_date) return 0;
|
|
2471
|
+
const weights = configured_weights(config);
|
|
2472
|
+
const daysRemaining = calendar_days(today, delivery.target_date);
|
|
2473
|
+
if (daysRemaining < 7) return weights.urgency.days_7;
|
|
2474
|
+
if (daysRemaining < 14) return weights.urgency.days_14;
|
|
2475
|
+
if (daysRemaining < 28) return weights.urgency.days_28;
|
|
2476
|
+
return 0;
|
|
2477
|
+
}
|
|
2478
|
+
function dependency_unlock_weight(taskId, graph, config) {
|
|
2479
|
+
const weights = configured_weights(config);
|
|
2480
|
+
const reverse = graph.reverse.get(taskId) ?? /* @__PURE__ */ new Set();
|
|
2481
|
+
const directBlocked = [];
|
|
2482
|
+
for (const dependentId of reverse) {
|
|
2483
|
+
const dependent = graph.nodes.get(dependentId);
|
|
2484
|
+
if (!dependent) continue;
|
|
2485
|
+
if (compute_readiness(dependent, graph) !== "blocked") continue;
|
|
2486
|
+
const unresolved = unresolved_dependencies(dependent, graph);
|
|
2487
|
+
if (unresolved.length === 1 && unresolved[0] === taskId) {
|
|
2488
|
+
directBlocked.push(dependentId);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
const directCount = directBlocked.length;
|
|
2492
|
+
let directWeight = 0;
|
|
2493
|
+
if (directCount >= 5) {
|
|
2494
|
+
directWeight = weights.dependency_unlock.gte_5;
|
|
2495
|
+
} else if (directCount >= 3) {
|
|
2496
|
+
directWeight = weights.dependency_unlock.gte_3;
|
|
2497
|
+
} else if (directCount >= 1) {
|
|
2498
|
+
directWeight = weights.dependency_unlock.gte_1;
|
|
2499
|
+
}
|
|
2500
|
+
const transitive = /* @__PURE__ */ new Set();
|
|
2501
|
+
for (const directId of directBlocked) {
|
|
2502
|
+
const reverseDepth2 = graph.reverse.get(directId) ?? /* @__PURE__ */ new Set();
|
|
2503
|
+
for (const transitiveId of reverseDepth2) {
|
|
2504
|
+
const task = graph.nodes.get(transitiveId);
|
|
2505
|
+
if (!task) continue;
|
|
2506
|
+
if (compute_readiness(task, graph) === "blocked") {
|
|
2507
|
+
transitive.add(transitiveId);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
const transitiveWeight = Math.min(transitive.size * 2, weights.dependency_unlock.transitive_cap);
|
|
2512
|
+
return directWeight + transitiveWeight;
|
|
2513
|
+
}
|
|
2514
|
+
function critical_path_weight(taskId, cpm, config) {
|
|
2515
|
+
if (!cpm) return 0;
|
|
2516
|
+
const weights = configured_weights(config);
|
|
2517
|
+
const isCritical = cpm.critical_path.includes(taskId);
|
|
2518
|
+
return isCritical ? weights.critical_path : 0;
|
|
2519
|
+
}
|
|
2520
|
+
function determinism_weight(task, config) {
|
|
2521
|
+
const weights = configured_weights(config);
|
|
2522
|
+
const defaultDeterminism = config?.defaults?.task?.determinism ?? "medium";
|
|
2523
|
+
const determinism = task.determinism ?? defaultDeterminism;
|
|
2524
|
+
return weights.determinism[determinism];
|
|
2525
|
+
}
|
|
2526
|
+
function executor_fit_weight(task, targetExecutor, config) {
|
|
2527
|
+
if (!targetExecutor) return 0;
|
|
2528
|
+
const weights = configured_weights(config);
|
|
2529
|
+
const defaultDeterminism = config?.defaults?.task?.determinism ?? "medium";
|
|
2530
|
+
const determinism = task.determinism ?? defaultDeterminism;
|
|
2531
|
+
if (targetExecutor === "ai") {
|
|
2532
|
+
return determinism === "high" || determinism === "medium" ? weights.executor_fit.match : weights.executor_fit.mismatch;
|
|
2533
|
+
}
|
|
2534
|
+
if (targetExecutor === "human") {
|
|
2535
|
+
return determinism === "low" || determinism === "experimental" ? weights.executor_fit.match : weights.executor_fit.mismatch;
|
|
2536
|
+
}
|
|
2537
|
+
if (targetExecutor === "ci") {
|
|
2538
|
+
const runbook = task.execution?.runbook ?? [];
|
|
2539
|
+
const ciReady = task.type === "chore" && runbook.length > 0 && runbook.every((step) => step.action === "run");
|
|
2540
|
+
return ciReady ? weights.executor_fit.ci_match : weights.executor_fit.mismatch;
|
|
2541
|
+
}
|
|
2542
|
+
return 0;
|
|
2543
|
+
}
|
|
2544
|
+
function type_weight(task, config) {
|
|
2545
|
+
const weights = configured_weights(config);
|
|
2546
|
+
return weights.type[task.type];
|
|
2547
|
+
}
|
|
2548
|
+
function complexity_penalty(task, config) {
|
|
2549
|
+
const weights = configured_weights(config);
|
|
2550
|
+
const complexity = task.complexity ?? config?.defaults?.task?.complexity ?? "medium";
|
|
2551
|
+
return weights.complexity_penalty[complexity];
|
|
2552
|
+
}
|
|
2553
|
+
function risk_penalty(task, config) {
|
|
2554
|
+
const weights = configured_weights(config);
|
|
2555
|
+
const level = task.risk?.level ?? "low";
|
|
2556
|
+
return weights.risk_penalty[level];
|
|
2557
|
+
}
|
|
2558
|
+
function compute_score(task, graph, context = {}) {
|
|
2559
|
+
const deliveries = context.deliveries ?? graph.deliveries;
|
|
2560
|
+
const today = context.today ?? /* @__PURE__ */ new Date();
|
|
2561
|
+
const config = context.config;
|
|
2562
|
+
return priority_weight(task, config) + urgency_weight(task, deliveries, today, config) + dependency_unlock_weight(task.id, graph, config) + critical_path_weight(task.id, context.cpm, config) + determinism_weight(task, config) + executor_fit_weight(task, context.target_executor, config) + type_weight(task, config) + complexity_penalty(task, config) + risk_penalty(task, config);
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
// src/planning/scheduler.ts
|
|
2566
|
+
function asDate3(value) {
|
|
2567
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
2568
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
2569
|
+
if (Number.isNaN(parsed.valueOf())) {
|
|
2570
|
+
throw new Error(`Invalid date '${value}'.`);
|
|
2571
|
+
}
|
|
2572
|
+
return parsed;
|
|
2573
|
+
}
|
|
2574
|
+
function isoDate(value) {
|
|
2575
|
+
return value.toISOString().slice(0, 10);
|
|
2576
|
+
}
|
|
2577
|
+
function addDays(value, days) {
|
|
2578
|
+
const out = new Date(value.getTime());
|
|
2579
|
+
out.setUTCDate(out.getUTCDate() + days);
|
|
2580
|
+
return out;
|
|
2581
|
+
}
|
|
2582
|
+
function clone_ledger(ledger) {
|
|
2583
|
+
return {
|
|
2584
|
+
slots: new Map(
|
|
2585
|
+
Array.from(ledger.slots.entries(), ([track, byWeek]) => [track, new Map(byWeek)])
|
|
2586
|
+
),
|
|
2587
|
+
weeks: new Map(ledger.weeks),
|
|
2588
|
+
ai_tokens: new Map(ledger.ai_tokens),
|
|
2589
|
+
ai_tokens_by_agent: new Map(
|
|
2590
|
+
Array.from(ledger.ai_tokens_by_agent.entries(), ([agent, byDay]) => [agent, new Map(byDay)])
|
|
2591
|
+
),
|
|
2592
|
+
ai_tokens_consumed_by_agent: new Map(
|
|
2593
|
+
Array.from(ledger.ai_tokens_consumed_by_agent.entries(), ([agent, byDay]) => [
|
|
2594
|
+
agent,
|
|
2595
|
+
new Map(byDay)
|
|
2596
|
+
])
|
|
2597
|
+
)
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
function normalize_track2(track) {
|
|
2601
|
+
return (track ?? "unassigned").trim().toLowerCase();
|
|
2602
|
+
}
|
|
2603
|
+
function find_track_slots(ledger, track) {
|
|
2604
|
+
const normalized = normalize_track2(track);
|
|
2605
|
+
return ledger.slots.get(normalized) ?? ledger.slots.get("unassigned") ?? null;
|
|
2606
|
+
}
|
|
2607
|
+
function check_capacity(task, ledger) {
|
|
2608
|
+
try {
|
|
2609
|
+
const slots = find_track_slots(ledger, task.track);
|
|
2610
|
+
if (!slots) return false;
|
|
2611
|
+
const week0 = slots.get(0) ?? 0;
|
|
2612
|
+
if (week0 <= 0) return false;
|
|
2613
|
+
const attempt = clone_ledger(ledger);
|
|
2614
|
+
return allocate(attempt, task, 0).success;
|
|
2615
|
+
} catch {
|
|
2616
|
+
return false;
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
function advisory_delivery(graph, tasks, options) {
|
|
2620
|
+
if (options.delivery) {
|
|
2621
|
+
const existing = graph.deliveries.get(options.delivery);
|
|
2622
|
+
if (existing) {
|
|
2623
|
+
return {
|
|
2624
|
+
id: existing.id,
|
|
2625
|
+
name: existing.name,
|
|
2626
|
+
status: "planning",
|
|
2627
|
+
target_date: existing.target_date ?? isoDate(addDays(asDate3(options.today ?? /* @__PURE__ */ new Date()), 56)),
|
|
2628
|
+
started_date: existing.started_date,
|
|
2629
|
+
delivered_date: existing.delivered_date,
|
|
2630
|
+
budget: existing.budget,
|
|
2631
|
+
capacity_profiles: existing.capacity_profiles,
|
|
2632
|
+
scope: existing.scope
|
|
2633
|
+
};
|
|
740
2634
|
}
|
|
741
|
-
ordered[key] = raw[key];
|
|
742
2635
|
}
|
|
743
|
-
|
|
2636
|
+
const targetDate = isoDate(addDays(asDate3(options.today ?? /* @__PURE__ */ new Date()), 56));
|
|
2637
|
+
return {
|
|
2638
|
+
id: "__SCHEDULE__",
|
|
2639
|
+
name: "Advisory Schedule",
|
|
2640
|
+
status: "planning",
|
|
2641
|
+
target_date: targetDate,
|
|
2642
|
+
started_date: null,
|
|
2643
|
+
delivered_date: null,
|
|
2644
|
+
budget: {},
|
|
2645
|
+
capacity_profiles: [],
|
|
2646
|
+
scope: {
|
|
2647
|
+
include: tasks.map((task) => task.id),
|
|
2648
|
+
exclude: []
|
|
2649
|
+
}
|
|
2650
|
+
};
|
|
744
2651
|
}
|
|
745
|
-
function
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
2652
|
+
function resolve_cpm(graph, options) {
|
|
2653
|
+
if (options.delivery) {
|
|
2654
|
+
const delivery = graph.deliveries.get(options.delivery);
|
|
2655
|
+
if (!delivery) return void 0;
|
|
2656
|
+
return compute_critical_path(delivery, graph);
|
|
2657
|
+
}
|
|
2658
|
+
const preferred = Array.from(graph.deliveries.values()).find(
|
|
2659
|
+
(delivery) => delivery.status === "committed" || delivery.status === "in_progress"
|
|
2660
|
+
);
|
|
2661
|
+
if (!preferred) return void 0;
|
|
2662
|
+
return compute_critical_path(preferred, graph);
|
|
2663
|
+
}
|
|
2664
|
+
function schedule_next(graph, options = {}) {
|
|
2665
|
+
const ready_tasks = Array.from(graph.nodes.values()).filter(
|
|
2666
|
+
(task) => compute_readiness(task, graph) === "ready"
|
|
2667
|
+
);
|
|
2668
|
+
let filtered = ready_tasks;
|
|
2669
|
+
if (options.track) {
|
|
2670
|
+
filtered = filtered.filter((task) => (task.track ?? "unassigned") === options.track);
|
|
2671
|
+
}
|
|
2672
|
+
const deliveryFilter = options.delivery;
|
|
2673
|
+
if (deliveryFilter) {
|
|
2674
|
+
const scope = graph.deliveries.get(deliveryFilter)?.scope.include ?? [];
|
|
2675
|
+
const scopeSet = new Set(scope);
|
|
2676
|
+
filtered = filtered.filter(
|
|
2677
|
+
(task) => task.delivery === deliveryFilter || scopeSet.has(task.id)
|
|
2678
|
+
);
|
|
2679
|
+
}
|
|
2680
|
+
if (options.executor) {
|
|
2681
|
+
filtered = filtered.filter(
|
|
2682
|
+
(task) => task.execution?.executor === options.executor || task.execution?.executor == null
|
|
2683
|
+
);
|
|
2684
|
+
}
|
|
2685
|
+
const cpm = resolve_cpm(graph, options);
|
|
2686
|
+
const scored = filtered.map((task) => ({
|
|
2687
|
+
task,
|
|
2688
|
+
score: compute_score(task, graph, {
|
|
2689
|
+
deliveries: graph.deliveries,
|
|
2690
|
+
cpm,
|
|
2691
|
+
today: options.today ?? /* @__PURE__ */ new Date(),
|
|
2692
|
+
target_executor: options.executor,
|
|
2693
|
+
config: options.config
|
|
2694
|
+
}),
|
|
2695
|
+
readiness: compute_readiness(task, graph),
|
|
2696
|
+
fits_capacity: true,
|
|
2697
|
+
fits_wip: true
|
|
2698
|
+
}));
|
|
2699
|
+
scored.sort((a, b) => {
|
|
2700
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
2701
|
+
return a.task.id.localeCompare(b.task.id);
|
|
2702
|
+
});
|
|
2703
|
+
const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit > 0 ? options.limit : void 0;
|
|
2704
|
+
const output = typeof limit === "number" ? scored.slice(0, limit) : scored;
|
|
2705
|
+
if (output.length === 0) return output;
|
|
2706
|
+
const delivery = advisory_delivery(graph, output.map((entry) => entry.task), options);
|
|
2707
|
+
const ledger = build_capacity_ledger(delivery, graph.resources, options.today ?? /* @__PURE__ */ new Date());
|
|
2708
|
+
for (const entry of output) {
|
|
2709
|
+
entry.fits_capacity = check_capacity(entry.task, ledger);
|
|
2710
|
+
entry.fits_wip = check_wip(
|
|
2711
|
+
graph.tracks.get(entry.task.track ?? "unassigned"),
|
|
2712
|
+
graph
|
|
2713
|
+
);
|
|
750
2714
|
}
|
|
751
2715
|
return output;
|
|
752
2716
|
}
|
|
753
2717
|
|
|
754
|
-
// src/planning/
|
|
755
|
-
|
|
756
|
-
|
|
2718
|
+
// src/planning/velocity.ts
|
|
2719
|
+
import fs10 from "fs";
|
|
2720
|
+
import path5 from "path";
|
|
2721
|
+
var DEFAULT_ESTIMATION_CONFIG2 = {
|
|
2722
|
+
version: 2,
|
|
2723
|
+
project: {
|
|
2724
|
+
name: "COOP",
|
|
2725
|
+
id: "coop"
|
|
2726
|
+
},
|
|
2727
|
+
defaults: {
|
|
2728
|
+
task: {
|
|
2729
|
+
complexity: "medium"
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
};
|
|
2733
|
+
function asDate4(value) {
|
|
2734
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
2735
|
+
const parsed = new Date(value);
|
|
2736
|
+
if (Number.isNaN(parsed.valueOf())) {
|
|
2737
|
+
throw new Error(`Invalid date '${value}'.`);
|
|
2738
|
+
}
|
|
2739
|
+
return parsed;
|
|
757
2740
|
}
|
|
758
|
-
function
|
|
759
|
-
|
|
760
|
-
|
|
2741
|
+
function isoDate2(value) {
|
|
2742
|
+
return value.toISOString().slice(0, 10);
|
|
2743
|
+
}
|
|
2744
|
+
function startOfWeek(date) {
|
|
2745
|
+
const copy = new Date(date.getTime());
|
|
2746
|
+
const day = copy.getUTCDay();
|
|
2747
|
+
const offset = day === 0 ? -6 : 1 - day;
|
|
2748
|
+
copy.setUTCDate(copy.getUTCDate() + offset);
|
|
2749
|
+
copy.setUTCHours(0, 0, 0, 0);
|
|
2750
|
+
return copy;
|
|
2751
|
+
}
|
|
2752
|
+
function addDays2(date, days) {
|
|
2753
|
+
const out = new Date(date.getTime());
|
|
2754
|
+
out.setUTCDate(out.getUTCDate() + days);
|
|
2755
|
+
return out;
|
|
2756
|
+
}
|
|
2757
|
+
function durationHours2(run) {
|
|
2758
|
+
const fromSteps = run.steps.reduce((sum, step) => sum + (step.duration_seconds ?? 0), 0) / 3600;
|
|
2759
|
+
if (fromSteps > 0) {
|
|
2760
|
+
return fromSteps;
|
|
761
2761
|
}
|
|
762
|
-
if (
|
|
763
|
-
|
|
2762
|
+
if (run.completed) {
|
|
2763
|
+
const started = asDate4(run.started);
|
|
2764
|
+
const completed = asDate4(run.completed);
|
|
2765
|
+
return Math.max(0, completed.getTime() - started.getTime()) / (60 * 60 * 1e3);
|
|
764
2766
|
}
|
|
765
|
-
|
|
766
|
-
|
|
2767
|
+
return 0;
|
|
2768
|
+
}
|
|
2769
|
+
function mean(values) {
|
|
2770
|
+
if (values.length === 0) return 0;
|
|
2771
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
2772
|
+
}
|
|
2773
|
+
function detectTrend(points) {
|
|
2774
|
+
if (points.length < 4) {
|
|
2775
|
+
return "stable";
|
|
767
2776
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
2777
|
+
const midpoint = Math.floor(points.length / 2);
|
|
2778
|
+
const firstHalf = points.slice(0, midpoint).map((point) => point.completed_tasks);
|
|
2779
|
+
const secondHalf = points.slice(midpoint).map((point) => point.completed_tasks);
|
|
2780
|
+
const firstAverage = mean(firstHalf);
|
|
2781
|
+
const secondAverage = mean(secondHalf);
|
|
2782
|
+
if (firstAverage === 0 && secondAverage === 0) {
|
|
2783
|
+
return "stable";
|
|
773
2784
|
}
|
|
774
|
-
|
|
2785
|
+
if (firstAverage === 0 && secondAverage > 0) {
|
|
2786
|
+
return "accelerating";
|
|
2787
|
+
}
|
|
2788
|
+
const ratio = secondAverage / Math.max(firstAverage, 1e-4);
|
|
2789
|
+
if (ratio >= 1.15) {
|
|
2790
|
+
return "accelerating";
|
|
2791
|
+
}
|
|
2792
|
+
if (ratio <= 0.85) {
|
|
2793
|
+
return "decelerating";
|
|
2794
|
+
}
|
|
2795
|
+
return "stable";
|
|
775
2796
|
}
|
|
776
|
-
function
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
2797
|
+
function load_completed_runs(coopDir) {
|
|
2798
|
+
const runsDir = path5.join(coopDir, "runs");
|
|
2799
|
+
if (!fs10.existsSync(runsDir)) {
|
|
2800
|
+
return [];
|
|
780
2801
|
}
|
|
781
|
-
return
|
|
2802
|
+
return fs10.readdirSync(runsDir).filter((entry) => entry.toLowerCase().endsWith(".yml") || entry.toLowerCase().endsWith(".yaml")).sort((a, b) => a.localeCompare(b)).map((entry) => parseYamlFile(path5.join(runsDir, entry))).filter((run) => run.status === "completed" && typeof run.completed === "string");
|
|
782
2803
|
}
|
|
783
|
-
function
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
2804
|
+
function compute_velocity(runs, windowWeeks, options = {}) {
|
|
2805
|
+
const normalizedWindow = Number.isInteger(windowWeeks) && windowWeeks > 0 ? windowWeeks : 4;
|
|
2806
|
+
const today = startOfWeek(asDate4(options.today ?? /* @__PURE__ */ new Date()));
|
|
2807
|
+
const windowStart = addDays2(today, -7 * (normalizedWindow - 1));
|
|
2808
|
+
const pointMap = /* @__PURE__ */ new Map();
|
|
2809
|
+
for (let weekIndex = 0; weekIndex < normalizedWindow; weekIndex += 1) {
|
|
2810
|
+
const weekStart = addDays2(windowStart, weekIndex * 7);
|
|
2811
|
+
const weekEnd = addDays2(weekStart, 6);
|
|
2812
|
+
pointMap.set(isoDate2(weekStart), {
|
|
2813
|
+
tasks: /* @__PURE__ */ new Set(),
|
|
2814
|
+
hours: 0,
|
|
2815
|
+
index: weekIndex,
|
|
2816
|
+
end: isoDate2(weekEnd)
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
let completedRuns = 0;
|
|
2820
|
+
let estimatedHours = 0;
|
|
2821
|
+
let actualHours = 0;
|
|
2822
|
+
for (const run of runs) {
|
|
2823
|
+
if (!run.completed) continue;
|
|
2824
|
+
const completedAt = asDate4(run.completed);
|
|
2825
|
+
const weekStart = startOfWeek(completedAt);
|
|
2826
|
+
const weekKey = isoDate2(weekStart);
|
|
2827
|
+
const bucket = pointMap.get(weekKey);
|
|
2828
|
+
if (!bucket) continue;
|
|
2829
|
+
completedRuns += 1;
|
|
2830
|
+
const hours = durationHours2(run);
|
|
2831
|
+
actualHours += hours;
|
|
2832
|
+
bucket.tasks.add(run.task);
|
|
2833
|
+
bucket.hours += hours;
|
|
2834
|
+
const task = options.graph?.nodes.get(run.task);
|
|
2835
|
+
if (task) {
|
|
2836
|
+
estimatedHours += effort_or_default(task, DEFAULT_ESTIMATION_CONFIG2);
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
const points = Array.from(pointMap.entries()).sort((a, b) => a[1].index - b[1].index).map(([weekStart, bucket]) => ({
|
|
2840
|
+
week_index: bucket.index,
|
|
2841
|
+
week_start: weekStart,
|
|
2842
|
+
week_end: bucket.end,
|
|
2843
|
+
completed_tasks: bucket.tasks.size,
|
|
2844
|
+
delivered_hours: Number(bucket.hours.toFixed(2))
|
|
2845
|
+
}));
|
|
2846
|
+
const tasksCompletedTotal = points.reduce((sum, point) => sum + point.completed_tasks, 0);
|
|
2847
|
+
const deliveredHoursTotal = points.reduce((sum, point) => sum + point.delivered_hours, 0);
|
|
2848
|
+
return {
|
|
2849
|
+
window_weeks: normalizedWindow,
|
|
2850
|
+
completed_runs: completedRuns,
|
|
2851
|
+
tasks_completed_total: tasksCompletedTotal,
|
|
2852
|
+
delivered_hours_total: Number(deliveredHoursTotal.toFixed(2)),
|
|
2853
|
+
tasks_completed_per_week: Number((tasksCompletedTotal / normalizedWindow).toFixed(2)),
|
|
2854
|
+
hours_delivered_per_week: Number((deliveredHoursTotal / normalizedWindow).toFixed(2)),
|
|
2855
|
+
accuracy_ratio: estimatedHours > 0 ? Number((actualHours / estimatedHours).toFixed(3)) : null,
|
|
2856
|
+
trend: detectTrend(points),
|
|
2857
|
+
points
|
|
790
2858
|
};
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
// src/planning/what-if.ts
|
|
2862
|
+
function asDate5(value) {
|
|
2863
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
2864
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
2865
|
+
if (Number.isNaN(parsed.valueOf())) {
|
|
2866
|
+
throw new Error(`Invalid date '${value}'.`);
|
|
794
2867
|
}
|
|
795
|
-
return
|
|
2868
|
+
return parsed;
|
|
796
2869
|
}
|
|
797
|
-
function
|
|
798
|
-
const
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
2870
|
+
function calendar_days2(from, to) {
|
|
2871
|
+
const start = asDate5(from);
|
|
2872
|
+
const end = asDate5(to);
|
|
2873
|
+
return Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1e3));
|
|
2874
|
+
}
|
|
2875
|
+
function signed_number(value, suffix = "") {
|
|
2876
|
+
if (!Number.isFinite(value) || value === 0) return "-";
|
|
2877
|
+
return `${value > 0 ? "+" : ""}${value}${suffix}`;
|
|
2878
|
+
}
|
|
2879
|
+
function signed_hours(value) {
|
|
2880
|
+
if (!Number.isFinite(value) || value === 0) return "-";
|
|
2881
|
+
return `${value > 0 ? "+" : ""}${value.toFixed(1)}h`;
|
|
2882
|
+
}
|
|
2883
|
+
function format_hours(value) {
|
|
2884
|
+
if (!Number.isFinite(value)) return "unbounded";
|
|
2885
|
+
return `${value.toFixed(1)}h`;
|
|
2886
|
+
}
|
|
2887
|
+
function format_headroom(result) {
|
|
2888
|
+
const budget = result.summary.effort_hours.budget;
|
|
2889
|
+
if (!Number.isFinite(budget)) return "unbounded";
|
|
2890
|
+
return `${(budget - result.summary.effort_hours.required).toFixed(1)}h`;
|
|
2891
|
+
}
|
|
2892
|
+
function modification_label(modification) {
|
|
2893
|
+
switch (modification.kind) {
|
|
2894
|
+
case "without":
|
|
2895
|
+
return `--without ${modification.task_id}`;
|
|
2896
|
+
case "add_member":
|
|
2897
|
+
return `--add-member ${modification.target}`;
|
|
2898
|
+
case "target":
|
|
2899
|
+
return `--target ${modification.target_date}`;
|
|
2900
|
+
case "set_priority":
|
|
2901
|
+
return `--set ${modification.task_id}:priority=${modification.priority}`;
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
function build_rows(baseline, scenario) {
|
|
2905
|
+
const baselineTarget = baseline.summary.dates.target ?? "-";
|
|
2906
|
+
const scenarioTarget = scenario.summary.dates.target ?? "-";
|
|
2907
|
+
const baselineProjected = baseline.summary.dates.projected ?? "-";
|
|
2908
|
+
const scenarioProjected = scenario.summary.dates.projected ?? "-";
|
|
2909
|
+
const baselineHeadroom = Number.isFinite(baseline.summary.effort_hours.budget) ? baseline.summary.effort_hours.budget - baseline.summary.effort_hours.required : Number.NaN;
|
|
2910
|
+
const scenarioHeadroom = Number.isFinite(scenario.summary.effort_hours.budget) ? scenario.summary.effort_hours.budget - scenario.summary.effort_hours.required : Number.NaN;
|
|
2911
|
+
return [
|
|
2912
|
+
{
|
|
2913
|
+
metric: "Target date",
|
|
2914
|
+
original: baselineTarget,
|
|
2915
|
+
scenario: scenarioTarget,
|
|
2916
|
+
delta: baseline.summary.dates.target && scenario.summary.dates.target ? signed_number(calendar_days2(baseline.summary.dates.target, scenario.summary.dates.target), " days") : "-"
|
|
2917
|
+
},
|
|
2918
|
+
{
|
|
2919
|
+
metric: "Projected date",
|
|
2920
|
+
original: baselineProjected,
|
|
2921
|
+
scenario: scenarioProjected,
|
|
2922
|
+
delta: baseline.summary.dates.projected && scenario.summary.dates.projected ? signed_number(
|
|
2923
|
+
calendar_days2(baseline.summary.dates.projected, scenario.summary.dates.projected),
|
|
2924
|
+
" days"
|
|
2925
|
+
) : "-"
|
|
2926
|
+
},
|
|
2927
|
+
{
|
|
2928
|
+
metric: "Effort (hours)",
|
|
2929
|
+
original: format_hours(baseline.summary.effort_hours.required),
|
|
2930
|
+
scenario: format_hours(scenario.summary.effort_hours.required),
|
|
2931
|
+
delta: signed_hours(scenario.summary.effort_hours.required - baseline.summary.effort_hours.required)
|
|
2932
|
+
},
|
|
2933
|
+
{
|
|
2934
|
+
metric: "Headroom",
|
|
2935
|
+
original: format_headroom(baseline),
|
|
2936
|
+
scenario: format_headroom(scenario),
|
|
2937
|
+
delta: signed_hours(scenarioHeadroom - baselineHeadroom)
|
|
2938
|
+
},
|
|
2939
|
+
{
|
|
2940
|
+
metric: "Critical path tasks",
|
|
2941
|
+
original: String(baseline.summary.critical_path.length),
|
|
2942
|
+
scenario: String(scenario.summary.critical_path.length),
|
|
2943
|
+
delta: signed_number(scenario.summary.critical_path.length - baseline.summary.critical_path.length)
|
|
2944
|
+
},
|
|
2945
|
+
{
|
|
2946
|
+
metric: "Status",
|
|
2947
|
+
original: baseline.status,
|
|
2948
|
+
scenario: scenario.status,
|
|
2949
|
+
delta: baseline.status === scenario.status ? "-" : `${baseline.status} -> ${scenario.status}`
|
|
820
2950
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
2951
|
+
];
|
|
2952
|
+
}
|
|
2953
|
+
function clone_graph(graph) {
|
|
2954
|
+
return structuredClone(graph);
|
|
2955
|
+
}
|
|
2956
|
+
function resolve_delivery(graph, deliveryId) {
|
|
2957
|
+
const delivery = graph.deliveries.get(deliveryId);
|
|
2958
|
+
if (!delivery) {
|
|
2959
|
+
throw new Error(`Delivery '${deliveryId}' not found.`);
|
|
2960
|
+
}
|
|
2961
|
+
return delivery;
|
|
2962
|
+
}
|
|
2963
|
+
function remove_task_and_exclusive_dependencies(graph, delivery, taskId) {
|
|
2964
|
+
const scoped = new Set(delivery.scope.include);
|
|
2965
|
+
for (const excluded of delivery.scope.exclude) {
|
|
2966
|
+
scoped.delete(excluded);
|
|
2967
|
+
}
|
|
2968
|
+
if (!scoped.has(taskId)) {
|
|
2969
|
+
throw new Error(`Task '${taskId}' is not in delivery '${delivery.id}' scope.`);
|
|
2970
|
+
}
|
|
2971
|
+
const toRemove = /* @__PURE__ */ new Set([taskId]);
|
|
2972
|
+
const queue = [taskId];
|
|
2973
|
+
while (queue.length > 0) {
|
|
2974
|
+
const current = queue.shift();
|
|
2975
|
+
if (!current) continue;
|
|
2976
|
+
for (const dependencyId of graph.forward.get(current) ?? /* @__PURE__ */ new Set()) {
|
|
2977
|
+
if (!scoped.has(dependencyId) || toRemove.has(dependencyId)) continue;
|
|
2978
|
+
const dependents = graph.reverse.get(dependencyId) ?? /* @__PURE__ */ new Set();
|
|
2979
|
+
const hasRemainingDependent = Array.from(dependents).some(
|
|
2980
|
+
(dependentId) => scoped.has(dependentId) && !toRemove.has(dependentId)
|
|
2981
|
+
);
|
|
2982
|
+
if (hasRemainingDependent) continue;
|
|
2983
|
+
toRemove.add(dependencyId);
|
|
2984
|
+
queue.push(dependencyId);
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
delivery.scope.include = delivery.scope.include.filter((id) => !toRemove.has(id));
|
|
2988
|
+
delivery.scope.exclude = delivery.scope.exclude.filter((id) => !toRemove.has(id));
|
|
2989
|
+
}
|
|
2990
|
+
function resolve_human_profile(graph, delivery, target) {
|
|
2991
|
+
const direct = graph.resources.get(target);
|
|
2992
|
+
if (direct?.type === "human") {
|
|
2993
|
+
return direct;
|
|
2994
|
+
}
|
|
2995
|
+
const track = graph.tracks.get(target);
|
|
2996
|
+
if (track) {
|
|
2997
|
+
for (const profileId of track.capacity_profiles) {
|
|
2998
|
+
const profile = graph.resources.get(profileId);
|
|
2999
|
+
if (profile?.type === "human") {
|
|
3000
|
+
return profile;
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
for (const profileId of delivery.capacity_profiles) {
|
|
3005
|
+
const profile = graph.resources.get(profileId);
|
|
3006
|
+
if (profile?.type === "human") {
|
|
3007
|
+
return profile;
|
|
831
3008
|
}
|
|
832
3009
|
}
|
|
3010
|
+
throw new Error(`No human capacity profile found for '${target}'.`);
|
|
3011
|
+
}
|
|
3012
|
+
function add_member(graph, delivery, target, hoursPerWeek) {
|
|
3013
|
+
const profile = resolve_human_profile(graph, delivery, target);
|
|
3014
|
+
const defaultHours = hoursPerWeek ?? profile.defaults?.hours_per_week ?? profile.members[0]?.hours_per_week ?? 40;
|
|
3015
|
+
const nextMemberIndex = profile.members.length + 1;
|
|
3016
|
+
profile.members = [
|
|
3017
|
+
...profile.members,
|
|
3018
|
+
{
|
|
3019
|
+
id: `what-if-${profile.id}-${nextMemberIndex}`,
|
|
3020
|
+
hours_per_week: defaultHours
|
|
3021
|
+
}
|
|
3022
|
+
];
|
|
3023
|
+
graph.resources.set(profile.id, profile);
|
|
3024
|
+
}
|
|
3025
|
+
function set_target(delivery, targetDate) {
|
|
3026
|
+
asDate5(targetDate);
|
|
3027
|
+
delivery.target_date = targetDate;
|
|
3028
|
+
}
|
|
3029
|
+
function set_priority(graph, taskId, priority) {
|
|
3030
|
+
const task = graph.nodes.get(taskId);
|
|
3031
|
+
if (!task) {
|
|
3032
|
+
throw new Error(`Task '${taskId}' not found.`);
|
|
3033
|
+
}
|
|
3034
|
+
task.priority = priority;
|
|
3035
|
+
graph.nodes.set(taskId, task);
|
|
3036
|
+
}
|
|
3037
|
+
function apply_modification(graph, deliveryId, modification) {
|
|
3038
|
+
const delivery = resolve_delivery(graph, deliveryId);
|
|
3039
|
+
switch (modification.kind) {
|
|
3040
|
+
case "without":
|
|
3041
|
+
remove_task_and_exclusive_dependencies(graph, delivery, modification.task_id);
|
|
3042
|
+
return;
|
|
3043
|
+
case "add_member":
|
|
3044
|
+
add_member(graph, delivery, modification.target, modification.hours_per_week);
|
|
3045
|
+
return;
|
|
3046
|
+
case "target":
|
|
3047
|
+
set_target(delivery, modification.target_date);
|
|
3048
|
+
graph.deliveries.set(delivery.id, delivery);
|
|
3049
|
+
return;
|
|
3050
|
+
case "set_priority":
|
|
3051
|
+
set_priority(graph, modification.task_id, modification.priority);
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
function analyze_what_if(baseline, modification) {
|
|
3056
|
+
const modifications = Array.isArray(modification) ? modification : [modification];
|
|
3057
|
+
if (modifications.length === 0) {
|
|
3058
|
+
throw new Error("At least one what-if modification is required.");
|
|
3059
|
+
}
|
|
3060
|
+
const baselineResult = analyze_feasibility(baseline.delivery_id, baseline.graph, baseline.today);
|
|
3061
|
+
const scenarioGraph = clone_graph(baseline.graph);
|
|
3062
|
+
for (const next of modifications) {
|
|
3063
|
+
apply_modification(scenarioGraph, baseline.delivery_id, next);
|
|
3064
|
+
}
|
|
3065
|
+
const scenarioResult = analyze_feasibility(baseline.delivery_id, scenarioGraph, baseline.today);
|
|
833
3066
|
return {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
3067
|
+
delivery: baseline.delivery_id,
|
|
3068
|
+
label: modifications.map(modification_label).join(" "),
|
|
3069
|
+
baseline: baselineResult,
|
|
3070
|
+
scenario: scenarioResult,
|
|
3071
|
+
rows: build_rows(baselineResult, scenarioResult)
|
|
838
3072
|
};
|
|
839
3073
|
}
|
|
840
3074
|
|
|
3075
|
+
// src/schema/migration.ts
|
|
3076
|
+
import fs12 from "fs";
|
|
3077
|
+
import path7 from "path";
|
|
3078
|
+
|
|
841
3079
|
// src/schema/version.ts
|
|
842
|
-
import
|
|
843
|
-
import
|
|
3080
|
+
import fs11 from "fs";
|
|
3081
|
+
import path6 from "path";
|
|
844
3082
|
var CURRENT_SCHEMA_VERSION = 2;
|
|
845
3083
|
function schemaVersionFile(coopDir) {
|
|
846
|
-
return
|
|
3084
|
+
return path6.join(coopDir, "schema-version");
|
|
847
3085
|
}
|
|
848
3086
|
function read_schema_version(coopDir) {
|
|
849
3087
|
const filePath = schemaVersionFile(coopDir);
|
|
850
|
-
if (!
|
|
3088
|
+
if (!fs11.existsSync(filePath)) {
|
|
851
3089
|
throw new Error(`Missing schema-version file at ${filePath}.`);
|
|
852
3090
|
}
|
|
853
|
-
const raw =
|
|
3091
|
+
const raw = fs11.readFileSync(filePath, "utf8").trim();
|
|
854
3092
|
if (!raw) {
|
|
855
3093
|
throw new Error(`Schema version file is empty at ${filePath}.`);
|
|
856
3094
|
}
|
|
@@ -865,10 +3103,168 @@ function write_schema_version(coopDir, version) {
|
|
|
865
3103
|
throw new Error(`Schema version must be a positive integer. Received: ${String(version)}.`);
|
|
866
3104
|
}
|
|
867
3105
|
const filePath = schemaVersionFile(coopDir);
|
|
868
|
-
|
|
3106
|
+
fs11.writeFileSync(filePath, `${version}
|
|
869
3107
|
`, "utf8");
|
|
870
3108
|
}
|
|
871
3109
|
|
|
3110
|
+
// src/schema/migration.ts
|
|
3111
|
+
function asString2(value) {
|
|
3112
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
3113
|
+
}
|
|
3114
|
+
function asRecord(value) {
|
|
3115
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
3116
|
+
}
|
|
3117
|
+
function cloneRaw(raw) {
|
|
3118
|
+
return JSON.parse(JSON.stringify(raw));
|
|
3119
|
+
}
|
|
3120
|
+
function estimate_to_three_point(value) {
|
|
3121
|
+
return {
|
|
3122
|
+
optimistic_hours: Number((value * 0.7).toFixed(2)),
|
|
3123
|
+
expected_hours: Number(value.toFixed(2)),
|
|
3124
|
+
pessimistic_hours: Number((value * 1.8).toFixed(2))
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
function walk_files(dirPath, extensions) {
|
|
3128
|
+
if (!fs12.existsSync(dirPath)) return [];
|
|
3129
|
+
const out = [];
|
|
3130
|
+
const entries = fs12.readdirSync(dirPath, { withFileTypes: true });
|
|
3131
|
+
for (const entry of entries) {
|
|
3132
|
+
const fullPath = path7.join(dirPath, entry.name);
|
|
3133
|
+
if (entry.isDirectory()) {
|
|
3134
|
+
out.push(...walk_files(fullPath, extensions));
|
|
3135
|
+
continue;
|
|
3136
|
+
}
|
|
3137
|
+
if (!entry.isFile()) continue;
|
|
3138
|
+
const ext = path7.extname(entry.name).toLowerCase();
|
|
3139
|
+
if (extensions.has(ext)) {
|
|
3140
|
+
out.push(fullPath);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
return out.sort((a, b) => a.localeCompare(b));
|
|
3144
|
+
}
|
|
3145
|
+
function file_mtime_iso(filePath) {
|
|
3146
|
+
const stats = fs12.statSync(filePath);
|
|
3147
|
+
return stats.mtime.toISOString().slice(0, 10);
|
|
3148
|
+
}
|
|
3149
|
+
function migration_v1_to_v2(rawTask, context = {}) {
|
|
3150
|
+
const migrated = { ...rawTask };
|
|
3151
|
+
if (migrated.status === "dropped") {
|
|
3152
|
+
migrated.status = "canceled";
|
|
3153
|
+
}
|
|
3154
|
+
if (migrated.type === "task") {
|
|
3155
|
+
migrated.type = "chore";
|
|
3156
|
+
} else if (migrated.type === "research") {
|
|
3157
|
+
migrated.type = "spike";
|
|
3158
|
+
}
|
|
3159
|
+
if (!Object.prototype.hasOwnProperty.call(migrated, "updated")) {
|
|
3160
|
+
migrated.updated = context.file_mtime_iso ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3161
|
+
}
|
|
3162
|
+
if (!Object.prototype.hasOwnProperty.call(migrated, "assignee")) {
|
|
3163
|
+
migrated.assignee = null;
|
|
3164
|
+
}
|
|
3165
|
+
const deliveryRecord = asRecord(migrated.delivery);
|
|
3166
|
+
if (deliveryRecord) {
|
|
3167
|
+
const include = deliveryRecord.include;
|
|
3168
|
+
if (Array.isArray(include)) {
|
|
3169
|
+
const first = include.find((entry) => typeof entry === "string");
|
|
3170
|
+
migrated.delivery = asString2(first) ?? null;
|
|
3171
|
+
} else if (typeof include === "string" && include.trim().length > 0) {
|
|
3172
|
+
migrated.delivery = include.trim();
|
|
3173
|
+
} else {
|
|
3174
|
+
migrated.delivery = null;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
const estimate = migrated.estimate;
|
|
3178
|
+
if (typeof estimate === "number" && Number.isFinite(estimate) && estimate > 0) {
|
|
3179
|
+
migrated.estimate = estimate_to_three_point(estimate);
|
|
3180
|
+
} else if (asRecord(estimate)) {
|
|
3181
|
+
const estimateRecord = estimate;
|
|
3182
|
+
const hasExpectedHours = typeof estimateRecord.expected_hours === "number" && Number.isFinite(estimateRecord.expected_hours);
|
|
3183
|
+
const hasOptimisticHours = typeof estimateRecord.optimistic_hours === "number" && Number.isFinite(estimateRecord.optimistic_hours);
|
|
3184
|
+
const hasPessimisticHours = typeof estimateRecord.pessimistic_hours === "number" && Number.isFinite(estimateRecord.pessimistic_hours);
|
|
3185
|
+
if (hasExpectedHours && (!hasOptimisticHours || !hasPessimisticHours)) {
|
|
3186
|
+
migrated.estimate = {
|
|
3187
|
+
...estimateRecord,
|
|
3188
|
+
optimistic_hours: hasOptimisticHours ? estimateRecord.optimistic_hours : Number((Number(estimateRecord.expected_hours) * 0.7).toFixed(2)),
|
|
3189
|
+
pessimistic_hours: hasPessimisticHours ? estimateRecord.pessimistic_hours : Number((Number(estimateRecord.expected_hours) * 1.8).toFixed(2))
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
return migrated;
|
|
3194
|
+
}
|
|
3195
|
+
var MIGRATIONS = [
|
|
3196
|
+
{
|
|
3197
|
+
from: 1,
|
|
3198
|
+
to: 2,
|
|
3199
|
+
description: "COOP schema v1 to v2 task migration.",
|
|
3200
|
+
migrate: migration_v1_to_v2
|
|
3201
|
+
}
|
|
3202
|
+
];
|
|
3203
|
+
function migration_for_step(fromVersion, toVersion) {
|
|
3204
|
+
const found = MIGRATIONS.find((migration) => migration.from === fromVersion && migration.to === toVersion);
|
|
3205
|
+
if (!found) {
|
|
3206
|
+
throw new Error(`No schema migration registered for v${fromVersion} -> v${toVersion}.`);
|
|
3207
|
+
}
|
|
3208
|
+
return found;
|
|
3209
|
+
}
|
|
3210
|
+
function migrate_task(rawTask, fromVersion, toVersion, context = {}) {
|
|
3211
|
+
if (!Number.isInteger(fromVersion) || fromVersion <= 0) {
|
|
3212
|
+
throw new Error(`Invalid fromVersion '${String(fromVersion)}'.`);
|
|
3213
|
+
}
|
|
3214
|
+
if (!Number.isInteger(toVersion) || toVersion <= 0) {
|
|
3215
|
+
throw new Error(`Invalid toVersion '${String(toVersion)}'.`);
|
|
3216
|
+
}
|
|
3217
|
+
if (fromVersion > toVersion) {
|
|
3218
|
+
throw new Error(`Downgrade migration is not supported: v${fromVersion} -> v${toVersion}.`);
|
|
3219
|
+
}
|
|
3220
|
+
if (fromVersion === toVersion) {
|
|
3221
|
+
return cloneRaw(rawTask);
|
|
3222
|
+
}
|
|
3223
|
+
let current = cloneRaw(rawTask);
|
|
3224
|
+
for (let version = fromVersion; version < toVersion; version += 1) {
|
|
3225
|
+
const migration = migration_for_step(version, version + 1);
|
|
3226
|
+
current = migration.migrate(current, context);
|
|
3227
|
+
}
|
|
3228
|
+
return current;
|
|
3229
|
+
}
|
|
3230
|
+
function migrate_repository(coopDir, targetVersion = CURRENT_SCHEMA_VERSION, options = {}) {
|
|
3231
|
+
if (!Number.isInteger(targetVersion) || targetVersion <= 0) {
|
|
3232
|
+
throw new Error(`Target version must be a positive integer. Received: ${String(targetVersion)}.`);
|
|
3233
|
+
}
|
|
3234
|
+
const dry_run = Boolean(options.dry_run);
|
|
3235
|
+
const from_version = read_schema_version(coopDir);
|
|
3236
|
+
if (from_version > targetVersion) {
|
|
3237
|
+
throw new Error(`Cannot migrate backwards from v${from_version} to v${targetVersion}.`);
|
|
3238
|
+
}
|
|
3239
|
+
const taskFiles = walk_files(path7.join(coopDir, "tasks"), /* @__PURE__ */ new Set([".md"]));
|
|
3240
|
+
const changed_files = [];
|
|
3241
|
+
for (const filePath of taskFiles) {
|
|
3242
|
+
const { frontmatter, body } = parseFrontmatterFile(filePath);
|
|
3243
|
+
const migrated = migrate_task(frontmatter, from_version, targetVersion, {
|
|
3244
|
+
file_path: filePath,
|
|
3245
|
+
file_mtime_iso: file_mtime_iso(filePath)
|
|
3246
|
+
});
|
|
3247
|
+
const before = JSON.stringify(frontmatter);
|
|
3248
|
+
const after = JSON.stringify(migrated);
|
|
3249
|
+
if (before === after) continue;
|
|
3250
|
+
changed_files.push(filePath);
|
|
3251
|
+
if (!dry_run) {
|
|
3252
|
+
fs12.writeFileSync(filePath, stringifyFrontmatter(migrated, body), "utf8");
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
if (!dry_run && from_version !== targetVersion) {
|
|
3256
|
+
write_schema_version(coopDir, targetVersion);
|
|
3257
|
+
}
|
|
3258
|
+
return {
|
|
3259
|
+
from_version,
|
|
3260
|
+
to_version: targetVersion,
|
|
3261
|
+
dry_run,
|
|
3262
|
+
files_scanned: taskFiles.length,
|
|
3263
|
+
files_changed: changed_files.length,
|
|
3264
|
+
changed_files
|
|
3265
|
+
};
|
|
3266
|
+
}
|
|
3267
|
+
|
|
872
3268
|
// src/state/auto-transitions.ts
|
|
873
3269
|
function toStatusMap(value) {
|
|
874
3270
|
if (!value) return /* @__PURE__ */ new Map();
|
|
@@ -946,6 +3342,14 @@ function toIsoDate(now) {
|
|
|
946
3342
|
const date = now instanceof Date ? now : /* @__PURE__ */ new Date();
|
|
947
3343
|
return date.toISOString().slice(0, 10);
|
|
948
3344
|
}
|
|
3345
|
+
function toIsoTimestamp2(now) {
|
|
3346
|
+
if (typeof now === "string") {
|
|
3347
|
+
if (now.includes("T")) return now;
|
|
3348
|
+
return `${now}T00:00:00.000Z`;
|
|
3349
|
+
}
|
|
3350
|
+
const date = now instanceof Date ? now : /* @__PURE__ */ new Date();
|
|
3351
|
+
return date.toISOString();
|
|
3352
|
+
}
|
|
949
3353
|
function validateGovernanceAndDeps(task, targetStatus, context) {
|
|
950
3354
|
if (task.status === "in_review" && targetStatus === "done" && task.governance?.approval_required) {
|
|
951
3355
|
const reviewer = task.governance.reviewer;
|
|
@@ -982,13 +3386,26 @@ function transition(task, targetStatus, context = {}) {
|
|
|
982
3386
|
error: governanceError
|
|
983
3387
|
};
|
|
984
3388
|
}
|
|
3389
|
+
const nextTask = {
|
|
3390
|
+
...task,
|
|
3391
|
+
status: targetStatus,
|
|
3392
|
+
updated: toIsoDate(context.now)
|
|
3393
|
+
};
|
|
3394
|
+
if (targetStatus !== task.status) {
|
|
3395
|
+
context.eventEmitter?.emit({
|
|
3396
|
+
type: "task.transitioned",
|
|
3397
|
+
timestamp: toIsoTimestamp2(context.now),
|
|
3398
|
+
payload: {
|
|
3399
|
+
task_id: task.id,
|
|
3400
|
+
from: task.status,
|
|
3401
|
+
to: targetStatus,
|
|
3402
|
+
actor: context.actor
|
|
3403
|
+
}
|
|
3404
|
+
});
|
|
3405
|
+
}
|
|
985
3406
|
return {
|
|
986
3407
|
success: true,
|
|
987
|
-
task:
|
|
988
|
-
...task,
|
|
989
|
-
status: targetStatus,
|
|
990
|
-
updated: toIsoDate(context.now)
|
|
991
|
-
}
|
|
3408
|
+
task: nextTask
|
|
992
3409
|
};
|
|
993
3410
|
}
|
|
994
3411
|
|
|
@@ -1206,8 +3623,9 @@ function validateSemantic(task, context = {}) {
|
|
|
1206
3623
|
}
|
|
1207
3624
|
|
|
1208
3625
|
// src/validator/structural.ts
|
|
1209
|
-
import
|
|
1210
|
-
var ID_PATTERN = /^[A-
|
|
3626
|
+
import path8 from "path";
|
|
3627
|
+
var ID_PATTERN = /^[A-Z0-9]+(?:-[A-Z0-9]+)+$/;
|
|
3628
|
+
var ALIAS_PATTERN = /^[A-Z0-9]+(?:[.-][A-Z0-9]+)*$/;
|
|
1211
3629
|
var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
1212
3630
|
function error4(field, rule, message) {
|
|
1213
3631
|
return { level: "error", field, rule, message };
|
|
@@ -1249,13 +3667,43 @@ function validateStructural(task, context = {}) {
|
|
|
1249
3667
|
errors.push(error4("updated", "struct.updated_iso_date", `Field 'updated' must be an ISO date (YYYY-MM-DD).`));
|
|
1250
3668
|
}
|
|
1251
3669
|
if (context.filePath) {
|
|
1252
|
-
const expected =
|
|
3670
|
+
const expected = path8.basename(context.filePath, path8.extname(context.filePath));
|
|
1253
3671
|
if (typeof task.id === "string" && task.id !== expected) {
|
|
1254
3672
|
errors.push(
|
|
1255
3673
|
error4("id", "struct.id_matches_filename", `Task id '${task.id}' must match filename '${expected}'.`)
|
|
1256
3674
|
);
|
|
1257
3675
|
}
|
|
1258
3676
|
}
|
|
3677
|
+
const aliases = task.aliases;
|
|
3678
|
+
if (aliases !== void 0) {
|
|
3679
|
+
if (!Array.isArray(aliases)) {
|
|
3680
|
+
errors.push(error4("aliases", "struct.aliases_array", "Field 'aliases' must be an array of strings."));
|
|
3681
|
+
} else {
|
|
3682
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3683
|
+
for (const rawAlias of aliases) {
|
|
3684
|
+
if (typeof rawAlias !== "string" || rawAlias.trim().length === 0) {
|
|
3685
|
+
errors.push(error4("aliases", "struct.aliases_string", "Field 'aliases' entries must be non-empty strings."));
|
|
3686
|
+
continue;
|
|
3687
|
+
}
|
|
3688
|
+
const normalized = rawAlias.trim().toUpperCase().replace(/_/g, ".");
|
|
3689
|
+
if (!ALIAS_PATTERN.test(normalized)) {
|
|
3690
|
+
errors.push(
|
|
3691
|
+
error4(
|
|
3692
|
+
"aliases",
|
|
3693
|
+
"struct.aliases_pattern",
|
|
3694
|
+
`Alias '${rawAlias}' is invalid. Use letters/numbers with '.' or '-' separators.`
|
|
3695
|
+
)
|
|
3696
|
+
);
|
|
3697
|
+
continue;
|
|
3698
|
+
}
|
|
3699
|
+
if (seen.has(normalized)) {
|
|
3700
|
+
errors.push(error4("aliases", "struct.aliases_unique", `Alias '${rawAlias}' is duplicated.`));
|
|
3701
|
+
continue;
|
|
3702
|
+
}
|
|
3703
|
+
seen.add(normalized);
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
1259
3707
|
return errors;
|
|
1260
3708
|
}
|
|
1261
3709
|
|
|
@@ -1343,9 +3791,131 @@ function validate(task, context = {}) {
|
|
|
1343
3791
|
};
|
|
1344
3792
|
}
|
|
1345
3793
|
|
|
3794
|
+
// src/workspace.ts
|
|
3795
|
+
import fs13 from "fs";
|
|
3796
|
+
import path9 from "path";
|
|
3797
|
+
var COOP_DIR_NAME = ".coop";
|
|
3798
|
+
function sanitizeProjectId(value, fallback) {
|
|
3799
|
+
const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
|
|
3800
|
+
return normalized || fallback;
|
|
3801
|
+
}
|
|
3802
|
+
function coop_workspace_dir(repoRoot) {
|
|
3803
|
+
return path9.join(path9.resolve(repoRoot), COOP_DIR_NAME);
|
|
3804
|
+
}
|
|
3805
|
+
function coop_projects_dir(repoRoot) {
|
|
3806
|
+
return path9.join(coop_workspace_dir(repoRoot), "projects");
|
|
3807
|
+
}
|
|
3808
|
+
function coop_workspace_config_path(repoRoot) {
|
|
3809
|
+
return path9.join(coop_workspace_dir(repoRoot), "config.yml");
|
|
3810
|
+
}
|
|
3811
|
+
function coop_project_root(repoRoot, projectId) {
|
|
3812
|
+
return path9.join(coop_projects_dir(repoRoot), projectId);
|
|
3813
|
+
}
|
|
3814
|
+
function coop_project_config_path(projectRoot) {
|
|
3815
|
+
return path9.join(projectRoot, "config.yml");
|
|
3816
|
+
}
|
|
3817
|
+
function repo_default_project_id(repoRoot) {
|
|
3818
|
+
return sanitizeProjectId(path9.basename(path9.resolve(repoRoot)), "workspace");
|
|
3819
|
+
}
|
|
3820
|
+
function repo_default_project_name(repoRoot) {
|
|
3821
|
+
const base = path9.basename(path9.resolve(repoRoot)).trim();
|
|
3822
|
+
return base || "COOP Workspace";
|
|
3823
|
+
}
|
|
3824
|
+
function has_v2_projects_layout(repoRoot) {
|
|
3825
|
+
return fs13.existsSync(coop_projects_dir(repoRoot));
|
|
3826
|
+
}
|
|
3827
|
+
function has_legacy_project_layout(repoRoot) {
|
|
3828
|
+
const workspaceDir = coop_workspace_dir(repoRoot);
|
|
3829
|
+
return fs13.existsSync(workspaceDir) && fs13.existsSync(path9.join(workspaceDir, "config.yml")) && !fs13.existsSync(coop_projects_dir(repoRoot));
|
|
3830
|
+
}
|
|
3831
|
+
function read_workspace_config(repoRoot) {
|
|
3832
|
+
const configPath = coop_workspace_config_path(repoRoot);
|
|
3833
|
+
if (!fs13.existsSync(configPath) || has_legacy_project_layout(repoRoot)) {
|
|
3834
|
+
return { version: 2 };
|
|
3835
|
+
}
|
|
3836
|
+
return parseYamlFile(configPath);
|
|
3837
|
+
}
|
|
3838
|
+
function write_workspace_config(repoRoot, config) {
|
|
3839
|
+
fs13.mkdirSync(coop_workspace_dir(repoRoot), { recursive: true });
|
|
3840
|
+
writeYamlFile(coop_workspace_config_path(repoRoot), {
|
|
3841
|
+
version: config.version ?? 2,
|
|
3842
|
+
...config.current_project ? { current_project: config.current_project } : {}
|
|
3843
|
+
});
|
|
3844
|
+
}
|
|
3845
|
+
function read_project_config(projectRoot) {
|
|
3846
|
+
return parseYamlFile(coop_project_config_path(projectRoot));
|
|
3847
|
+
}
|
|
3848
|
+
function project_ref_from_config(repoRoot, projectRoot, layout) {
|
|
3849
|
+
const config = read_project_config(projectRoot);
|
|
3850
|
+
const repoName = repo_default_project_name(repoRoot);
|
|
3851
|
+
const fallbackId = repo_default_project_id(repoRoot);
|
|
3852
|
+
return {
|
|
3853
|
+
id: sanitizeProjectId(config.project?.id ?? fallbackId, fallbackId),
|
|
3854
|
+
name: config.project?.name?.trim() || repoName,
|
|
3855
|
+
aliases: Array.isArray(config.project?.aliases) ? config.project.aliases.filter((entry) => typeof entry === "string" && entry.trim().length > 0) : [],
|
|
3856
|
+
root: projectRoot,
|
|
3857
|
+
repo_root: path9.resolve(repoRoot),
|
|
3858
|
+
layout
|
|
3859
|
+
};
|
|
3860
|
+
}
|
|
3861
|
+
function list_projects(repoRoot) {
|
|
3862
|
+
if (has_v2_projects_layout(repoRoot)) {
|
|
3863
|
+
const projectsDir = coop_projects_dir(repoRoot);
|
|
3864
|
+
return fs13.readdirSync(projectsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path9.join(projectsDir, entry.name)).filter((projectRoot) => fs13.existsSync(coop_project_config_path(projectRoot))).map((projectRoot) => project_ref_from_config(repoRoot, projectRoot, "v2")).sort((a, b) => a.id.localeCompare(b.id));
|
|
3865
|
+
}
|
|
3866
|
+
if (has_legacy_project_layout(repoRoot)) {
|
|
3867
|
+
return [project_ref_from_config(repoRoot, coop_workspace_dir(repoRoot), "legacy")];
|
|
3868
|
+
}
|
|
3869
|
+
return [];
|
|
3870
|
+
}
|
|
3871
|
+
function resolve_project(repoRoot, options = {}) {
|
|
3872
|
+
const projects = list_projects(repoRoot);
|
|
3873
|
+
const requested = options.project?.trim().toLowerCase();
|
|
3874
|
+
if (requested) {
|
|
3875
|
+
const match = projects.find(
|
|
3876
|
+
(project) => project.id.toLowerCase() === requested || project.name.toLowerCase() === requested || project.aliases.some((alias) => alias.toLowerCase() === requested)
|
|
3877
|
+
);
|
|
3878
|
+
if (!match) {
|
|
3879
|
+
throw new Error(`Project '${options.project}' not found.`);
|
|
3880
|
+
}
|
|
3881
|
+
return match;
|
|
3882
|
+
}
|
|
3883
|
+
if (projects.length === 1) {
|
|
3884
|
+
return projects[0];
|
|
3885
|
+
}
|
|
3886
|
+
const workspaceConfig = read_workspace_config(repoRoot);
|
|
3887
|
+
if (workspaceConfig.current_project) {
|
|
3888
|
+
const match = projects.find((project) => project.id === workspaceConfig.current_project);
|
|
3889
|
+
if (match) return match;
|
|
3890
|
+
}
|
|
3891
|
+
if (!options.require && projects.length === 0) {
|
|
3892
|
+
return {
|
|
3893
|
+
id: repo_default_project_id(repoRoot),
|
|
3894
|
+
name: repo_default_project_name(repoRoot),
|
|
3895
|
+
aliases: [],
|
|
3896
|
+
root: coop_project_root(repoRoot, repo_default_project_id(repoRoot)),
|
|
3897
|
+
repo_root: path9.resolve(repoRoot),
|
|
3898
|
+
layout: "v2"
|
|
3899
|
+
};
|
|
3900
|
+
}
|
|
3901
|
+
if (projects.length === 0) {
|
|
3902
|
+
throw new Error("No COOP project found. Run 'coop init'.");
|
|
3903
|
+
}
|
|
3904
|
+
throw new Error("Multiple COOP projects found. Pass --project <id> or run 'coop project use <id>'.");
|
|
3905
|
+
}
|
|
3906
|
+
function ensure_workspace_layout(repoRoot) {
|
|
3907
|
+
const workspaceDir = coop_workspace_dir(repoRoot);
|
|
3908
|
+
fs13.mkdirSync(workspaceDir, { recursive: true });
|
|
3909
|
+
fs13.mkdirSync(coop_projects_dir(repoRoot), { recursive: true });
|
|
3910
|
+
return workspaceDir;
|
|
3911
|
+
}
|
|
3912
|
+
function is_project_initialized(projectRoot) {
|
|
3913
|
+
return fs13.existsSync(coop_project_config_path(projectRoot));
|
|
3914
|
+
}
|
|
3915
|
+
|
|
1346
3916
|
// src/core.ts
|
|
1347
|
-
import
|
|
1348
|
-
import
|
|
3917
|
+
import fs14 from "fs";
|
|
3918
|
+
import path10 from "path";
|
|
1349
3919
|
import matter from "gray-matter";
|
|
1350
3920
|
|
|
1351
3921
|
// src/types.ts
|
|
@@ -1362,7 +3932,7 @@ var ITEM_DIRS = {
|
|
|
1362
3932
|
spike: "spikes"
|
|
1363
3933
|
};
|
|
1364
3934
|
var COOP_DIR = ".coop";
|
|
1365
|
-
var
|
|
3935
|
+
var DEFAULT_CONFIG2 = {
|
|
1366
3936
|
spec_version: 1,
|
|
1367
3937
|
id_prefix: "COOP",
|
|
1368
3938
|
id_strategy: "text"
|
|
@@ -1371,17 +3941,17 @@ function toIdKey(value) {
|
|
|
1371
3941
|
return value.trim().toUpperCase();
|
|
1372
3942
|
}
|
|
1373
3943
|
function repoRootByPackage(cwd) {
|
|
1374
|
-
let current =
|
|
3944
|
+
let current = path10.resolve(cwd);
|
|
1375
3945
|
let lastWorkspaceRoot = null;
|
|
1376
3946
|
while (true) {
|
|
1377
|
-
const packageJson =
|
|
1378
|
-
const workspaceYaml =
|
|
1379
|
-
if (
|
|
3947
|
+
const packageJson = path10.join(current, "package.json");
|
|
3948
|
+
const workspaceYaml = path10.join(current, "pnpm-workspace.yaml");
|
|
3949
|
+
if (fs14.existsSync(packageJson) && fs14.existsSync(workspaceYaml)) {
|
|
1380
3950
|
lastWorkspaceRoot = current;
|
|
1381
|
-
const hasCoop =
|
|
3951
|
+
const hasCoop = fs14.existsSync(path10.join(current, COOP_DIR, "config.yml"));
|
|
1382
3952
|
if (hasCoop) return current;
|
|
1383
3953
|
}
|
|
1384
|
-
const parent =
|
|
3954
|
+
const parent = path10.dirname(current);
|
|
1385
3955
|
if (parent === current) return lastWorkspaceRoot;
|
|
1386
3956
|
current = parent;
|
|
1387
3957
|
}
|
|
@@ -1390,24 +3960,24 @@ function findRepoRoot(cwd = process.cwd()) {
|
|
|
1390
3960
|
return repoRootByPackage(cwd);
|
|
1391
3961
|
}
|
|
1392
3962
|
function configPathFor(rootDir, workspaceDir) {
|
|
1393
|
-
return
|
|
3963
|
+
return path10.join(rootDir, workspaceDir, "config.yml");
|
|
1394
3964
|
}
|
|
1395
3965
|
function backlogPathFor(rootDir, workspaceDir) {
|
|
1396
|
-
return
|
|
3966
|
+
return path10.join(rootDir, workspaceDir, "backlog");
|
|
1397
3967
|
}
|
|
1398
3968
|
function releasesPathFor(rootDir, workspaceDir) {
|
|
1399
|
-
return
|
|
3969
|
+
return path10.join(rootDir, workspaceDir, "releases");
|
|
1400
3970
|
}
|
|
1401
3971
|
function detectWorkspaceDir(rootDir) {
|
|
1402
|
-
if (
|
|
1403
|
-
if (
|
|
3972
|
+
if (fs14.existsSync(configPathFor(rootDir, COOP_DIR))) return COOP_DIR;
|
|
3973
|
+
if (fs14.existsSync(path10.join(rootDir, COOP_DIR))) return COOP_DIR;
|
|
1404
3974
|
return null;
|
|
1405
3975
|
}
|
|
1406
3976
|
function preferredWorkspaceDir(rootDir) {
|
|
1407
3977
|
return detectWorkspaceDir(rootDir) ?? COOP_DIR;
|
|
1408
3978
|
}
|
|
1409
3979
|
function missingConfigError(rootDir) {
|
|
1410
|
-
const coopConfig =
|
|
3980
|
+
const coopConfig = path10.relative(rootDir, configPathFor(rootDir, COOP_DIR));
|
|
1411
3981
|
return new Error(`COOP config missing at ${coopConfig}. Run: coop init`);
|
|
1412
3982
|
}
|
|
1413
3983
|
function parseConfig(raw) {
|
|
@@ -1418,9 +3988,9 @@ function parseConfig(raw) {
|
|
|
1418
3988
|
if (ix <= 0) continue;
|
|
1419
3989
|
map.set(line.slice(0, ix).trim(), line.slice(ix + 1).trim());
|
|
1420
3990
|
}
|
|
1421
|
-
const spec = Number(map.get("spec_version") ??
|
|
1422
|
-
const idPrefix = map.get("id_prefix") ??
|
|
1423
|
-
const strategy = String(map.get("id_strategy") ??
|
|
3991
|
+
const spec = Number(map.get("spec_version") ?? DEFAULT_CONFIG2.spec_version);
|
|
3992
|
+
const idPrefix = map.get("id_prefix") ?? DEFAULT_CONFIG2.id_prefix;
|
|
3993
|
+
const strategy = String(map.get("id_strategy") ?? DEFAULT_CONFIG2.id_strategy ?? "text").trim().toLowerCase();
|
|
1424
3994
|
const rawNextId = map.get("next_id");
|
|
1425
3995
|
const nextId = rawNextId != null ? Number(rawNextId) : void 0;
|
|
1426
3996
|
if (!Number.isInteger(spec) || spec <= 0) throw new Error("config.yml must define a numeric spec_version.");
|
|
@@ -1453,10 +4023,10 @@ function configToString(config) {
|
|
|
1453
4023
|
return lines.join("\n");
|
|
1454
4024
|
}
|
|
1455
4025
|
function toPortablePath(value) {
|
|
1456
|
-
return value.split(
|
|
4026
|
+
return value.split(path10.sep).join("/");
|
|
1457
4027
|
}
|
|
1458
4028
|
function ensureReleasesDir(rootDir, workspaceDir) {
|
|
1459
|
-
|
|
4029
|
+
fs14.mkdirSync(releasesPathFor(rootDir, workspaceDir), { recursive: true });
|
|
1460
4030
|
}
|
|
1461
4031
|
function releaseHeader(date) {
|
|
1462
4032
|
return `## ${date}`;
|
|
@@ -1478,12 +4048,12 @@ function appendReleaseEntry(rootDir, workspaceDir, item, previousStatus, nextSta
|
|
|
1478
4048
|
ensureReleasesDir(rootDir, workspaceDir);
|
|
1479
4049
|
const now = /* @__PURE__ */ new Date();
|
|
1480
4050
|
const date = now.toISOString().slice(0, 10);
|
|
1481
|
-
const releasePath =
|
|
4051
|
+
const releasePath = path10.join(releasesPathFor(rootDir, workspaceDir), `${date}.md`);
|
|
1482
4052
|
const heading = "# COOP Release Notes";
|
|
1483
4053
|
const dayHeader = releaseHeader(date);
|
|
1484
4054
|
const entry = releaseEntryLine(item, previousStatus, nextStatus);
|
|
1485
|
-
if (!
|
|
1486
|
-
|
|
4055
|
+
if (!fs14.existsSync(releasePath)) {
|
|
4056
|
+
fs14.writeFileSync(
|
|
1487
4057
|
releasePath,
|
|
1488
4058
|
[
|
|
1489
4059
|
`${heading}
|
|
@@ -1496,10 +4066,10 @@ function appendReleaseEntry(rootDir, workspaceDir, item, previousStatus, nextSta
|
|
|
1496
4066
|
].join("\n"),
|
|
1497
4067
|
"utf8"
|
|
1498
4068
|
);
|
|
1499
|
-
return toPortablePath(
|
|
4069
|
+
return toPortablePath(path10.relative(rootDir, releasePath));
|
|
1500
4070
|
}
|
|
1501
|
-
const existing =
|
|
1502
|
-
if (hasReleaseEntry(existing, item.id)) return toPortablePath(
|
|
4071
|
+
const existing = fs14.readFileSync(releasePath, "utf8");
|
|
4072
|
+
if (hasReleaseEntry(existing, item.id)) return toPortablePath(path10.relative(rootDir, releasePath));
|
|
1503
4073
|
let nextContent = existing;
|
|
1504
4074
|
if (!existing.includes(`## ${date}`)) {
|
|
1505
4075
|
if (!nextContent.endsWith("\n")) nextContent += "\n";
|
|
@@ -1509,9 +4079,9 @@ function appendReleaseEntry(rootDir, workspaceDir, item, previousStatus, nextSta
|
|
|
1509
4079
|
if (!nextContent.endsWith("\n")) nextContent += "\n";
|
|
1510
4080
|
nextContent += `${entry}
|
|
1511
4081
|
`;
|
|
1512
|
-
|
|
4082
|
+
fs14.writeFileSync(releasePath, `${nextContent}
|
|
1513
4083
|
`, "utf8");
|
|
1514
|
-
return toPortablePath(
|
|
4084
|
+
return toPortablePath(path10.relative(rootDir, releasePath));
|
|
1515
4085
|
}
|
|
1516
4086
|
function completeItem(rootDir, id) {
|
|
1517
4087
|
const state = loadState(rootDir);
|
|
@@ -1589,28 +4159,28 @@ function validateAndNormalize(data, sourceFile) {
|
|
|
1589
4159
|
};
|
|
1590
4160
|
}
|
|
1591
4161
|
function parseItem(filePath, rootDir) {
|
|
1592
|
-
const raw =
|
|
4162
|
+
const raw = fs14.readFileSync(filePath, "utf8");
|
|
1593
4163
|
const parsed = matter(raw);
|
|
1594
|
-
const data = validateAndNormalize(parsed.data,
|
|
4164
|
+
const data = validateAndNormalize(parsed.data, path10.relative(rootDir, filePath));
|
|
1595
4165
|
return {
|
|
1596
4166
|
...data,
|
|
1597
4167
|
body: parsed.content || "",
|
|
1598
|
-
filePath:
|
|
4168
|
+
filePath: path10.relative(rootDir, filePath)
|
|
1599
4169
|
};
|
|
1600
4170
|
}
|
|
1601
4171
|
function walk(dir) {
|
|
1602
4172
|
const out = [];
|
|
1603
|
-
if (!
|
|
1604
|
-
const entries =
|
|
4173
|
+
if (!fs14.existsSync(dir)) return out;
|
|
4174
|
+
const entries = fs14.readdirSync(dir, { withFileTypes: true });
|
|
1605
4175
|
for (const entry of entries) {
|
|
1606
|
-
const file =
|
|
4176
|
+
const file = path10.join(dir, entry.name);
|
|
1607
4177
|
if (entry.isDirectory()) out.push(...walk(file));
|
|
1608
4178
|
if (entry.isFile() && file.endsWith(".md")) out.push(file);
|
|
1609
4179
|
}
|
|
1610
4180
|
return out;
|
|
1611
4181
|
}
|
|
1612
4182
|
function itemPath(type, id, rootDir, workspaceDir) {
|
|
1613
|
-
return
|
|
4183
|
+
return path10.join(backlogPathFor(rootDir, workspaceDir), ITEM_DIRS[type], `${id}.md`);
|
|
1614
4184
|
}
|
|
1615
4185
|
function normalizeFrontmatterValue(value) {
|
|
1616
4186
|
if (value == null) return void 0;
|
|
@@ -1697,30 +4267,30 @@ function nextGeneratedId(config, title, existing) {
|
|
|
1697
4267
|
}
|
|
1698
4268
|
function ensureCoopLayout(rootDir) {
|
|
1699
4269
|
const workspaceDir = preferredWorkspaceDir(rootDir);
|
|
1700
|
-
const root =
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
4270
|
+
const root = path10.join(rootDir, workspaceDir);
|
|
4271
|
+
fs14.mkdirSync(root, { recursive: true });
|
|
4272
|
+
fs14.mkdirSync(path10.join(root, "releases"), { recursive: true });
|
|
4273
|
+
fs14.mkdirSync(path10.join(root, "plans"), { recursive: true });
|
|
4274
|
+
fs14.mkdirSync(path10.join(root, "views"), { recursive: true });
|
|
4275
|
+
fs14.mkdirSync(path10.join(root, "templates"), { recursive: true });
|
|
1706
4276
|
for (const dir of Object.values(ITEM_DIRS)) {
|
|
1707
|
-
|
|
4277
|
+
fs14.mkdirSync(path10.join(root, "backlog", dir), { recursive: true });
|
|
1708
4278
|
}
|
|
1709
|
-
const configFile =
|
|
1710
|
-
if (!
|
|
1711
|
-
|
|
4279
|
+
const configFile = path10.join(root, "config.yml");
|
|
4280
|
+
if (!fs14.existsSync(configFile)) {
|
|
4281
|
+
fs14.writeFileSync(configFile, configToString(DEFAULT_CONFIG2), "utf8");
|
|
1712
4282
|
}
|
|
1713
4283
|
}
|
|
1714
4284
|
function loadState(rootDir) {
|
|
1715
4285
|
const workspaceDir = detectWorkspaceDir(rootDir);
|
|
1716
4286
|
if (!workspaceDir) throw missingConfigError(rootDir);
|
|
1717
4287
|
const configPath = configPathFor(rootDir, workspaceDir);
|
|
1718
|
-
if (!
|
|
1719
|
-
const config = parseConfig(
|
|
4288
|
+
if (!fs14.existsSync(configPath)) throw missingConfigError(rootDir);
|
|
4289
|
+
const config = parseConfig(fs14.readFileSync(configPath, "utf8"));
|
|
1720
4290
|
const items = [];
|
|
1721
4291
|
const itemsById = /* @__PURE__ */ new Map();
|
|
1722
4292
|
for (const type of ITEM_TYPES) {
|
|
1723
|
-
const dir =
|
|
4293
|
+
const dir = path10.join(backlogPathFor(rootDir, workspaceDir), ITEM_DIRS[type]);
|
|
1724
4294
|
const files = walk(dir);
|
|
1725
4295
|
for (const file of files) {
|
|
1726
4296
|
const item = parseItem(file, rootDir);
|
|
@@ -1804,21 +4374,21 @@ function createItem(rootDir, params) {
|
|
|
1804
4374
|
parent_id: params.parent_id
|
|
1805
4375
|
};
|
|
1806
4376
|
const itemPathName = itemPath(params.type, item.id, rootDir, state.workspaceDir);
|
|
1807
|
-
|
|
4377
|
+
fs14.writeFileSync(itemPathName, serialize(item, params.body || ""), "utf8");
|
|
1808
4378
|
if ((config.id_strategy ?? "text") === "counter") {
|
|
1809
4379
|
const numericMatch = /-(\d+)$/.exec(item.id);
|
|
1810
4380
|
if (numericMatch) {
|
|
1811
4381
|
const numericValue = Number(numericMatch[1]);
|
|
1812
4382
|
if (Number.isInteger(numericValue) && numericValue >= (config.next_id ?? 1)) {
|
|
1813
4383
|
config.next_id = numericValue + 1;
|
|
1814
|
-
|
|
4384
|
+
fs14.writeFileSync(configPathFor(rootDir, state.workspaceDir), configToString(config), "utf8");
|
|
1815
4385
|
}
|
|
1816
4386
|
}
|
|
1817
4387
|
}
|
|
1818
4388
|
return {
|
|
1819
4389
|
...item,
|
|
1820
4390
|
body: params.body || "",
|
|
1821
|
-
filePath:
|
|
4391
|
+
filePath: path10.relative(rootDir, itemPathName)
|
|
1822
4392
|
};
|
|
1823
4393
|
}
|
|
1824
4394
|
function updateItem(rootDir, id, patch) {
|
|
@@ -1844,8 +4414,8 @@ function updateItem(rootDir, id, patch) {
|
|
|
1844
4414
|
};
|
|
1845
4415
|
if (!ITEM_TYPES.includes(next.type)) throw new Error(`Unknown type ${next.type}.`);
|
|
1846
4416
|
if (!ITEM_STATUSES.includes(next.status)) throw new Error(`Unknown status ${next.status}.`);
|
|
1847
|
-
const filePath =
|
|
1848
|
-
|
|
4417
|
+
const filePath = path10.join(rootDir, existing.filePath);
|
|
4418
|
+
fs14.writeFileSync(filePath, serialize(next, patch.body || existing.body), "utf8");
|
|
1849
4419
|
return {
|
|
1850
4420
|
...next,
|
|
1851
4421
|
body: patch.body || existing.body,
|
|
@@ -1861,7 +4431,7 @@ function deleteItem(rootDir, id) {
|
|
|
1861
4431
|
if (children.length > 0) {
|
|
1862
4432
|
throw new Error(`Cannot delete ${existing.id} because it has ${children.length} child item(s). Remove children first.`);
|
|
1863
4433
|
}
|
|
1864
|
-
|
|
4434
|
+
fs14.unlinkSync(path10.join(rootDir, existing.filePath));
|
|
1865
4435
|
return existing;
|
|
1866
4436
|
}
|
|
1867
4437
|
function renderAgentPrompt(item) {
|
|
@@ -1900,8 +4470,8 @@ function validateRepo(rootDir) {
|
|
|
1900
4470
|
const errors = [];
|
|
1901
4471
|
const warnings = [];
|
|
1902
4472
|
const workspaceDir = detectWorkspaceDir(rootDir);
|
|
1903
|
-
if (!workspaceDir || !
|
|
1904
|
-
errors.push("Missing
|
|
4473
|
+
if (!workspaceDir || !fs14.existsSync(configPathFor(rootDir, workspaceDir))) {
|
|
4474
|
+
errors.push("Missing COOP config. Run coop init first.");
|
|
1905
4475
|
return { valid: false, errors, warnings };
|
|
1906
4476
|
}
|
|
1907
4477
|
let state;
|
|
@@ -1933,13 +4503,20 @@ function validateRepo(rootDir) {
|
|
|
1933
4503
|
}
|
|
1934
4504
|
export {
|
|
1935
4505
|
ArtifactType,
|
|
4506
|
+
COOP_EVENT_TYPES,
|
|
1936
4507
|
CURRENT_SCHEMA_VERSION,
|
|
4508
|
+
CoopEventEmitter,
|
|
4509
|
+
DEFAULT_SCORE_WEIGHTS,
|
|
1937
4510
|
DeliveryStatus,
|
|
1938
4511
|
ExecutorType,
|
|
1939
4512
|
ITEM_STATUSES,
|
|
1940
4513
|
ITEM_TYPES,
|
|
1941
4514
|
IdeaStatus,
|
|
4515
|
+
IndexManager,
|
|
4516
|
+
MIGRATIONS,
|
|
1942
4517
|
RiskLevel,
|
|
4518
|
+
RunStatus,
|
|
4519
|
+
RunStepStatus,
|
|
1943
4520
|
RunbookAction,
|
|
1944
4521
|
TaskComplexity,
|
|
1945
4522
|
TaskDeterminism,
|
|
@@ -1948,23 +4525,63 @@ export {
|
|
|
1948
4525
|
TaskType,
|
|
1949
4526
|
VALID_TASK_TRANSITIONS,
|
|
1950
4527
|
VALID_TRANSITIONS,
|
|
4528
|
+
allocate,
|
|
4529
|
+
allocate_ai,
|
|
4530
|
+
allocate_ai_tokens,
|
|
4531
|
+
analyze_feasibility,
|
|
4532
|
+
analyze_what_if,
|
|
4533
|
+
build_capacity_ledger,
|
|
1951
4534
|
build_graph,
|
|
1952
4535
|
check_blocked,
|
|
4536
|
+
check_permission,
|
|
1953
4537
|
check_unblocked,
|
|
4538
|
+
check_wip,
|
|
1954
4539
|
completeItem,
|
|
4540
|
+
complexity_penalty,
|
|
1955
4541
|
compute_all_readiness,
|
|
4542
|
+
compute_critical_path,
|
|
1956
4543
|
compute_readiness,
|
|
1957
4544
|
compute_readiness_with_corrections,
|
|
4545
|
+
compute_score,
|
|
4546
|
+
compute_velocity,
|
|
4547
|
+
coop_project_config_path,
|
|
4548
|
+
coop_project_root,
|
|
4549
|
+
coop_projects_dir,
|
|
4550
|
+
coop_workspace_config_path,
|
|
4551
|
+
coop_workspace_dir,
|
|
1958
4552
|
createItem,
|
|
4553
|
+
create_seeded_rng,
|
|
4554
|
+
critical_path_weight,
|
|
1959
4555
|
deleteItem,
|
|
4556
|
+
dependency_unlock_weight,
|
|
1960
4557
|
detect_cycle,
|
|
4558
|
+
detect_delivery_risks,
|
|
4559
|
+
determinism_weight,
|
|
4560
|
+
effective_weekly_hours,
|
|
4561
|
+
effort_or_default,
|
|
1961
4562
|
ensureCoopLayout,
|
|
4563
|
+
ensure_workspace_layout,
|
|
4564
|
+
executor_fit_weight,
|
|
4565
|
+
external_dependencies_for_task,
|
|
1962
4566
|
extract_subgraph,
|
|
1963
4567
|
findRepoRoot,
|
|
1964
4568
|
find_external_dependencies,
|
|
1965
4569
|
getItemById,
|
|
4570
|
+
get_remaining_tokens,
|
|
4571
|
+
get_user_role,
|
|
4572
|
+
has_legacy_project_layout,
|
|
4573
|
+
has_v2_projects_layout,
|
|
4574
|
+
is_external_dependency,
|
|
4575
|
+
is_project_initialized,
|
|
4576
|
+
list_projects,
|
|
1966
4577
|
loadState,
|
|
4578
|
+
load_auth_config,
|
|
4579
|
+
load_completed_runs,
|
|
1967
4580
|
load_graph,
|
|
4581
|
+
load_plugins,
|
|
4582
|
+
migrate_repository,
|
|
4583
|
+
migrate_task,
|
|
4584
|
+
monte_carlo_forecast,
|
|
1968
4585
|
parseDeliveryContent,
|
|
1969
4586
|
parseDeliveryFile,
|
|
1970
4587
|
parseFrontmatterContent,
|
|
@@ -1975,16 +4592,38 @@ export {
|
|
|
1975
4592
|
parseTaskFile,
|
|
1976
4593
|
parseYamlContent,
|
|
1977
4594
|
parseYamlFile,
|
|
4595
|
+
parse_external_dependency,
|
|
1978
4596
|
partition_by_readiness,
|
|
4597
|
+
pert_hours,
|
|
4598
|
+
pert_stddev,
|
|
4599
|
+
priority_weight,
|
|
1979
4600
|
queryItems,
|
|
4601
|
+
read_project_config,
|
|
1980
4602
|
read_schema_version,
|
|
4603
|
+
read_workspace_config,
|
|
1981
4604
|
renderAgentPrompt,
|
|
4605
|
+
repo_default_project_id,
|
|
4606
|
+
repo_default_project_name,
|
|
4607
|
+
resolve_external_dependencies,
|
|
4608
|
+
resolve_project,
|
|
4609
|
+
risk_penalty,
|
|
4610
|
+
run_hook,
|
|
4611
|
+
run_monte_carlo_chunk,
|
|
4612
|
+
run_plugins_for_event,
|
|
4613
|
+
sample_pert_beta,
|
|
4614
|
+
sample_task_hours,
|
|
4615
|
+
schedule_next,
|
|
4616
|
+
simulate_schedule,
|
|
1982
4617
|
stringifyFrontmatter,
|
|
4618
|
+
stringifyYamlContent,
|
|
4619
|
+
task_effort_hours,
|
|
1983
4620
|
topological_sort,
|
|
1984
4621
|
transition,
|
|
1985
4622
|
transitive_dependencies,
|
|
1986
4623
|
transitive_dependents,
|
|
4624
|
+
type_weight,
|
|
1987
4625
|
updateItem,
|
|
4626
|
+
urgency_weight,
|
|
1988
4627
|
validate,
|
|
1989
4628
|
validateReferential,
|
|
1990
4629
|
validateRepo,
|
|
@@ -1994,5 +4633,7 @@ export {
|
|
|
1994
4633
|
validate_graph,
|
|
1995
4634
|
validate_transition,
|
|
1996
4635
|
writeTask,
|
|
1997
|
-
|
|
4636
|
+
writeYamlFile,
|
|
4637
|
+
write_schema_version,
|
|
4638
|
+
write_workspace_config
|
|
1998
4639
|
};
|