deepclause-pi 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,991 @@
1
+ %%% specs.dml — deterministic DML spec engine (phase 1).
2
+ %%%
3
+ %%% Parses DeepClause/OpenSpec-style Markdown specs and deltas, validates their
4
+ %%% structure, and produces reports, status, queries and Mermaid graphs.
5
+ %%%
6
+ %%% Pure Prolog: never calls the model (no task/1, no prompt/1) and never
7
+ %%% executes a runtime tool. Skills consult it and supply agent_main.
8
+ %%%
9
+ %%% Layout it understands:
10
+ %%% .pi/deepclause/specs/**/*.md capability specs (source of truth)
11
+ %%% .pi/deepclause/changes/<slug>/specs/**/*.md change deltas
12
+
13
+ %% ---------------------------------------------------------------------------
14
+ %% Line and heading utilities
15
+ %% ---------------------------------------------------------------------------
16
+
17
+ sp_count_hashes(S, Acc, N) :-
18
+ ( sub_string(S, 0, 1, _, "#")
19
+ -> sub_string(S, 1, _, 0, S1),
20
+ Acc1 is Acc + 1,
21
+ sp_count_hashes(S1, Acc1, N)
22
+ ; N = Acc
23
+ ).
24
+
25
+ %% sp_heading(+Line, -Level, -Text). Level is 0 for a non-heading line.
26
+ sp_heading(Line, Level, Text) :-
27
+ sp_count_hashes(Line, 0, C),
28
+ ( C > 0,
29
+ sub_string(Line, C, _, 0, Rest),
30
+ sub_string(Rest, 0, 1, _, " ")
31
+ -> Level = C,
32
+ sub_string(Rest, 1, _, 0, Raw),
33
+ normalize_space(string(Text), Raw)
34
+ ; Level = 0,
35
+ Text = ""
36
+ ).
37
+
38
+ sp_number([], _, []).
39
+ sp_number([Line|Lines], N, [line(Line, N)|Out]) :-
40
+ N1 is N + 1,
41
+ sp_number(Lines, N1, Out).
42
+
43
+ sp_lines(Text, Numbered) :-
44
+ split_string(Text, "\n", "", Raw),
45
+ sp_number(Raw, 1, Numbered).
46
+
47
+ sp_trimmed(List, Out) :-
48
+ findall(Text, (
49
+ member(line(Line, _), List),
50
+ normalize_space(string(Text), Line),
51
+ Text \= ""
52
+ ), Out).
53
+
54
+ %% Join non-blank body lines with spaces, for prose (purpose, description).
55
+ sp_prose(List, Text) :-
56
+ sp_trimmed(List, Parts),
57
+ atomics_to_string(Parts, " ", Text).
58
+
59
+ %% ---------------------------------------------------------------------------
60
+ %% Section splitting: stops at heading levels 1-3; level 4+ stays in the body.
61
+ %% ---------------------------------------------------------------------------
62
+
63
+ sp_sections(Lines, Sections) :-
64
+ sp_skip_plain(Lines, Rest),
65
+ sp_sections_from(Rest, Sections).
66
+
67
+ sp_skip_plain([line(Line, Number)|Rest], [line(Line, Number)|Rest]) :- sp_heading(Line, Level, _), Level >= 1, Level =< 3, !.
68
+ sp_skip_plain([_|Rest], Out) :- sp_skip_plain(Rest, Out).
69
+ sp_skip_plain([], []).
70
+
71
+ sp_sections_from([], []).
72
+ sp_sections_from([line(Line, N)|Rest], [section(Level, Text, N, Body)|More]) :-
73
+ sp_heading(Line, Level, Text),
74
+ Level >= 1,
75
+ Level =< 3,
76
+ sp_take_body(Rest, Body, Tail),
77
+ sp_sections_from(Tail, More).
78
+
79
+ sp_take_body([], [], []).
80
+ sp_take_body([line(Line, Number)|Rest], [], [line(Line, Number)|Rest]) :-
81
+ sp_heading(Line, Level, _),
82
+ Level >= 1,
83
+ Level =< 3,
84
+ !.
85
+ sp_take_body([Item|Rest], [Item|Body], Tail) :- sp_take_body(Rest, Body, Tail).
86
+
87
+ %% Split a requirement body (level-4 headings inline) into description + scenarios.
88
+ sp_split_scenarios(Body, DescLines, ScenarioSections) :-
89
+ sp_take_plain(Body, DescLines, Rest),
90
+ sp_scenario_sections(Rest, ScenarioSections).
91
+
92
+ sp_take_plain([], [], []).
93
+ sp_take_plain([line(Line, Number)|Rest], [], [line(Line, Number)|Rest]) :-
94
+ sp_heading(Line, Level, _),
95
+ Level >= 4,
96
+ !.
97
+ sp_take_plain([Item|Rest], [Item|Body], Tail) :- sp_take_plain(Rest, Body, Tail).
98
+
99
+ sp_scenario_sections([], []).
100
+ sp_scenario_sections([line(Line, N)|Rest], [section(4, Text, N, Body)|More]) :-
101
+ sp_heading(Line, 4, Text),
102
+ sp_take_until_level4(Rest, Body, Tail),
103
+ sp_scenario_sections(Tail, More).
104
+
105
+ sp_take_until_level4([], [], []).
106
+ sp_take_until_level4([line(Line, Number)|Rest], [], [line(Line, Number)|Rest]) :-
107
+ sp_heading(Line, Level, _),
108
+ Level >= 4,
109
+ !.
110
+ sp_take_until_level4([Item|Rest], [Item|Body], Tail) :- sp_take_until_level4(Rest, Body, Tail).
111
+
112
+ %% ---------------------------------------------------------------------------
113
+ %% Term extraction
114
+ %% ---------------------------------------------------------------------------
115
+
116
+ sp_requirement_name(Text, Name) :-
117
+ string_concat("Requirement:", Rest, Text),
118
+ normalize_space(string(Name), Rest),
119
+ Name \= "".
120
+
121
+ sp_scenario_name(Text, Name) :-
122
+ string_concat("Scenario:", Rest, Text),
123
+ normalize_space(string(Name), Rest),
124
+ Name \= "".
125
+
126
+ sp_requirement_body(Body, Desc, Scenarios) :-
127
+ sp_split_scenarios(Body, DescLines, ScenarioSections),
128
+ sp_prose(DescLines, Desc),
129
+ findall(scenario(Name, N, Steps), (
130
+ member(section(4, Text, N, SBody), ScenarioSections),
131
+ sp_scenario_name(Text, Name),
132
+ sp_trimmed(SBody, Steps)
133
+ ), Scenarios).
134
+
135
+ %% spec(Title, Purpose, [req(Name, Line, Description, Scenarios)])
136
+ sp_parse_spec(Text, spec(Title, Purpose, Requirements)) :-
137
+ sp_lines(Text, Lines),
138
+ sp_sections(Lines, Sections),
139
+ ( member(section(1, T, _, _), Sections)
140
+ -> Title = T
141
+ ; Title = ""
142
+ ),
143
+ ( member(section(2, "Purpose", _, PurposeBody), Sections)
144
+ -> sp_prose(PurposeBody, Purpose)
145
+ ; Purpose = ""
146
+ ),
147
+ findall(req(Name, N, Desc, Scenarios), (
148
+ member(section(3, Heading, N, Body), Sections),
149
+ sp_requirement_name(Heading, Name),
150
+ sp_requirement_body(Body, Desc, Scenarios)
151
+ ), Requirements).
152
+
153
+ %% Delta op section headers.
154
+ sp_delta_header("ADDED Requirements", added).
155
+ sp_delta_header("MODIFIED Requirements", modified).
156
+ sp_delta_header("REMOVED Requirements", removed).
157
+ sp_delta_header("RENAMED Requirements", renamed).
158
+
159
+ %% [delta(Op, [req(Name, Line, Description, Scenarios)])]
160
+ sp_parse_delta(Text, Deltas) :-
161
+ sp_lines(Text, Lines),
162
+ sp_sections(Lines, Sections),
163
+ sp_drop_to_op(Sections, Rest),
164
+ sp_collect_ops(Rest, Deltas).
165
+
166
+ sp_drop_to_op([], []).
167
+ sp_drop_to_op([section(2, Header, Number, Body)|Rest], [section(2, Header, Number, Body)|Rest]) :-
168
+ sp_delta_header(Header, _),
169
+ !.
170
+ sp_drop_to_op([_|Rest], Out) :- sp_drop_to_op(Rest, Out).
171
+
172
+ sp_collect_ops([], []).
173
+ sp_collect_ops([section(2, Header, _, _)|Rest], Deltas) :-
174
+ ( sp_delta_header(Header, Op)
175
+ -> sp_take_until_op(Rest, Group, Tail),
176
+ findall(req(Name, N, Desc, Scenarios), (
177
+ member(section(3, Heading, N, Body), Group),
178
+ sp_requirement_name(Heading, Name),
179
+ sp_requirement_body(Body, Desc, Scenarios)
180
+ ), Reqs),
181
+ Deltas = [delta(Op, Reqs)|More],
182
+ sp_collect_ops(Tail, More)
183
+ ; sp_collect_ops(Rest, Deltas)
184
+ ).
185
+
186
+ sp_take_until_op([], [], []).
187
+ sp_take_until_op([section(2, Header, Number, Body)|Rest], [], [section(2, Header, Number, Body)|Rest]) :-
188
+ sp_delta_header(Header, _),
189
+ !.
190
+ sp_take_until_op([Item|Rest], [Item|Group], Tail) :- sp_take_until_op(Rest, Group, Tail).
191
+
192
+ %% ---------------------------------------------------------------------------
193
+ %% Validation
194
+ %% ---------------------------------------------------------------------------
195
+
196
+ %% 3-hash Scenario and 4-hash Requirement are the classic silent failures.
197
+ sp_heading_errors(File, Lines, Errors) :-
198
+ findall(error(File, N, Message), (
199
+ member(line(Line, N), Lines),
200
+ sp_heading(Line, Level, Text),
201
+ Level > 0,
202
+ sp_heading_error(Level, Text, Message)
203
+ ), Errors).
204
+
205
+ sp_heading_error(3, Text, "scenario heading must use #### (4 hashes), not ###") :-
206
+ string_concat("Scenario", _, Text),
207
+ !.
208
+ sp_heading_error(4, Text, "requirement heading must use ### (3 hashes), not ####") :-
209
+ string_concat("Requirement", _, Text),
210
+ !.
211
+
212
+ sp_requirement_errors(File, Requirements, Errors) :-
213
+ findall(error(File, N, Message), (
214
+ member(req(Name, N, _, Scenarios), Requirements),
215
+ Scenarios == [],
216
+ format(string(Message), "requirement '~w' has no scenario", [Name])
217
+ ), Errors).
218
+
219
+ sp_duplicate_errors(File, Requirements, Errors) :-
220
+ findall(error(File, N, Message), (
221
+ member(req(Name, N, _, _), Requirements),
222
+ member(req(Name, Earlier, _, _), Requirements),
223
+ Earlier < N,
224
+ format(string(Message), "duplicate requirement '~w' (first at line ~w)", [Name, Earlier])
225
+ ), Errors).
226
+
227
+ sp_delta_errors(File, Deltas, Errors) :-
228
+ findall(error(File, N, Message), (
229
+ member(delta(Op, Requirements), Deltas),
230
+ ( Op == added ; Op == modified ),
231
+ member(req(Name, N, _, Scenarios), Requirements),
232
+ Scenarios == [],
233
+ format(string(Message), "~w requirement '~w' has no scenario", [Op, Name])
234
+ ), Errors).
235
+
236
+ sp_validate_spec(File, Text, Errors) :-
237
+ sp_lines(Text, Lines),
238
+ sp_parse_spec(Text, spec(_, _, Requirements)),
239
+ sp_heading_errors(File, Lines, E1),
240
+ sp_requirement_errors(File, Requirements, E2),
241
+ sp_duplicate_errors(File, Requirements, E3),
242
+ append(E1, E2, A),
243
+ append(A, E3, Errors).
244
+
245
+ sp_validate_delta(File, Text, Errors) :-
246
+ sp_lines(Text, Lines),
247
+ sp_parse_delta(Text, Deltas),
248
+ sp_heading_errors(File, Lines, E1),
249
+ sp_delta_errors(File, Deltas, E2),
250
+ append(E1, E2, Errors).
251
+
252
+ %% ---------------------------------------------------------------------------
253
+ %% File discovery (missing directories simply yield no files)
254
+ %% ---------------------------------------------------------------------------
255
+
256
+ sp_walk(Dir, Exts, Files) :-
257
+ ( exists_directory(Dir)
258
+ -> directory_files(Dir, Names),
259
+ sp_walk_names(Names, Dir, Exts, Files)
260
+ ; Files = []
261
+ ).
262
+
263
+ sp_walk_names([], _, _, []).
264
+ sp_walk_names([Name|Rest], Dir, Exts, Files) :-
265
+ Name \= ".",
266
+ Name \= "..",
267
+ atomic_list_concat([Dir, "/", Name], Path),
268
+ ( exists_directory(Path)
269
+ -> sp_walk(Path, Exts, Sub)
270
+ ; ( file_name_extension(_, Ext, Path),
271
+ memberchk(Ext, Exts)
272
+ -> Sub = [Path]
273
+ ; Sub = []
274
+ )
275
+ ),
276
+ sp_walk_names(Rest, Dir, Exts, More),
277
+ append(Sub, More, Files).
278
+
279
+ sp_spec_files(Files) :-
280
+ sp_walk(".pi/deepclause/specs", [md], Files).
281
+
282
+ sp_change_dirs(Dirs) :-
283
+ ( exists_directory(".pi/deepclause/changes")
284
+ -> directory_files(".pi/deepclause/changes", Names),
285
+ findall(Path, (
286
+ member(Name, Names),
287
+ Name \= ".",
288
+ Name \= "..",
289
+ Name \= "archive",
290
+ atomic_list_concat([".pi/deepclause/changes/", Name], Path),
291
+ exists_directory(Path)
292
+ ), Dirs)
293
+ ; Dirs = []
294
+ ).
295
+
296
+ sp_change_delta_files(Change, Files) :-
297
+ atomic_list_concat([Change, "/specs"], Dir),
298
+ sp_walk(Dir, [md], Files).
299
+
300
+ sp_all_delta_files(Dirs, Files) :-
301
+ findall(All, (member(D, Dirs), sp_change_delta_files(D, All)), Nested),
302
+ append(Nested, Files).
303
+
304
+ %% Capability id from a spec path: specs/ui/theme.spec.md -> ui/theme
305
+ sp_capability_id(File, Id) :-
306
+ ( sub_string(File, Before, _, _, "/specs/")
307
+ -> After is Before + 7,
308
+ sub_string(File, After, _, 0, Rest),
309
+ ( string_concat(Id, ".spec.md", Rest)
310
+ -> true
311
+ ; string_concat(Id, ".md", Rest)
312
+ -> true
313
+ ; Id = Rest
314
+ )
315
+ ; sp_short_id(File, Id)
316
+ ).
317
+
318
+ %% Capability id from a single-file path: ui/theme.spec.md -> ui/theme
319
+ sp_short_id(File, Id) :-
320
+ file_base_name(File, Base),
321
+ ( file_name_extension(Stem, md, Base)
322
+ -> atom_string(StemText, Stem),
323
+ ( string_concat(Id, ".spec", StemText)
324
+ -> true
325
+ ; Id = StemText
326
+ )
327
+ ; Id = Base
328
+ ).
329
+
330
+ %% ---------------------------------------------------------------------------
331
+ %% Reports
332
+ %% ---------------------------------------------------------------------------
333
+
334
+ sp_read_check_spec(File, Summary, Errors) :-
335
+ read_file_to_string(File, Text, []),
336
+ sp_capability_id(File, Id),
337
+ sp_parse_spec(Text, spec(Title, _, Reqs)),
338
+ length(Reqs, ReqCount),
339
+ findall(S, (member(req(_, _, _, Ss), Reqs), member(S, Ss)), Scenarios),
340
+ length(Scenarios, ScenarioCount),
341
+ format(string(Summary), "spec ~w: ~w requirements, ~w scenarios", [Id, ReqCount, ScenarioCount]),
342
+ sp_validate_spec(File, Text, Errors).
343
+
344
+ sp_read_check_delta(File, Summary, Errors) :-
345
+ read_file_to_string(File, Text, []),
346
+ sp_parse_delta(Text, Deltas),
347
+ findall(op(Op, Reqs), member(delta(Op, Reqs), Deltas), Ops),
348
+ findall(S, (member(op(_, Reqs), Ops), member(req(_, _, _, Ss), Reqs), member(S, Ss)), Scenarios),
349
+ length(Scenarios, ScenarioCount),
350
+ format(string(Summary), "delta ~w: ~w operation sections, ~w scenarios", [File, Ops, ScenarioCount]),
351
+ sp_validate_delta(File, Text, Errors).
352
+
353
+ sp_error_line(error(File, Line, Message), Rendered) :-
354
+ format(string(Rendered), " ~w:~w ~w", [File, Line, Message]).
355
+
356
+ sp_check_all(Report) :-
357
+ sp_spec_files(SpecFiles),
358
+ sp_change_dirs(ChangeDirs),
359
+ sp_all_delta_files(ChangeDirs, DeltaFiles),
360
+ findall(Summary, (member(F, SpecFiles), sp_read_check_spec(F, Summary, _)), SpecSummaries),
361
+ findall(Summary, (member(F, DeltaFiles), sp_read_check_delta(F, Summary, _)), DeltaSummaries),
362
+ findall(E, (member(F, SpecFiles), sp_read_check_spec(F, _, Es), member(E, Es)), SpecErrors),
363
+ findall(E, (member(F, DeltaFiles), sp_read_check_delta(F, _, Es), member(E, Es)), DeltaErrors),
364
+ append(SpecErrors, DeltaErrors, BaseErrors),
365
+ findall(Line, (member(Dir, ChangeDirs), sp_coverage_line(Dir, Line)), CoverageLines),
366
+ findall(E, (member(Dir, ChangeDirs), sp_coverage_errors(Dir, Es), member(E, Es)), CoverageErrors),
367
+ append(BaseErrors, CoverageErrors, Errors),
368
+ length(Errors, ErrorCount),
369
+ ( ErrorCount =:= 0 -> Status = "OK" ; Status = "FAILED" ),
370
+ length(SpecFiles, CapCount),
371
+ length(DeltaFiles, DeltaCount),
372
+ findall(EL, (member(E, Errors), sp_error_line(E, EL)), ErrorLines),
373
+ ( ErrorLines == [] -> ErrorBlock = [" no structural errors"] ; ErrorBlock = ErrorLines ),
374
+ format(string(Header), "spec check: ~w (~w capabilities, ~w deltas, ~w errors)", [Status, CapCount, DeltaCount, ErrorCount]),
375
+ append([Header], SpecSummaries, L1),
376
+ append(L1, DeltaSummaries, L2),
377
+ append(L2, [""], L3),
378
+ append(L3, CoverageLines, L4),
379
+ append(L4, [""], L5),
380
+ append(L5, ErrorBlock, Body),
381
+ atomics_to_string(Body, "\n", Report).
382
+
383
+ sp_status(Report) :-
384
+ sp_spec_files(SpecFiles),
385
+ sp_change_dirs(ChangeDirs),
386
+ sp_all_delta_files(ChangeDirs, DeltaFiles),
387
+ findall(Cap, (member(F, SpecFiles), sp_capability_id(F, Cap)), CapIds),
388
+ length(SpecFiles, CapCount),
389
+ length(DeltaFiles, DeltaCount),
390
+ length(ChangeDirs, ChangeCount),
391
+ atomics_to_string(CapIds, ", ", CapList),
392
+ ( CapIds == [] -> CapList = "none" ; true ),
393
+ format(string(CapLine), "capabilities: ~w (~w)", [CapCount, CapList]),
394
+ format(string(ChangeLine), "active changes: ~w", [ChangeCount]),
395
+ format(string(DeltaLine), "delta files: ~w", [DeltaCount]),
396
+ atomics_to_string([CapLine, ChangeLine, DeltaLine], "\n", Report).
397
+
398
+ sp_capability_file(Capability, File) :-
399
+ sp_spec_files(Files),
400
+ member(File, Files),
401
+ sp_capability_id(File, Capability).
402
+
403
+ sp_query(Capability, Report) :-
404
+ ( sp_capability_file(Capability, File)
405
+ -> read_file_to_string(File, Text, []),
406
+ sp_parse_spec(Text, spec(Title, Purpose, Reqs)),
407
+ findall(RL, (member(req(Name, _, Desc, Ss), Reqs), length(Ss, SN),
408
+ format(string(RL), " ~w (~w scenarios) ~w", [Name, SN, Desc])), RLs),
409
+ format(string(Header), "capability ~w — ~w", [Capability, Title]),
410
+ format(string(PurposeLine), "purpose: ~w", [Purpose]),
411
+ ( RLs == [] -> ReqBlock = [" (no requirements)"] ; ReqBlock = RLs ),
412
+ append([Header, PurposeLine, "requirements:"], ReqBlock, Body),
413
+ atomics_to_string(Body, "\n", Report)
414
+ ; format(string(Report), "unknown capability: ~w", [Capability])
415
+ ).
416
+
417
+ %% ---------------------------------------------------------------------------
418
+ %% Mermaid graphs
419
+ %% ---------------------------------------------------------------------------
420
+
421
+ sp_graph_caps([], N, [], N).
422
+ sp_graph_caps([File|Rest], N0, Lines, N) :-
423
+ read_file_to_string(File, Text, []),
424
+ sp_parse_spec(Text, spec(Title, _, Reqs)),
425
+ sp_capability_id(File, Cap),
426
+ N1 is N0 + 1,
427
+ format(string(CapNode), " n~w[\"~w — ~w\"]", [N1, Cap, Title]),
428
+ N2 is N1 + 1,
429
+ sp_graph_reqs(Reqs, N1, N2, N3, ReqLines),
430
+ sp_graph_caps(Rest, N3, RestLines, N),
431
+ append([CapNode|ReqLines], RestLines, Lines).
432
+
433
+ sp_graph_reqs([], _, N, N, []).
434
+ sp_graph_reqs([req(Name, _, _, Scenarios)|Rest], Parent, N0, N, Lines) :-
435
+ N1 is N0 + 1,
436
+ format(string(NodeLine), " n~w[\"Requirement: ~w\"]", [N1, Name]),
437
+ format(string(EdgeLine), " n~w --> n~w", [Parent, N1]),
438
+ N2 is N1 + 1,
439
+ sp_graph_scenarios(Scenarios, N1, N2, N3, ScenarioLines),
440
+ sp_graph_reqs(Rest, Parent, N3, N, RestLines),
441
+ append([NodeLine, EdgeLine|ScenarioLines], RestLines, Lines).
442
+
443
+ sp_graph_scenarios([], _, N, N, []).
444
+ sp_graph_scenarios([scenario(Name, _, _)|Rest], Parent, N0, N, Lines) :-
445
+ N1 is N0 + 1,
446
+ format(string(NodeLine), " n~w[\"Scenario: ~w\"]", [N1, Name]),
447
+ format(string(EdgeLine), " n~w --> n~w", [Parent, N1]),
448
+ sp_graph_scenarios(Rest, Parent, N1, N, RestLines),
449
+ Lines = [NodeLine, EdgeLine|RestLines].
450
+
451
+ % The runtime passes CLI arguments as strings, while the view clauses below are
452
+ % keyed on atoms. Normalize before dispatch so a string view does not silently
453
+ % fall through to the capabilities catch-all.
454
+ sp_graph(View, Mermaid) :-
455
+ string(View),
456
+ atom_string(Atom, View),
457
+ sp_graph(Atom, Mermaid).
458
+
459
+ sp_graph(changes, Mermaid) :-
460
+ sp_change_dirs(ChangeDirs),
461
+ sp_graph_change_nodes(ChangeDirs, 0, Lines, _),
462
+ ( Lines == [] -> Body = [" empty[\"no active changes\"]"] ; Body = Lines ),
463
+ atomics_to_string(["flowchart LR"|Body], "\n", Mermaid).
464
+
465
+ sp_graph_change_nodes([], N, [], N).
466
+ sp_graph_change_nodes([Dir|Rest], N0, Lines, N) :-
467
+ file_base_name(Dir, Name),
468
+ N1 is N0 + 1,
469
+ format(string(NodeLine), " n~w[\"~w\"]", [N1, Name]),
470
+ sp_change_delta_files(Dir, DeltaFiles),
471
+ sp_graph_delta_edges(DeltaFiles, N1, N1, N2, EdgeLines),
472
+ sp_graph_change_nodes(Rest, N2, RestLines, N),
473
+ append([NodeLine|EdgeLines], RestLines, Lines).
474
+
475
+ sp_graph_delta_edges([], _, N, N, []).
476
+ sp_graph_delta_edges([File|Rest], Parent, N0, N, Lines) :-
477
+ sp_capability_id(File, Cap),
478
+ N1 is N0 + 1,
479
+ format(string(NodeLine), " n~w[\"~w\"]", [N1, Cap]),
480
+ format(string(EdgeLine), " n~w -- touches --> n~w", [Parent, N1]),
481
+ sp_graph_delta_edges(Rest, Parent, N1, N, RestLines),
482
+ Lines = [NodeLine, EdgeLine|RestLines].
483
+
484
+ sp_graph(capabilities, Mermaid) :-
485
+ sp_spec_files(Files),
486
+ sp_graph_caps(Files, 0, Lines, _),
487
+ ( Lines == [] -> Body = [" empty[\"no specs found\"]"] ; Body = Lines ),
488
+ atomics_to_string(["flowchart LR"|Body], "\n", Mermaid).
489
+
490
+ sp_graph(_, Mermaid) :-
491
+ sp_graph(capabilities, Mermaid).
492
+
493
+ %% ---------------------------------------------------------------------------
494
+ %% Merge and archive
495
+ %%
496
+ %% Reads an existing capability spec plus a change delta and produces the merged
497
+ %% spec. Unchanged requirement blocks are preserved line-for-line; only added,
498
+ %% modified and removed requirements change. RENAMED is refused for now.
499
+ %% ---------------------------------------------------------------------------
500
+
501
+ sp_line_at([line(Line, N)|_], N, Line) :- !.
502
+ sp_line_at([_|Rest], N, Line) :- sp_line_at(Rest, N, Line).
503
+
504
+ sp_raw_lines(List, Lines) :- findall(Line, member(line(Line, _), List), Lines).
505
+
506
+ sp_last([X], X) :- !.
507
+ sp_last([_|Rest], X) :- sp_last(Rest, X).
508
+
509
+ %% Purpose + delta operations, including the delta's ## Purpose section.
510
+ sp_parse_delta_full(Text, Purpose, Deltas) :-
511
+ sp_lines(Text, Lines),
512
+ sp_sections(Lines, Sections),
513
+ ( member(section(2, "Purpose", _, PurposeBody), Sections)
514
+ -> sp_prose(PurposeBody, Purpose)
515
+ ; Purpose = ""
516
+ ),
517
+ sp_drop_to_op(Sections, Rest),
518
+ sp_collect_ops(Rest, Deltas).
519
+
520
+ %% Existing requirements with their source line ranges.
521
+ sp_spec_ranges(Text, Reqs) :-
522
+ sp_lines(Text, Lines),
523
+ sp_sections(Lines, Sections),
524
+ findall(req_range(Name, Start, End, Desc, Scenarios), (
525
+ member(section(3, Heading, Start, Body), Sections),
526
+ sp_requirement_name(Heading, Name),
527
+ sp_body_end(Body, Start, End),
528
+ sp_requirement_body(Body, Desc, Scenarios)
529
+ ), Reqs).
530
+
531
+ sp_body_end([], Start, Start).
532
+ sp_body_end(Body, Start, End) :-
533
+ findall(N, member(line(_, N), Body), Numbers),
534
+ ( Numbers == []
535
+ -> End = Start
536
+ ; sp_last(Numbers, End)
537
+ ).
538
+
539
+ sp_read_optional(Path, Text) :-
540
+ ( catch(read_file_to_string(Path, Text0, []), _, fail)
541
+ -> Text = Text0
542
+ ; Text = none
543
+ ).
544
+
545
+ sp_write_file(Path, Text) :-
546
+ open(Path, write, Stream),
547
+ write(Stream, Text),
548
+ close(Stream).
549
+
550
+ %% Delta helpers.
551
+ sp_delta_ops(Deltas, Op, Reqs) :-
552
+ findall(Req, (member(delta(Op, Group), Deltas), member(Req, Group)), Reqs).
553
+
554
+ sp_delta_has(Deltas, Op, Name) :-
555
+ member(delta(Op, Group), Deltas),
556
+ member(req(Name, _, _, _), Group),
557
+ !.
558
+
559
+ sp_delta_req(Deltas, Op, Name, Req) :-
560
+ member(delta(Op, Group), Deltas),
561
+ member(Req, Group),
562
+ Req = req(Name, _, _, _),
563
+ !.
564
+
565
+ sp_delta_op_summary(Deltas, Ops) :-
566
+ findall(op(Op, Name), (
567
+ member(delta(Op, Group), Deltas),
568
+ member(req(Name, _, _, _), Group)
569
+ ), Ops).
570
+
571
+ %% Canonical rendering of one requirement and its scenarios.
572
+ sp_render_requirement(req(Name, _, Desc, Scenarios), Lines) :-
573
+ format(string(Heading), "### Requirement: ~w", [Name]),
574
+ findall(Block, (member(Scenario, Scenarios), sp_render_scenario(Scenario, Block)), Blocks),
575
+ append(Blocks, Flat),
576
+ ( Desc == ""
577
+ -> Head = [Heading, ""]
578
+ ; Head = [Heading, "", Desc, ""]
579
+ ),
580
+ append(Head, Flat, Lines).
581
+
582
+ sp_render_scenario(scenario(Name, _, Steps), Lines) :-
583
+ format(string(Heading), "#### Scenario: ~w", [Name]),
584
+ append([Heading|Steps], [""], Lines).
585
+
586
+ sp_render_new_spec(Capability, Purpose, Deltas, Lines) :-
587
+ format(string(Title), "# ~w Specification", [Capability]),
588
+ sp_delta_ops(Deltas, added, Added),
589
+ findall(Block, (member(Req, Added), sp_render_requirement(Req, Block)), Blocks),
590
+ append(Blocks, Flat),
591
+ ( Purpose == ""
592
+ -> Head = [Title, "", "## Purpose", "", "## Requirements", ""]
593
+ ; Head = [Title, "", "## Purpose", "", Purpose, "", "## Requirements", ""]
594
+ ),
595
+ append(Head, Flat, Lines).
596
+
597
+ %% Merge actions: drop or replace one existing requirement block.
598
+ sp_build_actions([], _, []).
599
+ sp_build_actions([req_range(Name, Start, End, _, _)|Rest], Deltas, Actions) :-
600
+ ( sp_delta_has(Deltas, removed, Name)
601
+ -> Action = op(Start, End, drop)
602
+ ; sp_delta_req(Deltas, modified, Name, NewReq)
603
+ -> sp_render_requirement(NewReq, NewLines),
604
+ Action = op(Start, End, replace(NewLines))
605
+ ; Action = none
606
+ ),
607
+ sp_build_actions(Rest, Deltas, RestActions),
608
+ ( Action == none
609
+ -> Actions = RestActions
610
+ ; Actions = [Action|RestActions]
611
+ ).
612
+
613
+ sp_skip_to([], _, []).
614
+ sp_skip_to([line(_, N)|Rest], End, Tail) :-
615
+ N =< End,
616
+ !,
617
+ sp_skip_to(Rest, End, Tail).
618
+ sp_skip_to(List, _, List).
619
+
620
+ sp_merge_walk([], _, []).
621
+ sp_merge_walk([line(Line, N)|Rest], Actions, Out) :-
622
+ ( memberchk(op(N, End, drop), Actions)
623
+ -> sp_skip_to(Rest, End, Tail),
624
+ sp_merge_walk(Tail, Actions, Out)
625
+ ; memberchk(op(N, End, replace(NewLines)), Actions)
626
+ -> sp_skip_to(Rest, End, Tail),
627
+ sp_merge_walk(Tail, Actions, Out1),
628
+ append(NewLines, Out1, Out)
629
+ ; Out = [Line|Out1],
630
+ sp_merge_walk(Rest, Actions, Out1)
631
+ ).
632
+
633
+ sp_added_lines(Deltas, Lines) :-
634
+ sp_delta_ops(Deltas, added, Added),
635
+ ( Added == []
636
+ -> Lines = []
637
+ ; findall(Block, (member(Req, Added), sp_render_requirement(Req, Block)), Blocks),
638
+ append(Blocks, Flat),
639
+ Lines = [""|Flat]
640
+ ).
641
+
642
+ %% Merge one delta file into its capability spec.
643
+ sp_do_merge(Capability, Purpose, Deltas, SpecPath, Mode, Ops) :-
644
+ sp_delta_op_summary(Deltas, Ops),
645
+ sp_read_optional(SpecPath, Existing),
646
+ ( Existing == none
647
+ -> sp_require_added_only(Deltas),
648
+ sp_render_new_spec(Capability, Purpose, Deltas, Lines)
649
+ ; sp_lines(Existing, Numbered),
650
+ sp_spec_ranges(Existing, Reqs),
651
+ sp_require_targets(Deltas, Reqs),
652
+ sp_build_actions(Reqs, Deltas, Actions),
653
+ sp_merge_walk(Numbered, Actions, Preserved),
654
+ sp_added_lines(Deltas, Added),
655
+ append(Preserved, Added, Lines)
656
+ ),
657
+ ( Mode == apply
658
+ -> atomics_to_string(Lines, "\n", Joined),
659
+ ( sub_string(Joined, _, 1, 0, "\n")
660
+ -> Text = Joined
661
+ ; string_concat(Joined, "\n", Text)
662
+ ),
663
+ sp_write_file(SpecPath, Text)
664
+ ; true
665
+ ).
666
+
667
+ sp_merge_error_message(error(merge_error(Message)), Message) :- !.
668
+ sp_merge_error_message(Err, Message) :- format(string(Message), "merge failed: ~w", [Err]).
669
+
670
+ %% A brand-new capability may only add requirements.
671
+ sp_require_added_only(Deltas) :-
672
+ findall(Op, (member(delta(Op, Reqs), Deltas), (Op == modified ; Op == removed), Reqs \= []), Ops0),
673
+ sort(Ops0, Ops),
674
+ ( Ops == []
675
+ -> true
676
+ ; atomics_to_string(Ops, ", ", OpList),
677
+ format(string(Message), "~w operations need an existing spec; only ## ADDED Requirements is allowed for a new capability", [OpList]),
678
+ throw(error(merge_error(Message)))
679
+ ).
680
+
681
+ %% MODIFIED and REMOVED must name requirements that exist in the target spec.
682
+ sp_require_targets(Deltas, Reqs) :-
683
+ findall(Name, (
684
+ member(delta(Op, Group), Deltas),
685
+ ( Op == modified ; Op == removed ),
686
+ member(req(Name, _, _, _), Group)
687
+ ), Targets0),
688
+ sort(Targets0, Targets),
689
+ findall(BaseName, member(req_range(BaseName, _, _, _, _), Reqs), BaseNames0),
690
+ sort(BaseNames0, BaseNames),
691
+ findall(Name, (member(Name, Targets), \+ memberchk(Name, BaseNames)), Missing0),
692
+ sort(Missing0, Missing),
693
+ ( Missing == []
694
+ -> true
695
+ ; atomics_to_string(Missing, ", ", MissingList),
696
+ format(string(Message), "MODIFIED/REMOVED requirements not found in the target spec: ~w", [MissingList]),
697
+ throw(error(merge_error(Message)))
698
+ ).
699
+
700
+ sp_spec_relpath(File, Rel) :-
701
+ sub_string(File, Before, _, _, "/specs/"),
702
+ After is Before + 7,
703
+ sub_string(File, After, _, 0, Rel).
704
+
705
+ sp_spec_path_from_delta(File, SpecPath) :-
706
+ sp_spec_relpath(File, Rel),
707
+ atomic_list_concat([".pi/deepclause/specs/", Rel], SpecPath).
708
+
709
+ sp_merge_capability(File, Mode, Result) :-
710
+ read_file_to_string(File, DeltaText, []),
711
+ sp_parse_delta_full(DeltaText, Purpose, Deltas),
712
+ sp_capability_id(File, Capability),
713
+ sp_spec_path_from_delta(File, SpecPath),
714
+ sp_delta_ops(Deltas, renamed, RenamedOps),
715
+ ( RenamedOps \= []
716
+ -> Result = result(Capability, SpecPath, [], error("RENAMED requirements are not supported by spec_archive yet"))
717
+ ; ( catch(sp_do_merge(Capability, Purpose, Deltas, SpecPath, Mode, Ops), Err,
718
+ (sp_merge_error_message(Err, Message), Ops = error(Message)))
719
+ -> ( Ops = error(Message)
720
+ -> Result = result(Capability, SpecPath, [], error(Message))
721
+ ; Result = result(Capability, SpecPath, Ops, ok)
722
+ )
723
+ ; Result = result(Capability, SpecPath, [], error("merge failed"))
724
+ )
725
+ ).
726
+
727
+ sp_archive_result_line(result(Capability, SpecPath, Ops, ok), Line) :-
728
+ length(Ops, Count),
729
+ format(string(Line), " ~w -> ~w (~w changes)", [Capability, SpecPath, Count]).
730
+ sp_archive_result_line(result(Capability, SpecPath, _, error(Reason)), Line) :-
731
+ format(string(Line), " ~w -> ~w ERROR: ~w", [Capability, SpecPath, Reason]).
732
+
733
+ sp_archive(Change, Mode, Report) :-
734
+ atomic_list_concat([".pi/deepclause/changes/", Change], ChangeDir),
735
+ ( catch(directory_files(ChangeDir, _), _, fail)
736
+ -> sp_change_delta_files(ChangeDir, DeltaFiles),
737
+ findall(R, (member(File, DeltaFiles), sp_merge_capability(File, Mode, R)), Results),
738
+ sp_archive_render(Change, Mode, Results, Body),
739
+ atomics_to_string(Body, "\n", Report)
740
+ ; format(string(Report), "unknown change: ~w", [Change])
741
+ ).
742
+
743
+ sp_archive_render(Change, Mode, Results, Lines) :-
744
+ ( Mode == apply
745
+ -> Verb = "apply"
746
+ ; Verb = "plan (read-only)"
747
+ ),
748
+ format(string(Header), "spec archive [~w]: ~w", [Verb, Change]),
749
+ findall(ResultLine, (member(Result, Results), sp_archive_result_line(Result, ResultLine)), ResultLines),
750
+ ( ResultLines == []
751
+ -> Body = [" no delta files found"]
752
+ ; Body = ResultLines
753
+ ),
754
+ append([Header, ""], Body, Lines).
755
+
756
+ %% ---------------------------------------------------------------------------
757
+ %% Tasks and scenario coverage (phase 3)
758
+ %%
759
+ %% tasks.dml holds `plan_task/2` facts. The functor is deliberately not
760
+ %% `task/2`, which collides with DML's built-in task/N predicate and makes the
761
+ %% term un-unifiable. Completion state uses `plan_task_status/2`.
762
+ %% ---------------------------------------------------------------------------
763
+
764
+ sp_read_terms(Path, Terms) :-
765
+ ( catch(open(Path, read, Stream), _, fail)
766
+ -> sp_read_terms_loop(Stream, Terms),
767
+ close(Stream)
768
+ ; Terms = []
769
+ ).
770
+
771
+ sp_read_terms_loop(Stream, Terms) :-
772
+ catch(read(Stream, Term), _, Term = end_of_file),
773
+ ( Term == end_of_file
774
+ -> Terms = []
775
+ ; Terms = [Term|Rest],
776
+ sp_read_terms_loop(Stream, Rest)
777
+ ).
778
+
779
+ sp_tasks(ChangeDir, Tasks) :-
780
+ atomic_list_concat([ChangeDir, "/tasks.dml"], Path),
781
+ sp_read_terms(Path, Terms),
782
+ findall(plan_task(Id, Props), (
783
+ member(Term, Terms),
784
+ Term = plan_task(Id, Props),
785
+ is_dict(Props)
786
+ ), Tasks).
787
+
788
+ sp_task_satisfies(Props, Ids) :- catch(get_dict(satisfies, Props, Ids), _, Ids = []).
789
+ sp_task_checks(Props, Checks) :- catch(get_dict(checks, Props, Checks), _, Checks = []).
790
+
791
+ %% Slug and scenario ids: "User toggles dark mode" -> "user-toggles-dark-mode".
792
+ sp_char_alnum(C) :- char_code(C, Code), Code >= 97, Code =< 122, !.
793
+ sp_char_alnum(C) :- char_code(C, Code), Code >= 48, Code =< 57, !.
794
+
795
+ sp_slug_chars([], []).
796
+ sp_slug_chars([C|Chars], [Mapped|Rest]) :-
797
+ ( sp_char_alnum(C)
798
+ -> Mapped = C
799
+ ; Mapped = "-"
800
+ ),
801
+ sp_slug_chars(Chars, Rest).
802
+
803
+ sp_keep_nonempty([], []).
804
+ sp_keep_nonempty([Part|Parts], Out) :-
805
+ ( Part == ""
806
+ -> Out = Rest
807
+ ; Out = [Part|Rest]
808
+ ),
809
+ sp_keep_nonempty(Parts, Rest).
810
+
811
+ sp_slug(Text, Slug) :-
812
+ string_lower(Text, Lower),
813
+ string_chars(Lower, Chars),
814
+ sp_slug_chars(Chars, Mapped),
815
+ atomics_to_string(Mapped, Raw),
816
+ split_string(Raw, "-", "-", Parts0),
817
+ sp_keep_nonempty(Parts0, Parts),
818
+ atomics_to_string(Parts, "-", Slug).
819
+
820
+ sp_scenario_id(Capability, Name, Id) :-
821
+ sp_slug(Name, Slug),
822
+ atomics_to_string([Capability, "#", Slug], Id).
823
+
824
+ sp_delta_scenarios_named(Deltas, Capability, Pairs) :-
825
+ findall(Id-Name, (
826
+ member(delta(Op, Reqs), Deltas),
827
+ ( Op == added ; Op == modified ),
828
+ member(req(_, _, _, Scenarios), Reqs),
829
+ member(scenario(Name, _, _), Scenarios),
830
+ sp_scenario_id(Capability, Name, Id)
831
+ ), Pairs).
832
+
833
+ sp_delta_scenario_ids(Deltas, Capability, Ids) :-
834
+ sp_delta_scenarios_named(Deltas, Capability, Pairs),
835
+ findall(Id, member(Id-_, Pairs), Ids).
836
+
837
+ sp_covered(Tasks, Id) :-
838
+ member(plan_task(_, Props), Tasks),
839
+ sp_task_satisfies(Props, Ids),
840
+ memberchk(Id, Ids).
841
+
842
+ %% Scenarios, tasks, uncovered scenarios, tasks without checks, orphan satisfies.
843
+ sp_coverage(ChangeDir, Scenarios, Tasks, Uncovered, NoCheck, Orphan) :-
844
+ sp_tasks(ChangeDir, Tasks),
845
+ sp_change_delta_files(ChangeDir, DeltaFiles),
846
+ findall(Id, (
847
+ member(File, DeltaFiles),
848
+ sp_capability_id(File, Capability),
849
+ read_file_to_string(File, Text, []),
850
+ sp_parse_delta_full(Text, _, Deltas),
851
+ sp_delta_scenario_ids(Deltas, Capability, Ids),
852
+ member(Id, Ids)
853
+ ), RawScenarios),
854
+ sort(RawScenarios, Scenarios),
855
+ findall(Id, (member(Id, Scenarios), \+ sp_covered(Tasks, Id)), Uncovered0),
856
+ sort(Uncovered0, Uncovered),
857
+ findall(TaskId, (
858
+ member(plan_task(TaskId, Props), Tasks),
859
+ sp_task_checks(Props, Checks),
860
+ Checks == []
861
+ ), NoCheck0),
862
+ sort(NoCheck0, NoCheck),
863
+ findall(Sat, (
864
+ member(plan_task(_, Props), Tasks),
865
+ sp_task_satisfies(Props, Sats),
866
+ member(Sat, Sats),
867
+ \+ memberchk(Sat, Scenarios)
868
+ ), Orphan0),
869
+ sort(Orphan0, Orphan).
870
+
871
+ sp_coverage_line(ChangeDir, Line) :-
872
+ file_base_name(ChangeDir, Name),
873
+ sp_coverage(ChangeDir, Scenarios, Tasks, Uncovered, NoCheck, _),
874
+ length(Scenarios, ScenarioCount),
875
+ length(Tasks, TaskCount),
876
+ length(Uncovered, UncoveredCount),
877
+ length(NoCheck, NoCheckCount),
878
+ Covered is ScenarioCount - UncoveredCount,
879
+ ( TaskCount =:= 0
880
+ -> format(string(Line), "coverage ~w: no tasks.dml yet (~w scenarios)", [Name, ScenarioCount])
881
+ ; format(string(Line), "coverage ~w: ~w/~w scenarios covered, ~w tasks, ~w without checks",
882
+ [Name, Covered, ScenarioCount, TaskCount, NoCheckCount])
883
+ ).
884
+
885
+ sp_coverage_errors(ChangeDir, Errors) :-
886
+ sp_tasks(ChangeDir, Tasks),
887
+ ( Tasks == []
888
+ -> Errors = []
889
+ ; sp_coverage(ChangeDir, _, _, Uncovered, NoCheck, _),
890
+ atomic_list_concat([ChangeDir, "/tasks.dml"], File),
891
+ findall(error(File, 0, Message), (
892
+ member(Id, Uncovered),
893
+ format(string(Message), "no task satisfies scenario ~w", [Id])
894
+ ), E1),
895
+ findall(error(File, 0, Message), (
896
+ member(Id, NoCheck),
897
+ format(string(Message), "task ~w declares no verification check", [Id])
898
+ ), E2),
899
+ append(E1, E2, Errors)
900
+ ).
901
+
902
+ %% Draft tasks.dml scaffold (read-only).
903
+ sp_scaffold_tasks([], _, []).
904
+ sp_scaffold_tasks([Id-Name|Rest], N, Lines) :-
905
+ format(string(TaskId), "1.~w", [N]),
906
+ format(string(Do), "Implement: ~w", [Name]),
907
+ format(string(Expected), "The scenario ~w holds.", [Id]),
908
+ format(string(Block),
909
+ "plan_task(\"~w\", task{\\n executor: pi,\\n do: \"~w\",\\n tools: [\"read\", \"edit\"],\\n expected: \"~w\",\\n satisfies: [\"~w\"],\\n checks: []\\n}).",
910
+ [TaskId, Do, Expected, Id]),
911
+ N1 is N + 1,
912
+ sp_scaffold_tasks(Rest, N1, RestLines),
913
+ Lines = [Block, ""|RestLines].
914
+
915
+ sp_scaffold_statuses([], _, []).
916
+ sp_scaffold_statuses([_|Rest], N, Lines) :-
917
+ format(string(TaskId), "1.~w", [N]),
918
+ format(string(Line), "plan_task_status(\"~w\", pending).", [TaskId]),
919
+ N1 is N + 1,
920
+ sp_scaffold_statuses(Rest, N1, RestLines),
921
+ Lines = [Line|RestLines].
922
+
923
+ sp_scaffold(Change, Draft) :-
924
+ atomic_list_concat([".pi/deepclause/changes/", Change], ChangeDir),
925
+ sp_change_delta_files(ChangeDir, DeltaFiles),
926
+ findall(Id-Name, (
927
+ member(File, DeltaFiles),
928
+ sp_capability_id(File, Capability),
929
+ read_file_to_string(File, Text, []),
930
+ sp_parse_delta_full(Text, _, Deltas),
931
+ sp_delta_scenarios_named(Deltas, Capability, Pairs),
932
+ member(Id-Name, Pairs)
933
+ ), Pairs),
934
+ sp_scaffold_tasks(Pairs, 1, TaskLines),
935
+ sp_scaffold_statuses(Pairs, 1, StatusLines),
936
+ format(string(Header), "% tasks.dml — draft scaffold for change ~w", [Change]),
937
+ append([
938
+ Header,
939
+ "% Fill in executor, tools, expected and checks, then run /dc-check.",
940
+ ""
941
+ ], TaskLines, A),
942
+ append(A, ["", "% --- execution state (managed by apply.dml; do not edit by hand) ---"], B),
943
+ append(B, StatusLines, All),
944
+ atomics_to_string(All, "\n", Draft0),
945
+ string_concat(Draft0, "\n", Draft).
946
+
947
+ %% ---------------------------------------------------------------------------
948
+ %% Task status: read and rewrite the managed block in tasks.dml
949
+ %% ---------------------------------------------------------------------------
950
+
951
+ sp_task_statuses(ChangeDir, Statuses) :-
952
+ atomic_list_concat([ChangeDir, "/tasks.dml"], Path),
953
+ sp_read_terms(Path, Terms),
954
+ findall(plan_task_status(Id, Status), (
955
+ member(Term, Terms),
956
+ Term = plan_task_status(Id, Status)
957
+ ), Statuses).
958
+
959
+ sp_set_status([], Id, Status, [plan_task_status(Id, Status)]).
960
+ sp_set_status([plan_task_status(Id, _)|Rest], Id, Status, [plan_task_status(Id, Status)|Rest]) :- !.
961
+ sp_set_status([Other|Rest], Id, Status, [Other|Out]) :- sp_set_status(Rest, Id, Status, Out).
962
+
963
+ sp_status_done(Statuses, Id) :- memberchk(plan_task_status(Id, done(_)), Statuses).
964
+
965
+ sp_count_repairs(Statuses, Count) :-
966
+ findall(1, (member(plan_task_status(_, done(N)), Statuses), N > 1), Repairs),
967
+ length(Repairs, Count).
968
+
969
+ sp_take_head([], [], no).
970
+ sp_take_head([Line|_], [Line], yes) :- sub_string(Line, _, _, _, "execution state"), !.
971
+ sp_take_head([Line|Rest], [Line|Head], Found) :- sp_take_head(Rest, Head, Found).
972
+
973
+ sp_split_managed(Text, Head) :-
974
+ split_string(Text, "\n", "", Lines),
975
+ sp_take_head(Lines, Head0, Found),
976
+ ( Found == yes
977
+ -> Head = Head0
978
+ ; append(Head0, ["", "% --- execution state (managed by apply.dml; do not edit by hand) ---"], Head)
979
+ ).
980
+
981
+ sp_write_statuses(TasksPath, Statuses) :-
982
+ read_file_to_string(TasksPath, Text, []),
983
+ sp_split_managed(Text, Head),
984
+ findall(Line, (
985
+ member(plan_task_status(Id, Status), Statuses),
986
+ format(string(Line), "plan_task_status(~q, ~q).", [Id, Status])
987
+ ), StatusLines),
988
+ append(Head, StatusLines, Body),
989
+ append(Body, [""], All),
990
+ atomics_to_string(All, "\n", Out),
991
+ sp_write_file(TasksPath, Out).