@zalom/plastic 1.0.0-beta.1 → 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 (50) hide show
  1. package/PLASTIC.md +21 -5
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-executor.md +10 -0
  4. package/agents/plastic-intent-curator.md +7 -5
  5. package/agents/plastic-planner.md +11 -1
  6. package/agents/plastic-spec-specialist.md +9 -1
  7. package/hooks/statusline +150 -41
  8. package/package.json +1 -1
  9. package/scripts/agent-report +142 -0
  10. package/scripts/dashboard.rb +5 -3
  11. package/scripts/doctor.rb +243 -0
  12. package/scripts/lib/bridge.rb +72 -24
  13. package/scripts/lib/frontmatter_writer.rb +130 -0
  14. package/scripts/lib/graph_rebuild.rb +328 -0
  15. package/scripts/lib/installer_core.rb +5 -0
  16. package/scripts/lib/intent_validator.rb +79 -0
  17. package/scripts/lib/links_projection.rb +160 -0
  18. package/scripts/lib/links_section.rb +207 -0
  19. package/scripts/lib/power_tools.rb +76 -0
  20. package/scripts/lib/qmd_hook.rb +38 -25
  21. package/scripts/lib/qmd_sync.rb +21 -0
  22. package/scripts/new-intent +172 -22
  23. package/scripts/project-links +287 -0
  24. package/scripts/qmd-sync +50 -3
  25. package/scripts/rebuild-graph +244 -0
  26. package/scripts/spawn-preamble +18 -1
  27. package/skills/auto/SKILL.md +13 -3
  28. package/skills/auto/evals/evals.json +48 -0
  29. package/skills/auto/references/agent-architecture.md +20 -0
  30. package/skills/auto/references/agent-report-contract.md +86 -0
  31. package/skills/brainstorming/SKILL.md +1 -0
  32. package/skills/brainstorming/evals/evals.json +22 -0
  33. package/skills/continuing/SKILL.md +8 -1
  34. package/skills/continuing/evals/evals.json +9 -0
  35. package/skills/creating-intent/SKILL.md +28 -8
  36. package/skills/creating-intent/evals/evals.json +72 -0
  37. package/skills/creating-intent/references/lifecycle.md +12 -4
  38. package/skills/dashboard/SKILL.md +5 -0
  39. package/skills/dashboard/evals/evals.json +22 -0
  40. package/skills/executing-plan/SKILL.md +2 -2
  41. package/skills/intent-curator/SKILL.md +3 -1
  42. package/skills/intent-curator/evals/evals.json +22 -0
  43. package/skills/linking-intents/SKILL.md +17 -6
  44. package/skills/linking-intents/evals/evals.json +22 -0
  45. package/skills/linking-intents/references/zettelkasten.md +15 -3
  46. package/skills/managing-index/SKILL.md +6 -0
  47. package/skills/managing-index/evals/evals.json +22 -0
  48. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  49. package/skills/research/SKILL.md +8 -0
  50. package/skills/research/evals/evals.json +22 -0
package/PLASTIC.md CHANGED
@@ -35,15 +35,28 @@ Identity and knowledge graph only. Nothing operational.
35
35
  ---
36
36
  id: "4a1"
37
37
  intent: "Short description of the desire"
38
- sources: ["4a"] # backward links what influenced this
39
- chain: ["4a1a"] # forward links — what this spawned
38
+ sources: ["4a"] # direct ascendants: intents this was created from
39
+ chain: ["4a1a"] # forward: what this spawned and related successors
40
40
  created: 2026-05-29
41
41
  author: human # human | agent-name
42
42
  tags: [plastic, architecture]
43
43
  ---
44
44
  ```
45
45
 
46
- - `sources` + `chain` form the double-linked knowledge graph (Folgezettel)
46
+ - `sources` (formative, must-load, acyclic) and `chain` (forward + relational, lighter,
47
+ may cycle) form the directed knowledge graph. Reciprocity is one-directional: every
48
+ `sources` edge has a reciprocal `chain` entry (I1), but `chain` may carry relational
49
+ entries with no reciprocal `sources` (I2), so the graph is not strictly symmetric.
50
+ - Context contract: load `sources` strongly (they are what the intent was built from);
51
+ traverse `chain` lightly for discovery. See
52
+ docs/concepts/how-plastic-sources-and-chains-intents.md for the full model.
53
+ - `## Links` (I5) is the human-readable projection of the graph. It mirrors the
54
+ frontmatter exactly: every entry is `- [[id--slug|<target's full intent: text>]]`, a
55
+ clickable `id--slug` wikilink target with the target intent's full `intent:` text as the
56
+ label (cross-store targets render `- [[store:id--slug|<target's full intent: text>]]`).
57
+ Ordering is mandatory: all `sources` first (top), then all `chain`, frontmatter order
58
+ preserved within each group. Sources never appear at the end. No source/chain tags, no
59
+ sub-grouping. An intent with empty `sources` and `chain` carries the empty-state comment.
47
60
  - IDs use Luhmann's alternating convention: `1` → `1a` → `1a1` → `1a1a`
48
61
  - Multiple branches increment: `1a`, `1b`, `1c`
49
62
 
@@ -155,8 +168,11 @@ Format: `ID--three-to-five-words` (all stores).
155
168
 
156
169
  - **Branch (`14a`, `14b`)** — a sub-task, refinement, or direct continuation of the
157
170
  parent. It cannot stand on its own; it only makes sense as part of the parent's work.
158
- - **Root (`15`, `16`)** an independent thought, even if inspired by another intent.
159
- Record provenance with `sources: ["14"]`, not by branching.
171
+ - **Root (`15`, `16`)**: an independent thought, even if inspired by another intent.
172
+ Reserve `sources` for true created-from provenance (intents this was built out of). An
173
+ independent intent merely related to or inspired by another carries NO `sources`; record
174
+ the relation on the PREDECESSOR's `chain` (and mirror it as a
175
+ `[[id--slug|<target's full intent: text>]]` wikilink in `## Links`).
160
176
  - **Rule of thumb:** if the intent could exist without its parent, it's a root.
161
177
 
162
178
  ## INDEX.md
@@ -27,7 +27,15 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
27
27
  2. Read the intent file, its `## Links`, and related code or docs
28
28
  3. Research with the adaptive budget the enforcer set (simple 2-3, medium 5-8, complex 10-15 steps)
29
29
  4. Produce (output handoff): an enriched `## Context` plus `### Decisions` with rationale
30
- 5. Log autonomous calls in `## Insights` with the `(autonomous)` marker, then report for gating
30
+ 5. Log autonomous calls in `## Insights` with the `(autonomous)` marker, then report for gating (see `## Completion Report`)
31
+
32
+ ## Completion Report
33
+
34
+ END your turn with a structured completion report as your final message, per the spawn preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.md`. Do not finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written, verification, checklist deltas, deviations, blockers) plus the brainstorming payload:
35
+
36
+ - Decisions recorded in `### Decisions`, each with its one-line rationale
37
+ - Context enriched: what was researched and the key findings
38
+ - Open questions resolved, and any deliberately left for the spec
31
39
 
32
40
  ## Constraints
33
41
 
@@ -28,6 +28,16 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
28
28
  3. Edit project code (the gate is open now that plan and checklist exist)
29
29
  4. Run the full suite, iterate to zero failures and zero errors
30
30
  5. Produce (output handoff): the code changes, a checked-off `checklist.md`, and `## Insights`
31
+ 6. Report for gating (see `## Completion Report`); the enforcer reviews the work at the final gate
32
+
33
+ ## Completion Report
34
+
35
+ END your turn with a structured completion report as your final message, per the spawn preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.md`. Do not finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written, verification, checklist deltas, deviations, blockers) plus the executor payload:
36
+
37
+ - Actions implemented this turn, mapped to checklist items checked off (checked / total)
38
+ - A summary of the code changed (files and the shape of the change)
39
+ - Test result: the full-suite command and its pass / fail counts
40
+ - Insights appended, with the `(autonomous)` marker
31
41
 
32
42
  ## Constraints
33
43
 
@@ -18,23 +18,25 @@ You are the Plastic Intent Curator. Your role is to maintain the health and navi
18
18
 
19
19
  ## Your Responsibilities
20
20
 
21
- 1. **Intent lifecycle management** — move intents between Active/Future/Completed in INDEX.md, fill in `## Outcome` sections
22
- 2. **INDEX.md maintenance** — keep Active/Future/Clusters/Completed sections accurate and well-organized
21
+ 1. **Intent lifecycle management** — move intents between Active/Future/Completed/Abandoned in INDEX.md, fill in `## Outcome` sections (including the abandonment rationale when an intent is abandoned)
22
+ 2. **INDEX.md maintenance** — keep Active/Future/Clusters/Completed/Abandoned sections accurate and well-organized
23
23
  3. **Link discovery** — suggest connections between intents that share topics but aren't linked
24
24
  4. **Cluster management** — create new clusters when 3+ unlinked intents share tags, merge or rename clusters as topics evolve
25
25
  5. **Orphan detection** — flag intents with no links and no cluster membership
26
26
 
27
27
  ## How You Work
28
28
 
29
+ 0. QMD-first (when available): when you need to locate a specific intent (to reclassify, link, or cluster it) rather than rebuild the whole landscape, before scanning the store with grep/Read run `ruby ~/.plastic/scripts/qmd-sync search "<terms>"` to surface candidate or related intents, then open the authoritative intent file for any hit you act on. The command is a no-op when QMD is absent, so fall back to the full scan below. (This is discovery; the reindex step at a terminal-state transition is separate.)
29
30
  1. Scan `~/.plastic/store/*/ID--slug.md` (or project store) to understand the full intent landscape
30
31
  2. Read `~/.plastic/INDEX.md` (or project INDEX.md) to understand current organization
31
- 3. Compare: are there intents not in any cluster? Missing from Active/Completed? Status mismatches?
32
+ 3. Compare: are there intents not in any cluster? Missing from Active/Completed/Abandoned? Status mismatches?
32
33
  4. Make targeted edits to INDEX.md and intent frontmatter/links
33
- 5. Report what you changed
34
+ 5. On a terminal-state transition: whenever you move an intent to Completed OR Abandoned, refresh the QMD index for that store so the new outcome (or abandonment rationale) is searchable. This is mandatory on any terminal-state move and a no-op when QMD is absent, and it runs in the background so it never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async`
35
+ 6. Report what you changed
34
36
 
35
37
  ## Constraints
36
38
 
37
39
  - You only edit `~/.plastic/INDEX.md` (or project INDEX.md) and `~/.plastic/store/*/ID--slug.md` (or project store) files
38
40
  - You never create new intents — that's the creating-intent skill's job
39
41
  - You never modify `## Insights`, `## Context`, or `## Outcome` content sections — those belong to the worker
40
- - You use Read and grep/find for discovery, Edit for targeted changes
42
+ - For discovery, put QMD first when available (`qmd-sync search`), then fall back to Read and grep/find; use Edit for targeted changes
@@ -27,7 +27,17 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
27
27
  2. Read `spec.md` and the plan/checklist templates; match their FORM
28
28
  3. Write `plan.md`, the `actions/` directory, and `checklist.md` into the intent directory
29
29
  4. Produce (output handoff): `plan.md` plus `actions/` plus `checklist.md`
30
- 5. Report for gating; the enforcer verifies plan and checklist exist before Exec is allowed
30
+ 5. Report for gating (see `## Completion Report`); the enforcer verifies plan and checklist exist before Exec is allowed
31
+
32
+ ## Completion Report
33
+
34
+ END your turn with a structured completion report as your final message, per the spawn preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.md`. Do not finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written, verification, checklist deltas, deviations, blockers) plus the planner payload, which EXPLAINS THE PLAN BACK TO THE ORCHESTRATOR:
35
+
36
+ - The ordered actions, one line each: what the action does and how it is verified
37
+ - Decomposition rationale: why this order, and why the actions are independent
38
+ - Checklist coverage: the item count, and that every action plus suite-green is covered
39
+
40
+ The plan is an argument; the orchestrator gates on whether that argument is sound before any code is written, so make the report make that case.
31
41
 
32
42
  ## Constraints
33
43
 
@@ -27,7 +27,15 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
27
27
  2. Read the spec template (`templates/spec.md`) and match its FORM exactly
28
28
  3. Write `spec.md` into the intent directory, resolving the decisions into a coherent approach
29
29
  4. Produce (output handoff): a complete `spec.md`
30
- 5. Report for gating; the enforcer checks the spec against its exit criteria before How begins
30
+ 5. Report for gating (see `## Completion Report`); the enforcer checks the spec against its exit criteria before How begins
31
+
32
+ ## Completion Report
33
+
34
+ END your turn with a structured completion report as your final message, per the spawn preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.md`. Do not finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written, verification, checklist deltas, deviations, blockers) plus the spec payload:
35
+
36
+ - The spec sections produced (Problem, Goals, Non-Goals, Approach, Decisions, Acceptance Criteria)
37
+ - How the recorded decisions resolved into the chosen approach
38
+ - The acceptance-criteria count, so the planner knows the surface to cover
31
39
 
32
40
  ## Constraints
33
41
 
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.1",
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": {
@@ -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")
@@ -223,15 +223,17 @@ def effort_of(rec, type)
223
223
  end
224
224
 
225
225
  # Value -> :high | :low (explicit frontmatter field wins).
226
- # High is deliberately rare: an explicit stamp, or a human-authored root idea that has
227
- # already spawned follow-on work (chain non-empty) — i.e. a strategic theme the user owns.
226
+ # High is deliberately rare: an explicit stamp, a human-authored root idea, or an intent
227
+ # that has SPAWNED follow-on work, i.e. a strategic theme the user owns. "Has spawned work"
228
+ # means a reciprocal (I1) edge: another intent lists this one in its `sources`, captured by
229
+ # `referenced`. A purely relational `chain` entry (D2, no reciprocal `sources`) does NOT
230
+ # count as spawned, so bare `chain` membership is not a high-value signal (intent 68).
228
231
  def value_of(rec, referenced = {})
229
232
  case rec[:value_field]
230
233
  when "high" then return :high
231
234
  when "low" then return :low
232
235
  end
233
236
  return :high if rec[:author] == "human" && root_intent?(rec[:id])
234
- return :high unless rec[:chain].empty?
235
237
  return :high if referenced[[rec[:scope], rec[:id]]]
236
238
  :low
237
239
  end