@blxzer/cursor-trellis 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -13
- package/README.zh-CN.md +1 -1
- package/bin/cstl.js +1 -1
- package/bin/smart-search.js +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/campaign/canvas-render.d.ts +68 -0
- package/dist/commands/campaign/canvas-render.d.ts.map +1 -0
- package/dist/commands/campaign/canvas-render.js +414 -0
- package/dist/commands/campaign/canvas-render.js.map +1 -0
- package/dist/commands/campaign/compose.d.ts +15 -0
- package/dist/commands/campaign/compose.d.ts.map +1 -0
- package/dist/commands/campaign/compose.js +110 -0
- package/dist/commands/campaign/compose.js.map +1 -0
- package/dist/commands/campaign/index.d.ts +13 -0
- package/dist/commands/campaign/index.d.ts.map +1 -0
- package/dist/commands/campaign/index.js +134 -0
- package/dist/commands/campaign/index.js.map +1 -0
- package/dist/commands/campaign/kind-map.d.ts +6 -0
- package/dist/commands/campaign/kind-map.d.ts.map +1 -0
- package/dist/commands/campaign/kind-map.js +21 -0
- package/dist/commands/campaign/kind-map.js.map +1 -0
- package/dist/commands/campaign/mcp-server.d.ts +6 -0
- package/dist/commands/campaign/mcp-server.d.ts.map +1 -0
- package/dist/commands/campaign/mcp-server.js +167 -0
- package/dist/commands/campaign/mcp-server.js.map +1 -0
- package/dist/commands/campaign/render.d.ts +5 -0
- package/dist/commands/campaign/render.d.ts.map +1 -0
- package/dist/commands/campaign/render.js +103 -0
- package/dist/commands/campaign/render.js.map +1 -0
- package/dist/commands/campaign/trellis-load.d.ts +13 -0
- package/dist/commands/campaign/trellis-load.d.ts.map +1 -0
- package/dist/commands/campaign/trellis-load.js +122 -0
- package/dist/commands/campaign/trellis-load.js.map +1 -0
- package/dist/commands/campaign/types.d.ts +61 -0
- package/dist/commands/campaign/types.d.ts.map +1 -0
- package/dist/commands/campaign/types.js +3 -0
- package/dist/commands/campaign/types.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +11 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/rpc/client.d.ts +51 -0
- package/dist/commands/rpc/client.d.ts.map +1 -0
- package/dist/commands/rpc/client.js +147 -0
- package/dist/commands/rpc/client.js.map +1 -0
- package/dist/commands/rpc/index.d.ts +10 -0
- package/dist/commands/rpc/index.d.ts.map +1 -0
- package/dist/commands/rpc/index.js +157 -0
- package/dist/commands/rpc/index.js.map +1 -0
- package/dist/commands/rpc/serve.d.ts +23 -0
- package/dist/commands/rpc/serve.d.ts.map +1 -0
- package/dist/commands/rpc/serve.js +60 -0
- package/dist/commands/rpc/serve.js.map +1 -0
- package/dist/commands/rpc/smoke.d.ts +16 -0
- package/dist/commands/rpc/smoke.d.ts.map +1 -0
- package/dist/commands/rpc/smoke.js +92 -0
- package/dist/commands/rpc/smoke.js.map +1 -0
- package/dist/commands/sdk/index.d.ts +9 -0
- package/dist/commands/sdk/index.d.ts.map +1 -0
- package/dist/commands/sdk/index.js +58 -0
- package/dist/commands/sdk/index.js.map +1 -0
- package/dist/commands/sdk/run.d.ts +42 -0
- package/dist/commands/sdk/run.d.ts.map +1 -0
- package/dist/commands/sdk/run.js +245 -0
- package/dist/commands/sdk/run.js.map +1 -0
- package/dist/commands/sdk/status.d.ts +14 -0
- package/dist/commands/sdk/status.d.ts.map +1 -0
- package/dist/commands/sdk/status.js +69 -0
- package/dist/commands/sdk/status.js.map +1 -0
- package/dist/templates/common/bundled-skills/smart-search-cli/SKILL.md +35 -29
- package/dist/templates/common/bundled-skills/smart-search-cli/references/cli-contract.md +11 -25
- package/dist/templates/cursor/rules/retrieval-routing.mdc +1 -1
- package/dist/templates/markdown/agents.md +2 -0
- package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +14 -0
- package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +14 -0
- package/dist/templates/trellis/scripts/common/parent_orchestration.py +341 -2
- package/dist/templates/trellis/scripts/common/task_map.py +638 -614
- package/dist/templates/trellis/scripts/common/task_store.py +47 -1
- package/dist/templates/trellis/scripts/task.py +32 -0
- package/dist/utils/codebase-retrieval-router.d.ts.map +1 -1
- package/dist/utils/codebase-retrieval-router.js +14 -0
- package/dist/utils/codebase-retrieval-router.js.map +1 -1
- package/dist/utils/cursor-sdk-gate.d.ts +10 -0
- package/dist/utils/cursor-sdk-gate.d.ts.map +1 -0
- package/dist/utils/cursor-sdk-gate.js +32 -0
- package/dist/utils/cursor-sdk-gate.js.map +1 -0
- package/dist/utils/project-capabilities.d.ts +30 -3
- package/dist/utils/project-capabilities.d.ts.map +1 -1
- package/dist/utils/project-capabilities.js +182 -11
- package/dist/utils/project-capabilities.js.map +1 -1
- package/dist/utils/readiness.d.ts.map +1 -1
- package/dist/utils/readiness.js +26 -0
- package/dist/utils/readiness.js.map +1 -1
- package/package.json +4 -3
|
@@ -1,614 +1,638 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Parent/Child task-map helpers.
|
|
4
|
-
|
|
5
|
-
task-map.md is the Parent Supervisor's orchestration authority. This module
|
|
6
|
-
parses and writes only the small YAML frontmatter subset generated here.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from datetime import datetime, timezone
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
TASK_MAP = "task-map.md"
|
|
16
|
-
|
|
17
|
-
CHILD_STATES = {
|
|
18
|
-
"open",
|
|
19
|
-
"working",
|
|
20
|
-
"blocked",
|
|
21
|
-
"review",
|
|
22
|
-
"changes",
|
|
23
|
-
"accepted",
|
|
24
|
-
"integrating",
|
|
25
|
-
"integrated",
|
|
26
|
-
"cancelled",
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
PARENT_TERMINAL_STATES = {"integrated", "cancelled"}
|
|
30
|
-
PARENT_CONTROLLED_STATES = {
|
|
31
|
-
"changes",
|
|
32
|
-
"accepted",
|
|
33
|
-
"integrating",
|
|
34
|
-
"integrated",
|
|
35
|
-
"cancelled",
|
|
36
|
-
}
|
|
37
|
-
CHILD_REPORT_STATES = CHILD_STATES - PARENT_CONTROLLED_STATES
|
|
38
|
-
REF_REQUIRED_STATES = {"accepted", "integrating", "integrated"}
|
|
39
|
-
HANDOFF_REQUIRED_STATES = {"accepted", "integrating", "integrated"}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def utc_now() -> str:
|
|
43
|
-
"""Return a compact UTC timestamp for task-map event log entries."""
|
|
44
|
-
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
|
|
45
|
-
"+00:00", "Z"
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def load_task_map(parent_dir: Path) -> tuple[dict | None, str]:
|
|
50
|
-
"""Load task-map.md frontmatter and body."""
|
|
51
|
-
path = parent_dir / TASK_MAP
|
|
52
|
-
if not path.is_file():
|
|
53
|
-
return None, ""
|
|
54
|
-
|
|
55
|
-
try:
|
|
56
|
-
content = path.read_text(encoding="utf-8")
|
|
57
|
-
except OSError:
|
|
58
|
-
return None, ""
|
|
59
|
-
|
|
60
|
-
frontmatter, body = _split_frontmatter(content)
|
|
61
|
-
if frontmatter is None:
|
|
62
|
-
return None, content
|
|
63
|
-
return _parse_frontmatter(frontmatter), body
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def ensure_task_map(
|
|
67
|
-
parent_dir: Path,
|
|
68
|
-
parent_data: dict,
|
|
69
|
-
child_names: list[str],
|
|
70
|
-
event: str | None = None,
|
|
71
|
-
) -> dict:
|
|
72
|
-
"""Ensure task-map.md exists and contains entries for all child_names."""
|
|
73
|
-
data, body = load_task_map(parent_dir)
|
|
74
|
-
if data is None:
|
|
75
|
-
data = {
|
|
76
|
-
"parent_id": parent_data.get("id") or parent_dir.name,
|
|
77
|
-
"contract_epoch": 1,
|
|
78
|
-
"execution_topology": "serial",
|
|
79
|
-
"merge_limit": 1,
|
|
80
|
-
"children": [],
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
data.setdefault("
|
|
87
|
-
data.setdefault("
|
|
88
|
-
data.setdefault("
|
|
89
|
-
data.setdefault("
|
|
90
|
-
data.setdefault("
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if not
|
|
141
|
-
errors.append("
|
|
142
|
-
if
|
|
143
|
-
errors.append("
|
|
144
|
-
if
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if not
|
|
188
|
-
errors.append("
|
|
189
|
-
if
|
|
190
|
-
errors.append("
|
|
191
|
-
if
|
|
192
|
-
errors.append(
|
|
193
|
-
if state in
|
|
194
|
-
errors.append(f"{state} requires --
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if state
|
|
221
|
-
errors.append(f"
|
|
222
|
-
if state == "
|
|
223
|
-
errors.append(f"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if
|
|
306
|
-
child["
|
|
307
|
-
if
|
|
308
|
-
child["
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if
|
|
326
|
-
event = f"{event}
|
|
327
|
-
if
|
|
328
|
-
event = f"{event}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if not _short_text(
|
|
347
|
-
errors.append("
|
|
348
|
-
if not _short_text(
|
|
349
|
-
errors.append("
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
child["
|
|
365
|
-
child["
|
|
366
|
-
child["
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
child
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if state
|
|
426
|
-
return [
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
"
|
|
451
|
-
"
|
|
452
|
-
"
|
|
453
|
-
"
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
"
|
|
462
|
-
"
|
|
463
|
-
"-
|
|
464
|
-
"
|
|
465
|
-
"`
|
|
466
|
-
"-
|
|
467
|
-
"
|
|
468
|
-
"
|
|
469
|
-
"
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
for
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
if child
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
if
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Parent/Child task-map helpers.
|
|
4
|
+
|
|
5
|
+
task-map.md is the Parent Supervisor's orchestration authority. This module
|
|
6
|
+
parses and writes only the small YAML frontmatter subset generated here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TASK_MAP = "task-map.md"
|
|
16
|
+
|
|
17
|
+
CHILD_STATES = {
|
|
18
|
+
"open",
|
|
19
|
+
"working",
|
|
20
|
+
"blocked",
|
|
21
|
+
"review",
|
|
22
|
+
"changes",
|
|
23
|
+
"accepted",
|
|
24
|
+
"integrating",
|
|
25
|
+
"integrated",
|
|
26
|
+
"cancelled",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
PARENT_TERMINAL_STATES = {"integrated", "cancelled"}
|
|
30
|
+
PARENT_CONTROLLED_STATES = {
|
|
31
|
+
"changes",
|
|
32
|
+
"accepted",
|
|
33
|
+
"integrating",
|
|
34
|
+
"integrated",
|
|
35
|
+
"cancelled",
|
|
36
|
+
}
|
|
37
|
+
CHILD_REPORT_STATES = CHILD_STATES - PARENT_CONTROLLED_STATES
|
|
38
|
+
REF_REQUIRED_STATES = {"accepted", "integrating", "integrated"}
|
|
39
|
+
HANDOFF_REQUIRED_STATES = {"accepted", "integrating", "integrated"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def utc_now() -> str:
|
|
43
|
+
"""Return a compact UTC timestamp for task-map event log entries."""
|
|
44
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
|
|
45
|
+
"+00:00", "Z"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_task_map(parent_dir: Path) -> tuple[dict | None, str]:
|
|
50
|
+
"""Load task-map.md frontmatter and body."""
|
|
51
|
+
path = parent_dir / TASK_MAP
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
return None, ""
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
content = path.read_text(encoding="utf-8")
|
|
57
|
+
except OSError:
|
|
58
|
+
return None, ""
|
|
59
|
+
|
|
60
|
+
frontmatter, body = _split_frontmatter(content)
|
|
61
|
+
if frontmatter is None:
|
|
62
|
+
return None, content
|
|
63
|
+
return _parse_frontmatter(frontmatter), body
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_task_map(
|
|
67
|
+
parent_dir: Path,
|
|
68
|
+
parent_data: dict,
|
|
69
|
+
child_names: list[str],
|
|
70
|
+
event: str | None = None,
|
|
71
|
+
) -> dict:
|
|
72
|
+
"""Ensure task-map.md exists and contains entries for all child_names."""
|
|
73
|
+
data, body = load_task_map(parent_dir)
|
|
74
|
+
if data is None:
|
|
75
|
+
data = {
|
|
76
|
+
"parent_id": parent_data.get("id") or parent_dir.name,
|
|
77
|
+
"contract_epoch": 1,
|
|
78
|
+
"execution_topology": "serial",
|
|
79
|
+
"merge_limit": 1,
|
|
80
|
+
"children": [],
|
|
81
|
+
"stages": [],
|
|
82
|
+
"integration_queue": [],
|
|
83
|
+
}
|
|
84
|
+
body = _default_body()
|
|
85
|
+
|
|
86
|
+
data.setdefault("parent_id", parent_data.get("id") or parent_dir.name)
|
|
87
|
+
data.setdefault("contract_epoch", 1)
|
|
88
|
+
data.setdefault("execution_topology", "serial")
|
|
89
|
+
data.setdefault("merge_limit", 1)
|
|
90
|
+
data.setdefault("children", [])
|
|
91
|
+
data.setdefault("stages", [])
|
|
92
|
+
data.setdefault("integration_queue", [])
|
|
93
|
+
|
|
94
|
+
existing = {
|
|
95
|
+
child.get("id"): child
|
|
96
|
+
for child in data.get("children", [])
|
|
97
|
+
if isinstance(child, dict)
|
|
98
|
+
}
|
|
99
|
+
for child_name in child_names:
|
|
100
|
+
if child_name not in existing:
|
|
101
|
+
child = _default_child(child_name)
|
|
102
|
+
data["children"].append(child)
|
|
103
|
+
existing[child_name] = child
|
|
104
|
+
|
|
105
|
+
write_task_map(parent_dir, data, body, event)
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def remove_child_from_task_map(
|
|
110
|
+
parent_dir: Path,
|
|
111
|
+
parent_data: dict,
|
|
112
|
+
child_name: str,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Remove a child entry from task-map.md when structural links are removed."""
|
|
115
|
+
data, body = load_task_map(parent_dir)
|
|
116
|
+
if data is None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
children = data.get("children")
|
|
120
|
+
if isinstance(children, list):
|
|
121
|
+
data["children"] = [
|
|
122
|
+
child
|
|
123
|
+
for child in children
|
|
124
|
+
if not isinstance(child, dict) or child.get("id") != child_name
|
|
125
|
+
]
|
|
126
|
+
data.setdefault("parent_id", parent_data.get("id") or parent_dir.name)
|
|
127
|
+
write_task_map(parent_dir, data, body, f"Unlinked Child `{child_name}`.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_child_state(
|
|
131
|
+
parent_dir: Path,
|
|
132
|
+
parent_data: dict,
|
|
133
|
+
child_name: str,
|
|
134
|
+
state: str,
|
|
135
|
+
evidence: str,
|
|
136
|
+
reason: str | None = None,
|
|
137
|
+
) -> tuple[bool, list[str]]:
|
|
138
|
+
"""Set a child state inside Parent task-map.md."""
|
|
139
|
+
errors: list[str] = []
|
|
140
|
+
if state not in CHILD_STATES:
|
|
141
|
+
errors.append(f"unknown child state: {state}")
|
|
142
|
+
if not _short_text(evidence):
|
|
143
|
+
errors.append("evidence must be a short reference")
|
|
144
|
+
if reason and not _short_text(reason, 500):
|
|
145
|
+
errors.append("reason must be short and single-line")
|
|
146
|
+
if errors:
|
|
147
|
+
return False, errors
|
|
148
|
+
|
|
149
|
+
structural_children = parent_data.get("children", [])
|
|
150
|
+
if child_name not in structural_children:
|
|
151
|
+
errors.append(f"child is not linked to parent: {child_name}")
|
|
152
|
+
return False, errors
|
|
153
|
+
|
|
154
|
+
data = ensure_task_map(parent_dir, parent_data, list(structural_children))
|
|
155
|
+
child = get_child_entry(data, child_name)
|
|
156
|
+
if child is None:
|
|
157
|
+
errors.append(f"child missing from task-map.md: {child_name}")
|
|
158
|
+
return False, errors
|
|
159
|
+
|
|
160
|
+
child["state"] = state
|
|
161
|
+
child["evidence"] = evidence
|
|
162
|
+
if reason:
|
|
163
|
+
child["reason"] = reason
|
|
164
|
+
|
|
165
|
+
event = f"Set Child `{child_name}` state to `{state}`. Evidence: {evidence}."
|
|
166
|
+
if reason:
|
|
167
|
+
event = f"{event} Reason: {reason}."
|
|
168
|
+
_, body = load_task_map(parent_dir)
|
|
169
|
+
write_task_map(parent_dir, data, body, event)
|
|
170
|
+
return True, []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_parent_child_integration(
|
|
174
|
+
parent_dir: Path,
|
|
175
|
+
parent_data: dict,
|
|
176
|
+
child_dir: Path,
|
|
177
|
+
child_data: dict,
|
|
178
|
+
state: str,
|
|
179
|
+
evidence: str,
|
|
180
|
+
ref: str | None = None,
|
|
181
|
+
reason: str | None = None,
|
|
182
|
+
current_state_override: str | None = None,
|
|
183
|
+
) -> list[str]:
|
|
184
|
+
"""Validate a Parent-controlled Child integration state transition."""
|
|
185
|
+
errors: list[str] = []
|
|
186
|
+
child_name = child_dir.name
|
|
187
|
+
if state not in PARENT_CONTROLLED_STATES:
|
|
188
|
+
errors.append(f"state is not Parent-controlled: {state}")
|
|
189
|
+
if not _short_text(evidence):
|
|
190
|
+
errors.append("evidence must be a short reference")
|
|
191
|
+
if reason and not _short_text(reason, 500):
|
|
192
|
+
errors.append("reason must be short and single-line")
|
|
193
|
+
if state in {"changes", "cancelled"} and not reason:
|
|
194
|
+
errors.append(f"{state} requires --reason")
|
|
195
|
+
if state in REF_REQUIRED_STATES and not _short_text(ref):
|
|
196
|
+
errors.append(f"{state} requires --ref")
|
|
197
|
+
|
|
198
|
+
if child_data.get("parent") != parent_dir.name:
|
|
199
|
+
errors.append(f"child is not linked to parent: {child_name}")
|
|
200
|
+
|
|
201
|
+
structural_children = parent_data.get("children", [])
|
|
202
|
+
if child_name not in structural_children:
|
|
203
|
+
errors.append(f"child is not linked to parent: {child_name}")
|
|
204
|
+
|
|
205
|
+
data, _ = load_task_map(parent_dir)
|
|
206
|
+
if data is None:
|
|
207
|
+
errors.append(f"{TASK_MAP}")
|
|
208
|
+
return errors
|
|
209
|
+
|
|
210
|
+
child = get_child_entry(data, child_name)
|
|
211
|
+
if child is None:
|
|
212
|
+
errors.append(f"child missing from task-map.md: {child_name}")
|
|
213
|
+
return errors
|
|
214
|
+
|
|
215
|
+
current_state = (
|
|
216
|
+
current_state_override
|
|
217
|
+
if current_state_override is not None
|
|
218
|
+
else child.get("state")
|
|
219
|
+
)
|
|
220
|
+
if state in {"accepted", "changes"} and current_state != "review":
|
|
221
|
+
errors.append(f"{state} requires current Child state 'review', got {current_state!r}")
|
|
222
|
+
if state == "integrating" and current_state != "accepted":
|
|
223
|
+
errors.append(f"integrating requires current Child state 'accepted', got {current_state!r}")
|
|
224
|
+
if state == "integrated" and current_state != "integrating":
|
|
225
|
+
errors.append(f"integrated requires current Child state 'integrating', got {current_state!r}")
|
|
226
|
+
|
|
227
|
+
if state in HANDOFF_REQUIRED_STATES:
|
|
228
|
+
errors.extend(_required_child_evidence(child_dir, ["verify.md", "handoff.md"]))
|
|
229
|
+
|
|
230
|
+
if state == "integrating":
|
|
231
|
+
merge_limit = data.get("merge_limit", 1)
|
|
232
|
+
if not isinstance(merge_limit, int) or merge_limit < 1:
|
|
233
|
+
errors.append("merge_limit must be a positive integer")
|
|
234
|
+
else:
|
|
235
|
+
integrating = [
|
|
236
|
+
item.get("id")
|
|
237
|
+
for item in data.get("children", [])
|
|
238
|
+
if isinstance(item, dict)
|
|
239
|
+
and item.get("id") != child_name
|
|
240
|
+
and item.get("state") == "integrating"
|
|
241
|
+
]
|
|
242
|
+
if len(integrating) >= merge_limit:
|
|
243
|
+
errors.append(
|
|
244
|
+
f"merge_limit {merge_limit} blocks integrating {child_name}; already integrating: {', '.join(str(item) for item in integrating)}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if state == "accepted":
|
|
248
|
+
from .task_gates import task_closeout_profile, validate_transition_readiness
|
|
249
|
+
|
|
250
|
+
profile = task_closeout_profile(child_dir, child_data)
|
|
251
|
+
if profile == "full":
|
|
252
|
+
errors.extend(
|
|
253
|
+
validate_transition_readiness(
|
|
254
|
+
child_dir,
|
|
255
|
+
child_data,
|
|
256
|
+
"child-review",
|
|
257
|
+
mode="complete",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
elif profile == "parent":
|
|
261
|
+
errors.extend(
|
|
262
|
+
validate_transition_readiness(
|
|
263
|
+
child_dir,
|
|
264
|
+
child_data,
|
|
265
|
+
"parent-integrated",
|
|
266
|
+
mode="complete",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return errors
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def set_parent_child_integration_state(
|
|
274
|
+
parent_dir: Path,
|
|
275
|
+
parent_data: dict,
|
|
276
|
+
child_dir: Path,
|
|
277
|
+
child_data: dict,
|
|
278
|
+
state: str,
|
|
279
|
+
evidence: str,
|
|
280
|
+
ref: str | None = None,
|
|
281
|
+
reason: str | None = None,
|
|
282
|
+
merge_ref: str | None = None,
|
|
283
|
+
) -> tuple[bool, list[str]]:
|
|
284
|
+
"""Set a Parent-controlled Child integration state."""
|
|
285
|
+
errors = validate_parent_child_integration(
|
|
286
|
+
parent_dir,
|
|
287
|
+
parent_data,
|
|
288
|
+
child_dir,
|
|
289
|
+
child_data,
|
|
290
|
+
state,
|
|
291
|
+
evidence,
|
|
292
|
+
ref,
|
|
293
|
+
reason,
|
|
294
|
+
)
|
|
295
|
+
if errors:
|
|
296
|
+
return False, errors
|
|
297
|
+
|
|
298
|
+
data = ensure_task_map(parent_dir, parent_data, list(parent_data.get("children", [])))
|
|
299
|
+
child = get_child_entry(data, child_dir.name)
|
|
300
|
+
if child is None:
|
|
301
|
+
return False, [f"child missing from task-map.md: {child_dir.name}"]
|
|
302
|
+
|
|
303
|
+
child["state"] = state
|
|
304
|
+
child["evidence"] = evidence
|
|
305
|
+
if ref:
|
|
306
|
+
child["ref"] = ref
|
|
307
|
+
if merge_ref:
|
|
308
|
+
child["merged_ref"] = merge_ref
|
|
309
|
+
if reason:
|
|
310
|
+
child["reason"] = reason
|
|
311
|
+
elif "reason" in child:
|
|
312
|
+
del child["reason"]
|
|
313
|
+
|
|
314
|
+
queue = data.get("integration_queue")
|
|
315
|
+
if not isinstance(queue, list):
|
|
316
|
+
queue = []
|
|
317
|
+
data["integration_queue"] = queue
|
|
318
|
+
if state == "integrating":
|
|
319
|
+
if child_dir.name not in queue:
|
|
320
|
+
queue.append(child_dir.name)
|
|
321
|
+
elif child_dir.name in queue:
|
|
322
|
+
data["integration_queue"] = [item for item in queue if item != child_dir.name]
|
|
323
|
+
|
|
324
|
+
event = f"Parent set Child `{child_dir.name}` integration state to `{state}`. Evidence: {evidence}."
|
|
325
|
+
if ref:
|
|
326
|
+
event = f"{event} Ref: {ref}."
|
|
327
|
+
if merge_ref:
|
|
328
|
+
event = f"{event} Merge executed: git merge --no-ff --no-commit {merge_ref}."
|
|
329
|
+
if reason:
|
|
330
|
+
event = f"{event} Reason: {reason}."
|
|
331
|
+
_, body = load_task_map(parent_dir)
|
|
332
|
+
write_task_map(parent_dir, data, body, event)
|
|
333
|
+
return True, []
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def record_child_worktree(
|
|
337
|
+
parent_dir: Path,
|
|
338
|
+
parent_data: dict,
|
|
339
|
+
child_name: str,
|
|
340
|
+
branch: str,
|
|
341
|
+
worktree_path: str,
|
|
342
|
+
base_ref: str,
|
|
343
|
+
) -> tuple[bool, list[str]]:
|
|
344
|
+
"""Record a Child git worktree checkout in Parent task-map.md."""
|
|
345
|
+
errors: list[str] = []
|
|
346
|
+
if not _short_text(branch):
|
|
347
|
+
errors.append("branch must be a short reference")
|
|
348
|
+
if not _short_text(worktree_path):
|
|
349
|
+
errors.append("worktree_path must be a short path reference")
|
|
350
|
+
if not _short_text(base_ref):
|
|
351
|
+
errors.append("base_ref must be a short reference")
|
|
352
|
+
|
|
353
|
+
structural_children = parent_data.get("children", [])
|
|
354
|
+
if child_name not in structural_children:
|
|
355
|
+
errors.append(f"child is not linked to parent: {child_name}")
|
|
356
|
+
if errors:
|
|
357
|
+
return False, errors
|
|
358
|
+
|
|
359
|
+
data = ensure_task_map(parent_dir, parent_data, list(structural_children))
|
|
360
|
+
child = get_child_entry(data, child_name)
|
|
361
|
+
if child is None:
|
|
362
|
+
return False, [f"child missing from task-map.md: {child_name}"]
|
|
363
|
+
|
|
364
|
+
child["isolation"] = "git-worktree"
|
|
365
|
+
child["branch"] = branch
|
|
366
|
+
child["worktree_path"] = worktree_path
|
|
367
|
+
child["base_ref"] = base_ref
|
|
368
|
+
child["ref"] = f"refs/heads/{branch}"
|
|
369
|
+
|
|
370
|
+
event = (
|
|
371
|
+
f"Prepared Child `{child_name}` git worktree. "
|
|
372
|
+
f"Branch: {branch}. Path: {worktree_path}. Base: {base_ref}."
|
|
373
|
+
)
|
|
374
|
+
_, body = load_task_map(parent_dir)
|
|
375
|
+
write_task_map(parent_dir, data, body, event)
|
|
376
|
+
return True, []
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def get_child_entry(data: dict | None, child_name: str) -> dict | None:
|
|
380
|
+
"""Return a child map entry by id."""
|
|
381
|
+
if not data:
|
|
382
|
+
return None
|
|
383
|
+
children = data.get("children")
|
|
384
|
+
if not isinstance(children, list):
|
|
385
|
+
return None
|
|
386
|
+
for child in children:
|
|
387
|
+
if isinstance(child, dict) and child.get("id") == child_name:
|
|
388
|
+
return child
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def get_child_state(parent_dir: Path, child_name: str) -> str | None:
|
|
393
|
+
"""Return child state from a Parent task-map.md."""
|
|
394
|
+
data, _ = load_task_map(parent_dir)
|
|
395
|
+
child = get_child_entry(data, child_name)
|
|
396
|
+
if not child:
|
|
397
|
+
return None
|
|
398
|
+
state = child.get("state")
|
|
399
|
+
return state if isinstance(state, str) else None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def validate_parent_children_complete(parent_dir: Path, child_names: list[str]) -> list[str]:
|
|
403
|
+
"""Validate every structural child is integrated or cancelled."""
|
|
404
|
+
errors: list[str] = []
|
|
405
|
+
data, _ = load_task_map(parent_dir)
|
|
406
|
+
if data is None:
|
|
407
|
+
return [f"{TASK_MAP}"]
|
|
408
|
+
|
|
409
|
+
for child_name in child_names:
|
|
410
|
+
child = get_child_entry(data, child_name)
|
|
411
|
+
if child is None:
|
|
412
|
+
errors.append(f"{TASK_MAP} missing child entry: {child_name}")
|
|
413
|
+
continue
|
|
414
|
+
state = child.get("state")
|
|
415
|
+
if state not in PARENT_TERMINAL_STATES:
|
|
416
|
+
errors.append(
|
|
417
|
+
f"child {child_name} must be integrated or cancelled before parent archive, got {state!r}"
|
|
418
|
+
)
|
|
419
|
+
return errors
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def validate_child_archive_state(parent_dir: Path, child_name: str) -> list[str]:
|
|
423
|
+
"""Validate the Parent has marked a Child terminal before child archive."""
|
|
424
|
+
state = get_child_state(parent_dir, child_name)
|
|
425
|
+
if state is None:
|
|
426
|
+
return [f"{TASK_MAP} missing child entry: {child_name}"]
|
|
427
|
+
if state not in PARENT_TERMINAL_STATES:
|
|
428
|
+
return [
|
|
429
|
+
f"Parent task-map.md must mark child {child_name} integrated or cancelled before archive, got {state!r}"
|
|
430
|
+
]
|
|
431
|
+
return []
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def write_task_map(
|
|
435
|
+
parent_dir: Path,
|
|
436
|
+
data: dict,
|
|
437
|
+
body: str,
|
|
438
|
+
event: str | None = None,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Write task-map.md from structured snapshot and Markdown body."""
|
|
441
|
+
normalized_body = body if body.strip() else _default_body()
|
|
442
|
+
if event:
|
|
443
|
+
normalized_body = _append_event(normalized_body, event)
|
|
444
|
+
content = f"---\n{_format_frontmatter(data)}---\n{normalized_body.lstrip()}"
|
|
445
|
+
(parent_dir / TASK_MAP).write_text(content, encoding="utf-8")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _default_child(child_name: str) -> dict:
|
|
449
|
+
return {
|
|
450
|
+
"id": child_name,
|
|
451
|
+
"state": "open",
|
|
452
|
+
"depends_on": [],
|
|
453
|
+
"touches": [],
|
|
454
|
+
"isolation": "git-worktree",
|
|
455
|
+
"ref": None,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _default_body() -> str:
|
|
460
|
+
return (
|
|
461
|
+
"# Task Map\n\n"
|
|
462
|
+
"## Orchestration notes\n\n"
|
|
463
|
+
"- `execution_topology: parallel` — children with empty `depends_on` may run concurrently; "
|
|
464
|
+
"Parent integrates serially up to `merge_limit`.\n"
|
|
465
|
+
"- Child-reported states: `open` → `working` → `blocked` | `review`.\n"
|
|
466
|
+
"- Parent-controlled states: `review` → `changes` | `accepted` → `integrating` → "
|
|
467
|
+
"`integrated` | `cancelled`.\n"
|
|
468
|
+
"- Declare `touches` per child before dispatch to reduce merge conflicts.\n"
|
|
469
|
+
"- `isolation: git-worktree` — run `prepare-child-worktree` from the **git package root** "
|
|
470
|
+
"(not a non-git harness root).\n\n"
|
|
471
|
+
"## Event Log\n\n"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _append_event(body: str, event: str) -> str:
|
|
476
|
+
stripped = body.rstrip()
|
|
477
|
+
if "## Event Log" not in stripped:
|
|
478
|
+
stripped = f"{stripped}\n\n## Event Log"
|
|
479
|
+
return f"{stripped}\n\n- {utc_now()} - {event}\n"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _split_frontmatter(content: str) -> tuple[str | None, str]:
|
|
483
|
+
lines = content.splitlines(keepends=True)
|
|
484
|
+
if not lines or lines[0].strip() != "---":
|
|
485
|
+
return None, content
|
|
486
|
+
|
|
487
|
+
for idx in range(1, len(lines)):
|
|
488
|
+
if lines[idx].strip() == "---":
|
|
489
|
+
frontmatter = "".join(lines[1:idx])
|
|
490
|
+
body = "".join(lines[idx + 1 :])
|
|
491
|
+
return frontmatter, body
|
|
492
|
+
return None, content
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _parse_frontmatter(frontmatter: str) -> dict:
|
|
496
|
+
"""Parse the small YAML subset written by this module.
|
|
497
|
+
|
|
498
|
+
Supports top-level scalars plus list-of-maps for ``children:`` and optional
|
|
499
|
+
``stages:`` (same indentation style).
|
|
500
|
+
"""
|
|
501
|
+
data: dict = {"children": [], "stages": []}
|
|
502
|
+
current_item: dict | None = None
|
|
503
|
+
list_key: str | None = None
|
|
504
|
+
|
|
505
|
+
for raw_line in frontmatter.splitlines():
|
|
506
|
+
if not raw_line.strip():
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
if raw_line.startswith("children:"):
|
|
510
|
+
list_key = "children"
|
|
511
|
+
data["children"] = []
|
|
512
|
+
current_item = None
|
|
513
|
+
continue
|
|
514
|
+
|
|
515
|
+
if raw_line.startswith("stages:"):
|
|
516
|
+
list_key = "stages"
|
|
517
|
+
data["stages"] = []
|
|
518
|
+
current_item = None
|
|
519
|
+
continue
|
|
520
|
+
|
|
521
|
+
if list_key and raw_line.startswith(" - "):
|
|
522
|
+
current_item = {}
|
|
523
|
+
data[list_key].append(current_item)
|
|
524
|
+
item = raw_line[4:].strip()
|
|
525
|
+
if ":" in item:
|
|
526
|
+
key, value = item.split(":", 1)
|
|
527
|
+
current_item[key.strip()] = _parse_value(value.strip())
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
if list_key and raw_line.startswith(" ") and current_item is not None:
|
|
531
|
+
item = raw_line.strip()
|
|
532
|
+
if ":" in item:
|
|
533
|
+
key, value = item.split(":", 1)
|
|
534
|
+
current_item[key.strip()] = _parse_value(value.strip())
|
|
535
|
+
continue
|
|
536
|
+
|
|
537
|
+
list_key = None
|
|
538
|
+
current_item = None
|
|
539
|
+
if ":" in raw_line:
|
|
540
|
+
key, value = raw_line.split(":", 1)
|
|
541
|
+
data[key.strip()] = _parse_value(value.strip())
|
|
542
|
+
|
|
543
|
+
return data
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _parse_value(value: str):
|
|
547
|
+
if value == "null":
|
|
548
|
+
return None
|
|
549
|
+
if value == "[]":
|
|
550
|
+
return []
|
|
551
|
+
if value.startswith("[") and value.endswith("]"):
|
|
552
|
+
inner = value[1:-1].strip()
|
|
553
|
+
if not inner:
|
|
554
|
+
return []
|
|
555
|
+
return [item.strip().strip("'\"") for item in inner.split(",") if item.strip()]
|
|
556
|
+
if value.isdigit():
|
|
557
|
+
return int(value)
|
|
558
|
+
return value.strip("'\"")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _format_frontmatter(data: dict) -> str:
|
|
562
|
+
lines = [
|
|
563
|
+
f"parent_id: {_format_value(data.get('parent_id'))}",
|
|
564
|
+
f"contract_epoch: {_format_value(data.get('contract_epoch', 1))}",
|
|
565
|
+
f"execution_topology: {_format_value(data.get('execution_topology', 'serial'))}",
|
|
566
|
+
f"merge_limit: {_format_value(data.get('merge_limit', 1))}",
|
|
567
|
+
"children:",
|
|
568
|
+
]
|
|
569
|
+
children = data.get("children", [])
|
|
570
|
+
if isinstance(children, list):
|
|
571
|
+
for child in children:
|
|
572
|
+
if not isinstance(child, dict):
|
|
573
|
+
continue
|
|
574
|
+
lines.append(f" - id: {_format_value(child.get('id'))}")
|
|
575
|
+
lines.append(f" state: {_format_value(child.get('state', 'open'))}")
|
|
576
|
+
lines.append(f" depends_on: {_format_value(child.get('depends_on', []))}")
|
|
577
|
+
lines.append(f" touches: {_format_value(child.get('touches', []))}")
|
|
578
|
+
lines.append(f" isolation: {_format_value(child.get('isolation', 'git-worktree'))}")
|
|
579
|
+
lines.append(f" ref: {_format_value(child.get('ref'))}")
|
|
580
|
+
if child.get("branch"):
|
|
581
|
+
lines.append(f" branch: {_format_value(child.get('branch'))}")
|
|
582
|
+
if child.get("worktree_path"):
|
|
583
|
+
lines.append(f" worktree_path: {_format_value(child.get('worktree_path'))}")
|
|
584
|
+
if child.get("base_ref"):
|
|
585
|
+
lines.append(f" base_ref: {_format_value(child.get('base_ref'))}")
|
|
586
|
+
if child.get("merged_ref"):
|
|
587
|
+
lines.append(f" merged_ref: {_format_value(child.get('merged_ref'))}")
|
|
588
|
+
if child.get("evidence"):
|
|
589
|
+
lines.append(f" evidence: {_format_value(child.get('evidence'))}")
|
|
590
|
+
if child.get("reason"):
|
|
591
|
+
lines.append(f" reason: {_format_value(child.get('reason'))}")
|
|
592
|
+
stages = data.get("stages") or []
|
|
593
|
+
if isinstance(stages, list) and stages:
|
|
594
|
+
lines.append("stages:")
|
|
595
|
+
for stage in stages:
|
|
596
|
+
if not isinstance(stage, dict):
|
|
597
|
+
continue
|
|
598
|
+
lines.append(f" - id: {_format_value(stage.get('id'))}")
|
|
599
|
+
if stage.get("title"):
|
|
600
|
+
lines.append(f" title: {_format_value(stage.get('title'))}")
|
|
601
|
+
lines.append(f" units: {_format_value(stage.get('units', []))}")
|
|
602
|
+
lines.append(f"integration_queue: {_format_value(data.get('integration_queue', []))}")
|
|
603
|
+
return "\n".join(lines) + "\n"
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _format_value(value) -> str:
|
|
607
|
+
if value is None:
|
|
608
|
+
return "null"
|
|
609
|
+
if isinstance(value, list):
|
|
610
|
+
if not value:
|
|
611
|
+
return "[]"
|
|
612
|
+
return "[" + ", ".join(str(item) for item in value) + "]"
|
|
613
|
+
return str(value)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _short_text(value: str | None, max_len: int = 240) -> bool:
|
|
617
|
+
if not value or not isinstance(value, str):
|
|
618
|
+
return False
|
|
619
|
+
if len(value) > max_len:
|
|
620
|
+
return False
|
|
621
|
+
if "\n" in value or "\r" in value:
|
|
622
|
+
return False
|
|
623
|
+
return True
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _required_child_evidence(child_dir: Path, names: list[str]) -> list[str]:
|
|
627
|
+
errors: list[str] = []
|
|
628
|
+
for name in names:
|
|
629
|
+
path = child_dir / name
|
|
630
|
+
if not path.is_file():
|
|
631
|
+
errors.append(name)
|
|
632
|
+
continue
|
|
633
|
+
try:
|
|
634
|
+
if not path.read_text(encoding="utf-8").strip():
|
|
635
|
+
errors.append(f"{name} is empty")
|
|
636
|
+
except OSError:
|
|
637
|
+
errors.append(f"{name} could not be read")
|
|
638
|
+
return errors
|