@zalom/plastic 1.0.0-beta.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +407 -84
  3. package/README.md +99 -82
  4. package/agents/plastic-brainstorming.md +12 -9
  5. package/agents/plastic-enforcer.md +52 -12
  6. package/agents/plastic-executor.md +10 -10
  7. package/agents/plastic-future-intent-researcher.md +4 -8
  8. package/agents/plastic-intent-curator.md +12 -14
  9. package/agents/plastic-intent-discovery.md +35 -0
  10. package/agents/plastic-planner.md +15 -10
  11. package/agents/plastic-spec-specialist.md +15 -10
  12. package/bin/plastic.js +7 -3
  13. package/hooks/code-gate +10 -1
  14. package/hooks/hooks.json +28 -3
  15. package/hooks/lock-gate +21 -0
  16. package/hooks/retrieval-gate +10 -0
  17. package/hooks/savepoint-pre +10 -0
  18. package/hooks/statusline +48 -6
  19. package/package.json +2 -1
  20. package/scripts/agent-report +21 -0
  21. package/scripts/dashboard.rb +98 -23
  22. package/scripts/doctor.rb +404 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +11 -6
  26. package/scripts/hook-continue +17 -0
  27. package/scripts/hook-create-gate +45 -9
  28. package/scripts/hook-gate-check +24 -23
  29. package/scripts/hook-lock-gate +83 -0
  30. package/scripts/hook-retrieval-gate +148 -0
  31. package/scripts/hook-savepoint-pre +32 -0
  32. package/scripts/hook-session-start +1 -1
  33. package/scripts/insight-append +51 -0
  34. package/scripts/install.rb +44 -7
  35. package/scripts/lib/agent_models.rb +43 -0
  36. package/scripts/lib/bridge.rb +924 -84
  37. package/scripts/lib/dashboard_banner.rb +42 -0
  38. package/scripts/lib/frontmatter_writer.rb +130 -0
  39. package/scripts/lib/graph_rebuild.rb +328 -0
  40. package/scripts/lib/hook_registry.rb +95 -0
  41. package/scripts/lib/insights.rb +86 -0
  42. package/scripts/lib/installer_core.rb +104 -60
  43. package/scripts/lib/link_suggestions.rb +319 -0
  44. package/scripts/lib/links_projection.rb +160 -0
  45. package/scripts/lib/links_section.rb +207 -0
  46. package/scripts/lib/lock.rb +375 -0
  47. package/scripts/lib/power_tools.rb +22 -21
  48. package/scripts/lib/preflight.rb +79 -0
  49. package/scripts/lib/qmd_sync.rb +15 -0
  50. package/scripts/lib/retrieval_gate.rb +211 -0
  51. package/scripts/lib/worktree.rb +384 -0
  52. package/scripts/link-suggest +213 -0
  53. package/scripts/new-intent +138 -29
  54. package/scripts/plastic-lock +164 -0
  55. package/scripts/project-links +287 -0
  56. package/scripts/read-config +4 -0
  57. package/scripts/rebuild-graph +244 -0
  58. package/scripts/spawn-preamble +11 -3
  59. package/scripts/update.rb +16 -7
  60. package/skills/auto/SKILL.md +126 -19
  61. package/skills/auto/references/agent-architecture.md +7 -4
  62. package/skills/auto/references/agent-report-contract.md +36 -1
  63. package/skills/auto/references/end-tail.md +56 -0
  64. package/skills/auto/references/human-report-contract.md +55 -0
  65. package/skills/auto/references/tiers.md +77 -0
  66. package/skills/brainstorming/SKILL.md +7 -34
  67. package/skills/brainstorming/references/design-principles.md +49 -0
  68. package/skills/continuing/SKILL.md +26 -7
  69. package/skills/creating-intent/SKILL.md +13 -28
  70. package/skills/creating-intent/references/lifecycle.md +9 -4
  71. package/skills/creating-project/SKILL.md +11 -74
  72. package/skills/creating-project/references/project-scaffolding.md +97 -0
  73. package/skills/creating-skills/SKILL.md +65 -0
  74. package/skills/creating-skills/evals/evals.json +108 -0
  75. package/skills/creating-skills/references/agents.md +168 -0
  76. package/skills/creating-skills/references/evals.md +41 -0
  77. package/skills/creating-skills/references/hooks.md +248 -0
  78. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  79. package/skills/creating-skills/references/scripts.md +166 -0
  80. package/skills/creating-skills/references/skills.md +165 -0
  81. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  82. package/skills/dashboard/SKILL.md +13 -11
  83. package/skills/dashboard/references/classification.md +22 -0
  84. package/skills/doctor/SKILL.md +10 -7
  85. package/skills/executing-plan/SKILL.md +4 -4
  86. package/skills/humanizer/SKILL.md +39 -0
  87. package/skills/humanizer/evals/evals.json +70 -0
  88. package/skills/humanizer/references/always-on-snippet.md +9 -0
  89. package/skills/humanizer/references/examples.md +48 -0
  90. package/skills/install/SKILL.md +75 -84
  91. package/skills/intent-curator/SKILL.md +5 -1
  92. package/skills/intent-discovery/SKILL.md +47 -0
  93. package/skills/intent-starting/SKILL.md +130 -0
  94. package/skills/intent-starting/evals/evals.json +117 -0
  95. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  96. package/skills/linking-intents/SKILL.md +48 -12
  97. package/skills/linking-intents/references/zettelkasten.md +7 -0
  98. package/skills/lock/SKILL.md +41 -0
  99. package/skills/managing-index/SKILL.md +6 -0
  100. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  101. package/skills/releasing/SKILL.md +44 -29
  102. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  103. package/skills/roadmap/SKILL.md +51 -0
  104. package/skills/roadmap/references/file-format.md +91 -0
  105. package/skills/roadmap/references/operations.md +78 -0
  106. package/skills/uninstall/SKILL.md +29 -11
  107. package/skills/update/SKILL.md +34 -23
  108. package/skills/versions/SKILL.md +27 -12
  109. package/skills/writing-plans/SKILL.md +10 -88
  110. package/skills/writing-plans/references/plan-format.md +102 -0
  111. package/templates/config.yml +8 -0
  112. package/templates/outcome.md +3 -0
  113. package/templates/revisions.md +58 -0
  114. package/templates/roadmap.md +30 -0
  115. package/skills/writing-instructions/SKILL.md +0 -159
  116. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -9,11 +9,12 @@
9
9
  require "json"
10
10
  require_relative "lib/bridge"
11
11
  require_relative "lib/intent_validator"
12
+ require_relative "lib/lock"
12
13
 
13
14
  file_path = ARGV[0]
14
15
  exit 0 unless file_path && !file_path.empty?
15
16
 
16
- session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
17
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
17
18
 
18
19
  file_path_abs = File.expand_path(file_path)
19
20
 
@@ -24,6 +25,12 @@ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
24
25
  if intent_dir_abs
25
26
  begin
26
27
  Bridge.append_savepoint(intent_dir_abs, file_path_abs)
28
+ # When checklist.md lands, How ends and Exec begins: emit the `Exec started`
29
+ # companion in the same event (intent 81). Guard on a real (non-placeholder)
30
+ # checklist so a scaffold sentinel does not trip it.
31
+ if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
32
+ Bridge.append_exec_started(intent_dir_abs)
33
+ end
27
34
  rescue StandardError
28
35
  # ignore — rebuildable from disk
29
36
  end
@@ -66,6 +73,15 @@ intent_dir = "#{store}/#{dir}"
66
73
  # Normalize both paths for comparison
67
74
  bridge_intent_dir_abs = File.expand_path(intent_dir)
68
75
 
76
+ # Lease heartbeat (intent 108, D1): every write by the session that owns (or
77
+ # delegates under) the lock refreshes the delivery.lock mtime. Best-effort:
78
+ # a heartbeat failure must never break the gate hook.
79
+ begin
80
+ Lock.heartbeat(bridge_intent_dir_abs, session: session)
81
+ rescue StandardError
82
+ # ignore
83
+ end
84
+
69
85
  # Not inside intent dir = not our business
70
86
  exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
71
87
 
@@ -112,32 +128,17 @@ if is_stage_file || is_action_file
112
128
 
113
129
  Bridge.write(session, bridge_data)
114
130
 
115
- # Build transition context
116
- stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
117
- next_hints = {
118
- "why" => "write spec.md",
119
- "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
120
- "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
121
- "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
122
- }
123
-
124
- context_parts = ["PLASTIC"]
125
- if old_stage != new_stage
126
- context_parts[0] = "PLASTIC — Stage transition: #{stage_labels[old_stage] || old_stage} → #{stage_labels[new_stage] || new_stage}."
127
- else
128
- context_parts[0] = "PLASTIC — #{basename} written (stage: #{stage_labels[new_stage] || new_stage})."
129
- end
130
- context_parts << "#{basename} written." if old_stage != new_stage
131
- if new_missing.any?
132
- context_parts << "Next: #{new_missing.join(", ")}"
133
- elsif next_hints[new_stage]
134
- context_parts << "Next: #{next_hints[new_stage]}"
135
- end
131
+ # Build transition context — ONE concise sentence (intent 84, Lever 1),
132
+ # preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
133
+ context = Bridge.gate_narration(
134
+ old_stage: old_stage, new_stage: new_stage,
135
+ basename: basename, new_missing: new_missing
136
+ )
136
137
 
137
138
  payload = {
138
139
  "hookSpecificOutput" => {
139
140
  "hookEventName" => "PostToolUse",
140
- "additionalContext" => context_parts.join(" ")
141
+ "additionalContext" => context
141
142
  }
142
143
  }
143
144
  puts JSON.generate(payload)
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ #
5
+ # Usage: hook-lock-gate <file_path> [session_id]
6
+ # Fail-CLOSED PreToolUse gate (intent 96). Blocks a mutating write to an active
7
+ # intent's lifecycle dir when THIS session holds no live lock. Project code is NOT
8
+ # gated here (D2). Emits the PreToolUse JSON deny contract at exit 0; reserves
9
+ # non-zero exit for hook-internal errors only. Matcher Write|Edit|NotebookEdit, so
10
+ # reads never reach here.
11
+ require "json"
12
+ require_relative "lib/bridge"
13
+ require_relative "lib/lock"
14
+
15
+ begin
16
+ file_path = ARGV[0]
17
+ exit 0 unless file_path && !file_path.empty?
18
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
19
+
20
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
21
+ reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
22
+ unless reason
23
+ # Allow path: refresh the lease for the session that holds this target's
24
+ # lock (owner or delegate). Best-effort, never blocks.
25
+ begin
26
+ dir = Bridge.intent_dir_for(file_path)
27
+ Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
28
+ rescue StandardError
29
+ # ignore
30
+ end
31
+
32
+ # Second, independent gate (intent 111 D7): the artifact-grain claim.
33
+ # Composes UNDER the delivery lock above; only reached once the delivery
34
+ # lock already allows. Dormant (nil) unless a claim file exists for this
35
+ # artifact, so AC7 (existing single-owner flows) is unaffected.
36
+ begin
37
+ dir = Bridge.intent_dir_for(file_path)
38
+ artifact = File.basename(file_path) if dir
39
+ if dir && artifact
40
+ claim_reason = Claim.claim_gate_reason(dir, artifact, session: session)
41
+ if claim_reason
42
+ print JSON.generate(
43
+ "hookSpecificOutput" => {
44
+ "hookEventName" => "PreToolUse",
45
+ "permissionDecision" => "deny",
46
+ "permissionDecisionReason" => claim_reason,
47
+ }
48
+ )
49
+ exit 0
50
+ end
51
+
52
+ # Fail-open surface (AC3): allow, but make the yielded/stale claim visible.
53
+ if Claim.fail_open?(dir, artifact)
54
+ $stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
55
+ "intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
56
+ "proceeds (see /plastic-lock status)"
57
+ end
58
+
59
+ # Heartbeat the session's own claim on the allow path (best-effort).
60
+ Claim.heartbeat(dir, artifact, session: session) rescue nil
61
+ end
62
+ rescue StandardError
63
+ # ignore: a claim-gate bug must never block a write (fail open)
64
+ end
65
+
66
+ exit 0
67
+ end
68
+
69
+ print JSON.generate(
70
+ "hookSpecificOutput" => {
71
+ "hookEventName" => "PreToolUse",
72
+ "permissionDecision" => "deny",
73
+ "permissionDecisionReason" => reason # "run /plastic-intent-starting to lock and begin"
74
+ }
75
+ )
76
+ exit 0
77
+ rescue => e
78
+ # Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
79
+ # here so a gate bug never bricks editing; the fail-CLOSED intent is about MISSING
80
+ # locks, not crashes.
81
+ $stderr.puts "plastic lock-gate error: #{e.message}"
82
+ exit 0
83
+ end
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a;
6
+ # ADVISORY since intent 108, D8). Reads the tool call (JSON on stdin: tool_name +
7
+ # tool_input), computes capabilities (QMD detect + freshness), and delegates the
8
+ # decision to RetrievalGate. It NEVER blocks: reads and searches always run
9
+ # (exit 0). A store content search that QMD could serve better gets a QMD hint
10
+ # via PreToolUse additionalContext on stdout. Hard gates guard writes, locks,
11
+ # and structure; never reads.
12
+ # Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
13
+ # STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
14
+ # Binds subagents (PreToolUse hooks apply to subagent tool calls too).
15
+ #
16
+ # Only CONTENT SEARCH over store markdown gets the hint; reads and structural
17
+ # ops stay silent. Code navigation is a soft prompt recommendation
18
+ # (UserPromptSubmit power-tools), not handled here.
19
+ #
20
+ # Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
21
+ # scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
22
+ #
23
+ # ARGV[0] is plastic_home (passed by the launcher), like hook-qmd-search.
24
+
25
+ require "json"
26
+ require "timeout"
27
+ require_relative "lib/retrieval_gate"
28
+ require_relative "lib/qmd_sync"
29
+
30
+ module RetrievalGateHook
31
+ module_function
32
+
33
+ # Pure-ish core: capabilities and reindex are injected so this is unit-testable
34
+ # with no real qmd. Returns [exit_code, stderr_string, stdout_json].
35
+ # stdin: raw PreToolUse JSON
36
+ # capabilities: { qmd:, qmd_fresh: }
37
+ # reindex: callable fired on the STALE path
38
+ def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
39
+ payload = parse(stdin)
40
+ return [0, nil, nil] unless payload
41
+
42
+ tool_name = payload["tool_name"].to_s
43
+ tool_input = payload["tool_input"]
44
+ tool_input = {} unless tool_input.is_a?(Hash)
45
+
46
+ bypassed = false
47
+ reason = RetrievalGate.decision(
48
+ tool_name: tool_name, tool_input: tool_input,
49
+ plastic_home: plastic_home, cwd: cwd,
50
+ capabilities: capabilities, reindex: reindex
51
+ ) { |_sig| bypassed = true }
52
+
53
+ if reason
54
+ # Advisory (intent 108, D8): reads and searches are never denied. The
55
+ # would-block reason becomes a hint the agent sees alongside the result.
56
+ hint = JSON.generate(
57
+ "hookSpecificOutput" => {
58
+ "hookEventName" => "PreToolUse",
59
+ "additionalContext" =>
60
+ "PLASTIC advisory: #{reason} (this search ran; the hint is not a block)"
61
+ }
62
+ )
63
+ [0, nil, hint]
64
+ else
65
+ # The `# qmd-ok` bypass token is still accepted (bypassed is set), but
66
+ # with nothing to bypass it no longer announces itself.
67
+ [0, nil, nil]
68
+ end
69
+ rescue StandardError
70
+ [0, nil, nil] # fail-open
71
+ end
72
+
73
+ def parse(raw)
74
+ data = JSON.parse(raw.to_s)
75
+ data.is_a?(Hash) ? data : nil
76
+ rescue StandardError
77
+ nil
78
+ end
79
+
80
+ # Detect real capabilities for the live executable. Probes are injected so the
81
+ # three-tier QMD failure model is unit-testable:
82
+ # - absent : QMD not on PATH -> allow, no warn.
83
+ # - broken : QMD present but freshness probe -> tier-b: WARN once, then treat
84
+ # raises/times out as absent this turn (allow).
85
+ # - fresh : probe returns truthy -> gate is live.
86
+ # A slow `qmd status` cannot stall a tool call: a 2s Timeout bounds the probe.
87
+ def detect_capabilities(cwd:,
88
+ detect: -> { QmdSync.detect },
89
+ fresh: -> { QmdSync.fresh? },
90
+ warn: ->(m) { $stderr.puts(m) })
91
+ qmd = detect.call
92
+ qmd_fresh = false
93
+ if qmd
94
+ begin
95
+ qmd_fresh = Timeout.timeout(2) { fresh.call }
96
+ rescue StandardError
97
+ # Tier-b: QMD is present but its freshness probe broke/stalled. Distinct
98
+ # from QMD being absent — warn so a degraded QMD is visible, then fail open
99
+ # (allow this turn, no reindex).
100
+ warn.call("PLASTIC GATE — QMD is present but its freshness probe failed; " \
101
+ "allowing this turn without routing search to QMD (check qmd).")
102
+ qmd = false
103
+ qmd_fresh = false
104
+ end
105
+ end
106
+ { qmd: qmd, qmd_fresh: qmd_fresh }
107
+ end
108
+
109
+ # Best-effort reindex callable for the STALE path. Resolves the collection from
110
+ # cwd (project + global) and fires the async reindexer for each; never raises.
111
+ def reindex_for(cwd:, plastic_home:)
112
+ lambda do
113
+ begin
114
+ cols = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
115
+ cols.each { |c| QmdSync.reindex_async(collection: c) }
116
+ rescue StandardError
117
+ # non-fatal; the read is already allowed this turn
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ # --- executable entrypoint ---
124
+ if $PROGRAM_NAME == __FILE__
125
+ raw = begin
126
+ $stdin.read
127
+ rescue StandardError
128
+ ""
129
+ end
130
+
131
+ plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
132
+ cwd = Dir.pwd
133
+
134
+ code, err, out = begin
135
+ caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
136
+ RetrievalGateHook.run(
137
+ stdin: raw, plastic_home: plastic_home, cwd: cwd,
138
+ capabilities: caps,
139
+ reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
140
+ )
141
+ rescue StandardError
142
+ [0, nil, nil] # fail-open at the outermost boundary too
143
+ end
144
+
145
+ $stderr.puts(err) if err && !err.empty?
146
+ print out if out && !out.empty?
147
+ exit code
148
+ end
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ # Usage: hook-savepoint-pre <file_path>
5
+ #
6
+ # PreToolUse savepoint trigger (intent 81). When a stage-opening lifecycle file
7
+ # (spec.md => Why, plan.md => How) is ABOUT to be written into an intent dir,
8
+ # append the pre-stage `started` ledger line so the ledger records "this stage
9
+ # was entered" before its artifact lands.
10
+ #
11
+ # Like the PostToolUse decoupled savepoint write (intent 52), it is derived from
12
+ # the file path alone: no bridge, no session, fires headless. It NEVER blocks a
13
+ # write: any non-match or failure exits 0. The append is idempotent and only
14
+ # fires while the stage is genuinely starting (the artifact is not yet a real,
15
+ # non-placeholder file), so re-edits add nothing.
16
+
17
+ require_relative "lib/bridge"
18
+
19
+ file_path = ARGV[0]
20
+ exit 0 if file_path.nil? || file_path.empty?
21
+
22
+ file_path_abs = File.expand_path(file_path)
23
+ intent_dir = Bridge.intent_dir_for(file_path_abs)
24
+ exit 0 unless intent_dir
25
+
26
+ begin
27
+ Bridge.append_started_savepoint(intent_dir, file_path_abs)
28
+ rescue StandardError
29
+ # best-effort; the ledger is rebuildable and the post line still lands
30
+ end
31
+
32
+ exit 0
@@ -48,7 +48,7 @@ bridge_data = nil
48
48
  if active.length == 1 && active.first =~ /store\/([\w-]+)\//
49
49
  dir_name = $1
50
50
  intent_dir = "#{store_root}/store/#{dir_name}"
51
- session = ENV["CLAUDE_SESSION_ID"] || Process.pid.to_s
51
+ session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
52
52
  intent_id = dir_name.split("--").first
53
53
  intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
54
54
  bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_root, name: intent_name)
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # insight-append - the blessed write path for an intent's `## Insights` section
6
+ # (intent 82). A thin CLI wrapper over Insights.append_insight: it formats the
7
+ # `{utc-iso8601} · {stage} · {author}` prefix, validates it, and appends one
8
+ # entry at the bottom of the section (creating the section or file if absent).
9
+ #
10
+ # Sibling to scripts/spawn-preamble and scripts/agent-report. The library's
11
+ # `now:` seam is the test seam; the CLI uses the default Time.now, which is fine
12
+ # because determinism is covered at the library level (test/insights_test.rb).
13
+ #
14
+ # Usage:
15
+ # insight-append <intent_dir> <text> --stage S --author A
16
+ #
17
+ # Exit codes: 0 (entry appended), 2 (usage).
18
+
19
+ require_relative "lib/insights"
20
+
21
+ def parse_args(argv)
22
+ stage = nil
23
+ author = nil
24
+ positional = []
25
+ i = 0
26
+ while i < argv.length
27
+ case argv[i]
28
+ when "--stage"
29
+ stage = argv[i + 1]
30
+ i += 2
31
+ when "--author"
32
+ author = argv[i + 1]
33
+ i += 2
34
+ else
35
+ positional << argv[i]
36
+ i += 1
37
+ end
38
+ end
39
+ [positional[0], positional[1], stage, author]
40
+ end
41
+
42
+ intent_dir, text, stage, author = parse_args(ARGV)
43
+
44
+ if [intent_dir, text, stage, author].any? { |v| v.nil? || v.to_s.empty? }
45
+ warn "usage: insight-append <intent_dir> <text> --stage S --author A"
46
+ exit 2
47
+ end
48
+
49
+ entry = Insights.append_insight(File.expand_path(intent_dir), text,
50
+ stage: stage, author: author)
51
+ puts "appended: #{entry}"
@@ -14,6 +14,7 @@
14
14
  # so install.rb is the single file-syncer; the ledger action is contextual.
15
15
 
16
16
  require_relative "lib/installer_core"
17
+ require_relative "lib/preflight"
17
18
 
18
19
  class Install < InstallerCore
19
20
  def cli(argv = ARGV)
@@ -22,6 +23,9 @@ class Install < InstallerCore
22
23
  return 0
23
24
  end
24
25
 
26
+ gate = preflight_gate
27
+ return gate unless gate.zero?
28
+
25
29
  force = argv.include?("--force")
26
30
  reinstall = argv.include?("--reinstall")
27
31
  ledger_action = flag_value(argv, "--ledger-action")
@@ -42,19 +46,19 @@ class Install < InstallerCore
42
46
  return 1
43
47
  end
44
48
 
45
- run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action)
49
+ run(selected: selected, force: force, reinstall: reinstall, ledger_action: ledger_action, argv: argv)
46
50
  0
47
51
  end
48
52
 
49
53
  # Hermetic entrypoint (no prompting / no exit). Returns the per-agent results array.
50
- def run(selected:, force: false, reinstall: false, ledger_action: nil)
54
+ def run(selected:, force: false, reinstall: false, ledger_action: nil, argv: ARGV, input: $stdin)
51
55
  fresh = !installed?
52
56
  mode = fresh ? :install : :update # :update here means "re-sync, skip bootstrap"
53
57
 
54
58
  distribute(mode)
55
59
  bootstrap if fresh
56
60
 
57
- results = selected.map { |key| install_for_agent(key, force) }
61
+ results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
58
62
 
59
63
  action = ledger_action || (fresh ? "install" : "reinstall")
60
64
  ledger_append(version, action)
@@ -72,8 +76,37 @@ class Install < InstallerCore
72
76
  File.exist?(path) ? File.read(path).strip : nil
73
77
  end
74
78
 
79
+ # Injectable pre-flight gate: real probes as default args, printing to an
80
+ # injectable `out:` IO so this is hermetically testable via StringIO. Returns
81
+ # 1 (stop the install) when Ruby is missing/too-old, else 0.
82
+ def preflight_gate(ruby_version: RUBY_VERSION, node_version: node_probe, git_present: git_probe,
83
+ mise_present: mise_probe, out: $stderr)
84
+ result = Preflight.check(ruby_version: ruby_version, node_version: node_version,
85
+ git_present: git_present, mise_present: mise_present)
86
+ result[:messages].each { |message| out.puts(message) }
87
+ result[:fatal] ? 1 : 0
88
+ end
89
+
75
90
  private
76
91
 
92
+ def node_probe
93
+ `node --version`.strip
94
+ rescue StandardError
95
+ ""
96
+ end
97
+
98
+ def git_probe
99
+ !`command -v git`.strip.empty?
100
+ rescue StandardError
101
+ false
102
+ end
103
+
104
+ def mise_probe
105
+ !`command -v mise`.strip.empty?
106
+ rescue StandardError
107
+ false
108
+ end
109
+
77
110
  def flag_value(argv, name)
78
111
  i = argv.index(name)
79
112
  return nil unless i && argv[i + 1]
@@ -96,7 +129,8 @@ class Install < InstallerCore
96
129
  verb = mode == :reinstall ? "re-synced" : "installed"
97
130
  puts "\n\u{2705} Plastic v#{version} #{verb}."
98
131
  puts " Registered for: #{installed.map { |r| r[:agent] }.join(", ")}"
99
- puts " Run /clear (or restart your agent) to pick up new conventions.\n\n"
132
+ puts " Run /clear (or restart your agent) to pick up new conventions."
133
+ puts " Next: read docs/guides/your-first-intent-in-10-minutes.md\n\n"
100
134
  end
101
135
 
102
136
  def show_help
@@ -119,9 +153,12 @@ class Install < InstallerCore
119
153
  --alpha Alpha channel
120
154
 
121
155
  Other options:
122
- --reinstall Re-sync core files for the installed version (repair). Store untouched.
123
- --force Overwrite existing files without prompting
124
- -h, --help Show this help
156
+ --reinstall Re-sync core files for the installed version (repair). Store untouched.
157
+ --force Overwrite existing files without prompting
158
+ --statusline VALUE keep or plastic. If an existing statusline is found, this
159
+ skips the interactive prompt. Interactive sessions ask by
160
+ default; non-interactive sessions default to keep.
161
+ -h, --help Show this help
125
162
 
126
163
  Notes:
127
164
  Install is one-shot. If Plastic is already installed, use `update` to upgrade or
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared model-tier resolution for Plastic subagents (intent 116).
4
+ #
5
+ # TIER_DEFAULTS mirrors the shipped `agents/*.md` frontmatter so `read-config`
6
+ # can answer `agents.models.<basename>` with the built-in default. The installer
7
+ # does NOT use TIER_DEFAULTS: it applies only genuine config overrides via
8
+ # `override_map`, so shipped frontmatter with no override passes through
9
+ # unchanged.
10
+ module AgentModels
11
+ # Claude Code aliases only (never pinned ids, never Fable). Keys are the agent
12
+ # file basenames without the `.md` extension.
13
+ TIER_DEFAULTS = {
14
+ "plastic-enforcer" => "opus",
15
+ "plastic-brainstorming" => "opus",
16
+ "plastic-planner" => "opus",
17
+ "plastic-spec-specialist" => "sonnet",
18
+ "plastic-executor" => "sonnet",
19
+ "plastic-intent-curator" => "sonnet",
20
+ "plastic-future-intent-researcher" => "sonnet",
21
+ "plastic-intent-discovery" => "sonnet"
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ # Pull the `agents.models` sub-hash out of a loaded config hash, tolerating a
27
+ # missing or malformed shape. Returns a plain { basename => model } hash.
28
+ def models_section(config)
29
+ return {} unless config.is_a?(Hash)
30
+ agents = config["agents"]
31
+ return {} unless agents.is_a?(Hash)
32
+ section = agents["models"]
33
+ section.is_a?(Hash) ? section : {}
34
+ end
35
+
36
+ # Override map for the installer: global overrides overlaid by project
37
+ # overrides (project wins). Defaults are intentionally excluded. Unknown agent
38
+ # keys are carried through as-is; install_agents simply never matches them to a
39
+ # copied file, so they are ignored without raising.
40
+ def override_map(project_config: {}, global_config: {})
41
+ models_section(global_config).merge(models_section(project_config))
42
+ end
43
+ end