mindforge-cc 11.9.2 → 11.9.3

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 (94) hide show
  1. package/.agent/CLAUDE.md +37 -13
  2. package/.agent/hooks/mindforge-block-no-verify.js +61 -13
  3. package/.agent/hooks/mindforge-config-protection.js +82 -3
  4. package/.agent/hooks/mindforge-context-monitor.js +1 -1
  5. package/.agent/hooks/mindforge-workflow-guard.js +2 -2
  6. package/.agent/hooks/run-with-flags.js +190 -20
  7. package/.agent/mindforge/browse.md +2 -2
  8. package/.agent/mindforge/checkpoint.md +1 -1
  9. package/.agent/mindforge/harness-audit.md +1 -1
  10. package/.agent/mindforge/orch-add-feature.md +1 -1
  11. package/.agent/mindforge/orch-build-mvp.md +1 -1
  12. package/.agent/mindforge/orch-change-feature.md +1 -1
  13. package/.agent/mindforge/orch-fix-defect.md +1 -1
  14. package/.agent/mindforge/orch-refine-code.md +1 -1
  15. package/.agent/mindforge/qa.md +2 -2
  16. package/.claude/CLAUDE.md +37 -13
  17. package/.claude/commands/mindforge/browse.md +2 -2
  18. package/.claude/commands/mindforge/checkpoint.md +1 -1
  19. package/.claude/commands/mindforge/harness-audit.md +1 -1
  20. package/.claude/commands/mindforge/orch-add-feature.md +1 -1
  21. package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
  22. package/.claude/commands/mindforge/orch-change-feature.md +1 -1
  23. package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
  24. package/.claude/commands/mindforge/orch-refine-code.md +1 -1
  25. package/.claude/commands/mindforge/qa.md +2 -2
  26. package/.mindforge/MINDFORGE-SCHEMA.json +1 -1
  27. package/.mindforge/config.json +3 -3
  28. package/.mindforge/engine/autonomous/headless-adapter.md +2 -2
  29. package/.mindforge/engine/temporal-protocol.md +2 -2
  30. package/.mindforge/governance/change-classifier.md +20 -4
  31. package/.mindforge/memory/sync-manifest.json +1 -1
  32. package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
  33. package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
  34. package/CHANGELOG.md +194 -0
  35. package/MINDFORGE.md +13 -6
  36. package/README.md +4 -3
  37. package/RELEASENOTES.md +2 -2
  38. package/SECURITY.md +22 -3
  39. package/bin/autonomous/auto-runner.js +65 -2
  40. package/bin/change-classifier.js +151 -16
  41. package/bin/dashboard/api-router.js +18 -38
  42. package/bin/dashboard/frontend/app.js +429 -0
  43. package/bin/dashboard/frontend/index.html +13 -406
  44. package/bin/dashboard/metrics-aggregator.js +46 -22
  45. package/bin/dashboard/server.js +160 -1
  46. package/bin/dashboard/sse-bridge.js +11 -8
  47. package/bin/engine/sre-manager.js +1 -1
  48. package/bin/engine/temporal-cli.js +56 -6
  49. package/bin/engine/verification-runner.js +134 -17
  50. package/bin/engine/verify-cli.js +25 -7
  51. package/bin/governance/approval-record.js +147 -0
  52. package/bin/governance/approve.js +12 -7
  53. package/bin/governance/policy-engine.js +33 -3
  54. package/bin/governance/policy-gate-hardened.js +36 -1
  55. package/bin/governance/verify-approvals.js +163 -0
  56. package/bin/harness-audit.js +224 -10
  57. package/bin/hooks/instinct-capture-hook.js +12 -4
  58. package/bin/install.js +63 -3
  59. package/bin/installer/harness-adapter-compliance.js +339 -28
  60. package/bin/installer/hook-registration.js +504 -0
  61. package/bin/installer-core.js +451 -63
  62. package/bin/learning/instinct-cli.js +7 -0
  63. package/bin/memory/vector-hub.js +196 -13
  64. package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
  65. package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
  66. package/bin/mindforge-cli.js +67 -6
  67. package/bin/models/cost-tracker.js +104 -6
  68. package/bin/models/model-client.js +6 -1
  69. package/bin/revops/debt-monitor.js +57 -13
  70. package/bin/security/trust-gate-hook.js +50 -6
  71. package/bin/skill-validator.js +6 -1
  72. package/bin/skills-builder/skill-scorer.js +46 -6
  73. package/bin/updater/self-update.js +6 -1
  74. package/bin/updater/version-comparator.js +21 -1
  75. package/bin/utils/mindforge-version.js +99 -0
  76. package/bin/utils/redact-secrets.js +106 -0
  77. package/bin/validate-config.js +42 -2
  78. package/bin/wizard/setup-wizard.js +4 -1
  79. package/bin/wizard/theme.js +9 -1
  80. package/changelogs/index.json +11 -9
  81. package/changelogs/v11.9.3.md +195 -0
  82. package/docs/References/config-reference.md +5 -2
  83. package/docs/References/sdk-api.md +1 -1
  84. package/docs/Templates/Codebase/architecture.md +1 -1
  85. package/docs/commands-reference.md +4 -5
  86. package/docs/faq.md +25 -5
  87. package/docs/getting-started.md +3 -3
  88. package/docs/sdk-reference.md +15 -7
  89. package/docs/troubleshooting.md +10 -6
  90. package/docs/user-guide.md +14 -14
  91. package/examples/sdk-integration/README.md +1 -1
  92. package/package.json +7 -3
  93. package/subagents/.claude-plugin/marketplace.json +1 -1
  94. package/bin/dashboard/approval-handler.js +0 -136
@@ -49,21 +49,119 @@ function getTodaySpendCached() {
49
49
  return _dailyCache.value;
50
50
  }
51
51
 
52
+ // COST-02 — the $25/day cap declared at MINDFORGE.md:54 was inert from the day it
53
+ // was written. v11.9.2 read `settings.MODEL_COST_HARD_LIMIT_USD`; the registry
54
+ // declares `[COST_HARD_LIMIT_USD]`. The MODEL_-prefixed name appears in exactly one
55
+ // shipped file (.mindforge/MINDFORGE-V2-SCHEMA.json:58) and that file has no code
56
+ // reader, so the lookup was always undefined -> parseFloat('0.0') -> 0 -> the
57
+ // `hardLimit <= 0` guard returned before any spend was compared. Canonical registry
58
+ // key first; the MODEL_-prefixed name is still read second so anyone who copied it
59
+ // out of the V2 schema keeps the working cap they have instead of silently losing it.
60
+ const HARD_LIMIT_KEYS = ['COST_HARD_LIMIT_USD', 'MODEL_COST_HARD_LIMIT_USD'];
61
+ const WARN_LIMIT_KEYS = ['COST_WARN_USD', 'MODEL_COST_WARN_USD'];
62
+
63
+ /** First non-empty key from `keys`, canonical-first. Returns null when none is set. */
64
+ function findThreshold(settings, keys) {
65
+ for (const key of keys) {
66
+ const raw = settings[key];
67
+ if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
68
+ return { key, raw: String(raw) };
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Classify a registry cost threshold. Returns a new object, never mutates input:
76
+ * { state: 'unset' } key absent/empty -> caller fails OPEN
77
+ * { state: 'disabled', key, value:0 } explicit 0 -> caller fails OPEN
78
+ * { state: 'armed', key, value } finite positive USD -> caller enforces
79
+ * { state: 'invalid', key, raw } unreadable or negative -> caller fails CLOSED
80
+ *
81
+ * parseFloat after stripping a leading `$` and thousands separators — not Number() —
82
+ * because `= $25.00` and `= 25.00 USD` are shapes a human types into MINDFORGE.md and
83
+ * both plainly mean 25; Number() would call them invalid and refuse every model call.
84
+ * Only a value with no leading number at all is invalid. Non-finite is invalid on
85
+ * purpose: `Infinity` compares false against every projection, i.e. it is not a cap.
86
+ */
87
+ function classifyThreshold(found) {
88
+ if (!found) return { state: 'unset' };
89
+ const value = parseFloat(found.raw.trim());
90
+ if (!Number.isFinite(value) || value < 0) return { state: 'invalid', key: found.key, raw: found.raw };
91
+ if (value === 0) return { state: 'disabled', key: found.key, value: 0 };
92
+ return { state: 'armed', key: found.key, value };
93
+ }
94
+
95
+ // [COST_WARN_USD] had no reader anywhere in bin/ before COST-02. At most one line per
96
+ // UTC day per threshold, so an armed warning does not append to stderr on every call.
97
+ // Replaced as a whole object rather than mutated in place.
98
+ let _warnState = { day: '', threshold: 0 };
99
+
100
+ /** Soft threshold. Must never throw — a warning that blocks is a second hard cap. */
101
+ function warnIfCrossed(settings, projected, hardLimit) {
102
+ const warn = classifyThreshold(findThreshold(settings, WARN_LIMIT_KEYS));
103
+ if (warn.state !== 'armed') return;
104
+ // Suppress only when an ARMED hard cap sits at or below the warn value — there the
105
+ // throw preempts this line anyway. With no hard cap, nothing preempts it, and an
106
+ // upgraded install without [COST_HARD_LIMIT_USD] is the common case
107
+ // (installer-core.js:706 never rewrites an existing MINDFORGE.md) — precisely the
108
+ // install that most needs a spend warning.
109
+ if (hardLimit !== null && warn.value >= hardLimit) return;
110
+ if (projected < warn.value) return;
111
+
112
+ const today = new Date().toISOString().slice(0, 10);
113
+ if (_warnState.day === today && _warnState.threshold === warn.value) return;
114
+ _warnState = { day: today, threshold: warn.value };
115
+ const cap = hardLimit === null ? 'no hard cap set' : `hard cap ${hardLimit}`;
116
+ process.stderr.write(
117
+ `[cost-tracker] Projected daily spend ${projected.toFixed(4)} crossed [${warn.key}] = ${warn.value} (${cap})\n`
118
+ );
119
+ }
120
+
52
121
  async function preflight(estimatedCost = 0) {
53
122
  const settings = require('./model-router').getAllSettings();
54
- const hardLimit = parseFloat(settings.MODEL_COST_HARD_LIMIT_USD || '0.0');
55
-
56
- if (hardLimit <= 0) return;
123
+ const limit = classifyThreshold(findThreshold(settings, HARD_LIMIT_KEYS));
124
+
125
+ // Fail CLOSED on a limit that is present but unreadable: a cap nobody can parse is
126
+ // not a cap. Not a new surprise either — bin/validate-config.js already rejects this
127
+ // exact config with exit 1, because COST_HARD_LIMIT_USD is typed "number" at
128
+ // .mindforge/MINDFORGE-SCHEMA.json:87. The code is distinct from COST_LIMIT_REACHED
129
+ // so a caller can tell a spend stop from a config fault; bin/models/model-client.js
130
+ // re-throws both, which is the only reason this throw is not swallowed.
131
+ if (limit.state === 'invalid') {
132
+ throw Object.assign(
133
+ new Error(`[${limit.key}] = "${limit.raw}" is not a USD amount — the daily cost cap cannot be evaluated. Set a number in MINDFORGE.md (0 disables the cap).`),
134
+ { code: 'COST_LIMIT_MISCONFIGURED', key: limit.key, raw: limit.raw }
135
+ );
136
+ }
137
+
138
+ // Fail OPEN when the key is absent or explicitly 0. DELIBERATE — do not invert it.
139
+ // docs/research/2026-08-v12-upgrade-report.md:85 recommends making an unset limit a
140
+ // config error, but bin/installer-core.js:706 writes MINDFORGE.md only when it does
141
+ // not already exist, so every install upgraded from a registry predating this key
142
+ // would start refusing every model call. The shipped schema agrees the key is
143
+ // optional: it sits in `recommended`, not `required` (.mindforge/MINDFORGE-SCHEMA.json
144
+ // :13-17), and bin/validate-config.js:48-49 only warns and exits 0 when it is absent.
145
+ if (limit.state !== 'armed') {
146
+ // The soft threshold must NOT depend on the hard cap. Guarded on the warn key so
147
+ // the no-cost-config fast path stays a pure early return and never reads the ledger.
148
+ if (classifyThreshold(findThreshold(settings, WARN_LIMIT_KEYS)).state === 'armed') {
149
+ warnIfCrossed(settings, getTodaySpendCached() + estimatedCost, null);
150
+ }
151
+ return;
152
+ }
57
153
 
58
154
  const todaySpend = getTodaySpendCached();
59
155
  const projected = todaySpend + estimatedCost;
60
156
 
61
- if (projected >= hardLimit) {
157
+ if (projected >= limit.value) {
62
158
  throw Object.assign(
63
- new Error(`Daily cost limit $${hardLimit} reached (Today: $${todaySpend.toFixed(4)})`),
64
- { code: 'COST_LIMIT_REACHED', spend: todaySpend, limit: hardLimit }
159
+ new Error(`Daily cost limit $${limit.value} reached (Today: $${todaySpend.toFixed(4)})`),
160
+ { code: 'COST_LIMIT_REACHED', spend: todaySpend, limit: limit.value }
65
161
  );
66
162
  }
163
+
164
+ warnIfCrossed(settings, projected, limit.value);
67
165
  }
68
166
 
69
167
  async function record(entry) {
@@ -41,10 +41,15 @@ class ModelClient {
41
41
  let modelId = routing.model;
42
42
 
43
43
  // 2. Pre-flight cost check
44
+ // Both COST_ codes must propagate. This catch re-threw only COST_LIMIT_REACHED,
45
+ // so COST-02's fail-closed config fault would have been discarded here and the
46
+ // call would have proceeded uncapped — an interlock reporting success while doing
47
+ // nothing. Anything else still falls through on purpose: an unreadable ledger is
48
+ // a broken meter, and a broken meter must not block work.
44
49
  try {
45
50
  await CostTracker.preflight(0.05); // Conservative estimate
46
51
  } catch (e) {
47
- if (e.code === 'COST_LIMIT_REACHED') throw e;
52
+ if (e.code === 'COST_LIMIT_REACHED' || e.code === 'COST_LIMIT_MISCONFIGURED') throw e;
48
53
  }
49
54
 
50
55
  // 3. Execute with fallbacks
@@ -4,27 +4,69 @@
4
4
  */
5
5
  'use strict';
6
6
 
7
- const fs = require('fs');
8
- const path = require('path');
7
+ // A `require('fs')` and a `this.auditPath = path.join(...)` used to sit here. Both were dead —
8
+ // `grep 'fs\.'` over this file returns nothing and no caller reads `auditPath` — and their presence
9
+ // implied this module reads the audit log, which it does not. Its only input is the array handed to
10
+ // monitor() by bin/dashboard/revops-api.js. Removed so the module's actual surface is legible.
9
11
 
10
- class DebtMonitor {
11
- constructor() {
12
- this.auditPath = path.join(process.cwd(), '.planning', 'AUDIT.jsonl');
13
- }
12
+ /**
13
+ * The score is computed from exactly these three event types. If an audit log contains none of them,
14
+ * this module measured NOTHING — which is a different state from measuring cleanly, and the whole
15
+ * point of the sentinel below.
16
+ */
17
+ const TRACKED_EVENTS = ['security_finding', 'approval_granted', 'policy_bypass'];
18
+
19
+ /**
20
+ * Reported in place of a score, a status and a debt level when nothing was measured.
21
+ *
22
+ * WHY A STRING and not null. Both dashboard frontends call `.toUpperCase()` on `governance_status`
23
+ * and `debt_level` (index.html:735,747 and, in PR #186's extracted copy, app.js:395,407 — verified
24
+ * byte-identical), so null or a number is a hard TypeError there, not a blank tile.
25
+ * `security_health_score` is assigned with a bare `textContent =` (index.html:732), so a string
26
+ * passes straight through and renders as the word. Confirmed by loading the real render function into
27
+ * a headless browser: the tile reads "Unmeasured", the badge "STATUS: UNMEASURED", and the `> 80`
28
+ * colour comparison is false, so the badge goes red rather than green. No frontend edit needed.
29
+ */
30
+ const UNMEASURED = 'Unmeasured';
14
31
 
32
+ class DebtMonitor {
15
33
  /**
16
34
  * Monitor governance debt and security health.
17
35
  * @param {Object} metrics - From MetricsAggregator
18
36
  */
19
37
  monitor(metrics) {
20
38
  const auditEntries = metrics.auditEntries || [];
21
-
22
- // 1. Identify high-risk events
23
- const criticalFindings = auditEntries.filter(e => e.event === 'security_finding' && e.severity === 'critical');
24
- const tier3Approvals = auditEntries.filter(e => e.event === 'approval_granted' && e.tier === 3);
25
- const policyBypasses = auditEntries.filter(e => e.event === 'policy_bypass');
26
39
 
27
- // 2. Calculate Health Score (starts at 100)
40
+ // 1. Identify high-risk events.
41
+ // Severity is compared case-INSENSITIVELY. It used to require lowercase 'critical', while
42
+ // .mindforge/audit/AUDIT-SCHEMA.md:269 declares severity uppercase ("HIGH"). Measured: three
43
+ // schema-compliant `severity: 'CRITICAL'` findings scored 100/"Excellent"/"Minimal", while the
44
+ // same three lowercase scored 70/"Warning"/"Moderate" — so a compliant writer was invisible.
45
+ const criticalFindings = auditEntries.filter((e) => e.event === 'security_finding'
46
+ && String(e.severity || '').toLowerCase() === 'critical');
47
+ const tier3Approvals = auditEntries.filter((e) => e.event === 'approval_granted' && e.tier === 3);
48
+ const policyBypasses = auditEntries.filter((e) => e.event === 'policy_bypass');
49
+
50
+ // 2. Refuse to report health that was never measured.
51
+ // Previously an absent audit log, an empty one, and a clean one were indistinguishable: all
52
+ // three returned 100 / "Excellent" / "Minimal". Measured, the absent and empty cases were
53
+ // byte-identical but for the timestamp. Reporting a maximum for the absence of evidence is
54
+ // the inverse of what a security score is for.
55
+ const measuredEvents = auditEntries.filter((e) => TRACKED_EVENTS.includes(e.event)).length;
56
+ if (measuredEvents === 0) {
57
+ return {
58
+ security_health_score: UNMEASURED,
59
+ governance_status: UNMEASURED,
60
+ critical_findings: 0,
61
+ tier3_approvals: 0,
62
+ policy_bypasses: 0,
63
+ debt_level: UNMEASURED,
64
+ measured: false,
65
+ timestamp: new Date().toISOString(),
66
+ };
67
+ }
68
+
69
+ // 3. Calculate Health Score (starts at 100)
28
70
  let score = 100;
29
71
  score -= (criticalFindings.length * 10);
30
72
  score -= (tier3Approvals.length * 5);
@@ -32,7 +74,7 @@ class DebtMonitor {
32
74
 
33
75
  const healthScore = Math.max(0, score);
34
76
 
35
- // 3. Determine status
77
+ // 4. Determine status
36
78
  let status = 'Excellent';
37
79
  if (healthScore < 90) status = 'Good';
38
80
  if (healthScore < 75) status = 'Warning';
@@ -45,6 +87,7 @@ class DebtMonitor {
45
87
  tier3_approvals: tier3Approvals.length,
46
88
  policy_bypasses: policyBypasses.length,
47
89
  debt_level: this.getDebtLevel(healthScore),
90
+ measured: true,
48
91
  timestamp: new Date().toISOString()
49
92
  };
50
93
  }
@@ -58,3 +101,4 @@ class DebtMonitor {
58
101
  }
59
102
 
60
103
  module.exports = new DebtMonitor();
104
+ module.exports.UNMEASURED = UNMEASURED;
@@ -3,6 +3,53 @@
3
3
 
4
4
  const { isHighImpact } = require('./trust-boundaries');
5
5
 
6
+ /**
7
+ * WHY THERE IS NO TOOL-NAME CHECK HERE.
8
+ *
9
+ * THE DEFECT. This hook used to open with `if (event.tool_name !== 'Bash') process.exit(0)`, so a
10
+ * deny-class gate permitted every call whose tool was not spelled with that exact string. Measured
11
+ * against the real hook with one destructive command and only the tool name varied:
12
+ *
13
+ * tool_name=Bash -> DENY (exit 2)
14
+ * tool_name=Shell -> PERMIT (exit 0)
15
+ * tool_name=shell -> PERMIT (exit 0)
16
+ * tool_name=PowerShell -> PERMIT (exit 0)
17
+ * tool_name=run_terminal_cmd -> PERMIT (exit 0)
18
+ * tool_name=Terminal -> PERMIT (exit 0)
19
+ *
20
+ * That is reachable today, not hypothetical. Verified inside cursor-agent 2026.04.17's own bundle:
21
+ * it loads the exact file the installer writes —
22
+ *
23
+ * claudeProjectConfigPath: join(e, ".claude", "settings.json")
24
+ * claudeUserConfigPath: join(homedir(), ".claude", "settings.json")
25
+ *
26
+ * — and translates Claude matcher names to its own tool names through
27
+ * `{Bash:"Shell", Read:"Read", Write:"Write", Edit:"Write", Grep:"Grep", ...}`, while its hook
28
+ * normaliser defaults to `{loop_limit:null, failClosed:!1}`. So opening a MindForge-installed project
29
+ * in Cursor silently drops this gate, and its shell payload carries the SAME
30
+ * `{command, workingDirectory, timeout}` shape — meaning nothing but the name check stood between a
31
+ * destructive command and execution, while the install receipt reported three deny-class hooks
32
+ * verified blocking.
33
+ *
34
+ * THE SCOPE IS `tool_input.command`, AND THAT IS SUFFICIENT. No name list replaces the check, because
35
+ * a list only ever covers the harnesses someone thought to enumerate and the next one that spells its
36
+ * tool differently reopens the hole in silence. Every shell tool observed — Claude Code's Bash and
37
+ * Cursor's Shell — passes the command as `tool_input.command`, so reading that field IS the scoping:
38
+ * a Write, Edit, Read or Grep payload has no `.command`, yields the empty string, and is permitted
39
+ * without a name ever being consulted. An unknown harness using the conventional shape now fails
40
+ * CLOSED instead of open.
41
+ *
42
+ * A FIRST ATTEMPT AT THIS FIX ADDED A `looksLikeShellCall()` GUARD, and falsification proved it did
43
+ * nothing. Deleting that guard outright left all 14 tests green, because the extraction below already
44
+ * scopes the gate. Its comment claimed removal would over-block Write payloads whose CONTENT mentions
45
+ * a destructive command — measurably false, since such payloads carry no `.command`. Keeping it would
46
+ * have shipped machinery that looks like enforcement and performs none, which is the exact defect
47
+ * class this gate was being repaired for. So it is gone, and this comment stands in its place.
48
+ *
49
+ * MCP calls are unaffected: Cursor passes `tool_input: JSON.stringify(d)` for those, so `.command` on
50
+ * a string is undefined and the gate declines rather than guessing.
51
+ */
52
+
6
53
  let input = '';
7
54
  process.stdin.setEncoding('utf8');
8
55
  process.stdin.on('data', (chunk) => { input += chunk; });
@@ -10,12 +57,9 @@ process.stdin.on('end', () => {
10
57
  try {
11
58
  const event = JSON.parse(input);
12
59
 
13
- // Only gate Bash tool calls
14
- if (event.tool_name !== 'Bash') {
15
- process.exit(0); // allow
16
- }
17
-
18
- const fullCommand = event.tool_input?.command || '';
60
+ // Scoped by payload shape, NOT by tool name — see the header for the measured fail-open this
61
+ // replaces. A non-executing call has no `.command`, so it yields '' and is permitted below.
62
+ const fullCommand = (typeof event.tool_input?.command === 'string' ? event.tool_input.command : '');
19
63
 
20
64
  // Check the whole command AND every individual line, blocking if ANY
21
65
  // segment is high-impact. Per-line scanning means a benign first line
@@ -22,7 +22,12 @@ const colors = {
22
22
  };
23
23
 
24
24
  function main() {
25
- const target = ARGS[0];
25
+ // The first NON-FLAG argument. This was `ARGS[0]`, so any flag placed before the path was taken
26
+ // as the filename: `validate-skill --no-color skill/SKILL.md` reported
27
+ // "File not found: <cwd>/--no-color" and exited 1. Flags-before-operands is the ordering most
28
+ // people reach for, and the two flags this tool documents (--no-color, --enterprise) are both
29
+ // read from anywhere in ARGS, so only the target lookup was position-sensitive.
30
+ const target = ARGS.find((a) => !a.startsWith('-'));
26
31
  if (!target || ARGS.includes('--help') || ARGS.includes('-h')) {
27
32
  console.log('\nUsage: mindforge-cc validate-skill <path-to-SKILL.md>\n');
28
33
  process.exit(0);
@@ -44,17 +44,57 @@ const GENERIC_TRIGGERS = new Set([
44
44
  'response', 'handler', 'controller', 'repository', 'schema',
45
45
  ]);
46
46
 
47
+ // ── Trigger frontmatter, in both grammars ─────────────────────────────────────
48
+ //
49
+ // THE DEFECT THIS FIXES. This parser required a YAML BLOCK list:
50
+ //
51
+ // const triggersSection = frontmatter.match(/^triggers:\n((?: {2}- .+\n?)*)/m);
52
+ //
53
+ // The shipped corpus is written in the INLINE comma form. Measured by driving this module's real
54
+ // parseSkill over the real .mindforge/skills tree: 232 skills, 232 with ZERO triggers, mean 0.00 —
55
+ // 232 inline, 0 block. So every engine skill scored 0 of the 30 points `score()` awards for trigger
56
+ // coverage (:95-99), and the suggestion at :233 told skills carrying 12 triggers to "add 25 more to
57
+ // reach 25+ (currently 0)". Three callers — learn-cli.js, skill-registrar.js, skill-generator.js —
58
+ // make registration decisions against that uniformly-zero scoreboard.
59
+ //
60
+ // bin/skill-validator.js:119 has always read the inline form (`fm.triggers.split(',')`). Two readers
61
+ // of the same field, two incompatible grammars, and the corpus written in the one the scorer could
62
+ // not read. The DIVERGENCE is the real defect; the regex is just where it surfaced.
63
+ //
64
+ // BOTH grammars are supported deliberately. The block form is not legacy cruft: all three fixtures
65
+ // in tests/self-building-skills.test.js use it, and :209 asserts >= 24 triggers on one of them, so
66
+ // dropping it would silently zero those fixtures and the test would still pass for the wrong reason.
67
+ //
68
+ // DELIBERATELY NOT DONE HERE: extracting one shared frontmatter reader so the scorer and the
69
+ // validator cannot diverge again. That is the durable fix, but it edits bin/skill-validator.js,
70
+ // which PR #185 is currently changing. Doing it here would collide. Follow-up once #185 lands.
71
+ function parseTriggers(frontmatter) {
72
+ // Block form first. The ordering is defence in depth, not the load-bearing part — verified rather
73
+ // than assumed: the `\S` in the inline pattern below already makes it REJECT a block-form header,
74
+ // because after `triggers:` the next character is a newline and `.` does not match one. Loosen
75
+ // that `\S` to `.` and the inline branch starts matching block form with an EMPTY capture, so a
76
+ // list of triggers would silently parse as none. Two independent things prevent that; keep both.
77
+ // `[ \t]+` rather than the old `{2}` — two spaces is a convention, not a requirement.
78
+ const block = frontmatter.match(/^triggers:[ \t]*\n((?:[ \t]+-[ \t]+.+\n?)+)/m);
79
+ if (block) {
80
+ return block[1].split('\n').map((l) => l.replace(/^\s*-\s*/, '').trim()).filter(Boolean);
81
+ }
82
+
83
+ // Inline comma form, e.g. `triggers: a11y testing, axe-core, WCAG compliance test`.
84
+ const inline = frontmatter.match(/^triggers:[ \t]*(\S.*)$/m);
85
+ if (inline) {
86
+ return inline[1].split(',').map((t) => t.trim()).filter(Boolean);
87
+ }
88
+
89
+ return [];
90
+ }
91
+
47
92
  // ── SKILL.md parser ───────────────────────────────────────────────────────────
48
93
  function parseSkill(content) {
49
94
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
50
95
  const frontmatter = frontmatterMatch?.[1] || '';
51
96
 
52
- // Extract triggers from frontmatter
53
- const triggersSection = frontmatter.match(/^triggers:\n((?: {2}- .+\n?)*)/m);
54
- const triggers = (triggersSection?.[1] || '')
55
- .split('\n')
56
- .map(l => l.replace(/^\s*- /, '').trim())
57
- .filter(Boolean);
97
+ const triggers = parseTriggers(frontmatter);
58
98
 
59
99
  // Count code blocks with ≥ 3 lines (meaningful examples, not one-liners)
60
100
  const codeBlockMatches = content.match(/```[\s\S]*?```/g) || [];
@@ -10,7 +10,12 @@ const { execSync } = require('child_process');
10
10
  const { compareSemver, upgradeType, fetchLatestVersion } = require('./version-comparator');
11
11
  const { fetchChangelog } = require('./changelog-fetcher');
12
12
 
13
- const CURRENT_VERSION = require('../../package.json').version;
13
+ // MindForge's OWN version, not the host project's. `require('../../package.json')` resolved to
14
+ // the CONSUMER's manifest once bin/updater/ was copied into a project — measured 1.0.0 for an
15
+ // app installed alongside MindForge 11.9.2, which made every upgrade read as MAJOR and fed a
16
+ // bogus "from" version into the migration path below.
17
+ const { resolveMindforgeVersion } = require('../utils/mindforge-version');
18
+ const CURRENT_VERSION = resolveMindforgeVersion({ fromDir: __dirname }).version;
14
19
 
15
20
  /**
16
21
  * Detect where MindForge was originally installed.
@@ -37,6 +37,26 @@ function upgradeType(current, latest) {
37
37
  * Returns null on any error — callers must handle gracefully.
38
38
  * Timeout: 5 seconds (respects enterprise proxies that may be slow).
39
39
  */
40
+ /**
41
+ * MindForge's own version for the User-Agent, or 'unknown'.
42
+ *
43
+ * The header used to interpolate `require('../../package.json').version`, which in an INSTALL is
44
+ * the consumer's manifest — measured 1.0.0 for a host app sitting alongside MindForge 11.9.2, so
45
+ * the registry saw the wrong client version. Resolved by package NAME instead.
46
+ *
47
+ * Required inline, and failure degrades to 'unknown' rather than throwing: this file documents
48
+ * itself as pure with no external dependencies, and an unresolvable version is no reason to break
49
+ * an update check. self-update.js throws in the same situation, correctly — there a wrong version
50
+ * would misclassify the upgrade.
51
+ */
52
+ function userAgentVersion() {
53
+ try {
54
+ return require('../utils/mindforge-version').resolveMindforgeVersion({ fromDir: __dirname }).version;
55
+ } catch {
56
+ return 'unknown';
57
+ }
58
+ }
59
+
40
60
  async function fetchLatestVersion(packageName = 'mindforge-cc') {
41
61
  const https = require('https');
42
62
  return new Promise(resolve => {
@@ -44,7 +64,7 @@ async function fetchLatestVersion(packageName = 'mindforge-cc') {
44
64
  hostname: 'registry.npmjs.org',
45
65
  path: `/${encodeURIComponent(packageName)}/latest`,
46
66
  method: 'GET',
47
- headers: { 'Accept': 'application/json', 'User-Agent': `mindforge-cc/${require('../../package.json').version}` },
67
+ headers: { 'Accept': 'application/json', 'User-Agent': `mindforge-cc/${userAgentVersion()}` },
48
68
  timeout: 5000,
49
69
  };
50
70
 
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ /**
3
+ * Resolve MINDFORGE's own version, from any context it might be running in.
4
+ *
5
+ * THE DEFECT THIS REPLACES. Three modules read `require('../../package.json').version`:
6
+ *
7
+ * bin/updater/self-update.js:13 const CURRENT_VERSION = ...
8
+ * bin/updater/version-comparator.js:47 User-Agent: `mindforge-cc/${...}`
9
+ * bin/wizard/setup-wizard.js:9 const VERSION = ...
10
+ *
11
+ * In the repo that path IS MindForge's manifest, so it looks correct. In an INSTALL it is not: the
12
+ * installer copies bin/updater/ into the consumer's project, where `../../package.json` is the
13
+ * CONSUMER's manifest. Measured on a clean `--claude --local` into an app at 1.0.0:
14
+ *
15
+ * ../../package.json from bin/updater/ -> <project>/package.json
16
+ * its version: 1.0.0 (the consumer app)
17
+ * MindForge actual: 11.9.2
18
+ *
19
+ * That is not cosmetic. self-update.js:89 classifies the upgrade with
20
+ * `upgradeType(CURRENT_VERSION, latestVersion)`, so 1.0.0 -> 11.9.x reads as a MAJOR upgrade, and
21
+ * :133 uses `readHandoffSchemaVersion() || CURRENT_VERSION` as the "from" version driving
22
+ * migrations. A bogus origin version steers migration behaviour.
23
+ *
24
+ * Same family as the auto-runner defect fixed in ed977e9, which passed process.cwd() to a version
25
+ * check that compares MindForge's own manifests: both mistake "the directory I am running in" for
26
+ * "the package I am part of".
27
+ *
28
+ * HOW IT RESOLVES, in order, with the discriminator that makes it work:
29
+ *
30
+ * 1. The nearest ancestor package.json whose `name` is 'mindforge-cc'. The NAME is the whole
31
+ * trick — it is what distinguishes MindForge's manifest from a consumer's, which a relative
32
+ * path cannot do. Covers the repo, a global npm install, and node_modules layouts.
33
+ * 2. <cwd>/.mindforge/config.json `.version`. In an install this is written BY the installer and
34
+ * carries MindForge's version (measured: 11.9.2 in a project whose app is 1.0.0).
35
+ * tests/version-consistency.test.js asserts it equals package.json in the repo, so the two
36
+ * sources cannot silently disagree.
37
+ * 3. <cwd>/node_modules/mindforge-cc/package.json, for a consumer that has the package but no
38
+ * .mindforge/ yet.
39
+ *
40
+ * It THROWS rather than guessing. A wrong version is worse than an absent one here: it produces a
41
+ * confident, incorrect upgrade classification instead of an error somebody can act on.
42
+ */
43
+
44
+ const fs = require('fs');
45
+ const path = require('path');
46
+
47
+ const PACKAGE_NAME = 'mindforge-cc';
48
+
49
+ function readJson(file) {
50
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
51
+ }
52
+
53
+ /** Walk up from `dir` looking for a package.json that belongs to MindForge itself. */
54
+ function findOwnManifest(dir) {
55
+ let current = path.resolve(dir);
56
+ // Bounded by the filesystem root; path.dirname('/') === '/' terminates the loop.
57
+ for (let depth = 0; depth < 40; depth++) {
58
+ const candidate = path.join(current, 'package.json');
59
+ const pkg = readJson(candidate);
60
+ if (pkg && pkg.name === PACKAGE_NAME && pkg.version) {
61
+ return { version: pkg.version, source: candidate };
62
+ }
63
+ const parent = path.dirname(current);
64
+ if (parent === current) break;
65
+ current = parent;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * @param {{fromDir?: string, cwd?: string}} [opts]
72
+ * fromDir - where to start walking for MindForge's own manifest (defaults to this file).
73
+ * cwd - the project root to consult for installed-context sources.
74
+ * @returns {{version: string, source: string}}
75
+ * @throws {Error} when no source can establish MindForge's version.
76
+ */
77
+ function resolveMindforgeVersion(opts = {}) {
78
+ const fromDir = opts.fromDir || __dirname;
79
+ const cwd = opts.cwd || process.cwd();
80
+
81
+ const own = findOwnManifest(fromDir);
82
+ if (own) return own;
83
+
84
+ const cfgPath = path.join(cwd, '.mindforge', 'config.json');
85
+ const cfg = readJson(cfgPath);
86
+ if (cfg && cfg.version) return { version: cfg.version, source: cfgPath };
87
+
88
+ const depPath = path.join(cwd, 'node_modules', PACKAGE_NAME, 'package.json');
89
+ const dep = readJson(depPath);
90
+ if (dep && dep.version) return { version: dep.version, source: depPath };
91
+
92
+ throw new Error(
93
+ 'Cannot determine the MindForge version. Looked for a package.json named ' +
94
+ `"${PACKAGE_NAME}" above ${fromDir}, then ${path.join(cwd, '.mindforge/config.json')}, then ` +
95
+ `${depPath}. Refusing to guess: a wrong version produces a confident but incorrect upgrade ` +
96
+ 'classification.');
97
+ }
98
+
99
+ module.exports = { resolveMindforgeVersion, findOwnManifest, PACKAGE_NAME };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+ /**
3
+ * Redact credential-shaped substrings from text that is about to be persisted.
4
+ *
5
+ * WHY THIS EXISTS. `bin/hooks/instinct-capture-hook.js` writes the first 200 characters of a raw
6
+ * Bash command — and of a raw Task description — into BOTH an `observation` and a `behavior` field
7
+ * of `.mindforge/engine/instincts/instinct-store.jsonl`. Measured on real store files left by
8
+ * probes: an entry whose `observation` begins `Bash command succeeded: AWS_SE...`, i.e. an
9
+ * `AWS_SECRET_ACCESS_KEY=<value>` assignment captured verbatim. The store path is not gitignored,
10
+ * `.mindforge/engine/` is inside `package.json` files[] with no negation for instincts, and the
11
+ * promotion path (`.mindforge/engine/instincts/promotion-engine.md:33,41,46`) copies these fields
12
+ * into a generated, tracked, PUBLISHED `SKILL.md`. So a captured secret is amplified from a
13
+ * data file into committed source.
14
+ *
15
+ * WHY REDACT RATHER THAN HASH. Measured, not assumed: three consumers read this text and all three
16
+ * need it readable.
17
+ *
18
+ * bin/learning/instinct-cli.js:114 `list` prints observation AND behavior verbatim to a human
19
+ * bin/learning/instinct-cli.js:241 `promote` prints behavior verbatim as the suggestion
20
+ * .mindforge/skills/instinct-clustering/SKILL.md:57-63 word-overlap Jaccard on observation
21
+ *
22
+ * And nothing in code dedups on the text — `cmdImport` keys on `e.id` and the hook stamps a fresh
23
+ * randomUUID per event — so a hash would preserve no collision that anything relies on. Hashing
24
+ * would break three consumers to buy nothing.
25
+ *
26
+ * WHY NOT "KEEP argv[0], REDACT THE REST". Because of the env-assignment form: in
27
+ * `SECRET=value cmd ...` the secret precedes the program, so the first retained token IS the
28
+ * secret. That is not hypothetical — it is the shape of the measured on-disk entry above.
29
+ *
30
+ * FAIL CLOSED. If redaction throws, this returns a placeholder rather than the input. A redactor
31
+ * that fails open is not a redactor. Note this does NOT make the calling hook fail closed: the hook
32
+ * stays advisory and still exits 0. "Do not block the tool call" and "do not write the secret
33
+ * anyway" are different promises, and both are kept.
34
+ */
35
+
36
+ const PLACEHOLDER = (kind) => `<redacted:${kind}>`;
37
+
38
+ /**
39
+ * Ordered most-specific first. Each entry replaces only the SECRET portion where a prefix carries
40
+ * meaning worth keeping (a scheme, a key name), so the redacted text still reads as the same shape
41
+ * of command — which is what the human-facing and Jaccard consumers need.
42
+ */
43
+ const RULES = [
44
+ // PEM private key blocks, header through footer.
45
+ { kind: 'pem', re: /-----BEGIN[^-]{0,40}(?:PRIVATE )?KEY-----[\s\S]*?-----END[^-]{0,40}KEY-----/g,
46
+ replace: () => PLACEHOLDER('pem') },
47
+
48
+ // Vendor tokens with recognisable prefixes. Keep the prefix: it says WHICH credential leaked,
49
+ // which is exactly what an operator needs in order to rotate the right one.
50
+ { kind: 'vendor-token',
51
+ re: /\b(sk-(?:proj-|ant-)?|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|glpat-|npm_|xox[baprse]-|AKIA|ASIA|AIza|hf_|dop_v1_)[A-Za-z0-9_-]{16,}/g,
52
+ replace: (m, prefix) => `${prefix}${PLACEHOLDER('token')}` },
53
+
54
+ // Authorization headers. Keep the scheme, drop the credential.
55
+ { kind: 'auth-header', re: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9+/=._~-]{8,}/gi,
56
+ replace: (m, scheme) => `${scheme} ${PLACEHOLDER('auth')}` },
57
+
58
+ // Credentials embedded in a URL: scheme://user:pass@host
59
+ { kind: 'url-credential', re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):[^\s@/]+@/gi,
60
+ replace: (m, upToUser) => `${upToUser}:${PLACEHOLDER('url-password')}@` },
61
+
62
+ // Environment-assignment form, keyed on the NAME looking secret-bearing. This is the case that
63
+ // defeats prefix-preserving designs, because the assignment can precede the program name.
64
+ { kind: 'assignment',
65
+ re: /\b([A-Za-z_][A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|APIKEY|API_KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL|AUTH)[A-Za-z0-9_]*)\s*=\s*("[^"]*"|'[^']*'|[^\s;|&]+)/gi,
66
+ replace: (m, name) => `${name}=${PLACEHOLDER('assignment')}` },
67
+
68
+ // Flag form: --password X, --token=X, -p X. Keep the flag so the command still parses visually.
69
+ { kind: 'flag',
70
+ re: /(--?(?:password|passwd|token|secret|api[-_]?key|access[-_]?key|auth|credential)(?:[=\s]))(?:"[^"]*"|'[^']*'|[^\s;|&]+)/gi,
71
+ replace: (m, flag) => `${flag}${PLACEHOLDER('flag-value')}` },
72
+
73
+ // Generic high-entropy run, last resort for credentials with no recognisable prefix. Requires
74
+ // BOTH a letter and a digit and >= 28 characters, so English words, long flag names and ordinary
75
+ // identifiers are left alone. `/` is excluded so a long path is examined segment by segment.
76
+ { kind: 'opaque', re: /\b(?=[A-Za-z0-9+=_-]{28,}\b)(?=[^\s]*[A-Za-z])(?=[^\s]*\d)[A-Za-z0-9+=_-]{28,}\b/g,
77
+ replace: () => PLACEHOLDER('opaque') },
78
+ ];
79
+
80
+ /**
81
+ * @param {string} text
82
+ * @returns {string} the text with credential-shaped substrings replaced
83
+ */
84
+ function redactSecrets(text) {
85
+ if (typeof text !== 'string' || text === '') return text;
86
+ try {
87
+ let out = text;
88
+ for (const rule of RULES) out = out.replace(rule.re, rule.replace);
89
+ return out;
90
+ } catch {
91
+ // Fail closed. Withholding telemetry is always cheaper than persisting a credential.
92
+ return PLACEHOLDER('redaction-failed');
93
+ }
94
+ }
95
+
96
+ /**
97
+ * True when redactSecrets would change the text. Useful for asserting a fixture is actually
98
+ * secret-shaped before relying on it, so a test cannot pass because its fixture was inert.
99
+ * @param {string} text
100
+ * @returns {boolean}
101
+ */
102
+ function containsSecretShape(text) {
103
+ return typeof text === 'string' && text !== '' && redactSecrets(text) !== text;
104
+ }
105
+
106
+ module.exports = { redactSecrets, containsSecretShape, RULES };