@zalom/plastic 2.0.1 → 2.0.3

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.
Files changed (50) hide show
  1. package/PLASTIC.md +2 -1
  2. package/README.md +40 -33
  3. package/agents/plastic-enforcer.md +13 -11
  4. package/agents/plastic-executor.md +2 -1
  5. package/agents/plastic-primary-advisor.md +2 -2
  6. package/agents/plastic-secondary-advisor.md +2 -2
  7. package/bin/plastic +2 -0
  8. package/docs/help/agent-architecture.md +9 -8
  9. package/docs/help/agent-report-contract.md +6 -4
  10. package/docs/help/completion-and-done.md +20 -4
  11. package/docs/help/human-report-contract.md +23 -20
  12. package/docs/help/knowledge-graph.md +2 -2
  13. package/docs/help/lifecycle-and-savepoints.md +9 -5
  14. package/docs/help/locks-and-worktrees.md +4 -3
  15. package/docs/help/maintenance-and-revisions.md +2 -2
  16. package/docs/help/roadmaps.md +16 -11
  17. package/docs/help/track-1-guided.md +48 -30
  18. package/docs/help/track-2-auto.md +35 -10
  19. package/docs/help/track-3-projects-and-roadmaps.md +10 -7
  20. package/docs/help/tutorial.md +421 -0
  21. package/package.json +1 -1
  22. package/scripts/end-intent +312 -69
  23. package/scripts/lib/arm.rb +28 -10
  24. package/scripts/lib/cli/commands/auto_lock.rb +17 -5
  25. package/scripts/lib/cli/commands/auto_take.rb +38 -1
  26. package/scripts/lib/cli/commands/doctor.rb +2 -2
  27. package/scripts/lib/cli/commands/intent_command.rb +6 -1
  28. package/scripts/lib/cli/commands/intent_end.rb +15 -2
  29. package/scripts/lib/cli/commands/intent_step.rb +8 -0
  30. package/scripts/lib/cli/commands/project_links.rb +5 -1
  31. package/scripts/lib/cli/commands/project_new.rb +24 -1
  32. package/scripts/lib/cli/commands/render.rb +3 -1
  33. package/scripts/lib/cli/commands/roadmap_next.rb +10 -3
  34. package/scripts/lib/cli/commands/roadmap_show.rb +21 -1
  35. package/scripts/lib/cli/commands/session_commit.rb +1 -1
  36. package/scripts/lib/cli/commands/sync.rb +10 -1
  37. package/scripts/lib/hook_replay.rb +51 -45
  38. package/scripts/lib/installer_core.rb +12 -0
  39. package/scripts/lib/node_input.rb +13 -5
  40. package/scripts/lib/revisions_writer.rb +1 -2
  41. package/scripts/lib/roadmap_queue.rb +12 -4
  42. package/scripts/lib/runner_dispatch.rb +8 -7
  43. package/scripts/lib/session_git.rb +42 -1
  44. package/scripts/lib/untouched_scaffold.rb +51 -0
  45. package/scripts/plastic-lock +24 -28
  46. package/scripts/project-links +6 -21
  47. package/scripts/roadmap-graph +10 -4
  48. package/scripts/rollback.rb +5 -1
  49. package/scripts/update.rb +7 -1
  50. package/skills/_decision-tables.md +3 -3
@@ -1,30 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "intent_command"
4
5
 
5
6
  # `plastic auto take` - arms the intent's delivery lock for this session
6
7
  # (`plastic-lock arm --intent-dir DIR --mode auto`, run through Legacy).
8
+ # The lock records who took it: --harness, --agent, --model and --thread pass
9
+ # through, and a Claude Code session names its harness when none is given.
10
+ # plastic-lock answers in JSON, so this command renders it as a screen and
11
+ # leaves the document to `--json`. A lock another session owns exits 3.
7
12
  module Plastic
8
13
  class CLI
9
14
  module Commands
10
15
  class AutoTake < IntentCommand
11
- USAGE_LINE = "plastic auto take ID [--allow-inline] [--json]"
16
+ USAGE_LINE = "plastic auto take ID [--allow-inline] [--harness NAME] [--agent NAME] " \
17
+ "[--model MODEL] [--thread ID] [--json]"
12
18
 
13
19
  SCRIPT = "plastic-lock"
14
20
  AFTER = "plastic auto brief ID"
15
21
  BECAUSE = "the preamble is the live state a taken intent is read from next"
22
+ PROVENANCE = %i[harness agent model thread].freeze
23
+
24
+ def call
25
+ return super if options[:json]
26
+
27
+ text, status = legacy.capture(SCRIPT, *script_arguments)
28
+ raise Failure, "#{SCRIPT} exited #{status}" unless status.zero?
29
+
30
+ screen(JSON.parse(text))
31
+ command, reason = after_run
32
+ @output.next_step(command, because: reason)
33
+ rescue JSON::ParserError
34
+ raise Failure, "#{SCRIPT} did not print a report"
35
+ end
16
36
 
17
37
  private
18
38
 
19
39
  def switches(parser)
20
40
  parser.on("--allow-inline") { @options[:allow_inline] = true }
41
+ PROVENANCE.each do |name|
42
+ parser.on("--#{name} VALUE") { |value| @options[name] = value }
43
+ end
21
44
  end
22
45
 
23
46
  def script_arguments
24
47
  args = ["arm", "--intent-dir", intent_dir, "--mode", "auto"]
48
+ provenance.each { |name, value| args.push("--#{name}", value) }
25
49
  args << "--allow-inline" if options[:allow_inline]
26
50
  args
27
51
  end
52
+
53
+ def provenance
54
+ given = PROVENANCE.to_h { |name| [name, options[name]] }
55
+ given[:harness] ||= "claude" if @env["CLAUDE_CODE_SESSION_ID"].to_s.strip != ""
56
+ given.compact
57
+ end
58
+
59
+ def screen(report)
60
+ worktree = report["worktree"] || {}
61
+ @output.row("intent", File.basename(report["intent_dir"].to_s))
62
+ @output.row("lock", "#{report["status"]} by #{report["session"]}, #{report["run_mode"]} mode")
63
+ @output.row("worktree", worktree["provisioned"] ? worktree["code"].to_s : "none")
64
+ end
28
65
  end
29
66
  end
30
67
  end
@@ -15,8 +15,8 @@ module Plastic
15
15
  class CLI
16
16
  module Commands
17
17
  class Doctor < InstallerVerb
18
- USAGE_LINE = "plastic doctor [--core] [--store WHICH] [--json]"
19
- FLAGS = %w[--core --store].freeze
18
+ USAGE_LINE = "plastic doctor [--core] [--agent claude|codex|hermes] [--store WHICH] [--json]"
19
+ FLAGS = %w[--core --agent --store].freeze
20
20
 
21
21
  SCRIPT = "doctor.rb"
22
22
  AFTER = "none"
@@ -22,11 +22,16 @@ module Plastic
22
22
  status = legacy.run(self.class::SCRIPT, *script_arguments)
23
23
  raise Failure, "#{self.class::SCRIPT} exited #{status}" unless status.zero?
24
24
 
25
- @output.next_step(self.class::AFTER.sub("ID", id.to_s), because: self.class::BECAUSE)
25
+ command, reason = after_run
26
+ @output.next_step(command, because: reason)
26
27
  end
27
28
 
28
29
  private
29
30
 
31
+ def after_run
32
+ [self.class::AFTER.sub("ID", id.to_s), self.class::BECAUSE]
33
+ end
34
+
30
35
  def id
31
36
  arguments.first
32
37
  end
@@ -15,6 +15,15 @@ module Plastic
15
15
 
16
16
  REFUSAL_STATUS = 4
17
17
 
18
+ # end-intent's own refusals the agent can act on: the reason is theirs to
19
+ # fix, so they stay failures (exit 1), but each is named rather than
20
+ # surfaced as a bare exit code.
21
+ REFUSED = {
22
+ 7 => "end-intent refused a hollow delivered close: write outcome.md's ## Delivered rows to match the action headings",
23
+ 8 => "end-intent refused to deliver an untouched scaffold: do the work first, or close it with --abandoned",
24
+ 9 => "end-intent refused a delivered close whose code is not merged: run the git merge it names in the repo, then run the close again"
25
+ }.freeze
26
+
18
27
  SUMMARY_GUIDANCE = [
19
28
  "a good summary is written for the reader deciding whether to merge, release, or accept",
20
29
  "delivered: what shipped, impact and risk first, in plain language, never the checklist",
@@ -28,9 +37,13 @@ module Plastic
28
37
 
29
38
  status = legacy.run("end-intent", *end_intent_arguments)
30
39
  raise Refusal, "end-intent needs the owner: the delivery lock is held" if status == REFUSAL_STATUS
31
- raise Failure, "end-intent exited #{status}" unless status.zero?
40
+ raise Failure, REFUSED.fetch(status) { "end-intent exited #{status}" } unless status.zero?
32
41
 
33
- @output.next_step("plastic status", because: "the intent moved out of Active")
42
+ if options[:dry_run]
43
+ @output.next_step("none", because: "the dry run wrote nothing and found nothing that would refuse the close")
44
+ else
45
+ @output.next_step("plastic status", because: "the intent is now closed")
46
+ end
34
47
  end
35
48
 
36
49
  private
@@ -16,6 +16,7 @@ module Plastic
16
16
  SCRIPT = "runner"
17
17
  AFTER = "plastic intent step ID"
18
18
  BECAUSE = "call step again after each dispatched node returns"
19
+ COMPLETE = "every node in the graph has finished"
19
20
 
20
21
  def call
21
22
  intent_dir
@@ -49,11 +50,18 @@ module Plastic
49
50
  env_session: @env["CLAUDE_CODE_SESSION_ID"], store: scope.store, intent_id: id)
50
51
  return true if session
51
52
  raise Refusal, "intent #{id} is held by another session; inspect with plastic auto lock status #{id} --project #{scope.slug}" if Lock.read(intent_dir)
53
+ raise Refusal, "return not accepted: take intent #{id} with plastic auto take #{id} --project #{scope.slug} before submitting it" unless Array(options[:returns]).empty?
52
54
 
53
55
  @output.next_step("plastic auto take #{id}", because: "graph execution requires this session to take the delivery lock")
54
56
  false
55
57
  end
56
58
 
59
+ def after_run
60
+ return super unless RunnerCore.complete?(RunnerCore.context(intent_dir: intent_dir, home: File.dirname(scope.plastic_home)))
61
+
62
+ ["plastic intent verify #{id}", COMPLETE]
63
+ end
64
+
57
65
  def graph?
58
66
  File.exist?(File.join(intent_dir, "graph.md"))
59
67
  end
@@ -20,7 +20,11 @@ module Plastic
20
20
  status = legacy.run("project-links", "--plastic-home", scope.plastic_home, *dry_run_flag)
21
21
  raise Failure, "project-links exited #{status}" unless status.zero?
22
22
 
23
- @output.next_step("plastic status", because: "the Links sections just rebuilt across every store")
23
+ if options[:dry_run]
24
+ @output.next_step("none", because: "the preview wrote no files")
25
+ else
26
+ @output.next_step("plastic status", because: "the Links sections just rebuilt across every store")
27
+ end
24
28
  end
25
29
 
26
30
  private
@@ -9,13 +9,18 @@ require_relative "../../store_provisioning"
9
9
  # `plastic project new` - registers a project in projects.yml, then provisions
10
10
  # and validates its store (`provision-project-store`, `validate-project`, run
11
11
  # through Legacy). The project directory must already exist; this command
12
- # never makes one, and a slug already registered is a Failure.
12
+ # never makes one. A slug is lowercase letters, digits and hyphens, starting
13
+ # with a letter or digit, and never `global`. A slug or a path already
14
+ # registered is a Failure.
13
15
  module Plastic
14
16
  class CLI
15
17
  module Commands
16
18
  class ProjectNew < Command
17
19
  USAGE_LINE = "plastic project new SLUG --path PATH [--parent ID] [--json]"
18
20
 
21
+ SLUG = /\A[a-z0-9][a-z0-9-]*\z/
22
+ RESERVED = "global"
23
+
19
24
  def initialize(argv, runner: nil, **streams)
20
25
  super(argv, **streams)
21
26
  @runner = runner
@@ -24,9 +29,14 @@ module Plastic
24
29
  def call
25
30
  raise Usage, "SLUG is required" if slug.to_s.empty?
26
31
  raise Usage, "--path is required" if options[:path].to_s.empty?
32
+ unless slug.match?(SLUG) && slug != RESERVED
33
+ raise Usage, "SLUG must be lowercase letters, digits and hyphens, start with a letter or digit, and not be global"
34
+ end
27
35
  raise Failure, "#{options[:path]} does not exist" unless Dir.exist?(options[:path])
28
36
  raise Failure, "#{options[:path]} has no AGENTS.md; write one first" unless File.file?(File.join(options[:path], "AGENTS.md"))
29
37
  raise Failure, "#{slug} is already registered" if StoreProvisioning.registered?(slug, scope.plastic_home)
38
+ owner = slug_for_path
39
+ raise Failure, "#{options[:path]} is already registered as #{owner}" if owner
30
40
 
31
41
  register
32
42
  run("provision-project-store", slug, "--home", scope.plastic_home)
@@ -45,6 +55,19 @@ module Plastic
45
55
  parser.on("--parent ID") { |v| @options[:parent] = v }
46
56
  end
47
57
 
58
+ def slug_for_path
59
+ path = File.realpath(options[:path])
60
+ projects.find do |_slug, info|
61
+ registered = info.is_a?(Hash) && info["path"].is_a?(String) && info["path"]
62
+ registered && Dir.exist?(registered) && File.realpath(registered) == path
63
+ end&.first
64
+ end
65
+
66
+ def projects
67
+ data = File.exist?(projects_yml) ? YAML.safe_load_file(projects_yml) : nil
68
+ (data.is_a?(Hash) && data["projects"].is_a?(Hash)) ? data["projects"] : {}
69
+ end
70
+
48
71
  def projects_yml
49
72
  File.join(scope.plastic_home, "projects.yml")
50
73
  end
@@ -8,6 +8,8 @@ module Plastic
8
8
  class Render < Command
9
9
  USAGE_LINE = "plastic render FILE"
10
10
  STYLE = File.expand_path("../../../../templates/render.css", __dir__)
11
+ # A leading YAML block is metadata; RDoc would render it as body text.
12
+ FRONTMATTER = /\A---\r?\n.*?^---[ \t]*(\r?\n|\z)/m
11
13
 
12
14
  def call
13
15
  file = arguments.first or raise Usage, "FILE is the markdown file to render"
@@ -15,7 +17,7 @@ module Plastic
15
17
 
16
18
  require "rubygems"
17
19
  require "rdoc"
18
- body = RDoc::Markdown.parse(File.read(file, encoding: "UTF-8")).accept(html_formatter)
20
+ body = RDoc::Markdown.parse(File.read(file, encoding: "UTF-8").sub(FRONTMATTER, "")).accept(html_formatter)
19
21
  @output.raw("<!doctype html>\n<meta charset=\"utf-8\">\n<title>#{File.basename(file)}</title>\n" \
20
22
  "<style>\n#{File.read(STYLE)}</style>\n#{body}")
21
23
  end
@@ -15,6 +15,7 @@ module Plastic
15
15
  USAGE_LINE = "plastic roadmap next [--json]"
16
16
  AFTER = "plastic roadmap show SLUG"
17
17
  BECAUSE = "the winner it just named is the slug that command wants"
18
+ TIED = "the roadmaps tie; the first is shown, and any of them may be picked"
18
19
 
19
20
  def initialize(argv, runner: nil, **streams)
20
21
  super(argv, **streams)
@@ -24,7 +25,7 @@ module Plastic
24
25
  def call
25
26
  report = parse(*legacy.capture("roadmap-next", "--roadmaps-dir", scope.roadmaps_dir, "--which"))
26
27
  screen(report)
27
- @output.next_step(after(report), because: BECAUSE)
28
+ @output.next_step(after(report), because: (report["state"] == "tie") ? TIED : BECAUSE)
28
29
  end
29
30
 
30
31
  private
@@ -38,10 +39,16 @@ module Plastic
38
39
  end
39
40
 
40
41
  def after(report)
41
- slug = report["roadmap"].to_s
42
+ slug = (report["roadmap"] || tied(report).first).to_s
42
43
  slug.empty? ? AFTER : AFTER.sub("SLUG", slug)
43
44
  end
44
45
 
46
+ # roadmap-next --which reports a tie as state "tie" with "tie" false and
47
+ # each candidate as an object; older reports carried plain names.
48
+ def tied(report)
49
+ Array(report["tie_candidates"]).map { |candidate| candidate.is_a?(Hash) ? candidate["roadmap"] : candidate }
50
+ end
51
+
45
52
  def screen(report)
46
53
  @output.row("state", report["state"])
47
54
  @output.row("roadmap", report["roadmap"] || "none")
@@ -49,7 +56,7 @@ module Plastic
49
56
  @output.row("next", queue(report["dispatchable_queue"]))
50
57
  @output.row("delivering", entries(report["in_flight"]))
51
58
  @output.row("blocked", entries(report["blocked"]))
52
- @output.row("tie", Array(report["tie_candidates"]).join(", ")) if report["tie"]
59
+ @output.row("tie", tied(report).join(", ")) if report["tie"] || report["state"] == "tie"
53
60
  end
54
61
 
55
62
  def queue(list)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../command"
4
4
  require_relative "../legacy"
5
+ require_relative "../../roadmap_graph"
5
6
 
6
7
  # `plastic roadmap show` - a roadmap's state screen (`report-screen roadmap
7
8
  # FILE state`, run through Legacy). SLUG is required: `plastic roadmap next`
@@ -23,11 +24,30 @@ module Plastic
23
24
  status = legacy.run("report-screen", "roadmap", path, "state")
24
25
  raise Failure, "report-screen exited #{status}" unless status.zero?
25
26
 
26
- @output.next_step("plastic roadmap log #{slug} EVENT \"TEXT\"", because: "a savepoint is how this state screen keeps moving")
27
+ problems = graph_problems
28
+ problems.each { |problem| @output.row("warning", problem) }
29
+ if problems.empty?
30
+ @output.next_step("plastic roadmap log #{slug} EVENT \"TEXT\"", because: "a savepoint is how this state screen keeps moving")
31
+ else
32
+ @output.next_step("plastic roadmap check #{slug}", because: "the graph has problems the state screen cannot show")
33
+ end
27
34
  end
28
35
 
29
36
  private
30
37
 
38
+ # A cycle or a graph id no batch lists (acceptance N8). The state screen
39
+ # cannot show either, so they are named under it.
40
+ def graph_problems
41
+ return [] unless File.file?(path)
42
+
43
+ result = RoadmapGraph.analyze(path)
44
+ problems = []
45
+ problems << "cyclic graph: #{result[:cycle].join(" > ")}" if result[:cycle]
46
+ dangling = Array(result[:dangling])
47
+ problems << "graph names #{dangling.join(", ")}, no batch entry" unless dangling.empty?
48
+ problems
49
+ end
50
+
31
51
  def slug
32
52
  arguments.first
33
53
  end
@@ -24,7 +24,7 @@ module Plastic
24
24
  status = legacy.run("session-commit", "--cwd", @directory, "--summary", summary)
25
25
  raise Failure, "session-commit exited #{status}" unless status.zero?
26
26
 
27
- @output.next_step("plastic session handoff", because: "a handoff after a commit keeps the day ledger current")
27
+ @output.next_step("plastic session handoff", because: "the line above says whether a commit landed; a handoff keeps the day ledger current")
28
28
  end
29
29
 
30
30
  private
@@ -10,9 +10,14 @@ module Plastic
10
10
  class Sync < Command
11
11
  USAGE_LINE = "plastic sync [--dry-run] [--json]"
12
12
 
13
+ # Rebuilt on every sync; a missing one is named in the plan, so the
14
+ # preview and the sync report the same work (acceptance N7).
15
+ DERIVED = [WorkGraph, ReferenceArchive].freeze
16
+
13
17
  def call
14
18
  home = scope.plastic_home
15
19
  plan = File.exist?(SearchIndex.path(home)) ? StoreSync.plan(home) : [["build", SearchIndex::NAME]]
20
+ plan += DERIVED.reject { |database| File.exist?(database.path(home)) }.map { |database| ["build", database::NAME] }
16
21
  refuse(home, plan.filter_map { |action, path| path if action == "conflict" })
17
22
  plan.group_by(&:first).each { |action, pairs| @output.row(action, pairs.map(&:last)) }
18
23
  @output.row("result", "nothing to do") if plan.empty?
@@ -36,7 +41,11 @@ module Plastic
36
41
  end
37
42
 
38
43
  def apply(home, plan)
39
- (plan == [["build", SearchIndex::NAME]]) ? SearchIndex.build(home) : StoreSync.apply(home, plan)
44
+ if plan.include?(["build", SearchIndex::NAME])
45
+ SearchIndex.build(home)
46
+ else
47
+ StoreSync.apply(home, plan.reject { |step| step.first == "build" })
48
+ end
40
49
  WorkGraph.build(home)
41
50
  ReferenceArchive.build(home)
42
51
  end
@@ -3,7 +3,6 @@
3
3
 
4
4
  require "json"
5
5
  require "open3"
6
- require "timeout"
7
6
 
8
7
  # HookReplay (intent 331a, T1; promoted to a production lib in 331e) - streams
9
8
  # text through a MessageDisplay launcher the way Claude Code streams an
@@ -31,14 +30,9 @@ module HookReplay
31
30
  # environment. Default `{}` keeps every existing caller's behavior
32
31
  # unchanged: this is an extension, not a fork.
33
32
  #
34
- # `timeout` (intent 331e): when given, bounds EACH chunk's spawn to that
35
- # many seconds. A bare `Timeout.timeout` around `Open3.capture3` does not
36
- # reliably bound a genuinely hanging child: capture3's own wait still
37
- # blocks on Process.waitpid for the child regardless of the raised
38
- # Timeout::Error (the same gotcha scripts/hook-record works around), so a
39
- # timeout here spawns directly and kills the child on expiry instead.
40
- # Default `nil` keeps every existing caller on the original unbounded
41
- # Open3.capture3 path.
33
+ # `timeout` (intent 331e): when given, bounds each chunk's whole exchange to
34
+ # that many seconds (see run_bounded). Default `nil` keeps every existing
35
+ # caller on the original unbounded Open3.capture3 path.
42
36
  def replay(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay", message_id: "replay",
43
37
  env: {}, timeout: nil)
44
38
  chunks = text.scan(/.{1,#{chunk}}/m)
@@ -168,46 +162,58 @@ module HookReplay
168
162
  [out, err, status.exitstatus]
169
163
  end
170
164
 
171
- # Spawn directly (never Open3.capture3) so a timeout can actually kill the
172
- # child, with stdin/stdout/stderr routed through scratch files under the
173
- # caller's own tmp_root, and never pipes, so a stalled or oversized write can
174
- # never deadlock the read side, and never anywhere outside tmp_root, so a
175
- # bounded replay carries the same "writes only under the injected tmp
176
- # root" guarantee as the unbounded path.
177
- def run_bounded(hook_path, payload, full_env, tmp_root, timeout)
178
- token = "#{Process.pid}-#{(Time.now.to_f * 1_000_000).to_i}-#{rand(1_000_000)}"
179
- in_path = File.join(tmp_root, ".hook-replay-in-#{token}")
180
- out_path = File.join(tmp_root, ".hook-replay-out-#{token}")
181
- err_path = File.join(tmp_root, ".hook-replay-err-#{token}")
182
- File.write(in_path, JSON.generate(payload))
183
-
184
- pid = Process.spawn(full_env, hook_path, in: in_path, out: out_path, err: err_path)
185
- exitstatus =
186
- begin
187
- Timeout.timeout(timeout) { Process.wait(pid) }
188
- $?.exitstatus
189
- rescue Timeout::Error
190
- kill_and_reap(pid)
191
- nil # nil exitstatus is the caller's signal that this chunk timed out
192
- end
193
-
194
- out = File.exist?(out_path) ? File.read(out_path) : ""
195
- err = File.exist?(err_path) ? File.read(err_path) : ""
196
- [out, err, exitstatus]
165
+ # Bounded run over pipes (acceptance N9). Scratch files for the child's
166
+ # stdin and stdout broke on Snap Ruby: the launcher's `ruby` could neither
167
+ # read nor write them, so every chunk came back empty with exit 1. Pipes
168
+ # work there, as the unbounded path already shows.
169
+ #
170
+ # One deadline covers the whole exchange: the launcher's exit, feeding
171
+ # stdin, and draining stdout and stderr. A launcher can exit while a child
172
+ # it started keeps the pipes open, so waiting for the launcher alone is not
173
+ # enough. A writer thread feeds stdin and reader threads drain both outputs
174
+ # at the same time, so large input or output cannot block.
175
+ #
176
+ # The launcher leads its own process group. When the deadline passes, the
177
+ # whole group is killed, the pipes are closed, and the result carries the
178
+ # output read so far with a nil exitstatus: the caller's signal that this
179
+ # chunk timed out. Nothing is written under tmp_root.
180
+ def run_bounded(hook_path, payload, full_env, _tmp_root, timeout)
181
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
182
+ stdin, stdout, stderr, wait = Open3.popen3(full_env, hook_path, pgroup: true)
183
+ buffers = [String.new, String.new]
184
+ threads = [Thread.new { feed(stdin, JSON.generate(payload)) }] +
185
+ [stdout, stderr].zip(buffers).map { |io, buffer| Thread.new { drain(io, buffer) } }
186
+ finished = [wait, *threads].all? { |thread| thread.join(remaining(deadline)) }
187
+ kill_group(wait.pid) unless finished
188
+ buffers.map { |buffer| buffer.force_encoding(Encoding.default_external) } +
189
+ [finished ? wait.value.exitstatus : nil]
197
190
  ensure
198
- [in_path, out_path, err_path].each { |p| File.delete(p) if p && File.exist?(p) }
191
+ [stdin, stdout, stderr].compact.each { |io| io.close unless io.closed? }
192
+ threads&.each { |thread| thread.join(1) }
199
193
  end
200
194
 
201
- def kill_and_reap(pid)
202
- Process.kill("KILL", pid)
203
- rescue StandardError
204
- nil
195
+ def remaining(deadline)
196
+ [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
197
+ end
198
+
199
+ def feed(io, data)
200
+ io.write(data)
201
+ rescue IOError, SystemCallError
202
+ # the hook exited without reading its input, or the deadline closed the pipe
205
203
  ensure
206
- begin
207
- Process.wait(pid)
208
- rescue StandardError
209
- nil
210
- end
204
+ io.close unless io.closed?
205
+ end
206
+
207
+ def drain(io, buffer)
208
+ loop { buffer << io.readpartial(65_536) }
209
+ rescue IOError, SystemCallError
210
+ # end of output, or the deadline closed the pipe
211
+ end
212
+
213
+ def kill_group(pid)
214
+ Process.kill("KILL", -pid)
215
+ rescue SystemCallError
216
+ nil # the whole group has already exited
211
217
  end
212
218
 
213
219
  # The final chunk's parsed displayContent, or nil when it emitted nothing
@@ -71,6 +71,17 @@ class InstallerCore
71
71
 
72
72
  attr_reader :package_root, :plastic_home, :version, :agents
73
73
 
74
+ def store_layout_compatible?(target)
75
+ !Plastic::StoreLayout.moved?(plastic_home) || semver_compare(target, "2.0.0-alpha.28") >= 0
76
+ end
77
+
78
+ def refuse_incompatible_store_layout(target)
79
+ return if store_layout_compatible?(target)
80
+
81
+ warn "Plastic #{target} cannot read stores/ in #{plastic_home}. Keep a version that supports this layout."
82
+ true
83
+ end
84
+
74
85
  def initialize(package_root:, plastic_home: DEFAULT_PLASTIC_HOME, version: nil, agents: DEFAULT_AGENTS)
75
86
  @package_root = package_root
76
87
  @plastic_home = plastic_home
@@ -405,6 +416,7 @@ class InstallerCore
405
416
  "scripts/lib/index_entry.rb" => "scripts/lib/index_entry.rb",
406
417
  "scripts/lib/project_config.rb" => "scripts/lib/project_config.rb",
407
418
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
419
+ "scripts/lib/untouched_scaffold.rb" => "scripts/lib/untouched_scaffold.rb",
408
420
  "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
409
421
  "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
410
422
  "scripts/lib/node_input_compatibility.rb" => "scripts/lib/node_input_compatibility.rb",
@@ -36,6 +36,10 @@ module NodeInput
36
36
  # reports is not provisioned.
37
37
  STOP_DIRECTIVE = "STOP: no lease is recorded for this node. Do not edit files or run any command until a runner dispatches this node with a holder, an expiry and a model."
38
38
 
39
+ # A work node whose worktree is missing gets its own reason (acceptance
40
+ # N12): the lease may be fine, and saying it is missing contradicts it.
41
+ WORKTREE_STOP_DIRECTIVE = "STOP: no worktree is provisioned for this node. Do not edit files until a runner dispatches it into one."
42
+
39
43
  # Pinned so `input=<sha>` is a function of the repo's history alone
40
44
  # (post-execution review finding B4): unpinned, `git log --stat` varies
41
45
  # with the terminal's COLUMNS (abbreviates paths, narrows the graph
@@ -386,12 +390,17 @@ module NodeInput
386
390
 
387
391
  # --- block 5: where to work (instruction) ---------------------------------
388
392
 
393
+ # A node kind that needs no worktree (research, verify) is marked
394
+ # `read_only` by the runner: it reads the repository and edits nothing, so
395
+ # the absent worktree is expected and carries no stop directive.
389
396
  def worktree_block(intent_dir:, worktree_reader: Arm.method(:worktree_block))
390
397
  info = worktree_reader.call(intent_dir: intent_dir)
391
398
  if info && info["provisioned"]
392
399
  "worktree: #{info['code']} (branch #{info['code_branch']})"
400
+ elsif info && info["read_only"]
401
+ "worktree: none; this node reads the repository and edits nothing"
393
402
  else
394
- "worktree: none provisioned\n#{STOP_DIRECTIVE}"
403
+ "worktree: none provisioned\n#{WORKTREE_STOP_DIRECTIVE}"
395
404
  end
396
405
  end
397
406
 
@@ -445,9 +454,8 @@ module NodeInput
445
454
 
446
455
  # `lease_missing` (post-execution review finding A1) hoists C7's stop
447
456
  # directive here, block 5 (instruction, spec D3), whenever the node input
448
- # carries no lease. `worktree_block` already renders its own copy when the
449
- # worktree is unprovisioned; the two conditions often fire together, so a
450
- # directive already present is never repeated.
457
+ # carries no lease. A missing worktree has its own directive, so each stop
458
+ # line names its own reason.
451
459
  #
452
460
  # `call_cap` (intent 355, n2, D2): one sentence naming this attempt's tool
453
461
  # call cap and the return it hits at, so the executor learns the number
@@ -458,7 +466,7 @@ module NodeInput
458
466
  files: [])
459
467
  wt = worktree_block(intent_dir: intent_dir, worktree_reader: worktree_reader)
460
468
  parts = [wt, test_command_block(intent_dir: intent_dir, files: files, project_reader: project_reader)]
461
- parts << STOP_DIRECTIVE if lease_missing && !wt.include?(STOP_DIRECTIVE)
469
+ parts << STOP_DIRECTIVE if lease_missing
462
470
  parts << call_cap_sentence(call_cap) if call_cap
463
471
  parts.join("\n")
464
472
  end
@@ -3,8 +3,7 @@
3
3
 
4
4
  # RevisionsWriter - the shared append-only revisions.md writer (intent 107's convention,
5
5
  # generalized from restore_intent_v1.rb's proven pattern, intent 197). Every tool that
6
- # performs structural maintenance on an intent (project-links, rebuild-graph,
7
- # restore-intent-v1) must record it here: PLASTIC.md's `revisions.md` contract is that a
6
+ # uses this writer (project-links, rebuild-graph, or rebuild-savepoint) records changes here: PLASTIC.md's `revisions.md` contract is that a
8
7
  # structural change and its receipt are never separated. This module owns rendering ONE
9
8
  # entry's text and appending it correctly; it does no git operations (that is
10
9
  # lib/maintenance_git.rb's job) and never overwrites a prior entry.
@@ -224,18 +224,26 @@ class RoadmapQueue
224
224
 
225
225
  # --- liveness ranking (ports plastic-intent-continuing's read-time algorithm) -
226
226
 
227
+ # A roadmap whose graph has a cycle cannot compute a frontier, so it ranks
228
+ # after every healthy one (acceptance N8). It still wins when nothing else
229
+ # is left, and the payload then reports the cycle as an error.
227
230
  def rank_candidates(parsed_list)
228
231
  parsed_list.map do |c|
229
232
  entries = c[:waves].flat_map { |w| w[:entries] }
230
233
  live = entries.any? { |e| %w[delivering blocked].include?(e[:status]) }
231
- c.merge(live: live, last_event: last_event_time(c[:path]))
232
- end.sort_by { |c| [c[:live] ? 0 : 1, -c[:last_event].to_i, c[:slug]] }
234
+ cyclic = !!(c[:graph_edges] && GraphEdges.cycle(c[:graph_edges][:edges]))
235
+ c.merge(live: live, cyclic: cyclic, last_event: last_event_time(c[:path]))
236
+ end.sort_by { |c| rank_key(c) + [c[:slug]] }
237
+ end
238
+
239
+ def rank_key(candidate)
240
+ [candidate[:cyclic] ? 1 : 0, candidate[:live] ? 0 : 1, -candidate[:last_event].to_i]
233
241
  end
234
242
 
235
243
  def tied_group(ranked)
236
244
  return [] if ranked.empty?
237
- top_key = [ranked.first[:live], ranked.first[:last_event].to_i]
238
- ranked.select { |c| [c[:live], c[:last_event].to_i] == top_key }
245
+ top_key = rank_key(ranked.first)
246
+ ranked.select { |c| rank_key(c) == top_key }
239
247
  end
240
248
 
241
249
  def last_event_time(path)