chati-dev 2.0.6 → 2.0.7
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/README.md +3 -2
- package/framework/constitution.md +1 -1
- package/framework/context/governance.md +1 -1
- package/framework/context/quality.md +2 -1
- package/framework/hooks/prism-engine.js +21 -1
- package/framework/hooks/read-protection.js +107 -0
- package/framework/hooks/settings.json +9 -0
- package/package.json +2 -2
- package/scripts/generate-signing-key.js +33 -0
- package/scripts/semantic-lint.js +328 -0
- package/scripts/sign-manifest.js +53 -0
- package/scripts/validate-package.js +257 -0
- package/src/autonomy/autonomous-gate.js +1 -1
- package/src/gates/g4-qa-implementation.js +9 -9
- package/src/installer/core.js +16 -1
- package/src/installer/manifest.js +33 -1
- package/src/installer/signing-public-key.pem +3 -0
- package/src/installer/validator.js +33 -0
- package/src/memory/agent-memory.js +20 -0
- package/src/orchestrator/pipeline-manager.js +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,8 @@ PLANNING (planning) → Quality Gate → BUILD → Quality Gate → DEPL
|
|
|
47
47
|
| **Memory System RECALL** | 4 cognitive sectors. Persistent knowledge across sessions with attention scoring and natural decay |
|
|
48
48
|
| **Decision Engine COMPASS** | Entity catalog with Jaccard similarity for REUSE/ADAPT/CREATE decisions. Self-healing registry |
|
|
49
49
|
| **Session Lock** | Once activated, user stays in system until explicit exit. Zero accidental leakage |
|
|
50
|
-
| **Hooks System** |
|
|
50
|
+
| **Hooks System** | 6 Claude Code hooks — constitution guard, mode governance, model governance, read protection, context injection, session digest |
|
|
51
|
+
| **Supply Chain Security** | Ed25519 manifest signing. Package integrity verification on install. Semantic linter for cross-reference validation |
|
|
51
52
|
| **Execution Modes** | Autonomous and human-in-the-loop modes with safety net (5 triggers) and circuit breaker |
|
|
52
53
|
| **Multi-Terminal** | Autonomous agents spawn in separate `claude -p` terminals with dedicated models. Parallel groups (Detail + Architect + UX) with write-scope isolation |
|
|
53
54
|
| **IDE-Agnostic** | Works with 6 IDEs through a thin router pattern |
|
|
@@ -276,7 +277,7 @@ your-project/
|
|
|
276
277
|
│ ├── schemas/ # 5 JSON schemas for validation
|
|
277
278
|
│ ├── intelligence/ # Context Engine, Memory Layer, Decision Engine
|
|
278
279
|
│ ├── domains/ # Domain loading configs (per-agent, per-workflow)
|
|
279
|
-
│ ├── hooks/ #
|
|
280
|
+
│ ├── hooks/ # 6 Claude Code hooks (enforcement)
|
|
280
281
|
│ ├── context/ # Context source files (deployed to .claude/rules/)
|
|
281
282
|
│ ├── frameworks/ # Decision heuristics, quality dims
|
|
282
283
|
│ ├── quality-gates/ # Planning & implementation gates
|
|
@@ -384,7 +384,7 @@ The system SHALL support two execution modes that govern the degree of human inv
|
|
|
384
384
|
|
|
385
385
|
3. Quality gate thresholds are conservative by default:
|
|
386
386
|
- **qa-planning**: 95% minimum (gates planning-to-build transition)
|
|
387
|
-
- **qa-implementation**:
|
|
387
|
+
- **qa-implementation**: 95% minimum (gates build-to-deploy transition)
|
|
388
388
|
- **All other agents**: 90% minimum
|
|
389
389
|
- Scores below threshold trigger escalation regardless of execution mode.
|
|
390
390
|
|
|
@@ -32,6 +32,6 @@ Extracted from `chati.dev/constitution.md` (17 Articles). Read the full constitu
|
|
|
32
32
|
- Model recorded in session for cost tracking
|
|
33
33
|
|
|
34
34
|
## Execution Mode (Article XVII)
|
|
35
|
-
- Autonomous mode requires gate score >= 90% (qa-planning >= 95%)
|
|
35
|
+
- Autonomous mode requires gate score >= 90% (qa-planning >= 95%, qa-implementation >= 95%)
|
|
36
36
|
- Safety net with 5 triggers: stuck loop, quality drop, scope creep, error cascade, user override
|
|
37
37
|
- Circuit breaker: CLOSED -> OPEN (3 failures) -> HALF_OPEN (probe)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
| Agent | Minimum Score |
|
|
5
5
|
|-------|--------------|
|
|
6
6
|
| qa-planning | 95% |
|
|
7
|
+
| qa-implementation | 95% |
|
|
7
8
|
| All others | 90% |
|
|
8
9
|
|
|
9
10
|
## Review Range
|
|
@@ -13,7 +14,7 @@ Scores within 5 points below threshold trigger REVIEW (human confirmation requir
|
|
|
13
14
|
1. **Planning Complete** — All PLANNING agents finished
|
|
14
15
|
2. **QA Planning** — QA-Planning agent validates plan coherence (95% threshold)
|
|
15
16
|
3. **Implementation** — Dev agent completes all tasks
|
|
16
|
-
4. **QA Implementation** — Tests pass, SAST clean, coverage adequate (
|
|
17
|
+
4. **QA Implementation** — Tests pass, SAST clean, coverage adequate (95% threshold)
|
|
17
18
|
5. **Deploy Ready** — All gates passed, ready for production
|
|
18
19
|
|
|
19
20
|
## Quality Dimensions
|
|
@@ -66,12 +66,26 @@ async function main() {
|
|
|
66
66
|
else if (remainingPercent < 40) bracket = 'DEPLETED';
|
|
67
67
|
else if (remainingPercent < 60) bracket = 'MODERATE';
|
|
68
68
|
|
|
69
|
+
// Load agent memory (if active agent has MEMORY.md)
|
|
70
|
+
let memoryBlock = '';
|
|
71
|
+
if (session.currentAgent) {
|
|
72
|
+
const memoryPath = join(projectDir, '.chati', 'memories', session.currentAgent, 'MEMORY.md');
|
|
73
|
+
if (existsSync(memoryPath)) {
|
|
74
|
+
const raw = readFileSync(memoryPath, 'utf-8').trim();
|
|
75
|
+
if (raw) {
|
|
76
|
+
const trimmed = raw.slice(0, 500);
|
|
77
|
+
memoryBlock = ` <agent-memory agent="${session.currentAgent}">\n${trimmed}\n </agent-memory>`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
// Build minimal context block (full PRISM pipeline is used by orchestrator internally)
|
|
70
83
|
const contextBlock = [
|
|
71
84
|
`<chati-context bracket="${bracket}">`,
|
|
72
85
|
` <mode>${session.mode}</mode>`,
|
|
73
86
|
session.currentAgent ? ` <agent>${session.currentAgent}</agent>` : '',
|
|
74
87
|
session.pipelinePosition ? ` <pipeline-position>${session.pipelinePosition}</pipeline-position>` : '',
|
|
88
|
+
memoryBlock,
|
|
75
89
|
bracket === 'CRITICAL' ? ' <advisory>Context running low. Consider handoff or summary.</advisory>' : '',
|
|
76
90
|
'</chati-context>',
|
|
77
91
|
].filter(Boolean).join('\n');
|
|
@@ -86,4 +100,10 @@ async function main() {
|
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
|
|
89
|
-
|
|
103
|
+
export { readSessionState };
|
|
104
|
+
|
|
105
|
+
// Only run main when executed directly (not imported by tests)
|
|
106
|
+
import { fileURLToPath } from 'url';
|
|
107
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
108
|
+
main();
|
|
109
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Read Protection Hook — PreToolUse (Read)
|
|
4
|
+
*
|
|
5
|
+
* BLOCKS reading sensitive files that may contain secrets:
|
|
6
|
+
* - .env files (except .env.example, .env.template)
|
|
7
|
+
* - Private keys (*.pem, *.key) — except signing-public-key.pem
|
|
8
|
+
* - Credentials and secrets files
|
|
9
|
+
* - .git/config (may contain tokens)
|
|
10
|
+
*
|
|
11
|
+
* Enforces Article IV: Secret protection.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { relative, basename } from 'path';
|
|
15
|
+
|
|
16
|
+
const SENSITIVE_PATTERNS = [
|
|
17
|
+
/^\.env$/,
|
|
18
|
+
/^\.env\.[^.]+$/,
|
|
19
|
+
/\.pem$/,
|
|
20
|
+
/^credentials\./,
|
|
21
|
+
/^secrets\./,
|
|
22
|
+
/\.key$/,
|
|
23
|
+
/^\.git\/config$/,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const ALLOWED_EXCEPTIONS = [
|
|
27
|
+
/signing-public-key\.pem$/,
|
|
28
|
+
/\.env\.example$/,
|
|
29
|
+
/\.env\.template$/,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a file path is sensitive (should not be read).
|
|
34
|
+
* @param {string} filePath - Absolute or relative file path
|
|
35
|
+
* @param {string} cwd - Current working directory
|
|
36
|
+
* @returns {{ sensitive: boolean, reason: string }}
|
|
37
|
+
*/
|
|
38
|
+
function isSensitivePath(filePath, cwd) {
|
|
39
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
40
|
+
return { sensitive: false, reason: '' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Normalize to relative path
|
|
44
|
+
let rel;
|
|
45
|
+
try {
|
|
46
|
+
rel = relative(cwd, filePath);
|
|
47
|
+
} catch {
|
|
48
|
+
rel = filePath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const name = basename(rel);
|
|
52
|
+
|
|
53
|
+
// Check allowed exceptions first
|
|
54
|
+
for (const pattern of ALLOWED_EXCEPTIONS) {
|
|
55
|
+
if (pattern.test(name) || pattern.test(rel)) {
|
|
56
|
+
return { sensitive: false, reason: '' };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check sensitive patterns against both filename and relative path
|
|
61
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
62
|
+
if (pattern.test(name) || pattern.test(rel)) {
|
|
63
|
+
return {
|
|
64
|
+
sensitive: true,
|
|
65
|
+
reason: `File matches sensitive pattern: ${pattern.source}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { sensitive: false, reason: '' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function main() {
|
|
74
|
+
let input = '';
|
|
75
|
+
for await (const chunk of process.stdin) {
|
|
76
|
+
input += chunk;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const event = JSON.parse(input);
|
|
81
|
+
const toolInput = event.tool_input || {};
|
|
82
|
+
const filePath = toolInput.file_path || '';
|
|
83
|
+
const cwd = event.cwd || process.cwd();
|
|
84
|
+
|
|
85
|
+
const result = isSensitivePath(filePath, cwd);
|
|
86
|
+
|
|
87
|
+
if (result.sensitive) {
|
|
88
|
+
process.stdout.write(JSON.stringify({
|
|
89
|
+
decision: 'block',
|
|
90
|
+
reason: `[Article IV] ${result.reason}. Use environment variables or secure vaults instead of reading sensitive files directly.`,
|
|
91
|
+
}));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
process.stdout.write(JSON.stringify({ decision: 'allow' }));
|
|
96
|
+
} catch {
|
|
97
|
+
process.stdout.write(JSON.stringify({ decision: 'allow' }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { isSensitivePath, SENSITIVE_PATTERNS, ALLOWED_EXCEPTIONS };
|
|
102
|
+
|
|
103
|
+
// Only run main when executed directly (not imported by tests)
|
|
104
|
+
import { fileURLToPath } from 'url';
|
|
105
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
106
|
+
main();
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chati-dev",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"description": "AI-Powered Multi-Agent Orchestration System — Structured vibe coding for Full Stack Development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"start": "node bin/chati.js",
|
|
26
26
|
"bundle": "node scripts/bundle-framework.js",
|
|
27
|
-
"prepublishOnly": "node scripts/bundle-framework.js",
|
|
27
|
+
"prepublishOnly": "node scripts/bundle-framework.js && node scripts/validate-package.js && node scripts/sign-manifest.js",
|
|
28
28
|
"test": "node --test test/**/*.test.js",
|
|
29
29
|
"lint": "eslint src/ bin/",
|
|
30
30
|
"lint:fix": "eslint src/ bin/ --fix"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time keypair generation for Ed25519 manifest signing.
|
|
3
|
+
* Run: node scripts/generate-signing-key.js
|
|
4
|
+
*
|
|
5
|
+
* Private key: .signing-key.pem (GITIGNORED — never commit)
|
|
6
|
+
* Public key: src/installer/signing-public-key.pem (committed — distributed with package)
|
|
7
|
+
*/
|
|
8
|
+
import { generateKeyPairSync } from 'crypto';
|
|
9
|
+
import { writeFileSync, existsSync } from 'fs';
|
|
10
|
+
import { join, dirname } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const privateKeyPath = join(__dirname, '..', '.signing-key.pem');
|
|
15
|
+
const publicKeyPath = join(__dirname, '..', 'src', 'installer', 'signing-public-key.pem');
|
|
16
|
+
|
|
17
|
+
if (existsSync(privateKeyPath)) {
|
|
18
|
+
console.error('Private key already exists at .signing-key.pem');
|
|
19
|
+
console.error('Delete it first if you want to regenerate.');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
24
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
25
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
writeFileSync(privateKeyPath, privateKey);
|
|
29
|
+
writeFileSync(publicKeyPath, publicKey);
|
|
30
|
+
|
|
31
|
+
console.log('Ed25519 keypair generated.');
|
|
32
|
+
console.log(` Private: .signing-key.pem (GITIGNORED)`);
|
|
33
|
+
console.log(` Public: src/installer/signing-public-key.pem`);
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Semantic Linter — Validates cross-reference consistency across the framework.
|
|
5
|
+
*
|
|
6
|
+
* Goes beyond structural validation (validate-agents, validate-tasks) to check
|
|
7
|
+
* semantic consistency: entity registry integrity, domain-agent alignment,
|
|
8
|
+
* workflow references, i18n completeness, schema existence, constitution articles.
|
|
9
|
+
*
|
|
10
|
+
* Exports:
|
|
11
|
+
* semanticLint(frameworkDir) → { errors, warnings, checks, passed }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
15
|
+
import { join, dirname, basename } from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import yaml from 'js-yaml';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check that all paths in entity-registry.yaml point to existing files.
|
|
23
|
+
*/
|
|
24
|
+
function checkEntityRegistryIntegrity(frameworkDir, results) {
|
|
25
|
+
results.checks++;
|
|
26
|
+
const registryPath = join(frameworkDir, 'data', 'entity-registry.yaml');
|
|
27
|
+
|
|
28
|
+
if (!existsSync(registryPath)) {
|
|
29
|
+
results.errors.push('Entity registry not found at data/entity-registry.yaml');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const registry = yaml.load(readFileSync(registryPath, 'utf-8'));
|
|
34
|
+
const entities = registry.entities || {};
|
|
35
|
+
let total = 0;
|
|
36
|
+
let found = 0;
|
|
37
|
+
const missing = [];
|
|
38
|
+
|
|
39
|
+
for (const category of Object.values(entities)) {
|
|
40
|
+
for (const [name, entity] of Object.entries(category)) {
|
|
41
|
+
if (!entity.path) continue;
|
|
42
|
+
total++;
|
|
43
|
+
|
|
44
|
+
// Path in registry is relative to project root (e.g., "chati.dev/agents/...")
|
|
45
|
+
// Strip "chati.dev/" prefix to get path relative to frameworkDir
|
|
46
|
+
const relPath = entity.path.replace(/^chati\.dev\//, '');
|
|
47
|
+
if (existsSync(join(frameworkDir, relPath))) {
|
|
48
|
+
found++;
|
|
49
|
+
} else {
|
|
50
|
+
missing.push(`${name}: ${entity.path}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (missing.length === 0) {
|
|
56
|
+
results.passed++;
|
|
57
|
+
results.details.push(`Entity registry integrity: ${found}/${total} paths exist`);
|
|
58
|
+
} else {
|
|
59
|
+
results.errors.push(`Entity registry: ${missing.length} missing files — ${missing.slice(0, 5).join('; ')}${missing.length > 5 ? '...' : ''}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check that each agent in domains/agents/ has a corresponding agent definition file.
|
|
65
|
+
*/
|
|
66
|
+
function checkDomainAgentAlignment(frameworkDir, results) {
|
|
67
|
+
results.checks++;
|
|
68
|
+
const domainsDir = join(frameworkDir, 'domains', 'agents');
|
|
69
|
+
|
|
70
|
+
if (!existsSync(domainsDir)) {
|
|
71
|
+
results.errors.push('Domain agents directory not found at domains/agents/');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const domainFiles = readdirSync(domainsDir).filter(f => f.endsWith('.yaml'));
|
|
76
|
+
const mismatches = [];
|
|
77
|
+
|
|
78
|
+
for (const file of domainFiles) {
|
|
79
|
+
const agentName = basename(file, '.yaml');
|
|
80
|
+
|
|
81
|
+
// orchestrator has a different path
|
|
82
|
+
if (agentName === 'orchestrator') {
|
|
83
|
+
if (!existsSync(join(frameworkDir, 'orchestrator', 'chati.md'))) {
|
|
84
|
+
mismatches.push('orchestrator');
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Other agents: check all category dirs
|
|
90
|
+
const agentDirs = ['agents/planning', 'agents/quality', 'agents/build', 'agents/deploy'];
|
|
91
|
+
const found = agentDirs.some(dir =>
|
|
92
|
+
existsSync(join(frameworkDir, dir, `${agentName}.md`))
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (!found) {
|
|
96
|
+
mismatches.push(agentName);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (mismatches.length === 0) {
|
|
101
|
+
results.passed++;
|
|
102
|
+
results.details.push(`Domain-agent alignment: ${domainFiles.length}/${domainFiles.length} agents matched`);
|
|
103
|
+
} else {
|
|
104
|
+
results.errors.push(`Domain-agent mismatch: ${mismatches.join(', ')} have domain YAML but no agent .md`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check that i18n files have consistent keys across all languages.
|
|
110
|
+
*/
|
|
111
|
+
function checkI18nCompleteness(frameworkDir, results) {
|
|
112
|
+
results.checks++;
|
|
113
|
+
const i18nDir = join(frameworkDir, 'i18n');
|
|
114
|
+
|
|
115
|
+
if (!existsSync(i18nDir)) {
|
|
116
|
+
results.errors.push('i18n directory not found');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const languages = ['en', 'pt', 'es', 'fr'];
|
|
121
|
+
const keysByLang = {};
|
|
122
|
+
|
|
123
|
+
for (const lang of languages) {
|
|
124
|
+
const filePath = join(i18nDir, `${lang}.yaml`);
|
|
125
|
+
if (!existsSync(filePath)) {
|
|
126
|
+
results.errors.push(`i18n file missing: ${lang}.yaml`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const content = yaml.load(readFileSync(filePath, 'utf-8'));
|
|
131
|
+
keysByLang[lang] = flattenKeys(content);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Use 'en' as reference
|
|
135
|
+
const refKeys = new Set(keysByLang.en);
|
|
136
|
+
const missingByLang = {};
|
|
137
|
+
|
|
138
|
+
for (const lang of languages) {
|
|
139
|
+
if (lang === 'en') continue;
|
|
140
|
+
const langKeys = new Set(keysByLang[lang]);
|
|
141
|
+
|
|
142
|
+
for (const key of refKeys) {
|
|
143
|
+
// Skip non-translatable keys
|
|
144
|
+
if (key === 'language' || key === 'name') continue;
|
|
145
|
+
|
|
146
|
+
if (!langKeys.has(key)) {
|
|
147
|
+
if (!missingByLang[lang]) missingByLang[lang] = [];
|
|
148
|
+
missingByLang[lang].push(key);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const totalMissing = Object.values(missingByLang).reduce((sum, arr) => sum + arr.length, 0);
|
|
154
|
+
|
|
155
|
+
if (totalMissing === 0) {
|
|
156
|
+
results.passed++;
|
|
157
|
+
results.details.push(`i18n completeness: all ${languages.length} languages have consistent keys`);
|
|
158
|
+
} else {
|
|
159
|
+
for (const [lang, keys] of Object.entries(missingByLang)) {
|
|
160
|
+
results.warnings.push(`i18n: '${lang}' missing ${keys.length} key(s): ${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}`);
|
|
161
|
+
}
|
|
162
|
+
results.passed++; // warnings don't fail the check
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check that all expected schema files exist.
|
|
168
|
+
*/
|
|
169
|
+
function checkSchemaExistence(frameworkDir, results) {
|
|
170
|
+
results.checks++;
|
|
171
|
+
const schemasDir = join(frameworkDir, 'schemas');
|
|
172
|
+
const expectedSchemas = [
|
|
173
|
+
'session.schema.json', 'config.schema.json', 'task.schema.json',
|
|
174
|
+
'context.schema.json', 'memory.schema.json',
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const missing = expectedSchemas.filter(s => !existsSync(join(schemasDir, s)));
|
|
178
|
+
|
|
179
|
+
if (missing.length === 0) {
|
|
180
|
+
results.passed++;
|
|
181
|
+
results.details.push(`Schema existence: ${expectedSchemas.length}/${expectedSchemas.length} schemas found`);
|
|
182
|
+
} else {
|
|
183
|
+
results.errors.push(`Missing schemas: ${missing.join(', ')}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check constitution has >= 17 articles.
|
|
189
|
+
*/
|
|
190
|
+
function checkConstitution(frameworkDir, results) {
|
|
191
|
+
results.checks++;
|
|
192
|
+
const constitutionPath = join(frameworkDir, 'constitution.md');
|
|
193
|
+
|
|
194
|
+
if (!existsSync(constitutionPath)) {
|
|
195
|
+
results.errors.push('constitution.md not found');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const content = readFileSync(constitutionPath, 'utf-8');
|
|
200
|
+
const articleCount = (content.match(/^## Article/gm) || []).length;
|
|
201
|
+
|
|
202
|
+
if (articleCount >= 17) {
|
|
203
|
+
results.passed++;
|
|
204
|
+
results.details.push(`Constitution: ${articleCount} articles found`);
|
|
205
|
+
} else {
|
|
206
|
+
results.errors.push(`Constitution has only ${articleCount} articles (expected >= 17)`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check that workflow files reference valid agents.
|
|
212
|
+
*/
|
|
213
|
+
function checkWorkflowAgentRefs(frameworkDir, results) {
|
|
214
|
+
results.checks++;
|
|
215
|
+
const workflowsDir = join(frameworkDir, 'workflows');
|
|
216
|
+
|
|
217
|
+
if (!existsSync(workflowsDir)) {
|
|
218
|
+
results.errors.push('workflows/ directory not found');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Collect known agent names
|
|
223
|
+
const knownAgents = new Set(['orchestrator']);
|
|
224
|
+
const agentDirs = ['agents/planning', 'agents/quality', 'agents/build', 'agents/deploy'];
|
|
225
|
+
for (const dir of agentDirs) {
|
|
226
|
+
const fullDir = join(frameworkDir, dir);
|
|
227
|
+
if (!existsSync(fullDir)) continue;
|
|
228
|
+
for (const file of readdirSync(fullDir)) {
|
|
229
|
+
if (file.endsWith('.md')) {
|
|
230
|
+
knownAgents.add(basename(file, '.md'));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const workflowFiles = readdirSync(workflowsDir).filter(f => f.endsWith('.yaml'));
|
|
236
|
+
const unknownRefs = [];
|
|
237
|
+
|
|
238
|
+
for (const file of workflowFiles) {
|
|
239
|
+
const content = readFileSync(join(workflowsDir, file), 'utf-8');
|
|
240
|
+
// Look for agent references in YAML (agent: xxx or - xxx in steps)
|
|
241
|
+
const agentRefs = content.match(/agent:\s*(\S+)/g) || [];
|
|
242
|
+
for (const ref of agentRefs) {
|
|
243
|
+
const agentName = ref.replace('agent:', '').trim();
|
|
244
|
+
if (agentName && !knownAgents.has(agentName)) {
|
|
245
|
+
unknownRefs.push(`${file}: ${agentName}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (unknownRefs.length === 0) {
|
|
251
|
+
results.passed++;
|
|
252
|
+
results.details.push(`Workflow-agent references: ${workflowFiles.length} workflows, all agents valid`);
|
|
253
|
+
} else {
|
|
254
|
+
results.warnings.push(`Unknown agent refs in workflows: ${unknownRefs.join('; ')}`);
|
|
255
|
+
results.passed++; // warnings don't fail
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Flatten a nested object into dot-notation keys.
|
|
261
|
+
*/
|
|
262
|
+
function flattenKeys(obj, prefix = '') {
|
|
263
|
+
const keys = [];
|
|
264
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
265
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
266
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
267
|
+
keys.push(...flattenKeys(value, fullKey));
|
|
268
|
+
} else {
|
|
269
|
+
keys.push(fullKey);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return keys;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Run all semantic lint checks.
|
|
277
|
+
* @param {string} frameworkDir - Path to the framework directory (chati.dev/)
|
|
278
|
+
* @returns {{ errors: string[], warnings: string[], details: string[], checks: number, passed: number }}
|
|
279
|
+
*/
|
|
280
|
+
export function semanticLint(frameworkDir) {
|
|
281
|
+
const results = { errors: [], warnings: [], details: [], checks: 0, passed: 0 };
|
|
282
|
+
|
|
283
|
+
checkEntityRegistryIntegrity(frameworkDir, results);
|
|
284
|
+
checkDomainAgentAlignment(frameworkDir, results);
|
|
285
|
+
checkWorkflowAgentRefs(frameworkDir, results);
|
|
286
|
+
checkI18nCompleteness(frameworkDir, results);
|
|
287
|
+
checkSchemaExistence(frameworkDir, results);
|
|
288
|
+
checkConstitution(frameworkDir, results);
|
|
289
|
+
|
|
290
|
+
return results;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Format results for CLI output.
|
|
295
|
+
*/
|
|
296
|
+
function formatResults(results) {
|
|
297
|
+
const status = results.errors.length === 0 ? 'PASS' : 'FAIL';
|
|
298
|
+
const lines = [
|
|
299
|
+
`Semantic Lint: ${status} (${results.passed}/${results.checks} checks, ${results.warnings.length} warnings, ${results.errors.length} errors)`,
|
|
300
|
+
'',
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
for (const detail of results.details) {
|
|
304
|
+
lines.push(` ✓ ${detail}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const warning of results.warnings) {
|
|
308
|
+
lines.push(` ⚠ ${warning}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const error of results.errors) {
|
|
312
|
+
lines.push(` ✗ ${error}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return lines.join('\n');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// CLI entry point
|
|
319
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
320
|
+
const frameworkDir = join(__dirname, '..', '..', '..', 'chati.dev');
|
|
321
|
+
const results = semanticLint(frameworkDir);
|
|
322
|
+
|
|
323
|
+
console.log(formatResults(results));
|
|
324
|
+
|
|
325
|
+
if (results.errors.length > 0) {
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign the framework manifest with Ed25519.
|
|
3
|
+
* Generates manifest.json (file hashes) + manifest.sig (signature).
|
|
4
|
+
*
|
|
5
|
+
* Run: node scripts/sign-manifest.js
|
|
6
|
+
* Requires: .signing-key.pem (generated by generate-signing-key.js)
|
|
7
|
+
*/
|
|
8
|
+
import { sign, createPrivateKey } from 'crypto';
|
|
9
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
|
|
10
|
+
import { join, dirname, relative } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { generateManifest } from '../src/installer/manifest.js';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const frameworkDir = join(__dirname, '..', 'framework');
|
|
16
|
+
const privateKeyPath = join(__dirname, '..', '.signing-key.pem');
|
|
17
|
+
|
|
18
|
+
if (!existsSync(frameworkDir)) {
|
|
19
|
+
console.error('Framework directory not found. Run bundle-framework.js first.');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!existsSync(privateKeyPath)) {
|
|
24
|
+
console.warn('No signing key found at .signing-key.pem — skipping manifest signing.');
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function collectFiles(dir, base = dir) {
|
|
29
|
+
let files = [];
|
|
30
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
31
|
+
const full = join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
files = files.concat(collectFiles(full, base));
|
|
34
|
+
} else if (entry.name !== 'manifest.json' && entry.name !== 'manifest.sig') {
|
|
35
|
+
files.push(relative(base, full));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return files.sort();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const files = collectFiles(frameworkDir);
|
|
42
|
+
const version = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
|
|
43
|
+
const manifest = generateManifest(frameworkDir, files, version);
|
|
44
|
+
|
|
45
|
+
const manifestJson = JSON.stringify(manifest, Object.keys(manifest).sort(), 2);
|
|
46
|
+
|
|
47
|
+
const privateKey = createPrivateKey(readFileSync(privateKeyPath));
|
|
48
|
+
const signature = sign(null, Buffer.from(manifestJson), privateKey);
|
|
49
|
+
|
|
50
|
+
writeFileSync(join(frameworkDir, 'manifest.json'), manifestJson + '\n');
|
|
51
|
+
writeFileSync(join(frameworkDir, 'manifest.sig'), signature.toString('base64'));
|
|
52
|
+
|
|
53
|
+
console.log(`Signed manifest: ${Object.keys(manifest.files).length} files, v${version}`);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Package Completeness Validator
|
|
5
|
+
*
|
|
6
|
+
* Validates that the npm package has all required files before publishing.
|
|
7
|
+
* Run as part of prepublishOnly (after bundle, before sign-manifest).
|
|
8
|
+
*
|
|
9
|
+
* Exports:
|
|
10
|
+
* validatePackage(packageRoot) → { errors, warnings, checks, passed }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
14
|
+
import { join, dirname } from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Expected directories in framework/ (must match bundle-framework.js)
|
|
21
|
+
*/
|
|
22
|
+
const EXPECTED_BUNDLE_DIRS = [
|
|
23
|
+
'orchestrator',
|
|
24
|
+
'agents/planning', 'agents/quality', 'agents/build', 'agents/deploy',
|
|
25
|
+
'templates', 'workflows', 'quality-gates',
|
|
26
|
+
'schemas', 'frameworks', 'intelligence', 'patterns',
|
|
27
|
+
'hooks', 'domains',
|
|
28
|
+
'i18n', 'migrations', 'data',
|
|
29
|
+
'tasks', 'context',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const EXPECTED_ROOT_FILES = ['constitution.md', 'config.yaml'];
|
|
33
|
+
|
|
34
|
+
const SENSITIVE_FILES = ['.signing-key.pem', '.env', '.env.local', '.env.production'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate that framework/ directory exists.
|
|
38
|
+
*/
|
|
39
|
+
function checkFrameworkExists(packageRoot, results) {
|
|
40
|
+
results.checks++;
|
|
41
|
+
const frameworkDir = join(packageRoot, 'framework');
|
|
42
|
+
if (existsSync(frameworkDir)) {
|
|
43
|
+
results.passed++;
|
|
44
|
+
} else {
|
|
45
|
+
results.errors.push('framework/ directory not found. Run `npm run bundle` first.');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate all expected bundle directories exist.
|
|
51
|
+
*/
|
|
52
|
+
function checkBundledDirs(packageRoot, results) {
|
|
53
|
+
results.checks++;
|
|
54
|
+
const frameworkDir = join(packageRoot, 'framework');
|
|
55
|
+
if (!existsSync(frameworkDir)) return;
|
|
56
|
+
|
|
57
|
+
const missing = [];
|
|
58
|
+
for (const dir of EXPECTED_BUNDLE_DIRS) {
|
|
59
|
+
if (!existsSync(join(frameworkDir, dir))) {
|
|
60
|
+
missing.push(dir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (missing.length === 0) {
|
|
65
|
+
results.passed++;
|
|
66
|
+
} else {
|
|
67
|
+
results.errors.push(`Missing framework directories: ${missing.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate root framework files exist.
|
|
73
|
+
*/
|
|
74
|
+
function checkRootFiles(packageRoot, results) {
|
|
75
|
+
results.checks++;
|
|
76
|
+
const frameworkDir = join(packageRoot, 'framework');
|
|
77
|
+
if (!existsSync(frameworkDir)) return;
|
|
78
|
+
|
|
79
|
+
const missing = [];
|
|
80
|
+
for (const file of EXPECTED_ROOT_FILES) {
|
|
81
|
+
if (!existsSync(join(frameworkDir, file))) {
|
|
82
|
+
missing.push(file);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (missing.length === 0) {
|
|
87
|
+
results.passed++;
|
|
88
|
+
} else {
|
|
89
|
+
results.errors.push(`Missing framework root files: ${missing.join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check that no sensitive files leaked into the bundle.
|
|
95
|
+
*/
|
|
96
|
+
function checkNoSensitiveFiles(packageRoot, results) {
|
|
97
|
+
results.checks++;
|
|
98
|
+
const found = [];
|
|
99
|
+
|
|
100
|
+
// Only check inside publishable directories (bin/, src/, assets/, framework/, scripts/)
|
|
101
|
+
// Root-level files like .signing-key.pem are excluded by package.json "files" field
|
|
102
|
+
const publishableDirs = ['framework', 'src', 'scripts', 'bin', 'assets'];
|
|
103
|
+
for (const file of SENSITIVE_FILES) {
|
|
104
|
+
for (const dir of publishableDirs) {
|
|
105
|
+
if (existsSync(join(packageRoot, dir, file))) {
|
|
106
|
+
found.push(`${dir}/${file}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (found.length === 0) {
|
|
112
|
+
results.passed++;
|
|
113
|
+
} else {
|
|
114
|
+
results.errors.push(`Sensitive files found in package: ${found.join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate that all package.json exports resolve to existing files.
|
|
120
|
+
*/
|
|
121
|
+
function checkExportsResolve(packageRoot, results) {
|
|
122
|
+
results.checks++;
|
|
123
|
+
const pkgPath = join(packageRoot, 'package.json');
|
|
124
|
+
if (!existsSync(pkgPath)) return;
|
|
125
|
+
|
|
126
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
127
|
+
const exports = pkg.exports || {};
|
|
128
|
+
const missing = [];
|
|
129
|
+
|
|
130
|
+
for (const [key, value] of Object.entries(exports)) {
|
|
131
|
+
const filePath = join(packageRoot, value);
|
|
132
|
+
if (!existsSync(filePath)) {
|
|
133
|
+
missing.push(`${key} → ${value}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (missing.length === 0) {
|
|
138
|
+
results.passed++;
|
|
139
|
+
} else {
|
|
140
|
+
results.errors.push(`Unresolved exports: ${missing.join(', ')}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate that bin entry exists.
|
|
146
|
+
*/
|
|
147
|
+
function checkBinExists(packageRoot, results) {
|
|
148
|
+
results.checks++;
|
|
149
|
+
const pkgPath = join(packageRoot, 'package.json');
|
|
150
|
+
if (!existsSync(pkgPath)) return;
|
|
151
|
+
|
|
152
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
153
|
+
const bin = pkg.bin || {};
|
|
154
|
+
const missing = [];
|
|
155
|
+
|
|
156
|
+
for (const [name, path] of Object.entries(bin)) {
|
|
157
|
+
if (!existsSync(join(packageRoot, path))) {
|
|
158
|
+
missing.push(`${name} → ${path}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (missing.length === 0) {
|
|
163
|
+
results.passed++;
|
|
164
|
+
} else {
|
|
165
|
+
results.errors.push(`Missing bin entries: ${missing.join(', ')}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compare entity count in registry vs actual files in framework/.
|
|
171
|
+
*/
|
|
172
|
+
function checkEntityCount(packageRoot, results) {
|
|
173
|
+
results.checks++;
|
|
174
|
+
const registryPath = join(packageRoot, 'framework', 'data', 'entity-registry.yaml');
|
|
175
|
+
if (!existsSync(registryPath)) {
|
|
176
|
+
results.warnings.push('Entity registry not found in framework/data/ — skipping count check.');
|
|
177
|
+
results.passed++;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Count files in framework/ recursively (excluding manifest.json, manifest.sig)
|
|
182
|
+
let fileCount = 0;
|
|
183
|
+
function countFiles(dir) {
|
|
184
|
+
try {
|
|
185
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
countFiles(join(dir, entry.name));
|
|
188
|
+
} else if (entry.name !== 'manifest.json' && entry.name !== 'manifest.sig') {
|
|
189
|
+
fileCount++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch { /* skip unreadable dirs */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
countFiles(join(packageRoot, 'framework'));
|
|
196
|
+
|
|
197
|
+
if (fileCount > 0) {
|
|
198
|
+
results.passed++;
|
|
199
|
+
} else {
|
|
200
|
+
results.errors.push('Framework directory is empty (0 files).');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validate the npm package completeness.
|
|
206
|
+
* @param {string} packageRoot - Root of the package (packages/chati-dev/)
|
|
207
|
+
* @returns {{ errors: string[], warnings: string[], checks: number, passed: number }}
|
|
208
|
+
*/
|
|
209
|
+
export function validatePackage(packageRoot) {
|
|
210
|
+
const results = { errors: [], warnings: [], checks: 0, passed: 0 };
|
|
211
|
+
|
|
212
|
+
checkFrameworkExists(packageRoot, results);
|
|
213
|
+
checkBundledDirs(packageRoot, results);
|
|
214
|
+
checkRootFiles(packageRoot, results);
|
|
215
|
+
checkNoSensitiveFiles(packageRoot, results);
|
|
216
|
+
checkExportsResolve(packageRoot, results);
|
|
217
|
+
checkBinExists(packageRoot, results);
|
|
218
|
+
checkEntityCount(packageRoot, results);
|
|
219
|
+
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Format results for CLI output.
|
|
225
|
+
*/
|
|
226
|
+
function formatResults(results) {
|
|
227
|
+
const status = results.errors.length === 0 ? 'PASS' : 'FAIL';
|
|
228
|
+
const lines = [
|
|
229
|
+
`Package Validation: ${status} (${results.passed}/${results.checks} checks passed)`,
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
if (results.errors.length > 0) {
|
|
233
|
+
lines.push('');
|
|
234
|
+
lines.push('Errors:');
|
|
235
|
+
results.errors.forEach(e => lines.push(` ✗ ${e}`));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (results.warnings.length > 0) {
|
|
239
|
+
lines.push('');
|
|
240
|
+
lines.push('Warnings:');
|
|
241
|
+
results.warnings.forEach(w => lines.push(` ⚠ ${w}`));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return lines.join('\n');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// CLI entry point
|
|
248
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
249
|
+
const packageRoot = join(__dirname, '..');
|
|
250
|
+
const results = validatePackage(packageRoot);
|
|
251
|
+
|
|
252
|
+
console.log(formatResults(results));
|
|
253
|
+
|
|
254
|
+
if (results.errors.length > 0) {
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -17,7 +17,7 @@ export const GATE_RESULTS = {
|
|
|
17
17
|
// Minimum passing scores per agent (conservative: 90 default)
|
|
18
18
|
const GATE_THRESHOLDS = {
|
|
19
19
|
'qa-planning': 95, // Highest — gates planning→build transition
|
|
20
|
-
'qa-implementation':
|
|
20
|
+
'qa-implementation': 95, // Gates build→deploy transition
|
|
21
21
|
'brief': 90, // Requirements are critical
|
|
22
22
|
'detail': 90,
|
|
23
23
|
'architect': 90,
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* benchmarks are met, and security scans are clean.
|
|
7
7
|
*
|
|
8
8
|
* Verdicts:
|
|
9
|
-
* PASS — All criteria met, score >=
|
|
10
|
-
* CONCERNS — Score
|
|
11
|
-
* FAIL — Score <
|
|
9
|
+
* PASS — All criteria met, score >= 95%
|
|
10
|
+
* CONCERNS — Score 90-95%, minor issues noted
|
|
11
|
+
* FAIL — Score < 90% or critical issues
|
|
12
12
|
* WAIVED — Human explicitly overrode
|
|
13
13
|
*/
|
|
14
14
|
|
|
@@ -127,11 +127,11 @@ export class QAImplementationGate extends GateBase {
|
|
|
127
127
|
return QA_IMPL_VERDICTS.FAIL;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
if (score >=
|
|
130
|
+
if (score >= 95 && evidence.allTestCriteriaMet) {
|
|
131
131
|
return QA_IMPL_VERDICTS.PASS;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
if (score >=
|
|
134
|
+
if (score >= 90) {
|
|
135
135
|
return QA_IMPL_VERDICTS.CONCERNS;
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -151,7 +151,7 @@ export class QAImplementationGate extends GateBase {
|
|
|
151
151
|
'No critical bugs open',
|
|
152
152
|
'Performance benchmarks met',
|
|
153
153
|
'Security scan clean',
|
|
154
|
-
'QA-Implementation score >=
|
|
154
|
+
'QA-Implementation score >= 95',
|
|
155
155
|
];
|
|
156
156
|
|
|
157
157
|
const criteriaResults = [];
|
|
@@ -192,10 +192,10 @@ export class QAImplementationGate extends GateBase {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
// Check score
|
|
195
|
-
if (evidence.qaImplHandoff && evidence.qaImplHandoff.score >=
|
|
196
|
-
criteriaResults.push('QA-Implementation score >=
|
|
195
|
+
if (evidence.qaImplHandoff && evidence.qaImplHandoff.score >= 95) {
|
|
196
|
+
criteriaResults.push('QA-Implementation score >= 95');
|
|
197
197
|
} else if (evidence.qaImplHandoff) {
|
|
198
|
-
warnings.push(`QA-Impl score: ${evidence.qaImplHandoff.score} (need >=
|
|
198
|
+
warnings.push(`QA-Impl score: ${evidence.qaImplHandoff.score} (need >= 95)`);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
const score = allCriteria.length > 0
|
package/src/installer/core.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from 'fs';
|
|
1
|
+
import { mkdirSync, writeFileSync, copyFileSync, existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { IDE_CONFIGS } from '../config/ide-configs.js';
|
|
5
5
|
import { generateClaudeMCPConfig } from '../config/mcp-configs.js';
|
|
6
6
|
import { generateSessionYaml, generateConfigYaml, generateClaudeMd, generateClaudeLocalMd } from './templates.js';
|
|
7
|
+
import { verifyManifest } from './manifest.js';
|
|
7
8
|
|
|
8
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
|
|
@@ -19,6 +20,20 @@ const FRAMEWORK_SOURCE = existsSync(BUNDLED_SOURCE) ? BUNDLED_SOURCE : MONOREPO_
|
|
|
19
20
|
export async function installFramework(config) {
|
|
20
21
|
const { targetDir, projectType, language, selectedIDEs, selectedMCPs, projectName, version } = config;
|
|
21
22
|
|
|
23
|
+
// 0. Verify framework signature (supply chain protection)
|
|
24
|
+
const manifestPath = join(FRAMEWORK_SOURCE, 'manifest.json');
|
|
25
|
+
const sigPath = join(FRAMEWORK_SOURCE, 'manifest.sig');
|
|
26
|
+
|
|
27
|
+
if (existsSync(manifestPath) && existsSync(sigPath)) {
|
|
28
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
29
|
+
const signature = readFileSync(sigPath, 'utf-8').trim();
|
|
30
|
+
const result = verifyManifest(manifest, signature);
|
|
31
|
+
|
|
32
|
+
if (!result.valid && result.reason === 'signature-mismatch') {
|
|
33
|
+
throw new Error('Framework signature verification failed. Package may have been tampered with.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
// 1. Create .chati/ session directory
|
|
23
38
|
createDir(join(targetDir, '.chati'));
|
|
24
39
|
writeFileSync(
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { verify as cryptoVerify, createPublicKey } from 'crypto';
|
|
3
5
|
import { hashFile } from './file-hasher.js';
|
|
4
6
|
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
8
|
const MANIFEST_FILENAME = 'manifest.json';
|
|
6
9
|
const MANIFEST_DIR = '.chati';
|
|
7
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Load the Ed25519 public key for signature verification.
|
|
13
|
+
* Returns null if key file doesn't exist (dev environment).
|
|
14
|
+
*/
|
|
15
|
+
let SIGNING_PUBLIC_KEY = null;
|
|
16
|
+
try {
|
|
17
|
+
const pemPath = join(__dirname, 'signing-public-key.pem');
|
|
18
|
+
SIGNING_PUBLIC_KEY = createPublicKey(readFileSync(pemPath));
|
|
19
|
+
} catch {
|
|
20
|
+
// Public key not available (dev environment without key generation)
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
/**
|
|
9
24
|
* Generate a manifest for a set of files under a root directory.
|
|
10
25
|
*
|
|
@@ -115,3 +130,20 @@ export function compareManifests(oldManifest, newManifest) {
|
|
|
115
130
|
|
|
116
131
|
return { added, removed, modified, unchanged };
|
|
117
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Verify a manifest signature using the embedded Ed25519 public key.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} manifest - The manifest object to verify
|
|
138
|
+
* @param {string} signatureBase64 - Base64-encoded Ed25519 signature
|
|
139
|
+
* @returns {{ valid: boolean, reason: string }}
|
|
140
|
+
*/
|
|
141
|
+
export function verifyManifest(manifest, signatureBase64) {
|
|
142
|
+
if (!SIGNING_PUBLIC_KEY) return { valid: false, reason: 'no-public-key' };
|
|
143
|
+
|
|
144
|
+
const manifestJson = JSON.stringify(manifest, Object.keys(manifest).sort(), 2);
|
|
145
|
+
const signature = Buffer.from(signatureBase64, 'base64');
|
|
146
|
+
|
|
147
|
+
const valid = cryptoVerify(null, Buffer.from(manifestJson), SIGNING_PUBLIC_KEY, signature);
|
|
148
|
+
return { valid, reason: valid ? 'ok' : 'signature-mismatch' };
|
|
149
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import { hashFile } from './file-hasher.js';
|
|
4
|
+
import { loadManifest } from './manifest.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Validate chati.dev installation
|
|
@@ -17,6 +19,7 @@ export async function validateInstallation(targetDir) {
|
|
|
17
19
|
registry: { pass: false, details: [] },
|
|
18
20
|
memories: { pass: false, details: [] },
|
|
19
21
|
context: { pass: false, details: [] },
|
|
22
|
+
integrity: { pass: false, details: [] },
|
|
20
23
|
total: 0,
|
|
21
24
|
passed: 0,
|
|
22
25
|
};
|
|
@@ -166,5 +169,35 @@ export async function validateInstallation(targetDir) {
|
|
|
166
169
|
results.total += 1;
|
|
167
170
|
if (results.context.pass) results.passed += 1;
|
|
168
171
|
|
|
172
|
+
// Check integrity: verify installed files match manifest hashes
|
|
173
|
+
const manifest = loadManifest(targetDir);
|
|
174
|
+
if (manifest && manifest.files) {
|
|
175
|
+
let matched = 0;
|
|
176
|
+
let mismatched = 0;
|
|
177
|
+
const mismatches = [];
|
|
178
|
+
|
|
179
|
+
for (const [relPath, entry] of Object.entries(manifest.files)) {
|
|
180
|
+
const absPath = join(targetDir, 'chati.dev', relPath);
|
|
181
|
+
if (existsSync(absPath)) {
|
|
182
|
+
const currentHash = hashFile(absPath);
|
|
183
|
+
if (currentHash === entry.hash) {
|
|
184
|
+
matched++;
|
|
185
|
+
} else {
|
|
186
|
+
mismatched++;
|
|
187
|
+
mismatches.push(relPath);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
results.integrity.pass = mismatched === 0;
|
|
193
|
+
results.integrity.details.push({ matched, mismatched, mismatches });
|
|
194
|
+
} else {
|
|
195
|
+
// No manifest = skip integrity (first install or dev environment)
|
|
196
|
+
results.integrity.pass = true;
|
|
197
|
+
results.integrity.details.push({ skipped: true, reason: 'no-manifest' });
|
|
198
|
+
}
|
|
199
|
+
results.total += 1;
|
|
200
|
+
if (results.integrity.pass) results.passed += 1;
|
|
201
|
+
|
|
169
202
|
return results;
|
|
170
203
|
}
|
|
@@ -215,6 +215,26 @@ export function searchAgentMemories(projectDir, query) {
|
|
|
215
215
|
return results;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Get top memory entries for an agent, sorted by confidence.
|
|
220
|
+
* Used by orchestrator/agents for programmatic memory access.
|
|
221
|
+
* @param {string} projectDir - Project directory
|
|
222
|
+
* @param {string} agentName - Agent name
|
|
223
|
+
* @param {number} [limit=5] - Max entries to return
|
|
224
|
+
* @returns {object[]} Top entries sorted by confidence (high → medium → low)
|
|
225
|
+
*/
|
|
226
|
+
export function getTopMemories(projectDir, agentName, limit = 5) {
|
|
227
|
+
const memory = readAgentMemory(projectDir, agentName);
|
|
228
|
+
if (!memory.loaded) return [];
|
|
229
|
+
|
|
230
|
+
const confidenceOrder = { high: 3, medium: 2, low: 1 };
|
|
231
|
+
const sorted = [...memory.entries].sort((a, b) => {
|
|
232
|
+
return (confidenceOrder[b.confidence] || 2) - (confidenceOrder[a.confidence] || 2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return sorted.slice(0, limit);
|
|
236
|
+
}
|
|
237
|
+
|
|
218
238
|
/**
|
|
219
239
|
* Get memory stats per agent.
|
|
220
240
|
* @param {string} projectDir - Project directory
|
|
@@ -26,7 +26,7 @@ export const AGENT_STATUS = {
|
|
|
26
26
|
* Required QA score for phase transitions.
|
|
27
27
|
*/
|
|
28
28
|
const QA_PLANNING_THRESHOLD = 95;
|
|
29
|
-
const QA_IMPLEMENTATION_THRESHOLD =
|
|
29
|
+
const QA_IMPLEMENTATION_THRESHOLD = 95;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Initialize a new pipeline for a project.
|