@zalom/plastic 2.0.0-alpha.17 → 2.0.0-alpha.19
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/package.json +2 -2
- package/scripts/dashboard.rb +20 -0
- package/scripts/doctor.rb +120 -2
- package/scripts/end-intent +134 -8
- package/scripts/hook-capture +4 -105
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/installer_core.rb +32 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +873 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +122 -12
- package/scripts/lib/roadmap_queue.rb +161 -3
- package/scripts/lib/roadmap_savepoint.rb +26 -5
- package/scripts/lib/savepoint.rb +123 -12
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +8 -1
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/validate-work-graph +39 -0
- package/skills/auto/SKILL.md +2 -3
- package/skills/auto/references/human-report-contract.md +3 -2
- package/skills/intent-continuing/references/boarding-matrix.md +1 -0
- package/skills/intent-ending/SKILL.md +30 -19
- package/skills/intent-executing/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/templates/graph.md +16 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +8 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zalom/plastic",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.19",
|
|
4
4
|
"description": "Intent-driven idea development system for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/zalom/plastic"
|
|
30
|
+
"url": "git+https://github.com/zalom/plastic.git"
|
|
31
31
|
},
|
|
32
32
|
"homepage": "https://github.com/zalom/plastic",
|
|
33
33
|
"files": [
|
package/scripts/dashboard.rb
CHANGED
|
@@ -33,6 +33,7 @@ require_relative "lib/report_screen"
|
|
|
33
33
|
require_relative "lib/roadmap_queue"
|
|
34
34
|
require_relative "lib/day_summary"
|
|
35
35
|
require_relative "lib/screen_paint"
|
|
36
|
+
require_relative "lib/ready_set"
|
|
36
37
|
|
|
37
38
|
# Intent 331a (D6/R8): every caller-added screen kind file registers itself on
|
|
38
39
|
# load, so this glob is the only wiring a new kind needs (scripts/report-screen:42
|
|
@@ -268,9 +269,27 @@ def parse_intent(store_info, dir_name, status_index)
|
|
|
268
269
|
# durable lock beside the intent. intent_line deliberately does not expose
|
|
269
270
|
# this filesystem path in --data or any rendered surface.
|
|
270
271
|
intent_dir: File.expand_path(dir),
|
|
272
|
+
# Intent 336 (G3, D14): the ready node count for a graph-shaped intent,
|
|
273
|
+
# read through ReadySet, nil for every other intent. Explicitly advisory,
|
|
274
|
+
# entirely separate from the sources-derived "unblocked" flag below,
|
|
275
|
+
# which is the cross-intent knowledge graph and stays exactly as it was
|
|
276
|
+
# (327 D40 forbids that field from gating work). ReadySet.analyze is
|
|
277
|
+
# called only when real.("graph.md") is true, so the cost of this read
|
|
278
|
+
# is zero for every intent that has no graph.
|
|
279
|
+
ready_node_count: real.("graph.md") ? ready_node_count_for(dir) : nil,
|
|
271
280
|
}
|
|
272
281
|
end
|
|
273
282
|
|
|
283
|
+
# The count of currently-ready nodes in a graph-shaped intent's own graph.md,
|
|
284
|
+
# or nil when ReadySet cannot analyze it (a malformed or cyclic graph never
|
|
285
|
+
# raises out of the dashboard).
|
|
286
|
+
def ready_node_count_for(dir)
|
|
287
|
+
analysis = ReadySet.analyze(dir)
|
|
288
|
+
return nil unless analysis[:ok]
|
|
289
|
+
|
|
290
|
+
analysis[:nodes].count { |_, view| view[:ready] }
|
|
291
|
+
end
|
|
292
|
+
|
|
274
293
|
def checklist_partially_done?(path)
|
|
275
294
|
txt = File.read(path)
|
|
276
295
|
checked = txt.scan(/^\s*- \[x\]/i).size
|
|
@@ -828,6 +847,7 @@ def next_work(records, cap: NEXT_WORK_CAP)
|
|
|
828
847
|
{ id: r[:id], intent: r[:intent], scope: r[:scope], lifecycle: r[:lifecycle],
|
|
829
848
|
value: r[:value].to_s, disposition: r[:disposition], flags: r[:flags],
|
|
830
849
|
what: cell(text), flags_label: cell(Array(r[:flags]).join(", ")),
|
|
850
|
+
ready_node_count: r[:ready_node_count],
|
|
831
851
|
line: "#{r[:id]} #{text}" }
|
|
832
852
|
end
|
|
833
853
|
end
|
package/scripts/doctor.rb
CHANGED
|
@@ -27,6 +27,8 @@ require_relative "lib/links_projection"
|
|
|
27
27
|
require_relative "lib/links_section"
|
|
28
28
|
require_relative "lib/lock"
|
|
29
29
|
require_relative "lib/savepoint"
|
|
30
|
+
require_relative "lib/node_ledger"
|
|
31
|
+
require_relative "lib/ready_set"
|
|
30
32
|
require_relative "lib/agent_models"
|
|
31
33
|
require_relative "lib/outcome_guard"
|
|
32
34
|
require_relative "lib/skill_lint"
|
|
@@ -620,6 +622,14 @@ class Doctor
|
|
|
620
622
|
# flagged: validate_graph does not compute it.
|
|
621
623
|
checks.concat(graph_invariant_checks(intent_dirs))
|
|
622
624
|
|
|
625
|
+
# node_graph - ReadySet's own view of every intent carrying a real graph.md
|
|
626
|
+
# (intent 336, n7): dead ends, stale done nodes, and expired running leases,
|
|
627
|
+
# none of which any other doctor rule surfaces. Lives here rather than
|
|
628
|
+
# doctor_core.rb because test/doctor_core_split_test.rb pins that file's
|
|
629
|
+
# boot path to exactly three project files and 781 bytes of headroom;
|
|
630
|
+
# ReadySet's own require chain is 65,648 bytes, far past that budget.
|
|
631
|
+
checks.concat(node_graph_checks(intent_dirs))
|
|
632
|
+
|
|
623
633
|
# cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
|
|
624
634
|
# `store:id` ref against the FULL store family via the relocation map
|
|
625
635
|
# (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
|
|
@@ -785,7 +795,14 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
|
|
|
785
795
|
# reported as a fixable warn (backfilled_complete). Its exclusions go to their OWN
|
|
786
796
|
# bucket so savepoint_operational's consumed/dead-row bookkeeping above never sees them.
|
|
787
797
|
backfill_gaps = %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
|
|
788
|
-
|
|
798
|
+
# Name whichever directory the intent actually used (post-execution
|
|
799
|
+
# review, non-blocking 8), consistent with Savepoint.missing_for_stage:
|
|
800
|
+
# a nodes/ directory on disk means the intent chose the node-graph
|
|
801
|
+
# convention, so a gap here is a missing nodes/, never the literal
|
|
802
|
+
# actions/ has_real_action? no longer implies.
|
|
803
|
+
unless Savepoint.has_real_action?(dir)
|
|
804
|
+
backfill_gaps << (File.directory?(File.join(dir, "nodes")) ? "nodes/" : "actions/")
|
|
805
|
+
end
|
|
789
806
|
if backfill_gaps.any?
|
|
790
807
|
suppressed_backfill = excluded_rules.include?("backfilled_complete")
|
|
791
808
|
target = suppressed_backfill ? findings[:excluded_backfill] : findings[:unbackfilled]
|
|
@@ -1461,20 +1478,37 @@ end
|
|
|
1461
1478
|
expected_pair = Savepoint.savepoint_milestone(intent_dir, File.basename(Savepoint.intent_file(intent_dir)))
|
|
1462
1479
|
|
|
1463
1480
|
phantoms = Savepoint.savepoint_phantom_lines(intent_dir)
|
|
1481
|
+
# Intent 335 (spec C2/C5): a torn or unattributed node/Intent transition
|
|
1482
|
+
# line has no reader anywhere else in the tree, so this is where it
|
|
1483
|
+
# surfaces. NodeLedger.anomalies never touches a stage line (its own
|
|
1484
|
+
# transition-candidate gate excludes them), so this is purely additive
|
|
1485
|
+
# beside the phantom check above.
|
|
1486
|
+
transition_anomalies = NodeLedger.anomalies(savepoint)
|
|
1464
1487
|
problems = []
|
|
1465
1488
|
problems << "born line #{born_pair.inspect} does not match the expected #{expected_pair.inspect}" \
|
|
1466
1489
|
if born_pair != expected_pair
|
|
1467
1490
|
problems << "#{phantoms.size} phantom savepoint line(s): " \
|
|
1468
1491
|
"#{phantoms.map { |l, r| "#{l} (#{r})" }.join("; ")}" if phantoms.any?
|
|
1492
|
+
problems << "#{transition_anomalies.size} torn/unattributed transition line(s): " \
|
|
1493
|
+
"#{transition_anomalies.map { |a| "#{a[:line]} (#{a[:reason]})" }.join("; ")}" \
|
|
1494
|
+
if transition_anomalies.any?
|
|
1469
1495
|
|
|
1470
1496
|
if problems.empty?
|
|
1471
1497
|
check(category: "intent_end", name: "intent_savepoint_truthful", status: "pass",
|
|
1472
1498
|
message: "Savepoint born line and phantom-line state are truthful")
|
|
1473
1499
|
else
|
|
1500
|
+
# A torn or unattributed transition line's CONTENT cannot be repaired by
|
|
1501
|
+
# a rebuild (rebuild_savepoint preserves it verbatim, spec D13), unlike a
|
|
1502
|
+
# born-line mismatch or a stage phantom, which rebuild does fix. Say so
|
|
1503
|
+
# explicitly rather than pointing at a fix that will not fix it.
|
|
1504
|
+
fix_hint = "Rebuild the ledger via Savepoint.rebuild_savepoint"
|
|
1505
|
+
fix_hint += "; note: rebuild preserves a torn or unattributed transition line " \
|
|
1506
|
+
"verbatim and does not repair one - hand-edit it or accept the finding" \
|
|
1507
|
+
if transition_anomalies.any?
|
|
1474
1508
|
check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
|
|
1475
1509
|
message: "#{problems.size} savepoint truthfulness issue(s) (advisory, never blocking)",
|
|
1476
1510
|
details: problems,
|
|
1477
|
-
fixable: true, fix_hint:
|
|
1511
|
+
fixable: true, fix_hint: fix_hint)
|
|
1478
1512
|
end
|
|
1479
1513
|
end
|
|
1480
1514
|
|
|
@@ -1620,6 +1654,90 @@ end
|
|
|
1620
1654
|
end
|
|
1621
1655
|
end
|
|
1622
1656
|
|
|
1657
|
+
# ReadySet over every intent carrying a real graph.md (intent 336, n7):
|
|
1658
|
+
# dead ends, stale done nodes, and expired running leases. An intent with
|
|
1659
|
+
# no real graph.md is skipped entirely (never touches ReadySet); a
|
|
1660
|
+
# malformed or cyclic one is reported by name, never raised out of doctor.
|
|
1661
|
+
def node_graph_checks(intent_dirs)
|
|
1662
|
+
dead_ends = []
|
|
1663
|
+
stale = []
|
|
1664
|
+
expired = []
|
|
1665
|
+
malformed = []
|
|
1666
|
+
|
|
1667
|
+
intent_dirs.each do |d|
|
|
1668
|
+
graph_path = File.join(d[:path], "graph.md")
|
|
1669
|
+
next unless Savepoint.stage_file_present?(graph_path)
|
|
1670
|
+
|
|
1671
|
+
analysis = ReadySet.analyze(d[:path])
|
|
1672
|
+
unless analysis[:ok]
|
|
1673
|
+
malformed << "#{d[:name]}: #{analysis[:errors].join('; ')}"
|
|
1674
|
+
next
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1677
|
+
savepoint_path = File.join(d[:path], "savepoint.md")
|
|
1678
|
+
analysis[:nodes].each do |id, view|
|
|
1679
|
+
dead_ends << "#{d[:name]}/#{id}" if view[:dead_end]
|
|
1680
|
+
stale << "#{d[:name]}/#{id}" if view[:stale]
|
|
1681
|
+
expired << "#{d[:name]}/#{id}" if view[:state] == "running" && expired_running_lease?(savepoint_path, id)
|
|
1682
|
+
end
|
|
1683
|
+
end
|
|
1684
|
+
|
|
1685
|
+
[
|
|
1686
|
+
node_graph_finding_check(
|
|
1687
|
+
"node_graph_dead_ends", dead_ends,
|
|
1688
|
+
"No node waits on a need that resolves to superseded or abandoned",
|
|
1689
|
+
"Re-plan or supersede the dead-end node's need chain, or abandon the node itself"
|
|
1690
|
+
),
|
|
1691
|
+
node_graph_finding_check(
|
|
1692
|
+
"node_graph_stale_done", stale,
|
|
1693
|
+
"No done node rests on a need superseded after it finished",
|
|
1694
|
+
"Re-verify the stale node against the superseding work, or record the staleness in revisions.md"
|
|
1695
|
+
),
|
|
1696
|
+
node_graph_finding_check(
|
|
1697
|
+
"node_graph_expired_running_lease", expired,
|
|
1698
|
+
"No running node's lease has expired unreclaimed",
|
|
1699
|
+
"Run `node-transition <intent_dir> --node <id> --state reclaimed --field holder=<h> " \
|
|
1700
|
+
"--field expired=<iso>` to sweep it"
|
|
1701
|
+
),
|
|
1702
|
+
node_graph_finding_check(
|
|
1703
|
+
"node_graph_malformed", malformed,
|
|
1704
|
+
"Every intent's graph.md parses cleanly",
|
|
1705
|
+
"Fix the malformed graph.md or nodes/ file named in the details"
|
|
1706
|
+
),
|
|
1707
|
+
]
|
|
1708
|
+
end
|
|
1709
|
+
|
|
1710
|
+
# True iff `id`'s last running line in the ledger at `savepoint_path` carries
|
|
1711
|
+
# an `expires=` strictly in the past. An unparseable or absent expires=
|
|
1712
|
+
# never counts as expired (fail milder than the bug).
|
|
1713
|
+
def expired_running_lease?(savepoint_path, id, now: Time.now)
|
|
1714
|
+
entry = NodeLedger.last_running(savepoint_path, id)
|
|
1715
|
+
return false unless entry
|
|
1716
|
+
|
|
1717
|
+
expires_raw = (entry[:fields] || {})["expires"]
|
|
1718
|
+
expiry = begin
|
|
1719
|
+
expires_raw && Time.iso8601(expires_raw)
|
|
1720
|
+
rescue ArgumentError
|
|
1721
|
+
nil
|
|
1722
|
+
end
|
|
1723
|
+
!!(expiry && now > expiry)
|
|
1724
|
+
end
|
|
1725
|
+
|
|
1726
|
+
# One node-graph check: pass when `findings` is empty, otherwise warn
|
|
1727
|
+
# (never fail, matching graph_finding_check's own precedent - an existing
|
|
1728
|
+
# store never turns red on an advisory graph finding).
|
|
1729
|
+
def node_graph_finding_check(name, findings, pass_message, fix_hint)
|
|
1730
|
+
if findings.empty?
|
|
1731
|
+
check(category: "conventions", name: name, status: "pass", message: pass_message)
|
|
1732
|
+
else
|
|
1733
|
+
check(
|
|
1734
|
+
category: "conventions", name: name, status: "warn",
|
|
1735
|
+
message: "#{findings.size} #{name} violation(s)",
|
|
1736
|
+
details: findings, fixable: false, fix_hint: fix_hint
|
|
1737
|
+
)
|
|
1738
|
+
end
|
|
1739
|
+
end
|
|
1740
|
+
|
|
1623
1741
|
# --- Check category 3: Agent registration ---
|
|
1624
1742
|
|
|
1625
1743
|
|
package/scripts/end-intent
CHANGED
|
@@ -81,6 +81,7 @@ require_relative "lib/intent_validator"
|
|
|
81
81
|
require_relative "lib/outcome_guard"
|
|
82
82
|
require_relative "lib/report_screen"
|
|
83
83
|
require_relative "lib/backfill_intent"
|
|
84
|
+
require_relative "lib/outcome_report"
|
|
84
85
|
|
|
85
86
|
DISPOSITIONS = %w[delivered abandoned].freeze
|
|
86
87
|
|
|
@@ -176,6 +177,64 @@ def resolve_plastic_home_and_scope(store)
|
|
|
176
177
|
end
|
|
177
178
|
end
|
|
178
179
|
|
|
180
|
+
# --- outcome generation, before the backfill (intent 339, G6, spec D10) -----
|
|
181
|
+
#
|
|
182
|
+
# Generates outcome.md from graph.md, nodes/, and the ledger, BEFORE the
|
|
183
|
+
# backfill step runs, and only into a file that is missing or still the
|
|
184
|
+
# scaffold placeholder (`Savepoint.stage_file_present?` false) - a real,
|
|
185
|
+
# hand-written outcome.md is never touched (row 7.2). Only when a graph.md
|
|
186
|
+
# exists at all (row 7.3): every intent without one closes exactly as it did
|
|
187
|
+
# before this intent. The model is parsed AFTER the `stage_file_present?`
|
|
188
|
+
# guard, not before: a real hand-written outcome.md returns at that guard
|
|
189
|
+
# regardless of graph.md's own health, so a close that was never going to
|
|
190
|
+
# generate anything must never print a parse warning about it. Refuses a
|
|
191
|
+
# report model that reports itself broken (row v1f.2, B2): a malformed
|
|
192
|
+
# `## Graph` fails `model[:ok]` and the close falls straight through to the
|
|
193
|
+
# backfill, rather than adopting parse damage as delivery fact - agreeing
|
|
194
|
+
# with the shipped `outcome-report` CLI, which exits 3 on the same
|
|
195
|
+
# directory. The generated text is then checked against
|
|
196
|
+
# BOTH close gates - `OutcomeGuard` and the hollow-report gate below - before
|
|
197
|
+
# being adopted (row 7.4); when either would refuse it, the write is
|
|
198
|
+
# reverted and the close falls through to intent 308's unchanged backfill
|
|
199
|
+
# (row 7.5), so no close gains an exit 7 it did not have before this intent.
|
|
200
|
+
# Fails open on any exception (row 7.6), the same report-and-proceed shape
|
|
201
|
+
# every other close step in this file uses. `generator:` is the injectable
|
|
202
|
+
# seam (mirrors run_structure_check's `gate:`).
|
|
203
|
+
def run_generate_outcome(intent_dir, disposition:, generator: OutcomeReport.method(:write))
|
|
204
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
205
|
+
return unless File.exist?(graph_path)
|
|
206
|
+
|
|
207
|
+
outcome_path = File.join(intent_dir, "outcome.md")
|
|
208
|
+
return if Savepoint.stage_file_present?(outcome_path)
|
|
209
|
+
|
|
210
|
+
report_model = OutcomeReport.model(intent_dir)
|
|
211
|
+
unless report_model[:ok]
|
|
212
|
+
warn "end-intent: graph.md failed to parse (#{report_model[:errors].join('; ')}); falling back to the backfill"
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
original_exists = File.exist?(outcome_path)
|
|
217
|
+
original_content = original_exists ? File.read(outcome_path) : nil
|
|
218
|
+
|
|
219
|
+
generator.call(intent_dir, disposition: disposition)
|
|
220
|
+
|
|
221
|
+
guard_reason = OutcomeGuard.reason(intent_dir, disposition)
|
|
222
|
+
hollow = hollow_report_reason(intent_dir, disposition)
|
|
223
|
+
if guard_reason || hollow
|
|
224
|
+
if original_exists
|
|
225
|
+
File.write(outcome_path, original_content)
|
|
226
|
+
elsif File.exist?(outcome_path)
|
|
227
|
+
File.delete(outcome_path)
|
|
228
|
+
end
|
|
229
|
+
warn "end-intent: generated outcome.md would be refused (#{[guard_reason, hollow].compact.join('; ')}); " \
|
|
230
|
+
"falling back to the backfill"
|
|
231
|
+
else
|
|
232
|
+
warn "end-intent: outcome.md generated from graph.md and the ledger"
|
|
233
|
+
end
|
|
234
|
+
rescue StandardError => e
|
|
235
|
+
warn "end-intent: outcome generation crashed (#{e.message}); proceeding without it"
|
|
236
|
+
end
|
|
237
|
+
|
|
179
238
|
# Backfill (intent 308): write the judgment documents that are missing or still the
|
|
180
239
|
# placeholder from the record (BackfillIntent). Fail-open: a crash here warns and the
|
|
181
240
|
# close proceeds, the same contract as the structure check below.
|
|
@@ -227,6 +286,48 @@ end
|
|
|
227
286
|
# closed exactly this way and the owner read a screen full of "not recorded".
|
|
228
287
|
# Abandoned closes and machine-backfilled outcomes (the backfill marker) are
|
|
229
288
|
# exempt: the reader cannot expect labels the writer never had.
|
|
289
|
+
#
|
|
290
|
+
# Intent 334 (G1, fold A3; post-execution review, blocking 2): the legacy
|
|
291
|
+
# "S3" label grammar applies only to actions/*.md headings, and the node id
|
|
292
|
+
# grammar (kind prefix n/v/d/r plus digits, D1r) applies only to nodes/*.md
|
|
293
|
+
# headings - never both over both directories. This repo's own house words
|
|
294
|
+
# for plan review and post-execution review are "v1" and "v2"; widening the
|
|
295
|
+
# node-id alternative onto actions/ headings turned that prose into a
|
|
296
|
+
# manufactured label and refused legitimate legacy closes at exit 7.
|
|
297
|
+
ACTION_ID_LABEL_RE = /\AS\d+\z/.freeze
|
|
298
|
+
NODE_ID_LABEL_RE = /\A[nvdr][1-9]\d*\z/.freeze
|
|
299
|
+
|
|
300
|
+
# The id-shaped tokens found in `paths`' headings, matched against
|
|
301
|
+
# `id_label_re` only - the caller picks the grammar for the directory.
|
|
302
|
+
def heading_id_labels(paths, id_label_re)
|
|
303
|
+
paths.flat_map do |path|
|
|
304
|
+
ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
|
|
305
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).select { |tok| tok.match?(id_label_re) }
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# 339 S9 (D18): a node id counts as a label in an actions/*.md heading too,
|
|
311
|
+
# but only when it stands alone as one of the heading's " - "-delimited
|
|
312
|
+
# segments - this repo's own convention, "### S1 - n1 - Title" (this action
|
|
313
|
+
# file's own headings, post-fold) or the bare "### n1 - Title". Never as a
|
|
314
|
+
# free word inside ordinary prose: the comment above ACTION_ID_LABEL_RE
|
|
315
|
+
# records exactly why intent 334 restricted the node-id grammar to
|
|
316
|
+
# nodes/*.md headings in the first place, and that reasoning does not change
|
|
317
|
+
# here - a word-scan widened onto actions/ headings would let this repo's own
|
|
318
|
+
# house words ("v1", "v2" for plan review and post-execution review)
|
|
319
|
+
# manufacture a label out of ordinary prose and refuse a legitimate legacy
|
|
320
|
+
# close. Segment position is what a word-scan cannot see: "v2" embedded
|
|
321
|
+
# mid-sentence is never its own " - "-delimited segment, but a heading built
|
|
322
|
+
# to this convention always puts the node id in one.
|
|
323
|
+
def heading_node_id_segments(paths, id_label_re)
|
|
324
|
+
paths.flat_map do |path|
|
|
325
|
+
ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
|
|
326
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/\s+-\s+/).map(&:strip).select { |seg| seg.match?(id_label_re) }
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
230
331
|
def hollow_report_reason(intent_dir, disposition)
|
|
231
332
|
return nil unless disposition == "delivered"
|
|
232
333
|
|
|
@@ -235,14 +336,34 @@ def hollow_report_reason(intent_dir, disposition)
|
|
|
235
336
|
text = File.read(outcome)
|
|
236
337
|
return nil if text.include?("<!-- backfilled from the record by end-intent on ")
|
|
237
338
|
|
|
238
|
-
# The gate's evidence bar (A7/B7): it judges only records that CLAIM
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
339
|
+
# The gate's evidence bar (A7/B7): it judges only records that CLAIM a
|
|
340
|
+
# labeled convention - at least one actions/*.md heading carrying an
|
|
341
|
+
# S-label or a node-id segment, or at least one nodes/*.md heading carrying
|
|
342
|
+
# a node id ("n1", "v2", "d3", "r4"). A terse legacy close with no labeled
|
|
343
|
+
# matrix stays report-and-proceed (intent 334, G1, D10r).
|
|
344
|
+
#
|
|
345
|
+
# Every matching token per heading is kept, not just the first (fold A3):
|
|
346
|
+
# a legacy actions/ heading can carry BOTH an S-label and prose that reads
|
|
347
|
+
# like a node id ("### The v2 rewrite (S3)"). Restricting the word-scan
|
|
348
|
+
# node-id grammar to nodes/*.md headings (post-execution review, blocking
|
|
349
|
+
# 2) means "v2" in that heading is never even tested against it, so the
|
|
350
|
+
# heading's real "S3" label is never at risk.
|
|
351
|
+
#
|
|
352
|
+
# 339 S9 (D18): a graph-era action file headed by node id alone ("### n1 -
|
|
353
|
+
# Title", no S-label) used to yield an empty label set here and the gate
|
|
354
|
+
# returned before judging anything - every such record closed unjudged by
|
|
355
|
+
# silent exemption. `heading_node_id_segments` widens the actions/*.md side
|
|
356
|
+
# to see that shape without reopening the word-scan hole above: it matches
|
|
357
|
+
# a node id only when the heading's own " - "-delimited segments carry one,
|
|
358
|
+
# never a free word in prose. The node_paths call stays exactly as it was;
|
|
359
|
+
# nodes/*.md headings already resolve node ids correctly (test_node_id_
|
|
360
|
+
# labels_are_recognized) and their heading grammar is controlled, so a
|
|
361
|
+
# word-scan there carries none of the prose risk.
|
|
362
|
+
action_paths = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort
|
|
363
|
+
node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
|
|
364
|
+
heading_labels = (heading_id_labels(action_paths, ACTION_ID_LABEL_RE) +
|
|
365
|
+
heading_node_id_segments(action_paths, NODE_ID_LABEL_RE) +
|
|
366
|
+
heading_id_labels(node_paths, NODE_ID_LABEL_RE)).uniq
|
|
246
367
|
return nil if heading_labels.empty?
|
|
247
368
|
|
|
248
369
|
rows = ReportScreen.delivered_rows(intent_dir)
|
|
@@ -718,6 +839,11 @@ def main(argv)
|
|
|
718
839
|
end
|
|
719
840
|
end
|
|
720
841
|
|
|
842
|
+
# 0a. Generate outcome.md from graph.md and the ledger, when there is one
|
|
843
|
+
# (intent 339, G6, spec D10) - before the backfill, so a real graph never
|
|
844
|
+
# falls through to the terse backfill it exists to replace.
|
|
845
|
+
run_generate_outcome(intent_dir, disposition: disposition)
|
|
846
|
+
|
|
721
847
|
# 1. Backfill from the record (intent 308), then the structure self-check and the
|
|
722
848
|
# outcome guard, both report-only since 308.
|
|
723
849
|
run_backfill(intent_dir, store: store, id: id, disposition: disposition,
|
package/scripts/hook-capture
CHANGED
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
# Usage: hook-capture (reads the UserPromptSubmit stdin JSON payload)
|
|
6
6
|
# Intent 298. One UserPromptSubmit process replacing hook-continue,
|
|
7
7
|
# hook-future-intent-check, and hook-auto-arm: it appends a pending line to
|
|
8
|
-
# the session day ledger
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# the session day ledger and detects "continue" and "auto" prompts. Every job
|
|
9
|
+
# runs in its own rescue and the hook always exits 0 (spec D2). Intent 345
|
|
10
|
+
# (D7, 323) removed the per-prompt Future-intent hint step entirely.
|
|
11
11
|
|
|
12
12
|
require "json"
|
|
13
|
-
require "yaml"
|
|
14
13
|
require "open3"
|
|
15
14
|
require "fileutils"
|
|
16
15
|
require_relative "lib/session_ledger"
|
|
@@ -42,87 +41,6 @@ templates = File.expand_path("../templates", __dir__)
|
|
|
42
41
|
sid = SessionLedger.short_session_id(nil, session_id)
|
|
43
42
|
today = SessionLedger.day_id
|
|
44
43
|
|
|
45
|
-
# --- Extracted matching logic, shared by both the global and project store
|
|
46
|
-
# passes of job (f) below (mirrors hook-future-intent-check verbatim). ---
|
|
47
|
-
def future_intent_matches(store_root, message)
|
|
48
|
-
index_path = File.join(store_root, "INDEX.md")
|
|
49
|
-
return [] unless File.exist?(index_path)
|
|
50
|
-
|
|
51
|
-
lines = File.readlines(index_path)
|
|
52
|
-
future_dirs = []
|
|
53
|
-
section = nil
|
|
54
|
-
|
|
55
|
-
lines.each do |line|
|
|
56
|
-
section = :future if line.start_with?("## Future")
|
|
57
|
-
section = nil if line.start_with?("## ") && !line.start_with?("## Future")
|
|
58
|
-
next unless section == :future && line.strip.start_with?("- [")
|
|
59
|
-
|
|
60
|
-
future_dirs << $1 if line =~ /store\/([\w-]+)\//
|
|
61
|
-
end
|
|
62
|
-
return [] if future_dirs.empty?
|
|
63
|
-
|
|
64
|
-
matches = []
|
|
65
|
-
message_words = message.split(/\W+/).reject { |w| w.length < 4 }
|
|
66
|
-
|
|
67
|
-
future_dirs.each do |dir|
|
|
68
|
-
intent_path = File.join(store_root, "store", dir, "#{dir}.md")
|
|
69
|
-
next unless File.exist?(intent_path)
|
|
70
|
-
|
|
71
|
-
content = File.read(intent_path)
|
|
72
|
-
|
|
73
|
-
tags = []
|
|
74
|
-
tags = $1.split(",").map(&:strip).map(&:downcase) if content =~ /^tags:\s*\[([^\]]+)\]/
|
|
75
|
-
|
|
76
|
-
intent_name = ""
|
|
77
|
-
intent_name = $1.downcase if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
|
|
78
|
-
|
|
79
|
-
keywords = (tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }).map(&:downcase).uniq
|
|
80
|
-
|
|
81
|
-
matched_keywords = keywords.select { |kw| message.include?(kw) }
|
|
82
|
-
name_matches = message_words.select { |w| intent_name.include?(w) }
|
|
83
|
-
matched_keywords = (matched_keywords + name_matches).uniq
|
|
84
|
-
|
|
85
|
-
next if matched_keywords.empty?
|
|
86
|
-
|
|
87
|
-
index_line = lines.find { |l| l.include?(dir) }&.strip || "#{dir} - #{intent_name}"
|
|
88
|
-
matches << { "index_line" => index_line, "keywords" => matched_keywords }
|
|
89
|
-
end
|
|
90
|
-
matches
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# The store_root that carries the current project's own store, resolved from
|
|
94
|
-
# cwd through projects.yml, mirroring SessionLedger.project_slug's matching
|
|
95
|
-
# but returning nil (not "global") when nothing matches, since there is then
|
|
96
|
-
# no distinct second store to hint against.
|
|
97
|
-
def resolve_project_store_root(cwd, plastic_home)
|
|
98
|
-
projects_path = File.join(plastic_home, "projects.yml")
|
|
99
|
-
return nil unless File.exist?(projects_path)
|
|
100
|
-
|
|
101
|
-
data = begin
|
|
102
|
-
YAML.safe_load(File.read(projects_path))
|
|
103
|
-
rescue StandardError
|
|
104
|
-
nil
|
|
105
|
-
end
|
|
106
|
-
data = {} unless data.is_a?(Hash)
|
|
107
|
-
projects = data["projects"].is_a?(Hash) ? data["projects"] : {}
|
|
108
|
-
expanded_cwd = File.expand_path(cwd)
|
|
109
|
-
|
|
110
|
-
matches = projects.filter_map do |slug, info|
|
|
111
|
-
next unless info.is_a?(Hash) && slug.is_a?(String)
|
|
112
|
-
|
|
113
|
-
path = info["path"]
|
|
114
|
-
next unless path
|
|
115
|
-
|
|
116
|
-
root = File.expand_path(path)
|
|
117
|
-
next unless expanded_cwd == root || expanded_cwd.start_with?("#{root}#{File::SEPARATOR}")
|
|
118
|
-
|
|
119
|
-
[root.length, slug]
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
best = matches.max_by { |(length, _slug)| length }
|
|
123
|
-
best ? File.join(plastic_home, "projects", best[1]) : nil
|
|
124
|
-
end
|
|
125
|
-
|
|
126
44
|
def truncate(text, max)
|
|
127
45
|
return text if text.length <= max
|
|
128
46
|
|
|
@@ -183,7 +101,7 @@ system_message = nil
|
|
|
183
101
|
|
|
184
102
|
# --- (d) "continue" cockpit -------------------------------------------------
|
|
185
103
|
begin
|
|
186
|
-
if prompt.
|
|
104
|
+
if prompt.to_s.strip.downcase == "continue"
|
|
187
105
|
dashboard = File.expand_path("dashboard.rb", __dir__)
|
|
188
106
|
if File.exist?(dashboard)
|
|
189
107
|
cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
|
|
@@ -224,25 +142,6 @@ rescue StandardError
|
|
|
224
142
|
nil
|
|
225
143
|
end
|
|
226
144
|
|
|
227
|
-
# --- (f) Future-intent hint, global store then the project's own store -----
|
|
228
|
-
begin
|
|
229
|
-
message = prompt.to_s.downcase
|
|
230
|
-
if message.strip.length >= 10 && message.strip != "continue"
|
|
231
|
-
matches = future_intent_matches(plastic_home, message)
|
|
232
|
-
project_root = resolve_project_store_root(cwd, plastic_home)
|
|
233
|
-
matches += future_intent_matches(project_root, message) if project_root
|
|
234
|
-
|
|
235
|
-
if matches.any?
|
|
236
|
-
parts = ["PLASTIC - Future intents related to this message:\n"]
|
|
237
|
-
matches.each { |m| parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})" }
|
|
238
|
-
parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
|
|
239
|
-
context_parts << parts.join("\n")
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
rescue StandardError
|
|
243
|
-
nil
|
|
244
|
-
end
|
|
245
|
-
|
|
246
145
|
exit 0 if context_parts.empty?
|
|
247
146
|
|
|
248
147
|
payload_out = {
|