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