mindforge-cc 11.9.1 → 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.
- package/.agent/CLAUDE.md +37 -13
- package/.agent/hooks/mindforge-block-no-verify.js +61 -13
- package/.agent/hooks/mindforge-config-protection.js +82 -3
- package/.agent/hooks/mindforge-context-monitor.js +1 -1
- package/.agent/hooks/mindforge-workflow-guard.js +2 -2
- package/.agent/hooks/run-with-flags.js +190 -20
- package/.agent/mindforge/browse.md +2 -2
- package/.agent/mindforge/checkpoint.md +1 -1
- package/.agent/mindforge/consult.md +1 -1
- package/.agent/mindforge/cost-report.md +1 -1
- package/.agent/mindforge/harness-audit.md +1 -1
- package/.agent/mindforge/orch-add-feature.md +1 -1
- package/.agent/mindforge/orch-build-mvp.md +1 -1
- package/.agent/mindforge/orch-change-feature.md +1 -1
- package/.agent/mindforge/orch-fix-defect.md +1 -1
- package/.agent/mindforge/orch-refine-code.md +1 -1
- package/.agent/mindforge/qa.md +2 -2
- package/.claude/CLAUDE.md +37 -13
- package/.claude/commands/mindforge/browse.md +2 -2
- package/.claude/commands/mindforge/checkpoint.md +1 -1
- package/.claude/commands/mindforge/consult.md +1 -1
- package/.claude/commands/mindforge/cost-report.md +1 -1
- package/.claude/commands/mindforge/harness-audit.md +1 -1
- package/.claude/commands/mindforge/orch-add-feature.md +1 -1
- package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
- package/.claude/commands/mindforge/orch-change-feature.md +1 -1
- package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
- package/.claude/commands/mindforge/orch-refine-code.md +1 -1
- package/.claude/commands/mindforge/qa.md +2 -2
- package/.mindforge/MINDFORGE-SCHEMA.json +126 -13
- package/.mindforge/config.json +4 -4
- package/.mindforge/engine/autonomous/headless-adapter.md +2 -2
- package/.mindforge/engine/cost-tracking/router.md +1 -1
- package/.mindforge/engine/cost-tracking/token-ledger.md +21 -24
- package/.mindforge/engine/temporal-protocol.md +2 -2
- package/.mindforge/governance/change-classifier.md +20 -4
- package/.mindforge/memory/sync-manifest.json +1 -1
- package/.mindforge/metrics/METRICS-SCHEMA.md +13 -4
- package/.mindforge/personas/cost-optimizer.md +2 -2
- package/.mindforge/personas/multi-model-bridge.md +1 -1
- package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
- package/.mindforge/skills/cost-aware-routing/SKILL.md +3 -3
- package/.mindforge/skills/multi-llm-consult/SKILL.md +2 -2
- package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
- package/CHANGELOG.md +402 -0
- package/MINDFORGE.md +13 -6
- package/README.md +51 -2
- package/RELEASENOTES.md +55 -2
- package/SECURITY.md +22 -3
- package/bin/autonomous/audit-writer.js +48 -33
- package/bin/autonomous/auto-runner.js +65 -2
- package/bin/change-classifier.js +151 -16
- package/bin/dashboard/api-router.js +28 -47
- package/bin/dashboard/error-response.js +44 -0
- package/bin/dashboard/frontend/app.js +429 -0
- package/bin/dashboard/frontend/index.html +14 -390
- package/bin/dashboard/metrics-aggregator.js +75 -30
- package/bin/dashboard/revops-api.js +12 -2
- package/bin/dashboard/server.js +245 -6
- package/bin/dashboard/sse-bridge.js +11 -8
- package/bin/dashboard/temporal-api.js +11 -5
- package/bin/engine/remediation-engine.js +12 -1
- package/bin/engine/sre-manager.js +1 -1
- package/bin/engine/temporal-cli.js +56 -6
- package/bin/engine/temporal-hub.js +41 -9
- package/bin/engine/verification-runner.js +134 -17
- package/bin/engine/verify-cli.js +25 -7
- package/bin/eval/eval-harness.js +212 -1
- package/bin/eval/golden-set-retrieval.json +9 -0
- package/bin/governance/approval-record.js +147 -0
- package/bin/governance/approve.js +12 -7
- package/bin/governance/policy-engine.js +41 -3
- package/bin/governance/policy-gate-hardened.js +36 -1
- package/bin/governance/verify-approvals.js +163 -0
- package/bin/harness-audit.js +224 -10
- package/bin/hindsight-injector.js +8 -2
- package/bin/hooks/instinct-capture-hook.js +19 -5
- package/bin/install.js +63 -3
- package/bin/installer/harness-adapter-compliance.js +339 -28
- package/bin/installer/hook-registration.js +504 -0
- package/bin/installer-core.js +451 -63
- package/bin/learning/instinct-cli.js +14 -24
- package/bin/memory/knowledge-capture.js +23 -3
- package/bin/memory/knowledge-graph.js +70 -31
- package/bin/memory/vector-hub.js +500 -44
- package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
- package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
- package/bin/mindforge-cli.js +110 -17
- package/bin/models/cost-tracker.js +126 -29
- package/bin/models/model-client.js +6 -1
- package/bin/models/model-router.js +28 -7
- package/bin/models/usage-record.js +71 -0
- package/bin/revops/debt-monitor.js +57 -13
- package/bin/security/trust-gate-hook.js +50 -6
- package/bin/skill-validator.js +6 -1
- package/bin/skills-builder/skill-scorer.js +46 -6
- package/bin/updater/self-update.js +6 -1
- package/bin/updater/version-comparator.js +21 -1
- package/bin/utils/file-lock.js +106 -0
- package/bin/utils/mindforge-params.js +124 -0
- package/bin/utils/mindforge-version.js +99 -0
- package/bin/utils/redact-secrets.js +106 -0
- package/bin/validate-config.js +75 -17
- package/bin/wizard/setup-wizard.js +4 -1
- package/bin/wizard/theme.js +9 -1
- package/changelogs/index.json +11 -9
- package/changelogs/v11.9.2.md +209 -0
- package/changelogs/v11.9.3.md +195 -0
- package/docs/References/config-reference.md +76 -14
- package/docs/References/sdk-api.md +1 -1
- package/docs/Templates/Codebase/architecture.md +1 -1
- package/docs/commands-reference.md +4 -5
- package/docs/faq.md +25 -5
- package/docs/getting-started.md +3 -3
- package/docs/sdk-reference.md +15 -7
- package/docs/troubleshooting.md +10 -6
- package/docs/user-guide.md +14 -14
- package/examples/sdk-integration/README.md +1 -1
- package/package.json +10 -4
- package/subagents/.claude-plugin/marketplace.json +1 -1
- package/bin/dashboard/approval-handler.js +0 -136
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MindForge — Token-usage ledger record shape.
|
|
3
|
+
*
|
|
4
|
+
* SINGLE SOURCE OF TRUTH for `.mindforge/metrics/token-usage.jsonl`.
|
|
5
|
+
* The writer (bin/models/cost-tracker.js, fed by every provider in
|
|
6
|
+
* bin/models/*-provider.js) and every reader (bin/models/cost-tracker.js,
|
|
7
|
+
* bin/dashboard/metrics-aggregator.js) MUST go through this module so the
|
|
8
|
+
* field names cannot drift apart again.
|
|
9
|
+
*
|
|
10
|
+
* Canonical row (all five providers emit exactly this cost field):
|
|
11
|
+
* {
|
|
12
|
+
* model, input_tokens, output_tokens,
|
|
13
|
+
* cache_read_input_tokens?, cache_creation_input_tokens?,
|
|
14
|
+
* cost_usd, // <- the ONLY cost field. Never total_cost_usd.
|
|
15
|
+
* task_name?, session_id?, phase?,
|
|
16
|
+
* date, // 'YYYY-MM-DD', added by buildRecord()
|
|
17
|
+
* timestamp // full ISO 8601, added by buildRecord()
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* `total_cost_usd` is a DIFFERENT concept that belongs to cross-review reports
|
|
21
|
+
* (bin/review/cross-review-engine.js:72) and must never appear in this ledger.
|
|
22
|
+
*
|
|
23
|
+
* NOTE ON PATHS: resolution stays on process.cwd() (not bin/utils/paths.js
|
|
24
|
+
* findProjectRoot) to preserve today's behaviour exactly; relocating user state
|
|
25
|
+
* is deliberately deferred to v12.
|
|
26
|
+
*
|
|
27
|
+
* KNOWN STALE SITE, deliberately NOT migrated here: bin/migrations/1.0.0-to-2.0.0.js:93
|
|
28
|
+
* resolves the ledger to .planning/token-usage.jsonl — a path that has never existed —
|
|
29
|
+
* so that migration silently no-ops. Repointing it is out of scope for COST-01 because
|
|
30
|
+
* it would start a migration that has never run against real data. Until then, this
|
|
31
|
+
* module is the single source of truth for every LIVE reader and writer, not literally
|
|
32
|
+
* every path-resolution site in the tree.
|
|
33
|
+
*/
|
|
34
|
+
'use strict';
|
|
35
|
+
|
|
36
|
+
const path = require('path');
|
|
37
|
+
|
|
38
|
+
// Project-root-relative location of the append-only usage ledger.
|
|
39
|
+
const LEDGER_SEGMENTS = ['.mindforge', 'metrics', 'token-usage.jsonl'];
|
|
40
|
+
|
|
41
|
+
/** Absolute path to the ledger. Resolved lazily so tests can chdir. */
|
|
42
|
+
function ledgerPath(root = process.cwd()) {
|
|
43
|
+
return path.join(root, ...LEDGER_SEGMENTS);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Directory containing the ledger. */
|
|
47
|
+
function ledgerDir(root = process.cwd()) {
|
|
48
|
+
return path.join(root, ...LEDGER_SEGMENTS.slice(0, -1));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Stamp a provider result into a canonical ledger row. */
|
|
52
|
+
function buildRecord(entry, now = new Date()) {
|
|
53
|
+
const iso = now.toISOString();
|
|
54
|
+
return { ...entry, date: iso.slice(0, 10), timestamp: iso };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Cost of one row, in USD. Returns 0 for absent/non-finite values. */
|
|
58
|
+
function entryCost(entry) {
|
|
59
|
+
const v = entry && entry.cost_usd;
|
|
60
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 'YYYY-MM-DD' day of one row: prefers `date`, falls back to `timestamp`. */
|
|
64
|
+
function entryDay(entry) {
|
|
65
|
+
if (!entry) return '';
|
|
66
|
+
if (typeof entry.date === 'string' && entry.date.length >= 10) return entry.date.slice(0, 10);
|
|
67
|
+
if (typeof entry.timestamp === 'string' && entry.timestamp.length >= 10) return entry.timestamp.slice(0, 10);
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { LEDGER_SEGMENTS, ledgerPath, ledgerDir, buildRecord, entryCost, entryDay };
|
|
@@ -4,27 +4,69 @@
|
|
|
4
4
|
*/
|
|
5
5
|
'use strict';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
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
|
package/bin/skill-validator.js
CHANGED
|
@@ -22,7 +22,12 @@ const colors = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
function main() {
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/${
|
|
67
|
+
headers: { 'Accept': 'application/json', 'User-Agent': `mindforge-cc/${userAgentVersion()}` },
|
|
48
68
|
timeout: 5000,
|
|
49
69
|
};
|
|
50
70
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MindForge — Fail-closed advisory file lock (LOCK-01).
|
|
5
|
+
*
|
|
6
|
+
* Promoted from bin/learning/instinct-cli.js:78-100 (`withStoreLock`), preserving its
|
|
7
|
+
* semantics: an O_CREAT|O_EXCL lockfile beside the target, a bounded retry spin, a
|
|
8
|
+
* stale-reclaim by mtime for locks orphaned by a killed process, and unlink in finally.
|
|
9
|
+
*
|
|
10
|
+
* FAIL-CLOSED: when the lock cannot be taken this THROWS. It NEVER writes anyway.
|
|
11
|
+
* Deliberately NOT modelled on .agent/bin/lib/state.cjs:784-789, which unlinks the other
|
|
12
|
+
* holder's lock and writes regardless on its last retry — that converts a detectable
|
|
13
|
+
* contention error into silent data loss.
|
|
14
|
+
*
|
|
15
|
+
* `fn` MUST be SYNCHRONOUS. The re-entrancy depth counter below is only sound because
|
|
16
|
+
* nothing else in this process can interleave between acquire and release.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const MAX_TRIES = 50; // 50 x 20-40ms => ~1-2s ceiling before failing closed
|
|
23
|
+
const WAIT_MS = 20;
|
|
24
|
+
const STALE_MS = 10000; // reclaim a lockfile whose mtime is older than this
|
|
25
|
+
|
|
26
|
+
// Re-entrancy depth per lockfile. Required: knowledge-graph applyDecay() holds this
|
|
27
|
+
// lock and calls deprecateEdge(), which takes the SAME lock. Without re-entrancy that
|
|
28
|
+
// self-deadlocks and then fails closed, so a decay pass could never prune an edge.
|
|
29
|
+
const _held = new Map(); // lockPath -> depth
|
|
30
|
+
|
|
31
|
+
const _sleepView = new Int32Array(new SharedArrayBuffer(4));
|
|
32
|
+
|
|
33
|
+
/** Blocking sleep that does not burn a core; busy-waits if Atomics.wait is refused. */
|
|
34
|
+
function sleepSync(ms) {
|
|
35
|
+
try {
|
|
36
|
+
Atomics.wait(_sleepView, 0, 0, ms);
|
|
37
|
+
} catch {
|
|
38
|
+
const until = Date.now() + ms;
|
|
39
|
+
while (Date.now() < until) { /* busy-wait fallback */ }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Rejects an async fn. The `finally` in withFileLock unlinks the lockfile the moment
|
|
45
|
+
* fn() returns, so a promise-returning fn would run its critical section UNLOCKED while
|
|
46
|
+
* looking guarded. Failing loudly here is far cheaper than debugging that corruption.
|
|
47
|
+
*/
|
|
48
|
+
function assertSync(out) {
|
|
49
|
+
if (out && typeof out.then === 'function') {
|
|
50
|
+
throw new TypeError('withFileLock requires a SYNCHRONOUS fn; got a thenable');
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Runs fn() while holding an exclusive advisory lock on `${targetPath}.lock`.
|
|
57
|
+
* @param {string} targetPath the file being protected (the lock is a sibling `.lock`)
|
|
58
|
+
* @param {Function} fn SYNCHRONOUS critical section; its return value is returned
|
|
59
|
+
* @param {object} [opts] { maxTries, waitMs, staleMs, label }
|
|
60
|
+
* @returns {*} whatever fn() returns
|
|
61
|
+
* @throws if the lock cannot be acquired within maxTries — the caller MUST NOT write
|
|
62
|
+
*/
|
|
63
|
+
function withFileLock(targetPath, fn, opts = {}) {
|
|
64
|
+
const lock = `${targetPath}.lock`;
|
|
65
|
+
|
|
66
|
+
const depth = _held.get(lock) || 0;
|
|
67
|
+
if (depth > 0) { // already ours — re-enter, do not re-acquire
|
|
68
|
+
_held.set(lock, depth + 1);
|
|
69
|
+
try { return assertSync(fn()); }
|
|
70
|
+
finally { _held.set(lock, _held.get(lock) - 1); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const maxTries = opts.maxTries ?? MAX_TRIES;
|
|
74
|
+
const waitMs = opts.waitMs ?? WAIT_MS;
|
|
75
|
+
const staleMs = opts.staleMs ?? STALE_MS;
|
|
76
|
+
const label = opts.label || targetPath;
|
|
77
|
+
|
|
78
|
+
// The lock must be creatable before the target's own mkdir runs, so ensure the dir
|
|
79
|
+
// here. A bad path still throws ENOTDIR/ENOENT to the caller, unchanged.
|
|
80
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
81
|
+
|
|
82
|
+
let acquired = false;
|
|
83
|
+
for (let i = 0; i < maxTries && !acquired; i++) {
|
|
84
|
+
try {
|
|
85
|
+
fs.closeSync(fs.openSync(lock, 'wx')); // O_CREAT|O_EXCL|O_WRONLY
|
|
86
|
+
acquired = true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err.code !== 'EEXIST') throw err;
|
|
89
|
+
try {
|
|
90
|
+
const age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
91
|
+
if (age > staleMs) { fs.unlinkSync(lock); continue; } // orphaned by a kill
|
|
92
|
+
} catch { /* lock vanished between EEXIST and stat — retry */ }
|
|
93
|
+
sleepSync(waitMs + Math.floor(Math.random() * waitMs)); // jitter breaks the herd
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!acquired) throw new Error(`could not acquire ${label} lock: ${lock}`);
|
|
97
|
+
|
|
98
|
+
_held.set(lock, 1);
|
|
99
|
+
try { return assertSync(fn()); }
|
|
100
|
+
finally {
|
|
101
|
+
_held.set(lock, 0);
|
|
102
|
+
try { fs.unlinkSync(lock); } catch { /* already gone */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { withFileLock, MAX_TRIES, WAIT_MS, STALE_MS };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* MindForge — MINDFORGE.md parameter parser (single source of truth).
|
|
4
|
+
*
|
|
5
|
+
* TWO on-disk forms are supported, because both ship:
|
|
6
|
+
*
|
|
7
|
+
* 1. BRACKETED (current, MINDFORGE.md) — 43 keys in the shipped registry:
|
|
8
|
+
* [PLANNER] = claude-opus-4-7
|
|
9
|
+
* [MODE] = "Platform Sovereign"
|
|
10
|
+
* [API_URL] = <http://localhost:3000>
|
|
11
|
+
* [PQAS_ENFORCED] = false # trailing note
|
|
12
|
+
* [FORBIDDEN] = """
|
|
13
|
+
* ...multi-line block...
|
|
14
|
+
* """
|
|
15
|
+
*
|
|
16
|
+
* 2. LEGACY PLAIN (shell-style) — 28 keys in examples/starter-project/MINDFORGE.md,
|
|
17
|
+
* which ships (package.json files[] contains "examples/"), and the
|
|
18
|
+
* tests/cli-router.test.js:110 fixture. A bracket-only parser silently
|
|
19
|
+
* zeroes those out:
|
|
20
|
+
* MAX_TASKS_PER_PHASE=999
|
|
21
|
+
* DISABLED_SKILLS= <- empty value is LEGAL, hence (.*) not (.+)
|
|
22
|
+
*
|
|
23
|
+
* Bracketed semantics are ported from sdk/src/client.ts:141-158 (the only
|
|
24
|
+
* previously correct bracket-aware reader) and extended with value
|
|
25
|
+
* normalisation so the runtime and the validator agree on one interpretation.
|
|
26
|
+
*
|
|
27
|
+
* NOT captured: prose bullets such as `- [MIN_SOUL_SCORE] — description`
|
|
28
|
+
* (section 7 of MINDFORGE.md). A bracketed key only counts when the bracket
|
|
29
|
+
* opens the line (leading whitespace allowed) and is followed by `=`.
|
|
30
|
+
*
|
|
31
|
+
* The legacy form deliberately allows NO leading whitespace, matching the two
|
|
32
|
+
* regexes it replaces (bin/validate-config.js:36, bin/models/model-router.js:48).
|
|
33
|
+
* That is a safety property, not an oversight: it stops indented `KEY=value`
|
|
34
|
+
* lines inside markdown code blocks from being read as configuration.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
|
|
39
|
+
/** [KEY] = value — key must open the line; only spaces/tabs may precede it. */
|
|
40
|
+
const ASSIGN_RE = /^[ \t]*\[([A-Z0-9_]+)\][ \t]*=[ \t]*(.*)$/;
|
|
41
|
+
|
|
42
|
+
/** KEY=value — legacy plain form. Column 0 only. `(.*)`: empty values are legal. */
|
|
43
|
+
const LEGACY_RE = /^([A-Z0-9_]+)=(.*)$/;
|
|
44
|
+
|
|
45
|
+
const FENCE = '"""';
|
|
46
|
+
|
|
47
|
+
/** Drop a trailing ` # comment`. Requires whitespace before `#` so that
|
|
48
|
+
* values legitimately containing `#` (URL fragments, colours) survive. */
|
|
49
|
+
function stripComment(raw) {
|
|
50
|
+
const i = raw.search(/\s#/);
|
|
51
|
+
return i === -1 ? raw : raw.slice(0, i);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Unwrap "quoted" values and <markdown-autolink> URLs. */
|
|
55
|
+
function unwrap(v) {
|
|
56
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1);
|
|
57
|
+
if (v.length >= 2 && v.startsWith('<') && v.endsWith('>')) return v.slice(1, -1);
|
|
58
|
+
return v;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalise(raw) {
|
|
62
|
+
return unwrap(stripComment(raw).trim());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} content raw MINDFORGE.md text
|
|
67
|
+
* @returns {Record<string,string>} key -> normalised string value
|
|
68
|
+
*/
|
|
69
|
+
function parseParams(content) {
|
|
70
|
+
const bracketed = {};
|
|
71
|
+
const legacy = {};
|
|
72
|
+
const lines = String(content).split(/\r?\n/);
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const m = lines[i].match(ASSIGN_RE);
|
|
76
|
+
|
|
77
|
+
if (!m) {
|
|
78
|
+
// Legacy plain form. Only reached when the line is not a bracketed
|
|
79
|
+
// assignment; the two patterns can never both match one line.
|
|
80
|
+
const g = lines[i].match(LEGACY_RE);
|
|
81
|
+
if (g) legacy[g[1]] = normalise(g[2]);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const key = m[1];
|
|
86
|
+
const rest = m[2].trim();
|
|
87
|
+
|
|
88
|
+
if (rest.startsWith(FENCE)) {
|
|
89
|
+
const inline = rest.slice(FENCE.length);
|
|
90
|
+
// Single-line fenced value: [K] = """text"""
|
|
91
|
+
if (inline.trimEnd().endsWith(FENCE) && inline.trim().length >= FENCE.length) {
|
|
92
|
+
bracketed[key] = inline.trimEnd().slice(0, -FENCE.length).trim();
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const block = [];
|
|
96
|
+
let j = i + 1;
|
|
97
|
+
for (; j < lines.length; j++) {
|
|
98
|
+
if (lines[j].trim() === FENCE) break;
|
|
99
|
+
block.push(lines[j]);
|
|
100
|
+
}
|
|
101
|
+
bracketed[key] = block.join('\n').trim();
|
|
102
|
+
i = j; // resume after the closing fence (or at EOF)
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
bracketed[key] = normalise(rest);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Bracketed is the canonical modern form and wins on collision, regardless
|
|
110
|
+
// of which appeared first in the file.
|
|
111
|
+
return { ...legacy, ...bracketed };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read + parse a MINDFORGE.md. Returns {} when the file is absent so callers
|
|
116
|
+
* can fall back to their own defaults (fail-open on absence, not on garbage).
|
|
117
|
+
* @param {string} filePath
|
|
118
|
+
*/
|
|
119
|
+
function readParams(filePath) {
|
|
120
|
+
if (!fs.existsSync(filePath)) return {};
|
|
121
|
+
return parseParams(fs.readFileSync(filePath, 'utf8'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { parseParams, readParams, ASSIGN_RE, LEGACY_RE };
|