@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.
- package/PLASTIC.md +2 -1
- package/README.md +40 -33
- package/agents/plastic-enforcer.md +13 -11
- package/agents/plastic-executor.md +2 -1
- package/agents/plastic-primary-advisor.md +2 -2
- package/agents/plastic-secondary-advisor.md +2 -2
- package/bin/plastic +2 -0
- package/docs/help/agent-architecture.md +9 -8
- package/docs/help/agent-report-contract.md +6 -4
- package/docs/help/completion-and-done.md +20 -4
- package/docs/help/human-report-contract.md +23 -20
- package/docs/help/knowledge-graph.md +2 -2
- package/docs/help/lifecycle-and-savepoints.md +9 -5
- package/docs/help/locks-and-worktrees.md +4 -3
- package/docs/help/maintenance-and-revisions.md +2 -2
- package/docs/help/roadmaps.md +16 -11
- package/docs/help/track-1-guided.md +48 -30
- package/docs/help/track-2-auto.md +35 -10
- package/docs/help/track-3-projects-and-roadmaps.md +10 -7
- package/docs/help/tutorial.md +421 -0
- package/package.json +1 -1
- package/scripts/end-intent +312 -69
- package/scripts/lib/arm.rb +28 -10
- package/scripts/lib/cli/commands/auto_lock.rb +17 -5
- package/scripts/lib/cli/commands/auto_take.rb +38 -1
- package/scripts/lib/cli/commands/doctor.rb +2 -2
- package/scripts/lib/cli/commands/intent_command.rb +6 -1
- package/scripts/lib/cli/commands/intent_end.rb +15 -2
- package/scripts/lib/cli/commands/intent_step.rb +8 -0
- package/scripts/lib/cli/commands/project_links.rb +5 -1
- package/scripts/lib/cli/commands/project_new.rb +24 -1
- package/scripts/lib/cli/commands/render.rb +3 -1
- package/scripts/lib/cli/commands/roadmap_next.rb +10 -3
- package/scripts/lib/cli/commands/roadmap_show.rb +21 -1
- package/scripts/lib/cli/commands/session_commit.rb +1 -1
- package/scripts/lib/cli/commands/sync.rb +10 -1
- package/scripts/lib/hook_replay.rb +51 -45
- package/scripts/lib/installer_core.rb +12 -0
- package/scripts/lib/node_input.rb +13 -5
- package/scripts/lib/revisions_writer.rb +1 -2
- package/scripts/lib/roadmap_queue.rb +12 -4
- package/scripts/lib/runner_dispatch.rb +8 -7
- package/scripts/lib/session_git.rb +42 -1
- package/scripts/lib/untouched_scaffold.rb +51 -0
- package/scripts/plastic-lock +24 -28
- package/scripts/project-links +6 -21
- package/scripts/roadmap-graph +10 -4
- package/scripts/rollback.rb +5 -1
- package/scripts/update.rb +7 -1
- 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] [--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
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: "
|
|
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
|
-
(
|
|
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
|
|
35
|
-
# many seconds
|
|
36
|
-
#
|
|
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
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
[
|
|
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
|
|
202
|
-
Process.
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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#{
|
|
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.
|
|
449
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
|
232
|
-
|
|
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 =
|
|
238
|
-
ranked.select { |c|
|
|
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)
|