@datafog/fogclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.github/workflows/harness-docs.yml +30 -0
  2. package/AGENTS.md +28 -0
  3. package/LICENSE +21 -0
  4. package/README.md +208 -0
  5. package/dist/config.d.ts +4 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +30 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/engines/gliner.d.ts +14 -0
  10. package/dist/engines/gliner.d.ts.map +1 -0
  11. package/dist/engines/gliner.js +75 -0
  12. package/dist/engines/gliner.js.map +1 -0
  13. package/dist/engines/regex.d.ts +5 -0
  14. package/dist/engines/regex.d.ts.map +1 -0
  15. package/dist/engines/regex.js +54 -0
  16. package/dist/engines/regex.js.map +1 -0
  17. package/dist/index.d.ts +19 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +157 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/redactor.d.ts +3 -0
  22. package/dist/redactor.d.ts.map +1 -0
  23. package/dist/redactor.js +37 -0
  24. package/dist/redactor.js.map +1 -0
  25. package/dist/scanner.d.ts +11 -0
  26. package/dist/scanner.d.ts.map +1 -0
  27. package/dist/scanner.js +77 -0
  28. package/dist/scanner.js.map +1 -0
  29. package/dist/types.d.ts +31 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/types.js +18 -0
  32. package/dist/types.js.map +1 -0
  33. package/docs/DATA.md +28 -0
  34. package/docs/DESIGN.md +17 -0
  35. package/docs/DOMAIN_DOCS.md +30 -0
  36. package/docs/FRONTEND.md +24 -0
  37. package/docs/OBSERVABILITY.md +25 -0
  38. package/docs/PLANS.md +171 -0
  39. package/docs/PRODUCT_SENSE.md +20 -0
  40. package/docs/RELIABILITY.md +60 -0
  41. package/docs/SECURITY.md +50 -0
  42. package/docs/design-docs/core-beliefs.md +17 -0
  43. package/docs/design-docs/index.md +8 -0
  44. package/docs/generated/README.md +36 -0
  45. package/docs/generated/memory.md +1 -0
  46. package/docs/plans/2026-02-16-fogclaw-design.md +172 -0
  47. package/docs/plans/2026-02-16-fogclaw-implementation.md +1606 -0
  48. package/docs/plans/README.md +15 -0
  49. package/docs/plans/active/2026-02-16-feat-openclaw-official-submission-plan.md +386 -0
  50. package/docs/plans/active/2026-02-17-feat-release-fogclaw-via-datafog-package-plan.md +318 -0
  51. package/docs/plans/active/2026-02-17-feat-submit-fogclaw-to-openclaw-plan.md +244 -0
  52. package/docs/plans/tech-debt-tracker.md +42 -0
  53. package/docs/plugins/fogclaw.md +95 -0
  54. package/docs/runbooks/address-review-findings.md +30 -0
  55. package/docs/runbooks/ci-failures.md +46 -0
  56. package/docs/runbooks/code-review.md +34 -0
  57. package/docs/runbooks/merge-change.md +28 -0
  58. package/docs/runbooks/pull-request.md +45 -0
  59. package/docs/runbooks/record-evidence.md +43 -0
  60. package/docs/runbooks/reproduce-bug.md +42 -0
  61. package/docs/runbooks/respond-to-feedback.md +42 -0
  62. package/docs/runbooks/review-findings.md +31 -0
  63. package/docs/runbooks/submit-openclaw-plugin.md +68 -0
  64. package/docs/runbooks/update-agents-md.md +59 -0
  65. package/docs/runbooks/update-domain-docs.md +42 -0
  66. package/docs/runbooks/validate-current-state.md +41 -0
  67. package/docs/runbooks/verify-release.md +69 -0
  68. package/docs/specs/2026-02-16-feat-openclaw-official-submission-spec.md +115 -0
  69. package/docs/specs/2026-02-17-feat-submit-fogclaw-to-openclaw.md +125 -0
  70. package/docs/specs/README.md +5 -0
  71. package/docs/specs/index.md +8 -0
  72. package/docs/spikes/README.md +8 -0
  73. package/fogclaw.config.example.json +15 -0
  74. package/openclaw.plugin.json +45 -0
  75. package/package.json +37 -0
  76. package/scripts/ci/he-docs-config.json +123 -0
  77. package/scripts/ci/he-docs-drift.sh +112 -0
  78. package/scripts/ci/he-docs-lint.sh +234 -0
  79. package/scripts/ci/he-plans-lint.sh +354 -0
  80. package/scripts/ci/he-runbooks-lint.sh +445 -0
  81. package/scripts/ci/he-specs-lint.sh +258 -0
  82. package/scripts/ci/he-spikes-lint.sh +249 -0
  83. package/scripts/runbooks/select-runbooks.sh +154 -0
  84. package/src/config.ts +46 -0
  85. package/src/engines/gliner.ts +88 -0
  86. package/src/engines/regex.ts +71 -0
  87. package/src/index.ts +223 -0
  88. package/src/redactor.ts +51 -0
  89. package/src/scanner.ts +90 -0
  90. package/src/types.ts +52 -0
  91. package/tests/config.test.ts +104 -0
  92. package/tests/gliner.test.ts +184 -0
  93. package/tests/plugin-smoke.test.ts +114 -0
  94. package/tests/redactor.test.ts +320 -0
  95. package/tests/regex.test.ts +345 -0
  96. package/tests/scanner.test.ts +199 -0
  97. package/tsconfig.json +20 -0
@@ -0,0 +1,234 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # ── Constants ────────────────────────────────────────────────────────────────
5
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6
+ DEFAULT_CONFIG_PATH="scripts/ci/he-docs-config.json"
7
+
8
+ # ── Globals ──────────────────────────────────────────────────────────────────
9
+ ERRORS=0
10
+ WARNINGS=0
11
+
12
+ # ── Helpers ──────────────────────────────────────────────────────────────────
13
+
14
+ _env_flag() {
15
+ local name="$1"
16
+ local default="${2:-0}"
17
+ local val="${!name:-$default}"
18
+ [[ "$val" == "1" ]]
19
+ }
20
+
21
+ _load_config() {
22
+ local config_path="${HARNESS_DOCS_CONFIG:-$DEFAULT_CONFIG_PATH}"
23
+ local path="$REPO_ROOT/$config_path"
24
+ if [[ ! -f "$path" ]]; then
25
+ echo "Error: he-docs-lint missing/invalid config: Missing config '$config_path'. Fix: create it (bootstrap should do this) or set HARNESS_DOCS_CONFIG." >&2
26
+ return 1
27
+ fi
28
+ # Validate it is a JSON object
29
+ if ! jq -e 'type == "object"' "$path" >/dev/null 2>&1; then
30
+ echo "Error: he-docs-lint missing/invalid config: Config must be a JSON object." >&2
31
+ return 1
32
+ fi
33
+ cat "$path"
34
+ }
35
+
36
+ _gh_annotate() {
37
+ local level="$1" file="$2" title="$3" msg="$4"
38
+ if [[ -n "$file" ]]; then
39
+ echo "::${level} file=${file},title=${title}::${msg}"
40
+ else
41
+ echo "::${level} title=${title}::${msg}"
42
+ fi
43
+ }
44
+
45
+ _emit() {
46
+ local level="$1" file="$2" title="$3" msg="$4"
47
+ _gh_annotate "$level" "$file" "$title" "$msg"
48
+ local upper
49
+ upper="$(echo "$level" | tr '[:lower:]' '[:upper:]')"
50
+ echo "${upper}: ${msg}" >&2
51
+ if [[ "$level" == "error" ]]; then
52
+ ERRORS=$((ERRORS + 1))
53
+ else
54
+ WARNINGS=$((WARNINGS + 1))
55
+ fi
56
+ }
57
+
58
+ _has_exact_line() {
59
+ local path="$1" needle="$2"
60
+ grep -Fxq "$needle" "$path" 2>/dev/null
61
+ }
62
+
63
+ # ── Checks ───────────────────────────────────────────────────────────────────
64
+
65
+ _check_required_docs() {
66
+ local cfg="$1"
67
+ local count
68
+ count="$(echo "$cfg" | jq -r '.required_docs | if type == "array" then length else 0 end')"
69
+ if [[ "$count" -eq 0 ]]; then
70
+ return
71
+ fi
72
+ local i doc
73
+ for ((i = 0; i < count; i++)); do
74
+ doc="$(echo "$cfg" | jq -r ".required_docs[$i]")"
75
+ if [[ "$doc" == "null" ]] || [[ -z "$doc" ]]; then
76
+ continue
77
+ fi
78
+ if [[ ! -e "$REPO_ROOT/$doc" ]]; then
79
+ _emit "error" "$doc" "Required doc missing" \
80
+ "Missing required doc: '$doc'. Fix: create it (run he-bootstrap if this repo is not bootstrapped) or adjust required_docs in config."
81
+ fi
82
+ done
83
+ }
84
+
85
+ _check_domain_doc_headings() {
86
+ local cfg="$1"
87
+ local is_obj
88
+ is_obj="$(echo "$cfg" | jq -r '.required_headings | type')"
89
+ if [[ "$is_obj" != "object" ]]; then
90
+ return
91
+ fi
92
+
93
+ local docs
94
+ docs="$(echo "$cfg" | jq -r '.required_headings | keys[]')"
95
+ if [[ -z "$docs" ]]; then
96
+ return
97
+ fi
98
+
99
+ local doc
100
+ while IFS= read -r doc; do
101
+ [[ -z "$doc" ]] && continue
102
+ local path="$REPO_ROOT/$doc"
103
+ if [[ ! -f "$path" ]]; then
104
+ continue # on-demand domain docs
105
+ fi
106
+
107
+ local headings_count
108
+ headings_count="$(echo "$cfg" | jq -r --arg d "$doc" '.required_headings[$d] | if type == "array" then length else 0 end')"
109
+ if [[ "$headings_count" -eq 0 ]]; then
110
+ _emit "error" "$doc" "Missing config headings" \
111
+ "No required headings configured for '$doc'. Fix: add required_headings['$doc'] in config or remove the entry."
112
+ continue
113
+ fi
114
+
115
+ local missing=()
116
+ local j heading
117
+ for ((j = 0; j < headings_count; j++)); do
118
+ heading="$(echo "$cfg" | jq -r --arg d "$doc" ".required_headings[\$d][$j]")"
119
+ if [[ "$heading" == "null" ]] || [[ -z "$heading" ]]; then
120
+ continue
121
+ fi
122
+ if ! _has_exact_line "$path" "$heading"; then
123
+ missing+=("$heading")
124
+ fi
125
+ done
126
+
127
+ if [[ ${#missing[@]} -gt 0 ]]; then
128
+ local joined
129
+ joined="$(printf "%s; " "${missing[@]}")"
130
+ joined="${joined%; }" # trim trailing "; "
131
+ _emit "error" "$doc" "Missing headings" \
132
+ "Missing required headings in '$doc': ${joined}. Fix: add them."
133
+ fi
134
+ done <<< "$docs"
135
+ }
136
+
137
+ _check_seed_markers() {
138
+ local cfg="$1"
139
+ local fail_level="warning"
140
+ if _env_flag "HARNESS_FAIL_ON_SEED_MARKERS" "0"; then
141
+ fail_level="error"
142
+ fi
143
+
144
+ local count
145
+ count="$(echo "$cfg" | jq -r '.domain_docs | if type == "array" then length else 0 end')"
146
+ if [[ "$count" -eq 0 ]]; then
147
+ return
148
+ fi
149
+
150
+ local i doc path
151
+ for ((i = 0; i < count; i++)); do
152
+ doc="$(echo "$cfg" | jq -r ".domain_docs[$i]")"
153
+ if [[ "$doc" == "null" ]] || [[ -z "$doc" ]]; then
154
+ continue
155
+ fi
156
+ path="$REPO_ROOT/$doc"
157
+ if [[ ! -f "$path" ]]; then
158
+ continue
159
+ fi
160
+ if grep -q '<!-- seed:' "$path" 2>/dev/null; then
161
+ _emit "$fail_level" "$doc" "Seed markers present" \
162
+ "Template seed markers remain in '$doc'. Fix: replace/remove <!-- seed: ... --> blocks once this repo has real domain context."
163
+ fi
164
+ done
165
+ }
166
+
167
+ _check_generated_last_updated() {
168
+ local gen_dir="$REPO_ROOT/docs/generated"
169
+ if [[ ! -d "$gen_dir" ]]; then
170
+ return
171
+ fi
172
+
173
+ local fail_level="warning"
174
+ if _env_flag "HARNESS_FAIL_ON_GENERATED_PLACEHOLDERS" "0"; then
175
+ fail_level="error"
176
+ fi
177
+
178
+ local path rel
179
+ for path in "$gen_dir"/*.md; do
180
+ [[ -e "$path" ]] || continue # handle no-match glob
181
+ rel="${path#"$REPO_ROOT/"}"
182
+ # Skip known non-generated docs
183
+ if [[ "$rel" == "docs/generated/README.md" ]] || [[ "$rel" == "docs/generated/memory.md" ]]; then
184
+ continue
185
+ fi
186
+ local text
187
+ text="$(cat "$path")"
188
+
189
+ # Check for missing last_updated line (BSD/GNU portable; avoid grep -P)
190
+ if ! echo "$text" | grep -Eq '^[[:space:]]*-[[:space:]]*last_updated:[[:space:]]*'; then
191
+ _emit "error" "$rel" "Missing last_updated" \
192
+ "Generated doc '$rel' must include a 'last_updated' line. Fix: add e.g. '- last_updated: 2026-02-15 12:34'."
193
+ fi
194
+
195
+ # Check for placeholder last_updated value
196
+ if echo "$text" | grep -Eq 'last_updated:[[:space:]]*<YYYY-'; then
197
+ _emit "$fail_level" "$rel" "Placeholder last_updated" \
198
+ "Generated doc '$rel' has a placeholder last_updated value. Fix: replace with a real timestamp."
199
+ fi
200
+ done
201
+ }
202
+
203
+ # ── Main ─────────────────────────────────────────────────────────────────────
204
+
205
+ main() {
206
+ local cfg
207
+ if ! cfg="$(_load_config)"; then
208
+ exit 2
209
+ fi
210
+
211
+ echo "he-docs-lint: starting"
212
+ echo "Repro: bash scripts/ci/he-docs-lint.sh"
213
+
214
+ _check_required_docs "$cfg"
215
+
216
+ # Runbooks lint is its own script. Fail fast if it fails.
217
+ if ! bash "$REPO_ROOT/scripts/ci/he-runbooks-lint.sh"; then
218
+ exit 1
219
+ fi
220
+
221
+ _check_domain_doc_headings "$cfg"
222
+ _check_seed_markers "$cfg"
223
+ _check_generated_last_updated
224
+
225
+ if [[ "$ERRORS" -gt 0 ]]; then
226
+ echo "he-docs-lint: FAIL ($ERRORS error(s), $WARNINGS warning(s))" >&2
227
+ exit 1
228
+ fi
229
+
230
+ echo "he-docs-lint: OK ($WARNINGS warning(s))"
231
+ exit 0
232
+ }
233
+
234
+ main "$@"
@@ -0,0 +1,354 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # ── Repo root (two levels above this script) ──────────────────────────
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
7
+
8
+ DEFAULT_CONFIG_PATH="scripts/ci/he-docs-config.json"
9
+
10
+ # ── Default required headings ─────────────────────────────────────────
11
+ DEFAULT_REQUIRED_HEADINGS=(
12
+ "## Purpose / Big Picture"
13
+ "## Progress"
14
+ "## Surprises & Discoveries"
15
+ "## Decision Log"
16
+ "## Outcomes & Retrospective"
17
+ "## Context and Orientation"
18
+ "## Milestones"
19
+ "## Plan of Work"
20
+ "## Concrete Steps"
21
+ "## Validation and Acceptance"
22
+ "## Idempotence and Recovery"
23
+ "## Artifacts and Notes"
24
+ "## Interfaces and Dependencies"
25
+ "## Pull Request"
26
+ "## Review Findings"
27
+ "## Verify/Release Decision"
28
+ "## Revision Notes"
29
+ )
30
+
31
+ # ── Counters ──────────────────────────────────────────────────────────
32
+ ERRORS=0
33
+ WARNINGS=0
34
+
35
+ # ── Emit a finding (GitHub annotation + stderr) ──────────────────────
36
+ emit() {
37
+ local level="$1" file="$2" title="$3" msg="$4"
38
+ if [[ -n "$file" ]]; then
39
+ echo "::${level} file=${file},title=${title}::${msg}"
40
+ else
41
+ echo "::${level} title=${title}::${msg}"
42
+ fi
43
+ echo "${level^^}: ${msg}" >&2
44
+ if [[ "$level" == "error" ]]; then
45
+ (( ERRORS++ )) || true
46
+ else
47
+ (( WARNINGS++ )) || true
48
+ fi
49
+ }
50
+
51
+ # ── Load config ───────────────────────────────────────────────────────
52
+ load_config() {
53
+ local config_rel="${HARNESS_DOCS_CONFIG:-$DEFAULT_CONFIG_PATH}"
54
+ local config_path="$REPO_ROOT/$config_rel"
55
+ if [[ ! -f "$config_path" ]]; then
56
+ echo "Error: he-plans-lint missing/invalid config: Missing config '${config_rel}'. Fix: create it (bootstrap should do this) or set HARNESS_DOCS_CONFIG." >&2
57
+ exit 2
58
+ fi
59
+ # Validate it is a JSON object
60
+ if ! jq -e 'type == "object"' "$config_path" >/dev/null 2>&1; then
61
+ echo "Error: he-plans-lint missing/invalid config: Config must be a JSON object." >&2
62
+ exit 2
63
+ fi
64
+ CONFIG_PATH="$config_path"
65
+ }
66
+
67
+ # ── Config helpers ────────────────────────────────────────────────────
68
+ cfg_get() {
69
+ # $1 = jq expression, returns raw output
70
+ jq -r "$1" "$CONFIG_PATH"
71
+ }
72
+
73
+ cfg_get_array() {
74
+ # $1 = jq path to array, outputs one element per line
75
+ jq -r "$1 // [] | if type == \"array\" then .[] else empty end" "$CONFIG_PATH" 2>/dev/null
76
+ }
77
+
78
+ # ── Extract YAML frontmatter (between first --- and second ---) ──────
79
+ # Sets FRONTMATTER variable. Returns 1 if no frontmatter found.
80
+ extract_frontmatter() {
81
+ local file="$1"
82
+ FRONTMATTER=""
83
+ local first_line
84
+ first_line="$(head -1 "$file")"
85
+ if [[ "$(echo "$first_line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" != "---" ]]; then
86
+ return 1
87
+ fi
88
+ # Find closing --- (skip line 1, find next ---)
89
+ local end_line
90
+ end_line="$(awk 'NR > 1 && /^[[:space:]]*---[[:space:]]*$/ { print NR; exit }' "$file")"
91
+ if [[ -z "$end_line" ]]; then
92
+ return 1
93
+ fi
94
+ # Extract lines between line 2 and end_line-1
95
+ FRONTMATTER="$(sed -n "2,$((end_line - 1))p" "$file")"
96
+ return 0
97
+ }
98
+
99
+ # ── Parse frontmatter key-value pairs ────────────────────────────────
100
+ # Reads FRONTMATTER, outputs "key=value" lines
101
+ frontmatter_keys=()
102
+ frontmatter_vals=()
103
+
104
+ parse_frontmatter_kv() {
105
+ frontmatter_keys=()
106
+ frontmatter_vals=()
107
+ while IFS= read -r raw; do
108
+ local line
109
+ line="$(echo "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
110
+ # Skip empty / comment lines
111
+ [[ -z "$line" || "$line" == \#* ]] && continue
112
+ # Must contain a colon
113
+ [[ "$line" != *:* ]] && continue
114
+ local key val
115
+ key="$(echo "$line" | cut -d: -f1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
116
+ val="$(echo "$line" | cut -d: -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
117
+ frontmatter_keys+=("$key")
118
+ frontmatter_vals+=("$val")
119
+ done <<< "$FRONTMATTER"
120
+ }
121
+
122
+ # ── Lookup a frontmatter value by key ────────────────────────────────
123
+ fm_get() {
124
+ local needle="$1"
125
+ for i in "${!frontmatter_keys[@]}"; do
126
+ if [[ "${frontmatter_keys[$i]}" == "$needle" ]]; then
127
+ echo "${frontmatter_vals[$i]}"
128
+ return 0
129
+ fi
130
+ done
131
+ return 1
132
+ }
133
+
134
+ fm_has_key() {
135
+ local needle="$1"
136
+ for k in "${frontmatter_keys[@]}"; do
137
+ [[ "$k" == "$needle" ]] && return 0
138
+ done
139
+ return 1
140
+ }
141
+
142
+ # ── Extract section lines (between heading and next ## heading) ──────
143
+ # Outputs section body lines to stdout
144
+ section_lines() {
145
+ local file="$1" heading="$2"
146
+ awk -v h="$heading" '
147
+ BEGIN { found=0 }
148
+ $0 == h { found=1; next }
149
+ found && /^## / { exit }
150
+ found { print }
151
+ ' "$file"
152
+ }
153
+
154
+ # ── Check: exact heading line exists in file ─────────────────────────
155
+ has_exact_line() {
156
+ local file="$1" needle="$2"
157
+ grep -qxF "$needle" "$file"
158
+ }
159
+
160
+ # ── Check: Progress section ──────────────────────────────────────────
161
+ check_progress() {
162
+ local file_rel="$1" file_abs="$2"
163
+ local body
164
+ body="$(section_lines "$file_abs" "## Progress")"
165
+
166
+ # Check non-empty (has at least one non-blank line)
167
+ if ! echo "$body" | grep -q '[^[:space:]]'; then
168
+ emit "error" "$file_rel" "Missing Progress content" \
169
+ "Plan '${file_rel}' has an empty ## Progress section."
170
+ return
171
+ fi
172
+
173
+ # Check timestamped checkbox pattern
174
+ if ! echo "$body" | grep -qE '^- \[[ xX]\] \([0-9]{4}-[0-9]{2}-[0-9]{2}[^)]*\) P[0-9]+'; then
175
+ emit "error" "$file_rel" "Progress format" \
176
+ "Plan '${file_rel}' must include timestamped progress checkboxes with IDs (e.g. '- [ ] (2026-02-15T12:00:00Z) P1 ...')."
177
+ fi
178
+ }
179
+
180
+ # ── Check: checklists only in Progress ───────────────────────────────
181
+ check_checklists_only_in_progress() {
182
+ local file_rel="$1" file_abs="$2"
183
+ local bad=0
184
+ local in_progress=0
185
+ while IFS= read -r line; do
186
+ if [[ "$line" == "## Progress" ]]; then
187
+ in_progress=1
188
+ continue
189
+ fi
190
+ if [[ "$line" == "## "* ]]; then
191
+ in_progress=0
192
+ fi
193
+ if [[ $in_progress -eq 0 ]] && echo "$line" | grep -qE '^- \[[ xX]\]'; then
194
+ bad=1
195
+ break
196
+ fi
197
+ done < "$file_abs"
198
+
199
+ if [[ $bad -eq 1 ]]; then
200
+ emit "error" "$file_rel" "Checklist scope" \
201
+ "Plan '${file_rel}' contains checklist items outside ## Progress."
202
+ fi
203
+ }
204
+
205
+ # ── Check: Decision Log ──────────────────────────────────────────────
206
+ check_decision_log() {
207
+ local file_rel="$1" file_abs="$2"
208
+ local body
209
+ body="$(section_lines "$file_abs" "## Decision Log")"
210
+
211
+ if ! echo "$body" | grep -q '[^[:space:]]'; then
212
+ emit "error" "$file_rel" "Missing Decision Log content" \
213
+ "Plan '${file_rel}' has an empty ## Decision Log section."
214
+ return
215
+ fi
216
+
217
+ if ! echo "$body" | grep -q '^- Decision:'; then
218
+ emit "error" "$file_rel" "Decision format" \
219
+ "Plan '${file_rel}' should record decisions using '- Decision:' entries."
220
+ fi
221
+ }
222
+
223
+ # ── Check: Revision Notes ────────────────────────────────────────────
224
+ check_revision_notes() {
225
+ local file_rel="$1" file_abs="$2"
226
+ local body
227
+ body="$(section_lines "$file_abs" "## Revision Notes")"
228
+
229
+ if ! echo "$body" | grep -q '[^[:space:]]'; then
230
+ emit "error" "$file_rel" "Missing Revision Notes content" \
231
+ "Plan '${file_rel}' has an empty ## Revision Notes section."
232
+ return
233
+ fi
234
+
235
+ if ! echo "$body" | grep -q '^- '; then
236
+ emit "error" "$file_rel" "Revision Notes format" \
237
+ "Plan '${file_rel}' should include at least one bullet in ## Revision Notes."
238
+ fi
239
+ }
240
+
241
+ # ── Check: placeholder tokens ────────────────────────────────────────
242
+ check_placeholders() {
243
+ local file_rel="$1" file_abs="$2"
244
+ local fail_ph=0
245
+ [[ "${HARNESS_FAIL_ON_ARTIFACT_PLACEHOLDERS:-0}" == "1" ]] && fail_ph=1
246
+
247
+ local text
248
+ text="$(cat "$file_abs")"
249
+
250
+ while IFS= read -r pattern; do
251
+ [[ -z "$pattern" ]] && continue
252
+ if echo "$text" | grep -qF "$pattern"; then
253
+ local level="warning"
254
+ local msg="Plan '${file_rel}' contains placeholder token '${pattern}'."
255
+ if [[ $fail_ph -eq 1 ]]; then
256
+ level="error"
257
+ else
258
+ msg="${msg} (Set HARNESS_FAIL_ON_ARTIFACT_PLACEHOLDERS=1 to enforce.)"
259
+ fi
260
+ emit "$level" "$file_rel" "Placeholder token" "$msg"
261
+ break
262
+ fi
263
+ done < <(cfg_get_array '.artifact_placeholder_patterns')
264
+ }
265
+
266
+ # ── Check a single plan file ─────────────────────────────────────────
267
+ check_plan() {
268
+ local file_abs="$1"
269
+ local file_rel="${file_abs#"$REPO_ROOT/"}"
270
+
271
+ # Frontmatter
272
+ if ! extract_frontmatter "$file_abs"; then
273
+ emit "error" "$file_rel" "Missing YAML frontmatter" \
274
+ "Plan '${file_rel}' must start with YAML frontmatter delimited by '---' lines."
275
+ return
276
+ fi
277
+
278
+ parse_frontmatter_kv
279
+
280
+ # Required frontmatter keys
281
+ while IFS= read -r key; do
282
+ [[ -z "$key" ]] && continue
283
+ if ! fm_has_key "$key"; then
284
+ emit "error" "$file_rel" "Missing frontmatter key" \
285
+ "Plan '${file_rel}' missing YAML frontmatter key '${key}:'."
286
+ fi
287
+ done < <(cfg_get_array '.required_plan_frontmatter_keys')
288
+
289
+ # plan_mode validation
290
+ local plan_mode
291
+ plan_mode="$(fm_get "plan_mode" 2>/dev/null || true)"
292
+ plan_mode="$(echo "$plan_mode" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
293
+ if [[ -n "$plan_mode" && "$plan_mode" != "trivial" && "$plan_mode" != "lightweight" && "$plan_mode" != "execution" ]]; then
294
+ emit "error" "$file_rel" "Invalid plan_mode" \
295
+ "Plan '${file_rel}' has invalid plan_mode '${plan_mode}' (must be 'trivial', 'lightweight', or 'execution')."
296
+ fi
297
+
298
+ # Required headings
299
+ for h in "${DEFAULT_REQUIRED_HEADINGS[@]}"; do
300
+ if ! has_exact_line "$file_abs" "$h"; then
301
+ emit "error" "$file_rel" "Missing heading" \
302
+ "Plan '${file_rel}' missing required heading line '${h}'."
303
+ fi
304
+ done
305
+
306
+ # Section-level checks
307
+ check_progress "$file_rel" "$file_abs"
308
+ check_checklists_only_in_progress "$file_rel" "$file_abs"
309
+ check_decision_log "$file_rel" "$file_abs"
310
+ check_revision_notes "$file_rel" "$file_abs"
311
+ check_placeholders "$file_rel" "$file_abs"
312
+ }
313
+
314
+ # ══════════════════════════════════════════════════════════════════════
315
+ # Main
316
+ # ══════════════════════════════════════════════════════════════════════
317
+ load_config
318
+
319
+ echo "he-plans-lint: starting"
320
+ echo "Repro: bash scripts/ci/he-plans-lint.sh"
321
+
322
+ plans_active="$REPO_ROOT/docs/plans/active"
323
+ plans_completed="$REPO_ROOT/docs/plans/completed"
324
+
325
+ files=()
326
+ if [[ -d "$plans_active" ]]; then
327
+ while IFS= read -r -d '' f; do
328
+ files+=("$f")
329
+ done < <(find "$plans_active" -maxdepth 1 -name '*.md' -print0 | sort -z)
330
+ fi
331
+
332
+ lint_completed="$(cfg_get '.lint_completed_plans // true')"
333
+ if [[ "$lint_completed" != "false" && -d "$plans_completed" ]]; then
334
+ while IFS= read -r -d '' f; do
335
+ files+=("$f")
336
+ done < <(find "$plans_completed" -maxdepth 1 -name '*.md' -print0 | sort -z)
337
+ fi
338
+
339
+ if [[ ${#files[@]} -eq 0 ]]; then
340
+ echo "he-plans-lint: OK (no plan files)"
341
+ exit 0
342
+ fi
343
+
344
+ for f in "${files[@]}"; do
345
+ check_plan "$f"
346
+ done
347
+
348
+ if [[ $ERRORS -gt 0 ]]; then
349
+ echo "he-plans-lint: FAIL (${ERRORS} error(s), ${WARNINGS} warning(s))" >&2
350
+ exit 1
351
+ fi
352
+
353
+ echo "he-plans-lint: OK (${WARNINGS} warning(s))"
354
+ exit 0