@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20
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/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -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 +886 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -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 +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/verify_intent.rb +33 -0
- 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/plastic-lock +8 -1
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +392 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +24 -17
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +17 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- 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/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -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 +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
# intent-screen (intent 316) - prints the intent screen for one intent directory:
|
|
5
|
+
# the title, the field table (Store, Status, Stage, Savepoint, Progress, Next,
|
|
6
|
+
# Insight, each with a note), and the Steps table, filled from the record by
|
|
7
|
+
# scripts/lib/intent_screen.rb. The session adds the What-this-means bullets and
|
|
8
|
+
# the close; it never edits the numbers.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# intent-screen <intent_dir> [--template <path>] [--ansi]
|
|
12
|
+
#
|
|
13
|
+
# The store root is the directory two levels above the intent (<root>/store/<id--slug>);
|
|
14
|
+
# the template defaults to templates/intent-screen.md next to this script's dir,
|
|
15
|
+
# in-repo (<repo>/scripts -> <repo>/templates) and installed (~/.plastic/scripts ->
|
|
16
|
+
# ~/.plastic/templates) alike.
|
|
17
|
+
#
|
|
18
|
+
# --ansi (intent 316a, O3): emits scripts/lib/intent_screen_ansi.rb's styled
|
|
19
|
+
# truecolor block instead of the plain Markdown screen. Plain stays the
|
|
20
|
+
# default with no flag. Two things force plain even WITH --ansi (D18): NO_COLOR
|
|
21
|
+
# present in the environment (any value counts), or a non-TTY stdout — the
|
|
22
|
+
# true default form of D2, not IntentScreenAnsi's own uncoloured layout. The
|
|
23
|
+
# library (scripts/lib/intent_screen_ansi.rb) is pure and never reads either;
|
|
24
|
+
# this script is the one place allowed to.
|
|
25
|
+
#
|
|
26
|
+
# Exit codes:
|
|
27
|
+
# 0 - the screen is on stdout
|
|
28
|
+
# 2 - usage error, or the path is not an intent directory (one line on stderr)
|
|
29
|
+
#
|
|
30
|
+
# Harness-agnostic core: no harness assumption lives here. This script holds
|
|
31
|
+
# the plain/ANSI selection (intent 316a1, D3) and belongs to the same
|
|
32
|
+
# harness-agnostic core as lib/intent_screen.rb and lib/intent_screen_ansi.rb.
|
|
33
|
+
|
|
34
|
+
require_relative "lib/intent_screen"
|
|
35
|
+
require_relative "lib/intent_screen_ansi"
|
|
36
|
+
|
|
37
|
+
def usage_abort(message)
|
|
38
|
+
warn "intent-screen: #{message}"
|
|
39
|
+
exit 2
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
args = ARGV.dup
|
|
43
|
+
template_path = nil
|
|
44
|
+
ansi = false
|
|
45
|
+
positional = []
|
|
46
|
+
while (arg = args.shift)
|
|
47
|
+
case arg
|
|
48
|
+
when "--template"
|
|
49
|
+
template_path = args.shift or usage_abort("--template needs a path")
|
|
50
|
+
when "--ansi"
|
|
51
|
+
ansi = true
|
|
52
|
+
else
|
|
53
|
+
usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
|
|
54
|
+
positional << arg
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
usage_abort("usage: intent-screen <intent_dir> [--template <path>] [--ansi]") unless positional.length == 1
|
|
59
|
+
intent_dir = File.expand_path(positional.first)
|
|
60
|
+
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
61
|
+
|
|
62
|
+
store_root = File.expand_path("../..", intent_dir)
|
|
63
|
+
template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
|
|
64
|
+
usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
|
|
65
|
+
|
|
66
|
+
plain = -> { IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path)) }
|
|
67
|
+
|
|
68
|
+
degrade_to_plain = ENV.key?("NO_COLOR") || !$stdout.tty?
|
|
69
|
+
|
|
70
|
+
$stdout.write(
|
|
71
|
+
if ansi && !degrade_to_plain
|
|
72
|
+
IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
|
|
73
|
+
else
|
|
74
|
+
plain.call
|
|
75
|
+
end
|
|
76
|
+
)
|
|
77
|
+
exit 0
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_file"
|
|
5
|
+
require_relative "graph_edges"
|
|
6
|
+
require_relative "node_file"
|
|
7
|
+
require_relative "savepoint"
|
|
8
|
+
require_relative "work_graph_validator"
|
|
9
|
+
|
|
10
|
+
# ActionGraphShim (intent 342, G9): a read-time backward shim that presents
|
|
11
|
+
# any intent directory's actions/*.md as a node graph, in the record shape
|
|
12
|
+
# NodeFile.parse returns, so 336, 338 and 339 read a legacy intent through
|
|
13
|
+
# the same call they already use for an authored graph.md (spec.md D1-D18).
|
|
14
|
+
#
|
|
15
|
+
# Writes nothing, anywhere, ever (D1). An authored graph.md always wins over
|
|
16
|
+
# the synthetic chain (D3) - the reverse of 334's forward shim, which
|
|
17
|
+
# resolves content and prefers the older, richer source. This one resolves
|
|
18
|
+
# structure, where an authored graph is the only real graph.
|
|
19
|
+
module ActionGraphShim
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# D10's heading test: unchanged, four tokens, verified correct on the live
|
|
23
|
+
# store. Governs only whether a "## Files ..." heading itself is negated
|
|
24
|
+
# (e.g. "## Files you must NOT change"), never the section body.
|
|
25
|
+
FILES_NEGATION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't/i.freeze
|
|
26
|
+
|
|
27
|
+
# D19's body-truncation vocabulary: the four heading tokens plus the
|
|
28
|
+
# exclusion phrases the live corpus actually uses ("Out of bounds, owned
|
|
29
|
+
# by leads running in parallel right now:" matched none of the original
|
|
30
|
+
# four). Governs whether a body LINE truncates the files harvest -
|
|
31
|
+
# never the heading test above, which stays on FILES_NEGATION_RE.
|
|
32
|
+
FILES_EXCLUSION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't|\bout of bounds\b|\bout of scope\b|\bhands off\b|\bleave alone\b|\bexcluded\b/i.freeze
|
|
33
|
+
|
|
34
|
+
BACKTICK_RE = /`([^`]+)`/.freeze
|
|
35
|
+
PROVEN_BY_LABEL_RE = /\AS\d+\z/.freeze
|
|
36
|
+
|
|
37
|
+
# :authored when graph.md exists, :actions when it does not but the intent
|
|
38
|
+
# holds at least one real action file (D7's exact realness test, reused
|
|
39
|
+
# rather than reimplemented so the shim can never disagree with
|
|
40
|
+
# Savepoint.has_real_action? about whether an intent has work in it), :none
|
|
41
|
+
# otherwise. A directory that does not exist returns :none and never
|
|
42
|
+
# raises.
|
|
43
|
+
def shape(intent_dir)
|
|
44
|
+
return :none unless intent_dir && File.directory?(intent_dir.to_s)
|
|
45
|
+
return :authored if File.exist?(File.join(intent_dir, "graph.md"))
|
|
46
|
+
return :actions if Savepoint.has_real_files_in?("actions", intent_dir)
|
|
47
|
+
|
|
48
|
+
:none
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The seven-key hash GraphFile.parse returns (ok:, goal:, decisions:,
|
|
52
|
+
# graph:, status:, verify:, errors:), for whichever shape the directory
|
|
53
|
+
# is in. On :authored this is literally GraphFile.parse, unmodified (D3).
|
|
54
|
+
# On :actions the same hash is built from the action files, with goal:,
|
|
55
|
+
# decisions: and status: nil (a 2024 action file was never asked for
|
|
56
|
+
# them) and verify: a fixed reason so a trivial-bar check the caller may
|
|
57
|
+
# run stays self-consistent. On :none it is GraphFile.parse's own
|
|
58
|
+
# not-found failure, produced by delegating to it rather than
|
|
59
|
+
# reformatting the message ourselves.
|
|
60
|
+
def view(intent_dir)
|
|
61
|
+
case shape(intent_dir)
|
|
62
|
+
when :authored
|
|
63
|
+
GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
64
|
+
when :actions
|
|
65
|
+
synth = synthetic_graph(intent_dir)
|
|
66
|
+
{
|
|
67
|
+
ok: synth[:errors].empty?,
|
|
68
|
+
goal: nil,
|
|
69
|
+
decisions: nil,
|
|
70
|
+
graph: synth,
|
|
71
|
+
status: nil,
|
|
72
|
+
verify: { reason: "backward shim: legacy actions/ carry no verify node" },
|
|
73
|
+
errors: synth[:errors],
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
# 342 post-execution review, should-fix 5: a nil intent_dir must never
|
|
77
|
+
# raise. shape(nil) is already nil-guarded, so `view` and `needs`
|
|
78
|
+
# honor the same contract rather than raising a File.join TypeError.
|
|
79
|
+
return GraphFile.failure(["graph file not found: #{intent_dir.inspect}"]) unless intent_dir
|
|
80
|
+
|
|
81
|
+
GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Node records for either shape (D8), always carrying NodeFile.parse's own
|
|
86
|
+
# key set (ok:, node:, kind:, files:, budget:, body:, errors:) plus
|
|
87
|
+
# needs:, path: and proven_by:. [] on :none, never a raise on a real
|
|
88
|
+
# directory.
|
|
89
|
+
def nodes(intent_dir)
|
|
90
|
+
case shape(intent_dir)
|
|
91
|
+
when :authored
|
|
92
|
+
authored_nodes(intent_dir)
|
|
93
|
+
when :actions
|
|
94
|
+
synthetic_nodes(intent_dir)
|
|
95
|
+
else
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The needs targets for one node id, under either shape; [] for an id the
|
|
101
|
+
# graph does not declare, and [] for a directory with no graph at all.
|
|
102
|
+
def needs(intent_dir, node_id)
|
|
103
|
+
graph = view(intent_dir)[:graph]
|
|
104
|
+
return [] unless graph
|
|
105
|
+
|
|
106
|
+
(graph[:edges] || {})[node_id] || []
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- authored shape -------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def authored_nodes(intent_dir)
|
|
112
|
+
parsed = GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
113
|
+
edges = parsed.dig(:graph, :edges) || {}
|
|
114
|
+
|
|
115
|
+
# 342 post-execution review, nit 8: sort_key numerically over the
|
|
116
|
+
# basename, the same key the synthetic chain uses, so an authored
|
|
117
|
+
# intent with ten or more nodes orders n10 after n9 rather than
|
|
118
|
+
# lexically between n1 and n2.
|
|
119
|
+
Dir.glob(File.join(intent_dir, "nodes", "*.md"))
|
|
120
|
+
.sort_by { |path| sort_key(File.basename(path)) }
|
|
121
|
+
.map do |path|
|
|
122
|
+
nf = NodeFile.parse(path)
|
|
123
|
+
nf.merge(
|
|
124
|
+
needs: edges[nf[:node]] || [],
|
|
125
|
+
path: path,
|
|
126
|
+
proven_by: proven_by_labels(nf[:body].to_s)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- actions shape ----------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# {nodes:, edges:, errors:} built directly over the minted chain, in the
|
|
134
|
+
# same shape GraphEdges.parse returns, rather than rendering a "## Graph"
|
|
135
|
+
# section and reparsing it.
|
|
136
|
+
def synthetic_graph(intent_dir)
|
|
137
|
+
ids = real_action_files(intent_dir).each_index.map { |i| "n#{i + 1}" }
|
|
138
|
+
edges = {}
|
|
139
|
+
ids.each_with_index { |id, i| edges[id] = i.zero? ? [] : [ids[i - 1]] }
|
|
140
|
+
{ nodes: ids, edges: edges, errors: [] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def synthetic_nodes(intent_dir)
|
|
144
|
+
files = real_action_files(intent_dir)
|
|
145
|
+
files.each_with_index.map do |path, i|
|
|
146
|
+
id = "n#{i + 1}"
|
|
147
|
+
needs_targets = i.zero? ? [] : ["n#{i}"]
|
|
148
|
+
begin
|
|
149
|
+
text = File.read(path)
|
|
150
|
+
{
|
|
151
|
+
ok: true,
|
|
152
|
+
node: id,
|
|
153
|
+
kind: "work",
|
|
154
|
+
files: files_section_paths(text),
|
|
155
|
+
budget: nil,
|
|
156
|
+
body: text,
|
|
157
|
+
errors: [],
|
|
158
|
+
needs: needs_targets,
|
|
159
|
+
path: path,
|
|
160
|
+
proven_by: proven_by_labels(text),
|
|
161
|
+
}
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
{
|
|
164
|
+
ok: false,
|
|
165
|
+
node: id,
|
|
166
|
+
kind: "work",
|
|
167
|
+
files: [],
|
|
168
|
+
budget: nil,
|
|
169
|
+
body: nil,
|
|
170
|
+
errors: ["could not read #{path}: #{e.message}"],
|
|
171
|
+
needs: needs_targets,
|
|
172
|
+
path: path,
|
|
173
|
+
proven_by: [],
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Real action files (File.file?, non-zero size, Savepoint.stage_file_present?
|
|
180
|
+
# - D7's own three-part predicate, applied per file since has_real_files_in?
|
|
181
|
+
# only answers true/false), ordered by the integer trailing the basename,
|
|
182
|
+
# falling back to the basename itself so the key is a total order over
|
|
183
|
+
# every basename shape the live store holds (D4).
|
|
184
|
+
def real_action_files(intent_dir)
|
|
185
|
+
Dir.glob(File.join(intent_dir.to_s, "actions", "*.md"))
|
|
186
|
+
.select { |f| File.file?(f) && File.size(f) > 0 && Savepoint.stage_file_present?(f) }
|
|
187
|
+
.sort_by { |f| sort_key(File.basename(f)) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def sort_key(basename)
|
|
191
|
+
m = basename.match(/\d+/)
|
|
192
|
+
m ? [0, m[0].to_i, basename] : [1, 0, basename]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The first heading whose leading token is "Files" (case-insensitively),
|
|
196
|
+
# unless the heading text carries a negation, in which case it is not a
|
|
197
|
+
# files section and the search continues (D10). Paths come out of that
|
|
198
|
+
# section's BODY through extract_files_paths, which is itself
|
|
199
|
+
# negation-aware and fence-aware (D17, D18). No qualifying heading gives
|
|
200
|
+
# [] (D9).
|
|
201
|
+
def files_section_paths(text)
|
|
202
|
+
NodeFile.split_by_headings(text).each do |heading, section|
|
|
203
|
+
next unless files_heading?(heading)
|
|
204
|
+
|
|
205
|
+
return extract_files_paths(section.to_s)
|
|
206
|
+
end
|
|
207
|
+
[]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Fence-aware (D18) and negation-aware over the section body, not only
|
|
211
|
+
# its heading (D17). D10 already stops a heading like "## Files you must
|
|
212
|
+
# NOT change" from being read as a files section at all; this handles
|
|
213
|
+
# the equally common case where a legitimate "## Files to touch" heading
|
|
214
|
+
# is followed, inside the same section, by an out-of-bounds paragraph -
|
|
215
|
+
# live on this intent's own dogfood fixture. A fenced block is dropped
|
|
216
|
+
# before anything else runs, reusing NodeFile.each_fence_line rather than
|
|
217
|
+
# a bare backtick scan, which is exactly what that helper exists to
|
|
218
|
+
# prevent.
|
|
219
|
+
#
|
|
220
|
+
# Lines are scanned in document order; harvesting stops at the first line
|
|
221
|
+
# an exclusion GOVERNS (D19), not merely mentions - a negation is a false
|
|
222
|
+
# positive as often as a real exclusion clause, since an action file
|
|
223
|
+
# routinely annotates an in-bounds path with a "do NOT rename it" aside.
|
|
224
|
+
# governs_exclusion? draws the line: the match governs when it begins
|
|
225
|
+
# before the line's first backtick (an exclusion clause introducing
|
|
226
|
+
# paths) or when the line carries no backtick at all (a bare warning
|
|
227
|
+
# sentence); it annotates, and the line is kept, when the match falls
|
|
228
|
+
# after the first backtick (a parenthetical about a path already named).
|
|
229
|
+
# Any span that still holds a newline is rejected as a backstop: a path
|
|
230
|
+
# is never more than one line.
|
|
231
|
+
def extract_files_paths(section)
|
|
232
|
+
kept = +""
|
|
233
|
+
NodeFile.each_fence_line(section) do |line, fenced|
|
|
234
|
+
next if fenced
|
|
235
|
+
break if governs_exclusion?(line)
|
|
236
|
+
|
|
237
|
+
kept << line
|
|
238
|
+
end
|
|
239
|
+
kept.scan(BACKTICK_RE).flatten.reject { |s| s.include?("\n") }.uniq
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# True when a FILES_EXCLUSION_RE match on this line governs the paths on
|
|
243
|
+
# it rather than merely annotating one (D19).
|
|
244
|
+
def governs_exclusion?(line)
|
|
245
|
+
match = line.match(FILES_EXCLUSION_RE)
|
|
246
|
+
return false unless match
|
|
247
|
+
|
|
248
|
+
first_backtick = line.index("`")
|
|
249
|
+
first_backtick.nil? || match.begin(0) < first_backtick
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def files_heading?(heading)
|
|
253
|
+
stripped = heading.to_s.sub(/\A#+\s*/, "")
|
|
254
|
+
first_token = stripped.split(/\s+/, 2).first.to_s.sub(/[^A-Za-z]+\z/, "")
|
|
255
|
+
return false unless first_token.casecmp?("Files")
|
|
256
|
+
|
|
257
|
+
!heading.to_s.match?(FILES_NEGATION_RE)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The S\d+ tokens of a file's headings, in heading order, taking a
|
|
261
|
+
# heading only when it owns at least one table data row (D5) - the same
|
|
262
|
+
# table-owning rule WorkGraphValidator.has_valid_matrix? and ReportScreen
|
|
263
|
+
# already apply, reused unmodified rather than adapted, so a prose
|
|
264
|
+
# mention of an S-label never manufactures a Proven-by source (334's
|
|
265
|
+
# post-execution review defect, in the other direction).
|
|
266
|
+
def proven_by_labels(text)
|
|
267
|
+
labels = []
|
|
268
|
+
NodeFile.split_by_headings(text).each do |heading, section|
|
|
269
|
+
next unless NodeFile.table_rows(section).any?
|
|
270
|
+
|
|
271
|
+
WorkGraphValidator.heading_tokens(heading).each do |token|
|
|
272
|
+
labels << token if token.match?(PROVEN_BY_LABEL_RE)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
labels
|
|
276
|
+
end
|
|
277
|
+
end
|
package/scripts/lib/arm.rb
CHANGED
|
@@ -94,8 +94,21 @@ module Arm
|
|
|
94
94
|
}
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# Owner rule 2026-08-31: does this session already have a live top-level
|
|
98
|
+
# pointer pointing SOMEWHERE ELSE (the day ledger or another intent)? A
|
|
99
|
+
# pointer already on this intent is the owner re-arming mid-delivery and
|
|
100
|
+
# stays idempotent. True means a
|
|
101
|
+
# conversation session. Reads only; rescues to false (fail open).
|
|
102
|
+
def preexisting_pointer?(session, home:)
|
|
103
|
+
path = pointer_path(session, home: home)
|
|
104
|
+
File.exist?(path) && !File.read(path).to_s.strip.empty?
|
|
105
|
+
rescue StandardError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
97
109
|
# --- the pointer -------------------------------------------------------------
|
|
98
110
|
|
|
111
|
+
|
|
99
112
|
def pointer_path(session, home:)
|
|
100
113
|
store = global_store(home)
|
|
101
114
|
SessionLedger.pointer_path(store, SessionLedger.short_session_id(nil, session))
|
|
@@ -125,12 +138,24 @@ module Arm
|
|
|
125
138
|
# lock data read and touches nothing.
|
|
126
139
|
def arm(intent_dir:, session:, mode: "auto", home: Dir.home, harness: nil,
|
|
127
140
|
agent: nil, model: nil, thread: nil, now: Time.now, runner: Worktree::ShellRunner.new,
|
|
128
|
-
host: Socket.gethostname)
|
|
141
|
+
host: Socket.gethostname, allow_inline: false)
|
|
129
142
|
raise ArgumentError, "mode must be auto or guided" unless %w[auto guided].include?(mode.to_s)
|
|
130
143
|
dir = File.expand_path(intent_dir)
|
|
131
144
|
key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
|
|
132
145
|
h = home_for(dir, home: home)
|
|
133
146
|
|
|
147
|
+
# Owner rule 2026-08-31: the main session never delivers an intent inline.
|
|
148
|
+
# A session that already carries a top-level session pointer is a
|
|
149
|
+
# conversation session (SessionStart wrote it at boot); arming there is
|
|
150
|
+
# inline delivery and is refused BEFORE any lock is taken. A dispatched or
|
|
151
|
+
# headless session has no pre-existing pointer and arms freely.
|
|
152
|
+
# --allow-inline is the explicit owner override. Fail open on read errors:
|
|
153
|
+
# a broken pointer file must never block a legitimate delivery.
|
|
154
|
+
if !allow_inline && preexisting_pointer?(key, home: h) &&
|
|
155
|
+
read_pointer(key, home: h).to_s.strip != intent_id_for(dir)
|
|
156
|
+
return { status: :inline_refused, lock: nil, worktree: nil, session: key, pointer: nil }
|
|
157
|
+
end
|
|
158
|
+
|
|
134
159
|
status, lock = Lock.acquire(dir, session: key, host: host, now: now,
|
|
135
160
|
harness: harness, agent: agent, model: model,
|
|
136
161
|
thread: thread, run_mode: mode.to_s)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# AtomicWrite (intent 334, n2, D19r): a sibling-temp-plus-rename write, the
|
|
5
|
+
# same shape as Lock#write (scripts/lib/lock.rb) - a content write is never
|
|
6
|
+
# an in-place truncate, so a crash mid-write can never leave an empty or
|
|
7
|
+
# half-written target on disk. The temp file is a SIBLING in the same
|
|
8
|
+
# directory as the target, never a system tmpdir, because File.rename can
|
|
9
|
+
# raise EXDEV when the temp and the target live on different filesystems.
|
|
10
|
+
# The temp name ends in .lock (post-execution review, non-blocking 5), the
|
|
11
|
+
# same property Lock#write_temp_path documents: an orphan left by a crash
|
|
12
|
+
# between the write and the rename is covered by the store's existing
|
|
13
|
+
# *.lock gitignore rule rather than swept into its git add -A auto-commit.
|
|
14
|
+
#
|
|
15
|
+
# The renamer is injectable so the interrupted-write case (a rename that
|
|
16
|
+
# raises) is testable with dependency injection, never eval or a global
|
|
17
|
+
# (D19r).
|
|
18
|
+
module AtomicWrite
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def write(path, content, renamer: File.method(:rename))
|
|
22
|
+
dir = File.dirname(path)
|
|
23
|
+
temp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{Time.now.to_f}.#{rand(0xFFFFFF)}.lock")
|
|
24
|
+
File.write(temp, content)
|
|
25
|
+
renamer.call(temp, path)
|
|
26
|
+
true
|
|
27
|
+
rescue StandardError
|
|
28
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
# CoreIntegrity (intent 340, G7, n1): re-hashes the files an installed
|
|
8
|
+
# ~/.plastic/manifest.json lists, so the runner's own "am I running trusted
|
|
9
|
+
# code" check has one implementation. scripts/doctor.rb's
|
|
10
|
+
# Doctor#check_install_integrity does this same arithmetic inline today,
|
|
11
|
+
# per-agent (Codex/Hermes/Claude), against each agent's own manifest; this
|
|
12
|
+
# module is not a refactor of that (out of scope for this node), it exists
|
|
13
|
+
# so the runner - and a later node that points doctor at it - never have to
|
|
14
|
+
# reimplement the same hashing loop a third time.
|
|
15
|
+
#
|
|
16
|
+
# Pure and side-effect-free: `check` takes plastic_home: and never raises
|
|
17
|
+
# across its boundary. A missing, unreadable, or non-JSON manifest.json is a
|
|
18
|
+
# named refusal (`reason:`), never an exception. A manifest-listed file that
|
|
19
|
+
# no longer exists is reported under `missing`, distinct from `drifted` (a
|
|
20
|
+
# file that exists but no longer hashes to the manifest's recorded value).
|
|
21
|
+
module CoreIntegrity
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def check(plastic_home:)
|
|
25
|
+
manifest_path = File.join(plastic_home.to_s, "manifest.json")
|
|
26
|
+
return refusal("manifest.json not found at #{manifest_path}") unless File.exist?(manifest_path)
|
|
27
|
+
|
|
28
|
+
raw = begin
|
|
29
|
+
File.read(manifest_path)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
return refusal("manifest.json is unreadable: #{e.message}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
data = begin
|
|
35
|
+
JSON.parse(raw)
|
|
36
|
+
rescue JSON::ParserError => e
|
|
37
|
+
return refusal("manifest.json is not valid JSON: #{e.message}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
files = data.is_a?(Hash) ? data["files"] : nil
|
|
41
|
+
return refusal("manifest.json's files: is not a mapping") unless files.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
drifted = []
|
|
44
|
+
missing = []
|
|
45
|
+
files.each do |path, expected_hash|
|
|
46
|
+
unless File.exist?(path)
|
|
47
|
+
missing << path
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# v1 minor 6/row 11.16: a tracked file that exists but cannot be read
|
|
52
|
+
# (permissions changed under this process) must never raise out of a
|
|
53
|
+
# module whose whole contract is "never raises across its boundary" -
|
|
54
|
+
# unreadable is reported the same way a hash mismatch is: this
|
|
55
|
+
# process cannot confirm the file matches what the manifest expects,
|
|
56
|
+
# which is exactly what `drifted` already means.
|
|
57
|
+
actual_hash = begin
|
|
58
|
+
Digest::SHA256.file(path).hexdigest
|
|
59
|
+
rescue SystemCallError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
drifted << path if actual_hash.nil? || actual_hash != expected_hash
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{ ok: drifted.empty? && missing.empty?, drifted: drifted, missing: missing, reason: nil }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def refusal(reason)
|
|
69
|
+
{ ok: false, drifted: [], missing: [], reason: reason }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "report_screen"
|
|
5
|
+
|
|
6
|
+
# DashboardScreen (intent 331d) - the presentation half of the dashboard
|
|
7
|
+
# screen. scripts/dashboard.rb's screen_fields sources every fact (Active, In
|
|
8
|
+
# delivery, Delivered, Roadmap, Sessions, Changed, the two capped row lists)
|
|
9
|
+
# through the same helpers report-screen and DaySummary already use, turning
|
|
10
|
+
# a missing source into "not recorded" or "none" before it ever reaches
|
|
11
|
+
# here; this module carries no data-sourcing logic of its own, only layout,
|
|
12
|
+
# exactly like ReportScreen.render_state and IntentScreen.render do for
|
|
13
|
+
# their own screens.
|
|
14
|
+
module DashboardScreen
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
TEMPLATE_PATH = File.expand_path("../../templates/dashboard-screen.md", __dir__)
|
|
18
|
+
|
|
19
|
+
def render(fields, template: nil)
|
|
20
|
+
out = (template || File.read(TEMPLATE_PATH)).dup
|
|
21
|
+
out = out.gsub("{{scope}}", fields.fetch(:scope).to_s)
|
|
22
|
+
out = out.gsub("{{active}}", fields.fetch(:active).to_s)
|
|
23
|
+
out = out.gsub("{{in_delivery}}", fields.fetch(:in_delivery).to_s)
|
|
24
|
+
out = out.gsub("{{delivered}}", fields.fetch(:delivered).to_s)
|
|
25
|
+
out = out.gsub("{{roadmap}}", fields.fetch(:roadmap).to_s)
|
|
26
|
+
out = out.gsub("{{sessions}}", fields.fetch(:sessions).to_s)
|
|
27
|
+
out = out.gsub("{{changed}}", fields.fetch(:changed).to_s)
|
|
28
|
+
out = out.gsub("{{where_we_are.rows}}", where_we_are_rows(fields.fetch(:where_we_are, [])))
|
|
29
|
+
out = out.gsub("{{where_we_go_next.rows}}", where_we_go_next_rows(fields.fetch(:where_we_go_next, [])))
|
|
30
|
+
ReportScreen.fit_screen(out.gsub(/\n{3,}/, "\n\n"))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def where_we_are_rows(rows)
|
|
34
|
+
rows.map { |r| "| #{r[:graph_id]} | #{r[:intent]} | #{r[:stage]} | #{r[:progress]} | #{r[:lead]} |" }.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def where_we_go_next_rows(rows)
|
|
38
|
+
rows.map { |r| "| #{r[:rank]} | #{r[:graph_id]} | #{r[:intent]} | #{r[:reason]} |" }.join("\n")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -12,6 +12,7 @@ require "time"
|
|
|
12
12
|
require_relative "session_ledger"
|
|
13
13
|
require_relative "handoff"
|
|
14
14
|
require_relative "lock"
|
|
15
|
+
require_relative "report_screen"
|
|
15
16
|
|
|
16
17
|
module DaySummary
|
|
17
18
|
module_function
|
|
@@ -113,8 +114,12 @@ module DaySummary
|
|
|
113
114
|
dir = File.join(store_dir, dirname)
|
|
114
115
|
next unless File.directory?(dir) && Lock.fresh?(dir, now: now)
|
|
115
116
|
# A guided session's lock is live but not autonomous; a lock with no
|
|
116
|
-
# run_mode (a 1.14 auto team) counts as auto.
|
|
117
|
-
|
|
117
|
+
# run_mode (a 1.14 auto team) counts as auto. 317a (B9): when the lock
|
|
118
|
+
# carries no run_mode, the outcome frontmatter stamp (D5) is asked
|
|
119
|
+
# before defaulting, so a guided close never reads as auto.
|
|
120
|
+
run_mode = (Lock.read(dir) || {})["run_mode"].to_s
|
|
121
|
+
run_mode = ReportScreen.outcome_frontmatter(dir)["mode"].to_s if run_mode.empty?
|
|
122
|
+
next if run_mode == "guided"
|
|
118
123
|
|
|
119
124
|
id, slug = dirname.split("--", 2)
|
|
120
125
|
"- #{id} #{slug}: #{last_savepoint_line(dir)}"
|