@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.10

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 (115) hide show
  1. package/PLASTIC.md +170 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +45 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +47 -0
  6. package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
  7. package/agents/{intent-curator.md → plastic-intent-curator.md} +8 -6
  8. package/agents/plastic-planner.md +47 -0
  9. package/agents/plastic-spec-specialist.md +45 -0
  10. package/bin/plastic.js +57 -0
  11. package/bin/test +28 -0
  12. package/deprecations.yml +1 -10
  13. package/hooks/auto-arm +5 -0
  14. package/hooks/bash-gate +3 -0
  15. package/hooks/check-update +12 -8
  16. package/hooks/code-gate +12 -0
  17. package/hooks/create-gate +3 -0
  18. package/hooks/gate-check +3 -1
  19. package/hooks/hooks.json +52 -0
  20. package/hooks/qmd-search +8 -0
  21. package/hooks/statusline +150 -41
  22. package/package.json +2 -2
  23. package/scripts/agent-report +142 -0
  24. package/scripts/dashboard.rb +687 -0
  25. package/scripts/doctor.rb +1219 -621
  26. package/scripts/hook-auto-arm +51 -0
  27. package/scripts/hook-bash-gate +41 -0
  28. package/scripts/hook-code-gate +27 -0
  29. package/scripts/hook-continue +15 -114
  30. package/scripts/hook-create-gate +59 -0
  31. package/scripts/hook-gate-check +47 -32
  32. package/scripts/hook-qmd-search +44 -0
  33. package/scripts/hook-session-start +106 -38
  34. package/scripts/install.rb +91 -529
  35. package/scripts/lib/boot_banner.rb +28 -0
  36. package/scripts/lib/bridge.rb +452 -19
  37. package/scripts/lib/frontmatter_writer.rb +130 -0
  38. package/scripts/lib/graph_rebuild.rb +328 -0
  39. package/scripts/lib/installer_core.rb +812 -0
  40. package/scripts/lib/intent_validator.rb +235 -0
  41. package/scripts/lib/links_projection.rb +160 -0
  42. package/scripts/lib/links_section.rb +207 -0
  43. package/scripts/lib/power_tools.rb +76 -0
  44. package/scripts/lib/qmd_hook.rb +57 -0
  45. package/scripts/lib/qmd_sync.rb +230 -0
  46. package/scripts/lib/store_provisioning.rb +100 -0
  47. package/scripts/migrate-to-global +1 -1
  48. package/scripts/new-intent +327 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/provision-project-store +53 -0
  51. package/scripts/qmd-sync +139 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/select-update-target +93 -0
  54. package/scripts/spawn-preamble +138 -0
  55. package/scripts/uninstall.rb +53 -0
  56. package/scripts/update.rb +164 -0
  57. package/scripts/validate-intent +54 -0
  58. package/scripts/versions.rb +141 -0
  59. package/skills/_active-intent-gate.md +1 -1
  60. package/skills/add-project-store/SKILL.md +54 -0
  61. package/skills/auto/SKILL.md +87 -7
  62. package/skills/auto/evals/evals.json +255 -0
  63. package/skills/auto/references/agent-architecture.md +155 -0
  64. package/skills/auto/references/agent-report-contract.md +86 -0
  65. package/skills/brainstorming/SKILL.md +10 -9
  66. package/skills/brainstorming/evals/evals.json +22 -0
  67. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  68. package/skills/continuing/SKILL.md +99 -82
  69. package/skills/continuing/evals/evals.json +145 -0
  70. package/skills/continuing/references/context-management.md +32 -0
  71. package/skills/creating-intent/SKILL.md +82 -36
  72. package/skills/creating-intent/evals/evals.json +72 -0
  73. package/skills/creating-intent/references/lifecycle.md +81 -0
  74. package/skills/creating-intent/references/wikilinks.md +8 -0
  75. package/skills/creating-project/SKILL.md +40 -8
  76. package/skills/creating-project/references/hubs-projects.md +55 -0
  77. package/skills/dashboard/SKILL.md +126 -0
  78. package/skills/dashboard/evals/evals.json +22 -0
  79. package/skills/dashboard/templates/dashboard-global.md +31 -0
  80. package/skills/dashboard/templates/dashboard-project.md +40 -0
  81. package/skills/doctor/SKILL.md +51 -4
  82. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  83. package/skills/doctor/report.md +4 -0
  84. package/skills/evaluating-skills/SKILL.md +140 -0
  85. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  86. package/skills/evaluating-skills/evals/evals.json +75 -0
  87. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  88. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  89. package/skills/executing-plan/SKILL.md +5 -3
  90. package/skills/install/SKILL.md +69 -8
  91. package/skills/intent-curator/SKILL.md +6 -4
  92. package/skills/intent-curator/evals/evals.json +22 -0
  93. package/skills/linking-intents/SKILL.md +22 -7
  94. package/skills/linking-intents/evals/evals.json +22 -0
  95. package/skills/linking-intents/references/zettelkasten.md +45 -0
  96. package/skills/managing-index/SKILL.md +11 -1
  97. package/skills/managing-index/evals/evals.json +22 -0
  98. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  99. package/skills/releasing/SKILL.md +80 -23
  100. package/skills/releasing/references/deprecations.md +60 -0
  101. package/skills/research/SKILL.md +10 -2
  102. package/skills/research/evals/evals.json +22 -0
  103. package/skills/savepoint/SKILL.md +46 -37
  104. package/skills/savepoint/references/context-management.md +32 -0
  105. package/skills/uninstall/SKILL.md +39 -28
  106. package/skills/update/SKILL.md +41 -44
  107. package/skills/versions/SKILL.md +65 -0
  108. package/skills/writing-instructions/SKILL.md +159 -0
  109. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  110. package/skills/writing-plans/SKILL.md +5 -5
  111. package/templates/agents.md +7 -7
  112. package/templates/outcome.md +13 -0
  113. package/templates/savepoint.md +14 -13
  114. package/templates/spec.md +25 -0
  115. package/bin/install.js +0 -29
package/hooks/gate-check CHANGED
@@ -6,5 +6,7 @@ if [ -z "$FILE_PATH" ]; then
6
6
  exit 0
7
7
  fi
8
8
 
9
+ SESSION_ID=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
10
+
9
11
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
- ruby "$SCRIPT_DIR/../scripts/hook-gate-check" "$FILE_PATH"
12
+ ruby "$SCRIPT_DIR/../scripts/hook-gate-check" "$FILE_PATH" "$SESSION_ID"
package/hooks/hooks.json CHANGED
@@ -29,6 +29,38 @@
29
29
  ]
30
30
  }
31
31
  ],
32
+ "PreToolUse": [
33
+ {
34
+ "matcher": "Write|Edit|NotebookEdit",
35
+ "hooks": [
36
+ {
37
+ "type": "command",
38
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" code-gate",
39
+ "statusMessage": "Checking lifecycle gate..."
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "matcher": "Write",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" create-gate",
49
+ "statusMessage": "Checking create gate..."
50
+ }
51
+ ]
52
+ },
53
+ {
54
+ "matcher": "Bash",
55
+ "hooks": [
56
+ {
57
+ "type": "command",
58
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" bash-gate",
59
+ "statusMessage": "Checking lifecycle gate..."
60
+ }
61
+ ]
62
+ }
63
+ ],
32
64
  "PostToolUse": [
33
65
  {
34
66
  "matcher": "Write|Edit",
@@ -61,6 +93,26 @@
61
93
  "statusMessage": "Checking future intents..."
62
94
  }
63
95
  ]
96
+ },
97
+ {
98
+ "matcher": "",
99
+ "hooks": [
100
+ {
101
+ "type": "command",
102
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/auto-arm\"",
103
+ "statusMessage": "Checking auto mode..."
104
+ }
105
+ ]
106
+ },
107
+ {
108
+ "matcher": "",
109
+ "hooks": [
110
+ {
111
+ "type": "command",
112
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" qmd-search",
113
+ "statusMessage": "Searching QMD..."
114
+ }
115
+ ]
64
116
  }
65
117
  ]
66
118
  }
@@ -0,0 +1,8 @@
1
+ #!/bin/bash
2
+ # qmd-first search hook launcher (intent 66). No-op when there is no global store.
3
+ GLOBAL_INDEX="$HOME/.plastic/INDEX.md"
4
+ if [ ! -f "$GLOBAL_INDEX" ]; then
5
+ exit 0
6
+ fi
7
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
+ exec ruby "$SCRIPT_DIR/../scripts/hook-qmd-search" "$HOME/.plastic"
package/hooks/statusline CHANGED
@@ -1,56 +1,165 @@
1
1
  #!/bin/bash
2
- # plastic-hook-version: 2.0.0
3
- # StatusLine hook — chains user's original statusline, adds Plastic info
2
+ # plastic-hook-version: 3.0.0
3
+ # StatusLine hook - Plastic owns the full statusline (no chaining).
4
+ # Renders: {model} - Plastic {version}[ -> {next}] - {path} - [{project}: {id} {title}]
5
+ # Pure bash (macOS 3.2 compatible). No ruby, no jq. Reads stdin once; small file reads only.
4
6
 
5
7
  INPUT=$(cat)
6
8
 
7
- # --- Chain original statusline ---
8
- ORIGINAL_CONFIG="$HOME/.plastic/.cache/original-statusline.json"
9
- ORIGINAL_OUTPUT=""
10
- if [ -f "$ORIGINAL_CONFIG" ]; then
11
- ORIGINAL_CMD=$(grep -o '"command":"[^"]*"' "$ORIGINAL_CONFIG" 2>/dev/null | head -1 | cut -d'"' -f4)
12
- if [ -z "$ORIGINAL_CMD" ]; then
13
- ORIGINAL_CMD=$(grep -o '"command": "[^"]*"' "$ORIGINAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*"command": "//;s/".*//')
14
- fi
15
- if [ -n "$ORIGINAL_CMD" ] && [ -x "$ORIGINAL_CMD" ]; then
16
- ORIGINAL_OUTPUT=$(echo "$INPUT" | "$ORIGINAL_CMD" 2>/dev/null)
9
+ # --- Colors (ANSI-C quoting; bash 3.2 supports $'\033') ---
10
+ C_MODEL=$'\033[38;2;90;140;220m' # navy blue - model (lightened for legibility)
11
+ C_VER=$'\033[37m' # white - "Plastic {version}" and update arrow
12
+ C_NEXT=$'\033[33m' # yellow - next version
13
+ C_PATH=$'\033[38;2;128;134;144m' # gray - path (matches dim UI text)
14
+ C_BRACK=$'\033[33m' # yellow - work-unit brackets
15
+ C_PROJ=$'\033[38;2;249;140;30m' # orange - project name
16
+ C_ID=$'\033[38;2;45;212;191m' # teal - intent id (accent between orange + yellow)
17
+ C_INTENT=$'\033[33m' # yellow - intent title
18
+ C_SEP=$'\033[38;2;128;134;144m' # gray - separators
19
+ RST=$'\033[0m'
20
+ SEP="${C_SEP} "$'\302\267'" ${RST}" # " . " middot, dim
21
+
22
+ # --- Extract a flat JSON string field from stdin: json_str <key> ---
23
+ json_str() {
24
+ printf '%s' "$INPUT" \
25
+ | grep -o "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" \
26
+ | head -1 \
27
+ | sed "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"//;s/\"$//"
28
+ }
29
+
30
+ # --- Model (model.display_name) ---
31
+ MODEL=$(json_str "display_name")
32
+
33
+ # --- Path (workspace.current_dir, fallback top-level cwd) ---
34
+ REAL_CWD=$(json_str "current_dir")
35
+ [ -z "$REAL_CWD" ] && REAL_CWD=$(json_str "cwd")
36
+ # Abbreviate $HOME -> ~
37
+ CWD="$REAL_CWD"
38
+ case "$CWD" in
39
+ "$HOME") CWD="~" ;;
40
+ "$HOME"/*) CWD="~${CWD#"$HOME"}" ;;
41
+ esac
42
+
43
+ # --- Plastic version ---
44
+ VERSION=""
45
+ [ -f "$HOME/.plastic/VERSION" ] && VERSION=$(tr -d ' \t\n' < "$HOME/.plastic/VERSION")
46
+
47
+ # --- Update (next version when one is available) ---
48
+ NEXT=""
49
+ CACHE_FILE="$HOME/.plastic/.cache/update-check.json"
50
+ if [ -f "$CACHE_FILE" ]; then
51
+ if grep -q '"updateAvailable"[[:space:]]*:[[:space:]]*true' "$CACHE_FILE" 2>/dev/null; then
52
+ LATEST=$(grep -o '"latest"[[:space:]]*:[[:space:]]*"[^"]*"' "$CACHE_FILE" 2>/dev/null \
53
+ | head -1 | sed 's/.*"latest"[[:space:]]*:[[:space:]]*"//;s/"$//')
54
+ if [ -n "$LATEST" ]; then
55
+ case "$LATEST" in
56
+ *-*) NEXT="${LATEST##*-}" ;; # 1.0.0-beta.3 -> beta.3
57
+ *) NEXT="$LATEST" ;;
58
+ esac
59
+ fi
17
60
  fi
18
61
  fi
19
62
 
20
- # --- Plastic status ---
21
- PLASTIC_PARTS=""
63
+ # --- Scope resolution: cwd -> registered project store, else global ---
64
+ # Match the longest registered project path that prefixes the real cwd.
65
+ PROJECTS_YML="$HOME/.plastic/projects.yml"
66
+ SLUG=""
67
+ if [ -n "$REAL_CWD" ] && [ -f "$PROJECTS_YML" ]; then
68
+ SLUG=$(awk -v cwd="$REAL_CWD" '
69
+ /^ [A-Za-z0-9_.-]+:[[:space:]]*$/ { s=$1; sub(/:$/,"",s); next }
70
+ /^ path:/ {
71
+ p=$0; sub(/^[[:space:]]*path:[[:space:]]*/,"",p); gsub(/"/,"",p);
72
+ if (length(p)>0 && (cwd==p || index(cwd, p"/")==1) && length(p)>best) { best=length(p); win=s }
73
+ }
74
+ END { if (win!="") print win }
75
+ ' "$PROJECTS_YML")
76
+ fi
22
77
 
23
- # Active intent from INDEX.md
24
- INDEX="$HOME/.plastic/INDEX.md"
25
- if [ -f "$INDEX" ]; then
26
- ACTIVE=$(sed -n '/^## Active$/,/^## /{/^- \[/p;}' "$INDEX" | head -1)
27
- if [ -n "$ACTIVE" ]; then
28
- INTENT=$(echo "$ACTIVE" | sed 's/^- \[\([^]]*\)\].*/\1/')
29
- PLASTIC_PARTS="plastic: ${INTENT}"
30
- fi
78
+ if [ -n "$SLUG" ]; then
79
+ INDEX="$HOME/.plastic/projects/$SLUG/INDEX.md"
80
+ PROJ_LABEL="$SLUG"
81
+ else
82
+ INDEX="$HOME/.plastic/INDEX.md"
83
+ PROJ_LABEL="global"
31
84
  fi
85
+ INDEX_DIR=$(dirname "$INDEX")
32
86
 
33
- # Update notification
34
- CACHE_FILE="$HOME/.plastic/.cache/update-check.json"
35
- if [ -f "$CACHE_FILE" ]; then
36
- UPDATE_AVAILABLE=$(grep -o '"updateAvailable":true' "$CACHE_FILE" 2>/dev/null)
37
- if [ -n "$UPDATE_AVAILABLE" ]; then
38
- LATEST=$(grep -o '"latest":"[^"]*"' "$CACHE_FILE" 2>/dev/null | head -1 | cut -d'"' -f4)
39
- CURRENT=$(grep -o '"current":"[^"]*"' "$CACHE_FILE" 2>/dev/null | head -1 | cut -d'"' -f4)
40
- UPDATE_MSG="plastic update: $CURRENT -> $LATEST"
41
- if [ -n "$PLASTIC_PARTS" ]; then
42
- PLASTIC_PARTS="$PLASTIC_PARTS | $UPDATE_MSG"
43
- else
44
- PLASTIC_PARTS="$UPDATE_MSG"
87
+ # --- Current-session work-unit: the live bridge wins over savepoint recency ---
88
+ # The statusline receives the real session_id on stdin; the session's bridge
89
+ # (plastic-{session_id}.json) is the authoritative "what THIS session is driving".
90
+ # This beats the shared, savepoint-recency heuristic so parallel sessions on one
91
+ # store no longer overwrite each other's line. grep/sed only - no jq, no ruby.
92
+ WORK=""
93
+ SID=$(json_str "session_id")
94
+ if [ -n "$SID" ]; then
95
+ BRIDGE="${PLASTIC_TMP:-/tmp}/plastic-${SID}.json"
96
+ if [ -f "$BRIDGE" ]; then
97
+ # Bridge JSON is pretty-printed (one field per line). Scope extraction to the
98
+ # "intent" object so a sibling top-level "id"/"name" can never win (the intent
99
+ # object holds only string fields, so the first "}" closes it). grep/sed only.
100
+ INTENT_BLOCK=$(sed -n '/"intent"[[:space:]]*:[[:space:]]*{/,/}/p' "$BRIDGE" 2>/dev/null)
101
+ B_ID=$(printf '%s\n' "$INTENT_BLOCK" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
102
+ | sed 's/.*"id"[[:space:]]*:[[:space:]]*"//;s/"$//')
103
+ B_NAME=$(printf '%s\n' "$INTENT_BLOCK" | grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
104
+ | sed 's/.*"name"[[:space:]]*:[[:space:]]*"//;s/"$//')
105
+ if [ -n "$B_ID" ] && [ -n "$B_NAME" ]; then
106
+ EMDASH=$'\342\200\224'
107
+ WORK="${B_ID} ${EMDASH} ${B_NAME}"
45
108
  fi
46
109
  fi
47
110
  fi
48
111
 
49
- # --- Combine output ---
50
- if [ -n "$ORIGINAL_OUTPUT" ] && [ -n "$PLASTIC_PARTS" ]; then
51
- echo "$ORIGINAL_OUTPUT | $PLASTIC_PARTS"
52
- elif [ -n "$ORIGINAL_OUTPUT" ]; then
53
- echo "$ORIGINAL_OUTPUT"
54
- elif [ -n "$PLASTIC_PARTS" ]; then
55
- echo "$PLASTIC_PARTS"
112
+ # --- Most-recently-accessed active intent (mirror dashboard: last savepoint ts) ---
113
+ # Fallback only: runs when no session bridge resolved a work-unit above.
114
+ if [ -z "$WORK" ] && [ -f "$INDEX" ]; then
115
+ BEST_TS=""
116
+ while IFS= read -r line; do
117
+ [ -z "$line" ] && continue
118
+ inside=${line#*[}; inside=${inside%%]*} # "{id} - {title}"
119
+ dir=${line#*](store/}; dir=${dir%%/*} # intent directory name
120
+ [ -z "$dir" ] && continue
121
+ sp="$INDEX_DIR/store/$dir/savepoint.md"
122
+ ts=""
123
+ if [ -f "$sp" ]; then
124
+ ts=$(grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' "$sp" 2>/dev/null | tail -1)
125
+ fi
126
+ # ISO8601 sorts lexicographically; keep the newest. Empty ts sorts lowest.
127
+ if [ -z "$BEST_TS" ] || { [ -n "$ts" ] && [ "$ts" \> "$BEST_TS" ]; }; then
128
+ BEST_TS="$ts"
129
+ WORK="$inside"
130
+ fi
131
+ done <<EOF
132
+ $(sed -n '/^## Active$/,/^## /{/^- \[/p;}' "$INDEX")
133
+ EOF
134
+ fi
135
+
136
+ # --- Build the work-unit "[{project}: {id} {title}]" with title truncation ---
137
+ WORK_SEG=""
138
+ if [ -n "$WORK" ]; then
139
+ # Split "{id} - {title}" on the em-dash (U+2014).
140
+ EMDASH=$'\342\200\224'
141
+ id=${WORK%% $EMDASH *}
142
+ title=${WORK#* $EMDASH }
143
+ # Fallback if the em-dash form is absent: split on first space.
144
+ if [ "$id" = "$WORK" ]; then id=${WORK%% *}; title=${WORK#* }; fi
145
+ # Truncate long titles (~40 chars).
146
+ if [ ${#title} -gt 40 ]; then title="$(printf '%.40s' "$title")..."; fi
147
+ WORK_SEG="${C_BRACK}[${RST}${C_PROJ}${PROJ_LABEL}${RST}${C_INTENT}: ${RST}${C_ID}${id}${RST}${C_INTENT} ${title}${RST}${C_BRACK}]${RST}"
56
148
  fi
149
+
150
+ # --- Assemble segments in order, joined by " . " ---
151
+ PARTS=""
152
+ add() { [ -z "$1" ] && return; if [ -z "$PARTS" ]; then PARTS="$1"; else PARTS="$PARTS$SEP$1"; fi; }
153
+
154
+ # Order: stable values first (model, work-unit), then the volatile-length
155
+ # values last (version, path) so the line does not visually jump as they change.
156
+ [ -n "$MODEL" ] && add "${C_MODEL}${MODEL}${RST}"
157
+ [ -n "$WORK_SEG" ] && add "$WORK_SEG"
158
+ if [ -n "$VERSION" ]; then
159
+ vseg="${C_VER}Plastic ${VERSION}${RST}"
160
+ [ -n "$NEXT" ] && vseg="${vseg}${C_VER} "$'\342\206\222'" ${RST}${C_NEXT}${NEXT}${RST}"
161
+ add "$vseg"
162
+ fi
163
+ [ -n "$CWD" ] && add "${C_PATH}${CWD}${RST}"
164
+
165
+ printf '%s\n' "$PARTS"
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.9",
3
+ "version": "1.0.0-beta.10",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
7
- "plastic": "bin/install.js"
7
+ "plastic": "bin/plastic.js"
8
8
  },
9
9
  "engines": {
10
10
  "node": ">=18.0.0"
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # agent-report - deterministic filesystem-derived completion report (intent 74).
6
+ #
7
+ # The auto-mode report contract is decision-shaped (the spawn preamble's
8
+ # REPORT_CONTRACT plus the role prompts ask every dispatched agent to END with a
9
+ # structured completion report). Because child-agent honor is best-effort across
10
+ # harnesses (Tier B/C, docs/reference/harness-adapters.md), the contract is never a
11
+ # hard block. This script is the always-a-report fallback: when a dispatched agent
12
+ # returns no usable report, the enforcer runs it to synthesize one from the intent
13
+ # directory, so the handoff account always exists.
14
+ #
15
+ # Like scripts/spawn-preamble, it is a PURE function of the intent dir: no network,
16
+ # no randomness, no wall-clock reads. Two runs over the same on-disk state produce
17
+ # byte-identical output.
18
+ #
19
+ # Usage:
20
+ # agent-report <intent_dir> [--role ROLE]
21
+ #
22
+ # Exit codes: 0 (report emitted), 2 (usage).
23
+
24
+ require_relative "lib/bridge"
25
+
26
+ def parse_args(argv)
27
+ role = nil
28
+ positional = []
29
+ i = 0
30
+ while i < argv.length
31
+ case argv[i]
32
+ when "--role"
33
+ role = argv[i + 1]
34
+ i += 2
35
+ else
36
+ positional << argv[i]
37
+ i += 1
38
+ end
39
+ end
40
+ [positional.first, role]
41
+ end
42
+
43
+ # Read the intent file frontmatter via the same path spawn-preamble uses, so the
44
+ # id/intent we report match what the rest of Plastic sees. Never raises.
45
+ def frontmatter_for(intent_dir)
46
+ ifile = Bridge.intent_file(intent_dir)
47
+ return {} unless File.exist?(ifile)
48
+
49
+ content = File.read(ifile)
50
+ return {} unless content.start_with?("---")
51
+
52
+ parts = content.split("---", 3)
53
+ return {} if parts.length < 3
54
+
55
+ require "yaml"
56
+ require "date"
57
+ require "time"
58
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
59
+ rescue StandardError
60
+ {}
61
+ end
62
+
63
+ # Current stage: prefer the last non-empty savepoint line (the ledger encodes the
64
+ # furthest-reached milestone), else the file-derived stage. Same logic as
65
+ # spawn-preamble's current_stage.
66
+ STAGE_LABELS = {
67
+ "what" => "What", "why" => "Why", "how" => "How",
68
+ "exec" => "Exec", "done" => "Done"
69
+ }.freeze
70
+
71
+ def current_stage(intent_dir)
72
+ ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
73
+ if File.exist?(ledger)
74
+ last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
75
+ return last if last
76
+ end
77
+ STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
78
+ end
79
+
80
+ # checklist.md checked/total: count GFM task-list items. Returns "n/a" when there
81
+ # is no checklist (a checklist is only meaningful from How onward).
82
+ def checklist_progress(intent_dir)
83
+ path = File.join(intent_dir, "checklist.md")
84
+ return "n/a" unless Bridge.stage_file_present?(path)
85
+
86
+ lines = File.readlines(path)
87
+ total = lines.count { |l| l =~ /^\s*- \[[ xX]\]/ }
88
+ checked = lines.count { |l| l =~ /^\s*- \[[xX]\]/ }
89
+ return "n/a" if total.zero?
90
+
91
+ "#{checked}/#{total}"
92
+ end
93
+
94
+ # The outcome line: first content line under outcome.md's "## Summary" (or the first
95
+ # non-heading content line), else a placeholder. Pure read of the intent dir.
96
+ def outcome_line(intent_dir)
97
+ path = File.join(intent_dir, "outcome.md")
98
+ return "(no outcome yet)" unless Bridge.stage_file_present?(path)
99
+
100
+ lines = File.readlines(path).map(&:rstrip)
101
+ start = lines.index { |l| l.strip.downcase == "## summary" }
102
+ scan = start ? lines[(start + 1)..] : lines
103
+ (scan || []).each do |l|
104
+ s = l.strip
105
+ next if s.empty? || s.start_with?("#") || s.start_with?("<!--")
106
+ return s
107
+ end
108
+ "(no outcome yet)"
109
+ end
110
+
111
+ intent_dir_arg, role = parse_args(ARGV)
112
+
113
+ if intent_dir_arg.nil? || intent_dir_arg.empty?
114
+ warn "usage: agent-report <intent_dir> [--role ROLE]"
115
+ exit 2
116
+ end
117
+
118
+ intent_dir = File.expand_path(intent_dir_arg)
119
+
120
+ fm = frontmatter_for(intent_dir)
121
+ id = fm["id"].to_s.strip
122
+ intent_name = fm["intent"].to_s.strip
123
+ id = "(unknown)" if id.empty?
124
+ intent_name = "(unknown)" if intent_name.empty?
125
+
126
+ stage = current_stage(intent_dir)
127
+ role_label = (role && !role.empty?) ? role : stage
128
+ artifacts = Bridge.has_files(intent_dir)
129
+ artifacts_str = artifacts.empty? ? "(none)" : artifacts.join(", ")
130
+
131
+ lines = []
132
+ lines << "=== Plastic agent report (synthesized) ==="
133
+ lines << "Intent: #{id} - #{intent_name}"
134
+ lines << "Stage: #{stage}"
135
+ lines << "Role: #{role_label}"
136
+ lines << "Status: synthesized (filesystem-derived; no agent-authored report)"
137
+ lines << "Artifacts present: #{artifacts_str}"
138
+ lines << "Checklist: #{checklist_progress(intent_dir)}"
139
+ lines << "Outcome: #{outcome_line(intent_dir)}"
140
+ lines << "=== end report ==="
141
+
142
+ puts lines.join("\n")