@zalom/plastic 1.0.3 → 1.1.1
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-reference.md +11 -11
- package/PLASTIC.md +11 -9
- package/README.md +48 -1
- package/agents/plastic-enforcer.md +8 -5
- package/agents/plastic-future-intent-researcher.md +1 -1
- package/agents/plastic-intent-curator.md +9 -10
- package/agents/plastic-spec-specialist.md +9 -6
- package/bin/plastic.js +2 -2
- package/hooks/check-update +1 -1
- package/hooks/continue +1 -1
- package/package.json +1 -1
- package/scripts/dashboard.rb +29 -24
- package/scripts/doctor.rb +188 -9
- package/scripts/end-intent +330 -0
- package/scripts/hook-code-gate +1 -1
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +2 -2
- package/scripts/hook-session-start +1 -1
- package/scripts/install.rb +2 -1
- package/scripts/lib/bridge.rb +116 -3
- package/scripts/lib/dashboard_banner.rb +8 -9
- package/scripts/lib/installer_core.rb +17 -8
- package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
- package/scripts/lib/release_guard.rb +62 -0
- package/scripts/lib/roadmap_queue.rb +285 -0
- package/scripts/lib/roadmap_savepoint.rb +213 -0
- package/scripts/lib/store_provisioning.rb +1 -1
- package/scripts/lib/worktree.rb +21 -0
- package/scripts/new-intent +2 -1
- package/scripts/read-config +3 -3
- package/scripts/roadmap-next +44 -0
- package/scripts/roadmap-savepoint +64 -0
- package/scripts/{versions.rb → rollback.rb} +33 -41
- package/scripts/update.rb +1 -1
- package/skills/_active-intent-gate.md +1 -1
- package/skills/_decision-tables.md +50 -0
- package/skills/auto/SKILL.md +69 -68
- package/skills/auto/evals/evals.json +4 -4
- package/skills/auto/references/agent-architecture.md +2 -2
- package/skills/continuing/SKILL.md +23 -130
- package/skills/continuing/evals/evals.json +26 -80
- package/skills/dashboard/SKILL.md +18 -15
- package/skills/dashboard/references/classification.md +3 -3
- package/skills/dashboard/templates/dashboard-global.md +8 -23
- package/skills/dashboard/templates/dashboard-project.md +7 -26
- package/skills/doctor/SKILL.md +3 -3
- package/skills/install/SKILL.md +10 -10
- package/skills/{brainstorming → intent-brainstorming}/SKILL.md +43 -45
- package/skills/intent-brainstorming/evals/evals.json +67 -0
- package/skills/{brainstorming → intent-brainstorming}/references/design-principles.md +12 -17
- package/skills/intent-continuing/SKILL.md +99 -0
- package/skills/intent-continuing/evals/evals.json +145 -0
- package/skills/intent-continuing/references/context-management.md +28 -0
- package/skills/{creating-intent → intent-creating}/SKILL.md +3 -3
- package/skills/{creating-intent → intent-creating}/evals/evals.json +1 -1
- package/skills/{intent-discovery → intent-discovering}/SKILL.md +1 -1
- package/skills/intent-ending/SKILL.md +151 -0
- package/skills/intent-ending/evals/evals.json +74 -0
- package/skills/{executing-plan → intent-executing}/SKILL.md +75 -15
- package/skills/intent-executing/evals/evals.json +66 -0
- package/skills/{brainstorming-grill-me → intent-grilling}/SKILL.md +4 -4
- package/skills/{linking-intents → intent-linking}/SKILL.md +1 -1
- package/skills/{linking-intents → intent-linking}/evals/evals.json +1 -1
- package/skills/{lock → intent-locking}/SKILL.md +1 -1
- package/skills/{writing-plans → intent-planning}/SKILL.md +48 -20
- package/skills/intent-planning/evals/evals.json +66 -0
- package/skills/{writing-plans → intent-planning}/references/plan-format.md +31 -7
- package/skills/{research → intent-researching}/SKILL.md +2 -2
- package/skills/{research → intent-researching}/evals/evals.json +1 -1
- package/skills/{savepoint → intent-savepoint}/SKILL.md +13 -1
- package/skills/intent-speccing/SKILL.md +69 -0
- package/skills/intent-speccing/evals/evals.json +126 -0
- package/skills/intent-speccing/references/per-section-fill-rules.md +92 -0
- package/skills/intent-speccing/references/self-verify-checklist.md +38 -0
- package/skills/intent-starting/SKILL.md +2 -2
- package/skills/intent-starting/evals/evals.json +2 -2
- package/skills/project-continuing/SKILL.md +104 -0
- package/skills/project-continuing/evals/evals.json +100 -0
- package/skills/project-continuing/references/board-fill.md +33 -0
- package/skills/{creating-project → project-creating}/SKILL.md +2 -2
- package/skills/releasing/SKILL.md +66 -14
- package/skills/releasing/references/release-lines.md +105 -0
- package/skills/roadmap/SKILL.md +7 -1
- package/skills/roadmap/references/file-format.md +30 -1
- package/skills/roadmap/references/operations.md +26 -6
- package/skills/roadmap-continuing/SKILL.md +85 -0
- package/skills/roadmap-continuing/evals/evals.json +82 -0
- package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
- package/skills/{versions → rollback}/SKILL.md +30 -20
- package/skills/{creating-skills → skill-creating}/SKILL.md +2 -2
- package/skills/{creating-skills → skill-creating}/evals/evals.json +5 -5
- package/skills/{creating-skills → skill-creating}/references/progressive-disclosure.md +2 -2
- package/skills/{evaluating-skills → skill-evaluating}/SKILL.md +1 -1
- package/skills/{evaluating-skills → skill-evaluating}/evals/evals.json +3 -3
- package/skills/{intent-curator → store-curating}/SKILL.md +6 -7
- package/skills/{intent-curator → store-curating}/evals/evals.json +1 -1
- package/skills/{managing-index → store-indexing}/SKILL.md +6 -7
- package/skills/{managing-index → store-indexing}/evals/evals.json +1 -1
- package/skills/{add-project-store → store-provisioning}/SKILL.md +2 -2
- package/skills/tutorial/SKILL.md +65 -0
- package/skills/tutorial/evals/evals.json +186 -0
- package/skills/tutorial/references/track-1-guided.md +123 -0
- package/skills/tutorial/references/track-2-auto.md +95 -0
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +110 -0
- package/skills/uninstall/SKILL.md +2 -2
- package/skills/update/SKILL.md +3 -3
- package/templates/agents.md +7 -7
- package/templates/config.yml +2 -1
- package/templates/index.md +4 -1
- package/skills/brainstorming/evals/evals.json +0 -22
- package/skills/savepoint/references/context-management.md +0 -32
- /package/skills/{creating-intent → intent-creating}/references/lifecycle.md +0 -0
- /package/skills/{creating-intent → intent-creating}/references/wikilinks.md +0 -0
- /package/skills/{executing-plan → intent-executing}/code-quality-reviewer-prompt.md +0 -0
- /package/skills/{executing-plan → intent-executing}/implementer-prompt.md +0 -0
- /package/skills/{executing-plan → intent-executing}/spec-reviewer-prompt.md +0 -0
- /package/skills/{linking-intents → intent-linking}/references/zettelkasten.md +0 -0
- /package/skills/{continuing → intent-savepoint}/references/context-management.md +0 -0
- /package/skills/{creating-project → project-creating}/references/hubs-projects.md +0 -0
- /package/skills/{creating-project → project-creating}/references/project-scaffolding.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/agents.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/evals.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/hooks.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/scripts.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/skills.md +0 -0
- /package/skills/{creating-skills → skill-creating}/scripts/scaffold.rb +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/assets/eval-template.json +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/references/convention-checks.md +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/references/eval-methodology.md +0 -0
- /package/skills/{managing-index → store-indexing}/references/zettelkasten-linking.md +0 -0
package/scripts/doctor.rb
CHANGED
|
@@ -23,6 +23,8 @@ require_relative "lib/links_section"
|
|
|
23
23
|
require_relative "lib/hook_registry"
|
|
24
24
|
require_relative "lib/lock"
|
|
25
25
|
require_relative "lib/bridge"
|
|
26
|
+
require_relative "lib/agent_models"
|
|
27
|
+
require_relative "lib/legacy_bookend_amnesty"
|
|
26
28
|
|
|
27
29
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
28
30
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -41,6 +43,11 @@ class Doctor
|
|
|
41
43
|
# (intent 60). Alias it here so the two can never drift.
|
|
42
44
|
REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
|
|
43
45
|
|
|
46
|
+
# Single source of truth for the pre-161 legacy Done-bookend amnesty list
|
|
47
|
+
# lives in LegacyBookendAmnesty (intent 170a). Alias it here so the two can
|
|
48
|
+
# never drift.
|
|
49
|
+
LEGACY_BOOKEND_AMNESTY = LegacyBookendAmnesty::LIST
|
|
50
|
+
|
|
44
51
|
CLAUDE_HOOK_SCRIPTS = %w[
|
|
45
52
|
plastic-session-start
|
|
46
53
|
plastic-check-update
|
|
@@ -66,9 +73,11 @@ class Doctor
|
|
|
66
73
|
|
|
67
74
|
attr_reader :plastic_home, :agents
|
|
68
75
|
|
|
69
|
-
def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS
|
|
76
|
+
def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
|
|
77
|
+
bookend_amnesty: LEGACY_BOOKEND_AMNESTY)
|
|
70
78
|
@plastic_home = plastic_home
|
|
71
79
|
@agents = agents
|
|
80
|
+
@bookend_amnesty = bookend_amnesty
|
|
72
81
|
end
|
|
73
82
|
|
|
74
83
|
# --- Flag parsing ---
|
|
@@ -476,7 +485,7 @@ class Doctor
|
|
|
476
485
|
message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
|
|
477
486
|
details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
|
|
478
487
|
fixable: true,
|
|
479
|
-
fix_hint: "Dispatch plastic-
|
|
488
|
+
fix_hint: "Dispatch plastic-store-curating to relocate each unsanctioned section into the " \
|
|
480
489
|
"intent's revisions.md via move-and-record (a missing required section is restored " \
|
|
481
490
|
"or reprojected instead); see PLASTIC.md > Structural maintenance and revisions.md"
|
|
482
491
|
)
|
|
@@ -577,6 +586,7 @@ class Doctor
|
|
|
577
586
|
conflicts = [] # canonical INDEX-wins disagreement (fail)
|
|
578
587
|
gaps = [] # terminal completeness gaps on immutable/legacy history (warn)
|
|
579
588
|
stalled = [] # terminal but End tail unfinished (warn)
|
|
589
|
+
phantoms = [] # savepoint.md line(s) disk evidence contradicts (warn, never fail; intent 134)
|
|
580
590
|
|
|
581
591
|
done_signal_stores(scopes).each do |store|
|
|
582
592
|
index_sections_by_dir(store[:index]).each do |dirname, in_sections|
|
|
@@ -596,6 +606,24 @@ class Doctor
|
|
|
596
606
|
"(INDEX is canonical — move it to its terminal section or revert outcome.md)"
|
|
597
607
|
end
|
|
598
608
|
|
|
609
|
+
# Savepoint truthfulness (advisory, never fail; intent 134): a line the disk contradicts
|
|
610
|
+
# (a phantom milestone, a duplicate pair, or a state line with an absent prerequisite).
|
|
611
|
+
# Live intents auto-rebuild via plastic-intent-savepoint; terminal intents are immutable,
|
|
612
|
+
# so a phantom there is report-only. Composition with intent 170a: a pre-161 legacy intent
|
|
613
|
+
# on the frozen bookend amnesty is grandfathered here too. Its immutable history carries
|
|
614
|
+
# known pre-convention drift (the same class the audit-echo gap forgives), so it must not
|
|
615
|
+
# resurface through this advisory. The suppression is scope-qualified exactly like the
|
|
616
|
+
# audit-echo amnesty; every live intent and every non-amnestied terminal still fires.
|
|
617
|
+
phantom_lines = Bridge.savepoint_phantom_lines(dir)
|
|
618
|
+
phantom_id = dirname.split("--", 2).first
|
|
619
|
+
phantom_amnestied = terminal && @bookend_amnesty.fetch(store[:scope], []).include?(phantom_id)
|
|
620
|
+
if phantom_lines.any? && !phantom_amnestied
|
|
621
|
+
detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
|
|
622
|
+
scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
|
|
623
|
+
phantoms << "#{label}: #{phantom_lines.size} phantom savepoint line(s) contradicted by " \
|
|
624
|
+
"disk, #{scope_note}: #{detail}"
|
|
625
|
+
end
|
|
626
|
+
|
|
599
627
|
# Completeness gap (warn, not fail): a terminal intent whose outcome.md is
|
|
600
628
|
# missing/placeholder. Legacy terminal intents predate this convention and
|
|
601
629
|
# are immutable, so this is advisory, never breakage.
|
|
@@ -608,11 +636,18 @@ class Doctor
|
|
|
608
636
|
next unless terminal
|
|
609
637
|
|
|
610
638
|
# Audit echo (weakest signal, D1): the savepoint should carry a
|
|
611
|
-
# `Done delivered|abandoned` line. Missing on legacy history -> advisory
|
|
639
|
+
# `Done delivered|abandoned` line. Missing on legacy history -> advisory
|
|
640
|
+
# gap, unless the (scope, id) is on the frozen pre-161 amnesty list
|
|
641
|
+
# (intent 170a). The amnesty is scope-qualified: an id on the list for
|
|
642
|
+
# one scope does not grandfather the same id in another scope.
|
|
612
643
|
savepoint = File.join(dir, "savepoint.md")
|
|
613
644
|
if File.exist?(savepoint) && File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
614
|
-
|
|
615
|
-
|
|
645
|
+
id = dirname.split("--", 2).first
|
|
646
|
+
amnestied = @bookend_amnesty.fetch(store[:scope], []).include?(id)
|
|
647
|
+
unless amnestied
|
|
648
|
+
gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
649
|
+
"`Done delivered|abandoned` line (audit echo missing)"
|
|
650
|
+
end
|
|
616
651
|
end
|
|
617
652
|
|
|
618
653
|
# Stalled completion: the End tail never released the delivery lock, so the
|
|
@@ -660,7 +695,7 @@ class Doctor
|
|
|
660
695
|
"(outcome.md or the savepoint Done echo is missing; advisory on immutable history)",
|
|
661
696
|
details: gaps, fixable: true,
|
|
662
697
|
fix_hint: "For live terminals, author a real outcome.md with the `disposition:` header via the " \
|
|
663
|
-
"completion/abandon path (plastic-auto or plastic-
|
|
698
|
+
"completion/abandon path (plastic-auto or plastic-store-curating) and stamp the terminal " \
|
|
664
699
|
"savepoint. Legacy terminal intents are immutable, so pre-convention gaps stay advisory."
|
|
665
700
|
)
|
|
666
701
|
end
|
|
@@ -682,6 +717,26 @@ class Doctor
|
|
|
682
717
|
)
|
|
683
718
|
end
|
|
684
719
|
|
|
720
|
+
# savepoint_truthful: advisory only, never fails the run (intent 134). A phantom line on a
|
|
721
|
+
# live intent auto-rebuilds; on terminal (immutable) history it stays report-only.
|
|
722
|
+
if phantoms.empty?
|
|
723
|
+
checks << check(
|
|
724
|
+
category: "done_signals", name: "savepoint_truthful", status: "pass",
|
|
725
|
+
message: "No savepoint.md lines are contradicted by disk (phantom-line check clean)"
|
|
726
|
+
)
|
|
727
|
+
else
|
|
728
|
+
checks << check(
|
|
729
|
+
category: "done_signals", name: "savepoint_truthful", status: "warn",
|
|
730
|
+
message: "#{phantoms.size} intent#{phantoms.size == 1 ? "" : "s"} carry savepoint.md " \
|
|
731
|
+
"line(s) contradicted by disk (advisory; terminal history stays report-only)",
|
|
732
|
+
details: phantoms, fixable: true,
|
|
733
|
+
fix_hint: "For a live (Active) intent, run plastic-intent-savepoint to rebuild via " \
|
|
734
|
+
"Bridge.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
|
|
735
|
+
"a phantom there stays advisory unless an explicit human grant authorizes the " \
|
|
736
|
+
"124a manual Done-bookend repair."
|
|
737
|
+
)
|
|
738
|
+
end
|
|
739
|
+
|
|
685
740
|
checks
|
|
686
741
|
end
|
|
687
742
|
|
|
@@ -882,7 +937,7 @@ class Doctor
|
|
|
882
937
|
checks << graph_finding_check(
|
|
883
938
|
"graph_i4_danglers", i4,
|
|
884
939
|
"Every sources/chain id resolves to a real intent (I4)",
|
|
885
|
-
"Dispatch plastic-
|
|
940
|
+
"Dispatch plastic-store-curating to record the dangling sources/chain edge as a " \
|
|
886
941
|
"broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
|
|
887
942
|
"PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
|
|
888
943
|
)
|
|
@@ -1052,6 +1107,11 @@ class Doctor
|
|
|
1052
1107
|
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
|
1053
1108
|
checks << flat_skills_check(agent_dir, "--claude")
|
|
1054
1109
|
|
|
1110
|
+
# stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
|
|
1111
|
+
# e.g. an old-name copy after a rename; intent 158a AC15)
|
|
1112
|
+
stray_check = stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
|
|
1113
|
+
checks << stray_check if stray_check
|
|
1114
|
+
|
|
1055
1115
|
# agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
|
|
1056
1116
|
checks << flat_agents_check(agent_dir, "--claude")
|
|
1057
1117
|
|
|
@@ -1078,6 +1138,43 @@ class Doctor
|
|
|
1078
1138
|
end
|
|
1079
1139
|
end
|
|
1080
1140
|
|
|
1141
|
+
# Backstop for a rename or removal (intent 158a, AC15): an installed plastic-*
|
|
1142
|
+
# skill directory under agent_dir/skills that has NO corresponding entry in the
|
|
1143
|
+
# current install manifest is a stray (e.g. a leftover old-name copy the
|
|
1144
|
+
# install/update prune should have removed, or one it never saw because the
|
|
1145
|
+
# manifest predates it). Complements flat_skills_check (which only confirms at
|
|
1146
|
+
# least one skill exists). Defers to the manifest check when the manifest itself
|
|
1147
|
+
# is missing or malformed, so the two checks never double-report the same gap.
|
|
1148
|
+
def stray_skills_check(agent_dir, installer_flag, manifest_path)
|
|
1149
|
+
skills_root = File.join(agent_dir, "skills")
|
|
1150
|
+
manifest = read_json_safe(manifest_path)
|
|
1151
|
+
files = manifest.is_a?(Hash) ? manifest["files"] : nil
|
|
1152
|
+
return nil unless files.is_a?(Hash)
|
|
1153
|
+
|
|
1154
|
+
installed = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
|
|
1155
|
+
.map { |f| File.basename(File.dirname(f)) }
|
|
1156
|
+
tracked = files.keys
|
|
1157
|
+
.select { |p| p.start_with?("#{skills_root}/") }
|
|
1158
|
+
.map { |p| p.sub("#{skills_root}/", "").split("/").first }
|
|
1159
|
+
.uniq
|
|
1160
|
+
|
|
1161
|
+
strays = (installed - tracked).sort
|
|
1162
|
+
|
|
1163
|
+
if strays.empty?
|
|
1164
|
+
check(
|
|
1165
|
+
category: "agent_registration", name: "stray_skills", status: "pass",
|
|
1166
|
+
message: "No stray plastic-* skill directories in #{tilde(skills_root)}"
|
|
1167
|
+
)
|
|
1168
|
+
else
|
|
1169
|
+
check(
|
|
1170
|
+
category: "agent_registration", name: "stray_skills", status: "warn",
|
|
1171
|
+
message: "#{strays.size} installed skill dir(s) not in the current manifest (stray, e.g. a leftover old-name copy)",
|
|
1172
|
+
details: strays,
|
|
1173
|
+
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
|
|
1174
|
+
)
|
|
1175
|
+
end
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1081
1178
|
# Auto-mode role agents install as <dir>/agents/plastic-*.md. Pass if at least
|
|
1082
1179
|
# one such role file is present (the installer syncs agents/ on every install).
|
|
1083
1180
|
def flat_agents_check(agent_dir, installer_flag)
|
|
@@ -1104,6 +1201,12 @@ class Doctor
|
|
|
1104
1201
|
|
|
1105
1202
|
# For codex/hermes: just check skills exist (no settings.json hooks)
|
|
1106
1203
|
checks << flat_skills_check(agent_dir, "--#{agent_key}")
|
|
1204
|
+
|
|
1205
|
+
# stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
|
|
1206
|
+
# e.g. an old-name copy after a rename; intent 158a AC15)
|
|
1207
|
+
stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic-manifest.json"))
|
|
1208
|
+
checks << stray_check if stray_check
|
|
1209
|
+
|
|
1107
1210
|
checks << flat_agents_check(agent_dir, "--#{agent_key}")
|
|
1108
1211
|
|
|
1109
1212
|
checks
|
|
@@ -1223,9 +1326,85 @@ class Doctor
|
|
|
1223
1326
|
end
|
|
1224
1327
|
end
|
|
1225
1328
|
|
|
1329
|
+
checks += check_agent_model_drift(agent_key)
|
|
1330
|
+
|
|
1226
1331
|
checks
|
|
1227
1332
|
end
|
|
1228
1333
|
|
|
1334
|
+
# agent_model_drift - advisory (never fail) config-vs-frontmatter comparison
|
|
1335
|
+
# for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1).
|
|
1336
|
+
# Resolution mirrors read-config's own precedence (project override -> global
|
|
1337
|
+
# override -> shipped AgentModels::TIER_DEFAULTS), reusing AgentModels'
|
|
1338
|
+
# `override_map` directly so this stays hermetically DI-testable (no
|
|
1339
|
+
# shell-out to `read-config`, no ENV/global seam). There is no live project
|
|
1340
|
+
# scope at doctor-run time for a globally-installed agent file, so the
|
|
1341
|
+
# project layer is empty here; the resolver still supports one, matching the
|
|
1342
|
+
# same `override_map(project_config:, global_config:)` shape the installer
|
|
1343
|
+
# already calls.
|
|
1344
|
+
#
|
|
1345
|
+
# Classification per installed agent basename:
|
|
1346
|
+
# - no `agents.models.<basename>` override AND frontmatter == resolved
|
|
1347
|
+
# default -> pass (clean)
|
|
1348
|
+
# - no override AND frontmatter != resolved default -> warn (real drift)
|
|
1349
|
+
# - an override IS configured -> pass/informational, LISTED as a
|
|
1350
|
+
# sanctioned override, regardless of whether frontmatter matches (that
|
|
1351
|
+
# mismatch is the override working, e.g. `plastic-brainstorming: fable`)
|
|
1352
|
+
# - zero installed agent files -> pass (nothing to check)
|
|
1353
|
+
# This check never returns "fail".
|
|
1354
|
+
def check_agent_model_drift(agent_key)
|
|
1355
|
+
agent_config = agents[agent_key]
|
|
1356
|
+
return [] unless agent_config
|
|
1357
|
+
|
|
1358
|
+
agents_dir = File.join(agent_config[:dir], "agents")
|
|
1359
|
+
installed = Dir.glob(File.join(agents_dir, "plastic-*.md")).sort
|
|
1360
|
+
|
|
1361
|
+
if installed.empty?
|
|
1362
|
+
return [check(
|
|
1363
|
+
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1364
|
+
message: "No installed plastic-* agent files to check for model drift"
|
|
1365
|
+
)]
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
|
|
1369
|
+
overrides = AgentModels.override_map(project_config: {}, global_config: global_config)
|
|
1370
|
+
|
|
1371
|
+
drifted = []
|
|
1372
|
+
sanctioned = []
|
|
1373
|
+
|
|
1374
|
+
installed.each do |path|
|
|
1375
|
+
basename = File.basename(path, ".md")
|
|
1376
|
+
installed_model = (parse_frontmatter(path) || {})["model"]
|
|
1377
|
+
resolved_default = AgentModels::TIER_DEFAULTS[basename]
|
|
1378
|
+
override = overrides[basename]
|
|
1379
|
+
|
|
1380
|
+
if override
|
|
1381
|
+
sanctioned << "#{basename}: frontmatter=#{installed_model.inspect}, sanctioned override=#{override.inspect}"
|
|
1382
|
+
elsif installed_model != resolved_default
|
|
1383
|
+
drifted << "#{basename}: frontmatter=#{installed_model.inspect}, resolved default=#{resolved_default.inspect}"
|
|
1384
|
+
end
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1387
|
+
if drifted.empty?
|
|
1388
|
+
message = if sanctioned.empty?
|
|
1389
|
+
"No agent-model drift (#{installed.size} installed agent(s) match the resolved default)"
|
|
1390
|
+
else
|
|
1391
|
+
"No unsanctioned agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
|
|
1392
|
+
end
|
|
1393
|
+
[check(
|
|
1394
|
+
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1395
|
+
message: message,
|
|
1396
|
+
details: sanctioned
|
|
1397
|
+
)]
|
|
1398
|
+
else
|
|
1399
|
+
[check(
|
|
1400
|
+
category: "core_files", name: "agent_model_drift", status: "warn",
|
|
1401
|
+
message: "#{drifted.size} installed agent(s) have unsanctioned model drift vs the config-resolved default",
|
|
1402
|
+
details: drifted + sanctioned,
|
|
1403
|
+
fixable: false
|
|
1404
|
+
)]
|
|
1405
|
+
end
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1229
1408
|
# --- Check category: manifest sync (binary core integrity) ---
|
|
1230
1409
|
|
|
1231
1410
|
# Verify, for BOTH the global manifest and the agent-side manifest, that every
|
|
@@ -1405,7 +1584,7 @@ class Doctor
|
|
|
1405
1584
|
checks << check(
|
|
1406
1585
|
category: "project_stores", name: "project_yml_exists", status: "warn",
|
|
1407
1586
|
message: "project.yml missing for project '#{slug}'",
|
|
1408
|
-
fixable: true, fix_hint: "Create project.yml from template — see plastic-creating
|
|
1587
|
+
fixable: true, fix_hint: "Create project.yml from template — see plastic-project-creating"
|
|
1409
1588
|
)
|
|
1410
1589
|
end
|
|
1411
1590
|
|
|
@@ -1753,4 +1932,4 @@ class Doctor
|
|
|
1753
1932
|
|
|
1754
1933
|
end
|
|
1755
1934
|
|
|
1756
|
-
Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__
|
|
1935
|
+
Doctor.new(plastic_home: ENV.fetch("PLASTIC_HOME") { Doctor::DEFAULT_PLASTIC_HOME }).cli(ARGV) if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# end-intent (intent 161) - the mechanical core of the single owned Done
|
|
6
|
+
# procedure (D2 steps 1-4). Pure with respect to worktrees, locks, and QMD:
|
|
7
|
+
# those stay in the plastic-intent-ending skill body (steps 0 and 5-7), never
|
|
8
|
+
# duplicated here. Pairs new-intent's shape: one callable script instead of
|
|
9
|
+
# raw one-liners repeated in skill prose (the failure mode this intent fixes).
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# end-intent --store <store_path> --id <intent_id> --disposition delivered|abandoned \
|
|
13
|
+
# [--index <path>] [--outcome-summary <text>] [--index-note <text>] \
|
|
14
|
+
# [--no-commit] [--dry-run]
|
|
15
|
+
#
|
|
16
|
+
# Steps performed (in order), all idempotent:
|
|
17
|
+
# 1. Guard outcome.md (exit 2, authors nothing, on any failure), then stamp
|
|
18
|
+
# the intent file's `## Outcome` section with --outcome-summary if given.
|
|
19
|
+
# 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
|
|
20
|
+
# (delivered) or `## Abandoned` (abandoned), dated today, appending
|
|
21
|
+
# --index-note (if given) after the date so the terminal entry stays
|
|
22
|
+
# the store's existing rich Completed/Abandoned shape rather than
|
|
23
|
+
# thinning to bare id+date.
|
|
24
|
+
# 3. Append the savepoint `Done` bookend (Bridge.append_terminal_savepoint).
|
|
25
|
+
# 4. Commit the store repo, unless --no-commit.
|
|
26
|
+
#
|
|
27
|
+
# Exit codes: 0 (ok), 1 (usage/resolution failure), 2 (outcome.md guard refused).
|
|
28
|
+
|
|
29
|
+
require "fileutils"
|
|
30
|
+
require "date"
|
|
31
|
+
require "open3"
|
|
32
|
+
require_relative "lib/bridge"
|
|
33
|
+
require_relative "lib/intent_validator"
|
|
34
|
+
|
|
35
|
+
DISPOSITIONS = %w[delivered abandoned].freeze
|
|
36
|
+
|
|
37
|
+
# The store-line separator this script must reproduce is a real em dash
|
|
38
|
+
# (U+2014), the existing INDEX.md convention (store files are exempt from the
|
|
39
|
+
# no-em-dash shipped-file rule). Built from the codepoint, not a literal byte
|
|
40
|
+
# in this source file, so the script's own source stays em-dash free.
|
|
41
|
+
EM_DASH = "\u2014"
|
|
42
|
+
|
|
43
|
+
# --- Explicit flag parsing (no eval, no global injection) -------------------
|
|
44
|
+
|
|
45
|
+
def parse_args(argv)
|
|
46
|
+
opts = {
|
|
47
|
+
store: nil, id: nil, disposition: nil, index: nil,
|
|
48
|
+
outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
|
|
49
|
+
}
|
|
50
|
+
i = 0
|
|
51
|
+
while i < argv.length
|
|
52
|
+
arg = argv[i]
|
|
53
|
+
case arg
|
|
54
|
+
when "--store" then opts[:store] = argv[i += 1]
|
|
55
|
+
when "--id" then opts[:id] = argv[i += 1]
|
|
56
|
+
when "--disposition" then opts[:disposition] = argv[i += 1]
|
|
57
|
+
when "--index" then opts[:index] = argv[i += 1]
|
|
58
|
+
when "--outcome-summary" then opts[:outcome_summary] = argv[i += 1]
|
|
59
|
+
when "--index-note" then opts[:index_note] = argv[i += 1]
|
|
60
|
+
when "--no-commit" then opts[:no_commit] = true
|
|
61
|
+
when "--dry-run" then opts[:dry_run] = true
|
|
62
|
+
else
|
|
63
|
+
usage_abort("unknown argument #{arg.inspect}")
|
|
64
|
+
end
|
|
65
|
+
i += 1
|
|
66
|
+
end
|
|
67
|
+
opts
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def usage
|
|
71
|
+
"usage: end-intent --store <store_path> --id <intent_id> " \
|
|
72
|
+
"--disposition delivered|abandoned [--index <path>] " \
|
|
73
|
+
"[--outcome-summary <text>] [--index-note <text>] [--no-commit] [--dry-run]"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def usage_abort(message)
|
|
77
|
+
warn "end-intent: #{message}"
|
|
78
|
+
warn usage
|
|
79
|
+
exit 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def expand(path)
|
|
83
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Resolve the single "<store>/<id>--*" directory, or abort (usage error: exit 1).
|
|
87
|
+
def resolve_intent_dir(store, id)
|
|
88
|
+
matches = Dir.glob(File.join(store, "#{id}--*")).select { |d| File.directory?(d) }
|
|
89
|
+
usage_abort("no intent directory matches #{id}--* under #{store}") if matches.empty?
|
|
90
|
+
if matches.length > 1
|
|
91
|
+
usage_abort("ambiguous id #{id.inspect}: #{matches.length} matching directories under #{store}")
|
|
92
|
+
end
|
|
93
|
+
matches.first
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- outcome.md guard (D2 step 1a) ------------------------------------------
|
|
97
|
+
#
|
|
98
|
+
# Returns nil when the guard PASSES, or a reason String to refuse with exit 2.
|
|
99
|
+
# Read-only: authors nothing regardless of outcome. Refuses a missing file, a
|
|
100
|
+
# still-placeholder file (first line is the sentinel), or a disposition that
|
|
101
|
+
# does not exactly equal the requested one (this is what naturally refuses a
|
|
102
|
+
# freshly scaffolded outcome.md, whose disposition is the literal
|
|
103
|
+
# "delivered|abandoned" string, not one of the two real values).
|
|
104
|
+
def outcome_guard_reason(intent_dir, disposition)
|
|
105
|
+
path = File.join(intent_dir, "outcome.md")
|
|
106
|
+
return "outcome.md is missing at #{path}" unless File.exist?(path)
|
|
107
|
+
|
|
108
|
+
content = File.read(path)
|
|
109
|
+
first_line = content.each_line.first.to_s.chomp
|
|
110
|
+
if first_line == Bridge::PLACEHOLDER_SENTINEL
|
|
111
|
+
return "outcome.md is still the scaffold placeholder (first line is the sentinel)"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
fm = IntentValidator.parse_frontmatter_text(content)
|
|
115
|
+
actual = fm.is_a?(Hash) ? fm["disposition"] : nil
|
|
116
|
+
unless DISPOSITIONS.include?(actual) && actual == disposition
|
|
117
|
+
return "outcome.md frontmatter disposition is #{actual.inspect}, expected #{disposition.inspect}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- intent-file `## Outcome` summary stamp (D2 step 1b) --------------------
|
|
124
|
+
#
|
|
125
|
+
# Replace the body of the intent file's `## Outcome` section with `summary`,
|
|
126
|
+
# leaving every other section and the frontmatter untouched. No-op (returns
|
|
127
|
+
# content unchanged) when summary is blank or the section is not found.
|
|
128
|
+
def stamp_outcome_summary(content, summary)
|
|
129
|
+
return content if summary.nil? || summary.to_s.strip.empty?
|
|
130
|
+
|
|
131
|
+
lines = content.lines
|
|
132
|
+
start = lines.index { |l| l.rstrip == "## Outcome" }
|
|
133
|
+
return content if start.nil?
|
|
134
|
+
|
|
135
|
+
stop = start + 1
|
|
136
|
+
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
137
|
+
|
|
138
|
+
body = "#{summary.to_s.strip}\n"
|
|
139
|
+
replacement = (stop < lines.length) ? [body, "\n"] : [body]
|
|
140
|
+
(lines[0..start] + replacement + lines[stop..]).join
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- INDEX.md terminal move (D2 step 2) -------------------------------------
|
|
144
|
+
|
|
145
|
+
ACTIVE_LINE_RE = /\A- \[(\S+)\s+#{Regexp.escape(EM_DASH)}\s+(.*?)\]\(([^)]+)\)/.freeze
|
|
146
|
+
|
|
147
|
+
def index_target_heading(disposition)
|
|
148
|
+
disposition == "delivered" ? "## Completed" : "## Abandoned"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Index of the first `## ` heading at or after heading_idx + 1, or lines.length
|
|
152
|
+
# at EOF (the section's exclusive end bound).
|
|
153
|
+
def section_stop(lines, heading_idx)
|
|
154
|
+
stop = heading_idx + 1
|
|
155
|
+
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
156
|
+
stop
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def active_line_id(line)
|
|
160
|
+
m = line.match(ACTIVE_LINE_RE)
|
|
161
|
+
m && m[1]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def section_contains_id?(lines, heading_idx, id)
|
|
165
|
+
stop = section_stop(lines, heading_idx)
|
|
166
|
+
(heading_idx + 1...stop).any? { |i| active_line_id(lines[i]) == id }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Remove the entry at entry_idx from the `## Active` section. When no real
|
|
170
|
+
# entries remain, install the canonical "_(none)_" placeholder (the same
|
|
171
|
+
# empty-state convention INDEX.md already uses).
|
|
172
|
+
def remove_active_entry(lines, active_start, entry_idx)
|
|
173
|
+
new_lines = lines.dup
|
|
174
|
+
new_lines.delete_at(entry_idx)
|
|
175
|
+
stop = section_stop(new_lines, active_start)
|
|
176
|
+
remaining = (active_start + 1...stop).reject { |i| new_lines[i].strip.empty? }
|
|
177
|
+
if remaining.empty?
|
|
178
|
+
# Keep the blank-line separator before the next heading (if any); only
|
|
179
|
+
# collapse the section body to the placeholder, never the spacing.
|
|
180
|
+
placeholder = (stop < new_lines.length) ? ["_(none)_\n", "\n"] : ["_(none)_\n"]
|
|
181
|
+
new_lines[(active_start + 1)...stop] = placeholder
|
|
182
|
+
end
|
|
183
|
+
new_lines
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Insert new_entry into the target terminal section: replace the "_(none)_"
|
|
187
|
+
# placeholder if that is the ONLY non-blank content the section holds (a
|
|
188
|
+
# trailing blank line before the next heading is common and must not
|
|
189
|
+
# defeat this check), otherwise prepend (newest-first, matching the
|
|
190
|
+
# existing Completed/Abandoned convention).
|
|
191
|
+
def insert_terminal_entry(lines, target_start, new_entry)
|
|
192
|
+
new_lines = lines.dup
|
|
193
|
+
stop = section_stop(new_lines, target_start)
|
|
194
|
+
body_range = (target_start + 1...stop)
|
|
195
|
+
placeholder_idx = body_range.find { |i| new_lines[i].strip == "_(none)_" }
|
|
196
|
+
non_blank = body_range.reject { |i| new_lines[i].strip.empty? }
|
|
197
|
+
if placeholder_idx && non_blank == [placeholder_idx]
|
|
198
|
+
new_lines[placeholder_idx] = new_entry
|
|
199
|
+
else
|
|
200
|
+
new_lines.insert(target_start + 1, new_entry)
|
|
201
|
+
end
|
|
202
|
+
new_lines
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Move the intent's `## Active` line into the terminal section. Returns the
|
|
206
|
+
# new INDEX.md content, or the original content unchanged when there is
|
|
207
|
+
# nothing to do (idempotent: a second run finds no Active entry and the
|
|
208
|
+
# terminal section already carries it, so it is a clean no-op). `index_note`
|
|
209
|
+
# (D2 "rich entry", review F3), when given, is appended verbatim after the
|
|
210
|
+
# date with a single leading space: content authoring (mode/tier, what
|
|
211
|
+
# shipped, suite result, and so on) stays with the caller, this only places
|
|
212
|
+
# the text where the store's existing Completed/Abandoned lines carry it.
|
|
213
|
+
def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
|
|
214
|
+
target_heading = index_target_heading(disposition)
|
|
215
|
+
lines = content.lines
|
|
216
|
+
|
|
217
|
+
active_start = lines.index { |l| l.rstrip == "## Active" }
|
|
218
|
+
target_start = lines.index { |l| l.rstrip == target_heading }
|
|
219
|
+
return content if active_start.nil? || target_start.nil?
|
|
220
|
+
|
|
221
|
+
already_in_target = section_contains_id?(lines, target_start, id)
|
|
222
|
+
active_stop = section_stop(lines, active_start)
|
|
223
|
+
entry_idx = (active_start + 1...active_stop).find { |i| active_line_id(lines[i]) == id }
|
|
224
|
+
|
|
225
|
+
return content if entry_idx.nil? # already moved (or never present): no-op either way
|
|
226
|
+
|
|
227
|
+
m = lines[entry_idx].match(ACTIVE_LINE_RE)
|
|
228
|
+
title, link = m[2], m[3]
|
|
229
|
+
note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
|
|
230
|
+
new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
|
|
231
|
+
|
|
232
|
+
lines = remove_active_entry(lines, active_start, entry_idx)
|
|
233
|
+
target_start = lines.index { |l| l.rstrip == target_heading }
|
|
234
|
+
lines = insert_terminal_entry(lines, target_start, new_entry) unless already_in_target
|
|
235
|
+
|
|
236
|
+
lines.join
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# --- store auto-commit (D2 step 4) ------------------------------------------
|
|
240
|
+
|
|
241
|
+
def git_toplevel(dir)
|
|
242
|
+
out, _err, status = Open3.capture3("git", "-C", dir, "rev-parse", "--show-toplevel")
|
|
243
|
+
return nil unless status.success?
|
|
244
|
+
top = out.strip
|
|
245
|
+
top.empty? ? nil : top
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Best-effort store commit: never raises, never blocks the mechanical close.
|
|
249
|
+
# Returns true when a commit was created, false otherwise (not a git repo,
|
|
250
|
+
# nothing to commit, or the commit failed). Pins a local committer identity so
|
|
251
|
+
# the commit succeeds even with no ambient git config (hermetic).
|
|
252
|
+
def store_commit(store, id, disposition)
|
|
253
|
+
root = git_toplevel(store)
|
|
254
|
+
return false if root.nil?
|
|
255
|
+
|
|
256
|
+
Open3.capture3("git", "-C", root, "add", "-A")
|
|
257
|
+
_out, _err, status = Open3.capture3(
|
|
258
|
+
"git", "-C", root,
|
|
259
|
+
"-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
|
|
260
|
+
"commit", "--quiet", "-m", "chore: complete intent #{id} (#{disposition})"
|
|
261
|
+
)
|
|
262
|
+
status.success?
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# --- main --------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def main(argv)
|
|
268
|
+
opts = parse_args(argv)
|
|
269
|
+
usage_abort("--store is required") if opts[:store].nil? || opts[:store].empty?
|
|
270
|
+
usage_abort("--id is required") if opts[:id].nil? || opts[:id].empty?
|
|
271
|
+
unless DISPOSITIONS.include?(opts[:disposition])
|
|
272
|
+
usage_abort("--disposition must be one of #{DISPOSITIONS.join('|')}")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
store = expand(opts[:store])
|
|
276
|
+
usage_abort("store dir does not exist: #{store}") unless Dir.exist?(store)
|
|
277
|
+
|
|
278
|
+
id = opts[:id]
|
|
279
|
+
disposition = opts[:disposition]
|
|
280
|
+
intent_dir = resolve_intent_dir(store, id)
|
|
281
|
+
index_path = opts[:index] ? expand(opts[:index]) : File.join(File.dirname(store), "INDEX.md")
|
|
282
|
+
today = Date.today.iso8601
|
|
283
|
+
|
|
284
|
+
reason = outcome_guard_reason(intent_dir, disposition)
|
|
285
|
+
if reason
|
|
286
|
+
warn "end-intent: #{reason}"
|
|
287
|
+
exit 2
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
if opts[:dry_run]
|
|
291
|
+
puts "end-intent: dry run for #{File.basename(intent_dir)} (#{disposition})"
|
|
292
|
+
puts " would stamp ## Outcome: #{opts[:outcome_summary].inspect}" if opts[:outcome_summary]
|
|
293
|
+
puts " would move INDEX.md entry from ## Active to #{index_target_heading(disposition)} (#{index_path})"
|
|
294
|
+
puts " would append index note: #{opts[:index_note].inspect}" if opts[:index_note]
|
|
295
|
+
puts " would append savepoint bookend: Done #{disposition}"
|
|
296
|
+
puts(opts[:no_commit] ? " would skip store commit (--no-commit)" : " would commit the store repo")
|
|
297
|
+
exit 0
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
|
|
301
|
+
if opts[:outcome_summary]
|
|
302
|
+
intent_file = Bridge.intent_file(intent_dir)
|
|
303
|
+
if File.exist?(intent_file)
|
|
304
|
+
original = File.read(intent_file)
|
|
305
|
+
updated = stamp_outcome_summary(original, opts[:outcome_summary])
|
|
306
|
+
File.write(intent_file, updated) if updated != original
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# 2. INDEX.md terminal move.
|
|
311
|
+
if File.exist?(index_path)
|
|
312
|
+
original_index = File.read(index_path)
|
|
313
|
+
updated_index = move_index_to_terminal(original_index, id, disposition, today: today,
|
|
314
|
+
index_note: opts[:index_note])
|
|
315
|
+
File.write(index_path, updated_index) if updated_index != original_index
|
|
316
|
+
else
|
|
317
|
+
warn "end-intent: INDEX.md not found at #{index_path}, skipping the terminal move"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# 3. Savepoint Done bookend (idempotent).
|
|
321
|
+
Bridge.append_terminal_savepoint(intent_dir, disposition)
|
|
322
|
+
|
|
323
|
+
# 4. Store auto-commit, unless --no-commit.
|
|
324
|
+
store_commit(store, id, disposition) unless opts[:no_commit]
|
|
325
|
+
|
|
326
|
+
puts intent_dir
|
|
327
|
+
exit 0
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
main(ARGV) if $PROGRAM_NAME == __FILE__
|
package/scripts/hook-code-gate
CHANGED
|
@@ -39,7 +39,7 @@ if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
42
|
-
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path)
|
|
42
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
|
|
43
43
|
exit 0 unless bridge_data
|
|
44
44
|
|
|
45
45
|
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
package/scripts/hook-continue
CHANGED
|
@@ -20,8 +20,8 @@ exit 0 unless status.success? && !cockpit.strip.empty?
|
|
|
20
20
|
|
|
21
21
|
context = cockpit.rstrip +
|
|
22
22
|
"\n\nFollow the plastic-continuing skill workflow to resume: " \
|
|
23
|
-
"if active intents exist, read their state and resume; otherwise
|
|
24
|
-
"
|
|
23
|
+
"if active intents exist, read their state and resume; otherwise surface the " \
|
|
24
|
+
"most-valuable next work and any stale or triage intents from the board above."
|
|
25
25
|
|
|
26
26
|
payload = {
|
|
27
27
|
"hookSpecificOutput" => {
|
package/scripts/hook-create-gate
CHANGED
|
@@ -67,7 +67,7 @@ result =
|
|
|
67
67
|
# the validator.
|
|
68
68
|
unless File.exist?(abs)
|
|
69
69
|
block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
70
|
-
"create intents via new-intent / plastic-creating
|
|
70
|
+
"create intents via new-intent / plastic-intent-creating.")
|
|
71
71
|
end
|
|
72
72
|
current = File.read(abs)
|
|
73
73
|
unless current.include?(old_string)
|
|
@@ -91,5 +91,5 @@ exit 0 if result[:ok]
|
|
|
91
91
|
|
|
92
92
|
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
|
93
93
|
result[:errors].each { |e| $stderr.puts " #{e}" }
|
|
94
|
-
$stderr.puts "Create intents via new-intent / plastic-creating
|
|
94
|
+
$stderr.puts "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
|
|
95
95
|
exit 2
|