chati-dev 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -13
- package/bin/chati.js +32 -4
- package/framework/agents/build/dev.md +9 -5
- package/framework/agents/discover/brief.md +2 -0
- package/framework/agents/discover/brownfield-wu.md +2 -0
- package/framework/agents/discover/greenfield-wu.md +18 -1
- package/framework/agents/plan/detail.md +2 -0
- package/framework/agents/plan/tasks.md +31 -0
- package/framework/agents/plan/ux-brand-architect.md +21 -2
- package/framework/agents/plan/ux-component-engineer.md +10 -0
- package/framework/agents/quality/qa-implementation.md +1 -1
- package/framework/agents/quality/qa-planning.md +1 -1
- package/framework/agents/quality/qa-visual.md +33 -21
- package/framework/config.yaml +5 -4
- package/framework/constitution.md +58 -11
- package/framework/context/governance.md +12 -10
- package/framework/context/root.md +2 -2
- package/framework/data/entity-registry.yaml +12 -4
- package/framework/data/qa-rubrics.yaml +335 -0
- package/framework/domains/constitution.yaml +3 -3
- package/framework/executors/json-validate.js +80 -0
- package/framework/executors/npm-script.js +72 -0
- package/framework/executors/path-check.js +67 -0
- package/framework/executors/registry.yaml +36 -0
- package/framework/hooks/advance-trigger.js +47 -3
- package/framework/hooks/constitution-guard.js +9 -4
- package/framework/hooks/git-push-authority.js +113 -0
- package/framework/hooks/license-guard.js +59 -9
- package/framework/hooks/model-governance.js +40 -17
- package/framework/hooks/prism-engine.js +78 -11
- package/framework/hooks/session-digest.js +12 -3
- package/framework/hooks/settings.json +7 -3
- package/framework/hooks/style-guard.js +52 -3
- package/framework/hooks/team-quality-gate.js +43 -5
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/confidence.yaml +85 -6
- package/framework/intelligence/context-engine.md +9 -5
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/orchestrator/chati-router.js +59 -5
- package/framework/orchestrator/chati-update.md +19 -3
- package/framework/orchestrator/chati.md +20 -1
- package/framework/quality-gates/planning-gate.md +2 -0
- package/framework/rules/human-writing-style.md +47 -0
- package/framework/scaffold/motion-premium/README.md +12 -2
- package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
- package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
- package/framework/scaffold/motion-premium-3d/README.md +10 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
- package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
- package/framework/scaffold/saas-dashboard/README.md +58 -0
- package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
- package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
- package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
- package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
- package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
- package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
- package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
- package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
- package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
- package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
- package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
- package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
- package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
- package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
- package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
- package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
- package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
- package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
- package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
- package/framework/schemas/session.schema.json +5 -0
- package/framework/schemas/task.schema.json +9 -0
- package/framework/scripts/visual-qa.js +101 -3
- package/framework/tasks/orchestrator-health.md +4 -4
- package/framework/tasks/qa-impl-verdict.md +11 -5
- package/framework/templates/brandbook-html-tmpl.md +1 -1
- package/framework/templates/qa-gate-tmpl.yaml +24 -11
- package/package.json +4 -3
- package/src/config/context-file-generator.js +0 -6
- package/src/dashboard/renderer.js +0 -36
- package/src/executors/runner.js +204 -0
- package/src/installer/core.js +13 -3
- package/src/installer/templates.js +5 -6
- package/src/intelligence/registry-manager.js +1 -1
- package/src/license/client.js +9 -3
- package/src/license/commands.js +12 -2
- package/src/license/machine-id.js +42 -1
- package/src/memory/gotchas.js +58 -40
- package/src/memory/magic-docs.js +1 -1
- package/src/memory/session-digest.js +9 -4
- package/src/orchestrator/cli.js +186 -15
- package/src/orchestrator/doctor.js +98 -16
- package/src/orchestrator/pipeline-manager.js +44 -22
- package/src/orchestrator/session-manager.js +64 -8
- package/src/telemetry/sender.js +7 -19
- package/src/terminal/run-team.js +3 -3
- package/src/upgrade/tracked-files-detector.js +34 -16
- package/src/utils/feature-flags.js +1 -1
- package/src/utils/flatten-entities.js +4 -40
- package/src/utils/schema-validator.js +0 -14
- package/src/wizard/i18n.js +3 -3
- package/src/api/index.js +0 -120
- package/src/autonomy/autonomous-gate.js +0 -294
- package/src/autonomy/build-loop.js +0 -281
- package/src/autonomy/build-state.js +0 -286
- package/src/autonomy/cause-analyzer.js +0 -177
- package/src/autonomy/escalation.js +0 -214
- package/src/autonomy/index.js +0 -51
- package/src/autonomy/mode-manager.js +0 -225
- package/src/autonomy/mode-suggester.js +0 -283
- package/src/autonomy/progress-reporter.js +0 -275
- package/src/autonomy/safety-net.js +0 -370
- package/src/config/agent-customizer.js +0 -231
- package/src/decision/analyzer.js +0 -291
- package/src/decision/engine.js +0 -250
- package/src/decision/index.js +0 -38
- package/src/decision/registry-healer.js +0 -468
- package/src/decision/registry-updater.js +0 -339
- package/src/extensions/loader.js +0 -145
- package/src/extensions/registry.js +0 -134
- package/src/gates/circuit-breaker.js +0 -151
- package/src/gates/g1-planning-complete.js +0 -154
- package/src/gates/g2-qa-planning.js +0 -156
- package/src/gates/g3-implementation.js +0 -215
- package/src/gates/g4-qa-implementation.js +0 -240
- package/src/gates/g5-deploy-ready.js +0 -181
- package/src/gates/gate-base.js +0 -185
- package/src/gates/index.js +0 -46
- package/src/health/auto-fix.js +0 -216
- package/src/health/engine.js +0 -246
- package/src/merger/semantic-merger.js +0 -292
- package/src/preview/detector.js +0 -238
- package/src/preview/index.js +0 -20
- package/src/preview/launcher.js +0 -235
- package/src/preview/log-buffer.js +0 -103
- package/src/quality/metrics-collector.js +0 -281
- package/src/quality/test-runner.js +0 -366
- package/src/tasks/executor.js +0 -195
- package/src/tasks/index.js +0 -4
- package/src/tasks/loader.js +0 -210
- package/src/tasks/router.js +0 -182
- package/src/utils/event-bus.js +0 -126
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* json-validate executor - parse JSON files and report any that are malformed.
|
|
4
|
+
* A deterministic, zero-LLM well-formedness check (valid JSON, not schema
|
|
5
|
+
* conformance). Reads a JSON request on stdin and writes a JSON result on
|
|
6
|
+
* stdout (the executor contract; see executors/registry.yaml).
|
|
7
|
+
*
|
|
8
|
+
* arg is a comma-separated list of project-relative .json paths. Paths are
|
|
9
|
+
* contained to projectDir, and parse failures report only "not valid JSON"
|
|
10
|
+
* (never the parser message), so the executor cannot read or leak file content
|
|
11
|
+
* outside the project.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from 'fs';
|
|
14
|
+
import { resolve, sep } from 'path';
|
|
15
|
+
|
|
16
|
+
function readStdin() {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let data = '';
|
|
19
|
+
process.stdin.setEncoding('utf-8');
|
|
20
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
21
|
+
process.stdin.on('end', () => resolve(data));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when `p`, resolved against the project root, stays inside the project. */
|
|
26
|
+
function within(root, p) {
|
|
27
|
+
const full = resolve(root, p);
|
|
28
|
+
return full === root || full.startsWith(root + sep);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
let input;
|
|
33
|
+
try {
|
|
34
|
+
input = JSON.parse((await readStdin()) || '{}');
|
|
35
|
+
} catch {
|
|
36
|
+
process.stdout.write(JSON.stringify({ status: 'error', summary: 'json-validate: request was not valid JSON.' }));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const root = resolve(input.projectDir || process.cwd());
|
|
41
|
+
const files = String(input.arg || '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
42
|
+
|
|
43
|
+
if (files.length === 0) {
|
|
44
|
+
process.stdout.write(JSON.stringify({ status: 'error', summary: 'json-validate: no files given (arg is a comma-separated list).' }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const escaping = files.filter((f) => !within(root, f));
|
|
49
|
+
if (escaping.length > 0) {
|
|
50
|
+
process.stdout.write(JSON.stringify({
|
|
51
|
+
status: 'error',
|
|
52
|
+
summary: `json-validate: ${escaping.length} path(s) are outside the project directory and were refused.`,
|
|
53
|
+
details: { refused: escaping },
|
|
54
|
+
}));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const errors = [];
|
|
59
|
+
for (const f of files) {
|
|
60
|
+
const full = resolve(root, f);
|
|
61
|
+
if (!existsSync(full)) { errors.push({ file: f, error: 'not found' }); continue; }
|
|
62
|
+
try {
|
|
63
|
+
JSON.parse(readFileSync(full, 'utf-8'));
|
|
64
|
+
} catch {
|
|
65
|
+
// Report only that the file is not valid JSON. The parser message can echo
|
|
66
|
+
// file content, so it is never forwarded.
|
|
67
|
+
errors.push({ file: f, error: 'not valid JSON' });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
process.stdout.write(JSON.stringify({
|
|
72
|
+
status: errors.length === 0 ? 'passed' : 'failed',
|
|
73
|
+
summary: errors.length === 0
|
|
74
|
+
? `All ${files.length} JSON file(s) are well-formed.`
|
|
75
|
+
: `${errors.length} of ${files.length} file(s) failed to parse.`,
|
|
76
|
+
details: { errors },
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
main();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* npm-script executor - run a whitelisted project npm script as a deterministic,
|
|
4
|
+
* zero-LLM task. Reads a JSON request on stdin and writes a JSON result on
|
|
5
|
+
* stdout (the executor contract; see executors/registry.yaml).
|
|
6
|
+
*
|
|
7
|
+
* Only a fixed set of terminating script names is allowed, so a script task can
|
|
8
|
+
* never run an arbitrary or long-running command: a task declares execution_ref
|
|
9
|
+
* "npm-script:test" and this runs `npm run test` and reports pass or fail from
|
|
10
|
+
* the exit code.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
|
|
14
|
+
const ALLOWED = new Set(['lint', 'test', 'typecheck', 'build', 'format']);
|
|
15
|
+
|
|
16
|
+
function readStdin() {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let data = '';
|
|
19
|
+
process.stdin.setEncoding('utf-8');
|
|
20
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
21
|
+
process.stdin.on('end', () => resolve(data));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
// Emit exactly once: spawn can fire both 'error' and 'close', and emitting
|
|
27
|
+
// twice would put two JSON objects on stdout and break the runner's parse.
|
|
28
|
+
let settled = false;
|
|
29
|
+
const emit = (result) => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
settled = true;
|
|
32
|
+
process.stdout.write(JSON.stringify(result));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let input;
|
|
36
|
+
try {
|
|
37
|
+
input = JSON.parse((await readStdin()) || '{}');
|
|
38
|
+
} catch {
|
|
39
|
+
emit({ status: 'error', summary: 'npm-script: request was not valid JSON.' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const projectDir = input.projectDir || process.cwd();
|
|
44
|
+
const script = String(input.arg || '').trim();
|
|
45
|
+
|
|
46
|
+
if (!ALLOWED.has(script)) {
|
|
47
|
+
emit({
|
|
48
|
+
status: 'error',
|
|
49
|
+
summary: `npm-script: "${script || '(none)'}" is not an allowed script. Allowed: ${[...ALLOWED].join(', ')}.`,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const child = spawn('npm', ['run', script], { cwd: projectDir, shell: false });
|
|
55
|
+
let out = '';
|
|
56
|
+
let err = '';
|
|
57
|
+
child.stdout.on('data', (c) => { out += c; });
|
|
58
|
+
child.stderr.on('data', (c) => { err += c; });
|
|
59
|
+
child.on('error', (e) => {
|
|
60
|
+
emit({ status: 'error', summary: `npm-script: could not spawn npm (${e.message}).` });
|
|
61
|
+
});
|
|
62
|
+
child.on('close', (code) => {
|
|
63
|
+
const tail = (out + err).split('\n').filter(Boolean).slice(-20).join('\n');
|
|
64
|
+
emit({
|
|
65
|
+
status: code === 0 ? 'passed' : 'failed',
|
|
66
|
+
summary: `npm run ${script} exited ${code}.`,
|
|
67
|
+
details: { exitCode: code, tail },
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
main();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* path-check executor - assert that required paths exist. A deterministic,
|
|
4
|
+
* zero-LLM verification gate (for example "the build output and the generated
|
|
5
|
+
* client both exist"). Reads a JSON request on stdin and writes a JSON result
|
|
6
|
+
* on stdout (the executor contract; see executors/registry.yaml).
|
|
7
|
+
*
|
|
8
|
+
* arg is a comma-separated list of project-relative paths. Paths are contained
|
|
9
|
+
* to projectDir: an absolute path or a "../" sequence that escapes the project
|
|
10
|
+
* is rejected, so the executor cannot be used to probe the wider filesystem.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'fs';
|
|
13
|
+
import { resolve, sep } from 'path';
|
|
14
|
+
|
|
15
|
+
function readStdin() {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
let data = '';
|
|
18
|
+
process.stdin.setEncoding('utf-8');
|
|
19
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
20
|
+
process.stdin.on('end', () => resolve(data));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True when `p`, resolved against the project root, stays inside the project. */
|
|
25
|
+
function within(root, p) {
|
|
26
|
+
const full = resolve(root, p);
|
|
27
|
+
return full === root || full.startsWith(root + sep);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
let input;
|
|
32
|
+
try {
|
|
33
|
+
input = JSON.parse((await readStdin()) || '{}');
|
|
34
|
+
} catch {
|
|
35
|
+
process.stdout.write(JSON.stringify({ status: 'error', summary: 'path-check: request was not valid JSON.' }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const root = resolve(input.projectDir || process.cwd());
|
|
40
|
+
const paths = String(input.arg || '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
41
|
+
|
|
42
|
+
if (paths.length === 0) {
|
|
43
|
+
process.stdout.write(JSON.stringify({ status: 'error', summary: 'path-check: no paths given (arg is a comma-separated list).' }));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const escaping = paths.filter((p) => !within(root, p));
|
|
48
|
+
if (escaping.length > 0) {
|
|
49
|
+
process.stdout.write(JSON.stringify({
|
|
50
|
+
status: 'error',
|
|
51
|
+
summary: `path-check: ${escaping.length} path(s) are outside the project directory and were refused.`,
|
|
52
|
+
details: { refused: escaping },
|
|
53
|
+
}));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const missing = paths.filter((p) => !existsSync(resolve(root, p)));
|
|
58
|
+
process.stdout.write(JSON.stringify({
|
|
59
|
+
status: missing.length === 0 ? 'passed' : 'failed',
|
|
60
|
+
summary: missing.length === 0
|
|
61
|
+
? `All ${paths.length} path(s) exist.`
|
|
62
|
+
: `${missing.length} of ${paths.length} path(s) missing: ${missing.join(', ')}.`,
|
|
63
|
+
details: { checked: paths, missing },
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Executor Registry - deterministic, zero-LLM task executors.
|
|
2
|
+
#
|
|
3
|
+
# Each executor is a standalone Node script that reads a JSON request on stdin
|
|
4
|
+
# and writes a JSON result on stdout. The runner (packages/chati-dev/src/
|
|
5
|
+
# executors/runner.js) resolves an executor by id, spawns it, enforces a
|
|
6
|
+
# timeout, and writes a synthetic handoff. Executors run ONLY when a task
|
|
7
|
+
# declares execution_kind: script AND the execution_kinds feature flag is on.
|
|
8
|
+
#
|
|
9
|
+
# Contract:
|
|
10
|
+
# stdin {"task": {id, title, execution_ref}, "projectDir": "...", "arg": "..."}
|
|
11
|
+
# stdout {"status": "passed" | "failed" | "error", "summary": "...", "details": {...}}
|
|
12
|
+
# arg the part after the first colon in execution_ref
|
|
13
|
+
# (e.g. "npm-script:test" resolves executor "npm-script", arg "test").
|
|
14
|
+
# status passed = the deterministic check succeeded;
|
|
15
|
+
# failed = it ran and the result is negative (route to dev);
|
|
16
|
+
# error = the executor could not run (bad arg, spawn failure).
|
|
17
|
+
#
|
|
18
|
+
# Adding an executor: write a standalone script under executors/, register it
|
|
19
|
+
# here with a path and a timeout, and cover it with a test. Executors must be
|
|
20
|
+
# deterministic, side-effect-light, and never call an LLM.
|
|
21
|
+
|
|
22
|
+
version: "1.0.0"
|
|
23
|
+
|
|
24
|
+
executors:
|
|
25
|
+
npm-script:
|
|
26
|
+
path: executors/npm-script.js
|
|
27
|
+
description: "Run a project npm script and report deterministic pass/fail. arg is the script name; only lint, test, typecheck, build, and format are allowed (no arbitrary or long-running commands)."
|
|
28
|
+
timeout_ms: 600000
|
|
29
|
+
path-check:
|
|
30
|
+
path: executors/path-check.js
|
|
31
|
+
description: "Assert that one or more paths exist (a deterministic verification gate). arg is a comma-separated list of project-relative paths."
|
|
32
|
+
timeout_ms: 30000
|
|
33
|
+
json-validate:
|
|
34
|
+
path: executors/json-validate.js
|
|
35
|
+
description: "Parse JSON files and report any that are malformed (a deterministic well-formedness check). arg is a comma-separated list of project-relative .json paths."
|
|
36
|
+
timeout_ms: 60000
|
|
@@ -17,10 +17,33 @@
|
|
|
17
17
|
* ignored so it outlives the hook process.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { existsSync, readFileSync } from 'fs';
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
21
21
|
import { join } from 'path';
|
|
22
22
|
import { spawn } from 'child_process';
|
|
23
23
|
|
|
24
|
+
// Canonical gate thresholds live in data/qa-rubrics.yaml. These literals are
|
|
25
|
+
// only a last-resort fallback for when that file is briefly unavailable (for
|
|
26
|
+
// example mid-upgrade); they must match the YAML.
|
|
27
|
+
const FALLBACK_QA_THRESHOLDS = { 'qa-planning': 95, 'qa-implementation': 95, 'qa-visual': 90 };
|
|
28
|
+
|
|
29
|
+
function loadQaThresholds(cwd) {
|
|
30
|
+
const candidates = [
|
|
31
|
+
join(cwd, '.chati.dev', 'data', 'qa-rubrics.yaml'),
|
|
32
|
+
join(cwd, 'chati.dev', 'data', 'qa-rubrics.yaml'),
|
|
33
|
+
];
|
|
34
|
+
let raw = null;
|
|
35
|
+
for (const p of candidates) {
|
|
36
|
+
if (existsSync(p)) { try { raw = readFileSync(p, 'utf-8'); break; } catch { /* fall through */ } }
|
|
37
|
+
}
|
|
38
|
+
if (!raw) return { ...FALLBACK_QA_THRESHOLDS };
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const key of Object.keys(FALLBACK_QA_THRESHOLDS)) {
|
|
41
|
+
const m = raw.match(new RegExp(`^\\s*${key}:\\s*(\\d+)`, 'm'));
|
|
42
|
+
out[key] = m ? parseInt(m[1], 10) : FALLBACK_QA_THRESHOLDS[key];
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
async function readStdin() {
|
|
25
48
|
let data = '';
|
|
26
49
|
for await (const chunk of process.stdin) data += chunk;
|
|
@@ -95,7 +118,7 @@ async function main() {
|
|
|
95
118
|
|
|
96
119
|
// Decide whether to fire under interactive mode.
|
|
97
120
|
if (profile !== 'autonomous') {
|
|
98
|
-
const QA_THRESHOLDS =
|
|
121
|
+
const QA_THRESHOLDS = loadQaThresholds(cwd);
|
|
99
122
|
const threshold = QA_THRESHOLDS[agent];
|
|
100
123
|
const isQA = threshold !== undefined;
|
|
101
124
|
const verdict = (fm.verdict || '').toUpperCase();
|
|
@@ -118,14 +141,35 @@ async function main() {
|
|
|
118
141
|
if (!routerPath) return;
|
|
119
142
|
|
|
120
143
|
// Fire and forget: detach so the router outlives this hook process.
|
|
144
|
+
// A silent spawn failure would stall the pipeline with no signal. Warn on
|
|
145
|
+
// stderr (Claude Code surfaces it immediately as a non-blocking hook warning)
|
|
146
|
+
// and drop a durable post-mortem marker in .chati/failures/ for inspection.
|
|
147
|
+
// The stderr warning is the surfaced signal; nothing scans the marker dir
|
|
148
|
+
// automatically yet.
|
|
149
|
+
const recordFailure = (err) => {
|
|
150
|
+
const msg = (err && err.message) ? err.message : String(err);
|
|
151
|
+
try {
|
|
152
|
+
const failuresDir = join(cwd, '.chati', 'failures');
|
|
153
|
+
mkdirSync(failuresDir, { recursive: true });
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(failuresDir, `advance-${agent}-${Date.now()}.json`),
|
|
156
|
+
JSON.stringify({ type: 'advance_spawn_failure', agent, score, error: msg, timestamp: new Date().toISOString() }),
|
|
157
|
+
'utf-8'
|
|
158
|
+
);
|
|
159
|
+
} catch { /* truly non-fatal: filesystem unavailable */ }
|
|
160
|
+
try { process.stderr.write(`[chati] advance-trigger: pipeline advance for "${agent}" failed to spawn: ${msg}\n`); } catch { /* ignore */ }
|
|
161
|
+
};
|
|
121
162
|
try {
|
|
122
163
|
const child = spawn('node', [routerPath, 'advance', '--agent', agent, '--score', String(score)], {
|
|
123
164
|
cwd,
|
|
124
165
|
detached: true,
|
|
125
166
|
stdio: 'ignore',
|
|
126
167
|
});
|
|
168
|
+
child.on('error', recordFailure);
|
|
127
169
|
child.unref();
|
|
128
|
-
} catch {
|
|
170
|
+
} catch (err) {
|
|
171
|
+
recordFailure(err);
|
|
172
|
+
}
|
|
129
173
|
}
|
|
130
174
|
|
|
131
175
|
main().catch(() => process.stdout.write('{}\n'));
|
|
@@ -137,10 +137,15 @@ async function main() {
|
|
|
137
137
|
|
|
138
138
|
process.stdout.write(JSON.stringify(allowOutput()));
|
|
139
139
|
} catch (err) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
140
|
+
const msg = err?.message || 'unknown';
|
|
141
|
+
process.stderr.write(`[chati-hook-error] constitution-guard: ${msg}\n`);
|
|
142
|
+
// Fail CLOSED. A security/integrity guard that crashes must not
|
|
143
|
+
// silently permit the operation. The cost of a false block (user re-runs)
|
|
144
|
+
// is far lower than a false pass (a secret leak or governance violation
|
|
145
|
+
// slipping through). Deny with a clear, actionable reason.
|
|
146
|
+
process.stdout.write(JSON.stringify(denyOutput(
|
|
147
|
+
`constitution-guard could not complete its security checks (internal error: ${msg}). The operation was blocked as a precaution. Re-run once resolved, or report this if it keeps happening.`
|
|
148
|
+
)));
|
|
144
149
|
}
|
|
145
150
|
}
|
|
146
151
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Git-push authority hook — PreToolUse (Bash).
|
|
4
|
+
*
|
|
5
|
+
* During an active pipeline, only the devops agent may push to a remote or open
|
|
6
|
+
* a pull request. This stops a non-devops agent (for example dev, mid
|
|
7
|
+
* implementation) from pushing unreviewed work past the quality gates.
|
|
8
|
+
*
|
|
9
|
+
* Scope is deliberately narrow so normal git use is never blocked:
|
|
10
|
+
* - it only ever denies push / PR-creation / release-creation commands;
|
|
11
|
+
* - it only enforces while a pipeline is active (session.mode is a pipeline
|
|
12
|
+
* phase) AND the current agent is set AND is not devops;
|
|
13
|
+
* - outside an active session (no session, completed session) it does
|
|
14
|
+
* nothing.
|
|
15
|
+
*
|
|
16
|
+
* Fails open (with an stderr log) on any internal error: a guard crash must
|
|
17
|
+
* never block every Bash command.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
|
|
22
|
+
const PUSH_PATTERNS = [
|
|
23
|
+
// git push, including global options that carry a value (git -C <path> push,
|
|
24
|
+
// git -c k=v push) so an agent cannot side-step the guard with -C.
|
|
25
|
+
/\bgit\s+(?:-{1,2}\S+(?:\s+\S+)?\s+)*push\b/i,
|
|
26
|
+
/\bgh\s+pr\s+(?:create|merge)\b/i,
|
|
27
|
+
/\bgh\s+release\s+create\b/i,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// Pipeline phases during which a non-devops agent is actively driving work.
|
|
31
|
+
const ACTIVE_MODES = ['discover', 'plan', 'build', 'validate', 'deploy'];
|
|
32
|
+
|
|
33
|
+
function isPushCommand(command) {
|
|
34
|
+
return PUSH_PATTERNS.some((p) => p.test(command || ''));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Block only when a push/PR command runs while a pipeline is active (mode is a
|
|
39
|
+
* pipeline phase) and the current driver is a non-devops agent.
|
|
40
|
+
*/
|
|
41
|
+
function shouldBlockPush(command, mode, agent) {
|
|
42
|
+
if (!isPushCommand(command)) return false;
|
|
43
|
+
return Boolean(mode && ACTIVE_MODES.includes(mode) && agent && agent !== 'devops');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readSessionField(projectDir, key) {
|
|
47
|
+
const p = join(projectDir, '.chati', 'session.yaml');
|
|
48
|
+
if (!existsSync(p)) return null;
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(p, 'utf-8');
|
|
51
|
+
const m = raw.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm'));
|
|
52
|
+
if (!m) return null;
|
|
53
|
+
const v = m[1].trim().replace(/^["']|["']$/g, '');
|
|
54
|
+
return v === 'null' || v === '' ? null : v;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
let input = '';
|
|
62
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const event = JSON.parse(input);
|
|
66
|
+
if ((event.tool_name || '') !== 'Bash') {
|
|
67
|
+
process.stdout.write('{}');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const command = (event.tool_input && event.tool_input.command) || '';
|
|
71
|
+
if (!isPushCommand(command)) {
|
|
72
|
+
process.stdout.write('{}');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const projectDir = event.cwd || process.cwd();
|
|
77
|
+
const mode = readSessionField(projectDir, 'mode');
|
|
78
|
+
const agent = readSessionField(projectDir, 'current_agent');
|
|
79
|
+
|
|
80
|
+
// Only enforce while a pipeline is active with a non-devops driver.
|
|
81
|
+
if (shouldBlockPush(command, mode, agent)) {
|
|
82
|
+
process.stdout.write(JSON.stringify({
|
|
83
|
+
hookSpecificOutput: {
|
|
84
|
+
hookEventName: 'PreToolUse',
|
|
85
|
+
permissionDecision: 'deny',
|
|
86
|
+
permissionDecisionReason: `[Article IV] Only the devops agent may push to a remote or open a pull request during an active pipeline. The current agent is "${agent}". Let the pipeline reach the deploy phase (devops), or run /chati exit to take manual control.`,
|
|
87
|
+
},
|
|
88
|
+
}));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
process.stdout.write('{}');
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Fail open: a guard crash must not block every Bash command. Log so the
|
|
95
|
+
// failure is visible instead of silently disabling enforcement.
|
|
96
|
+
process.stderr.write(`[chati] git-push-authority error (allowing operation): ${err && err.message ? err.message : err}\n`);
|
|
97
|
+
process.stdout.write('{}');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { PUSH_PATTERNS, ACTIVE_MODES, isPushCommand, shouldBlockPush, readSessionField };
|
|
102
|
+
|
|
103
|
+
// Only run main when executed directly (not when imported by tests).
|
|
104
|
+
// realpathSync resolves symlinks on both sides (macOS /tmp -> /private/tmp).
|
|
105
|
+
import { fileURLToPath } from 'url';
|
|
106
|
+
import { realpathSync } from 'fs';
|
|
107
|
+
if (process.argv[1]) {
|
|
108
|
+
try {
|
|
109
|
+
if (realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
110
|
+
main();
|
|
111
|
+
}
|
|
112
|
+
} catch { /* path resolution failed — not invoked as a script */ }
|
|
113
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Telemetry pings are owned by session-digest.js (PreCompact event), not here.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
|
23
23
|
import { join } from 'path';
|
|
24
24
|
import { homedir } from 'os';
|
|
25
25
|
|
|
@@ -70,9 +70,21 @@ export async function checkLicense() {
|
|
|
70
70
|
// cache or detects revocation. Network failures NEVER block the user.
|
|
71
71
|
try {
|
|
72
72
|
const machineId = await computeMachineId();
|
|
73
|
+
// The license key travels in a request header, never in the URL query
|
|
74
|
+
// string (which lands in server access, CDN and proxy logs in plaintext).
|
|
75
|
+
// Server contract: POST /license/validate, key in X-License-Key header,
|
|
76
|
+
// machine_id in the JSON body.
|
|
73
77
|
const res = await fetch(
|
|
74
|
-
`${API_BASE}/license/validate
|
|
75
|
-
{
|
|
78
|
+
`${API_BASE}/license/validate`,
|
|
79
|
+
{
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
'X-License-Key': licenseKey,
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({ machine_id: machineId }),
|
|
86
|
+
signal: AbortSignal.timeout(5000),
|
|
87
|
+
}
|
|
76
88
|
);
|
|
77
89
|
const data = await res.json();
|
|
78
90
|
|
|
@@ -89,6 +101,8 @@ export async function checkLicense() {
|
|
|
89
101
|
};
|
|
90
102
|
mkdirSync(GLOBAL_DIR, { recursive: true });
|
|
91
103
|
writeFileSync(LICENSE_PATH, dumpYaml(updated));
|
|
104
|
+
// The license file holds the activation key; restrict to owner-only.
|
|
105
|
+
try { chmodSync(LICENSE_PATH, 0o600); } catch { /* no-op on Windows / read-only FS */ }
|
|
92
106
|
|
|
93
107
|
if (data.status === 'VALID') return { valid: true, status: 'VALID' };
|
|
94
108
|
return { valid: false, reason: buildMessage(data.status, data.reason), status: data.status };
|
|
@@ -138,7 +152,12 @@ async function main() {
|
|
|
138
152
|
process.exit(2);
|
|
139
153
|
}
|
|
140
154
|
}
|
|
141
|
-
} catch {
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// Fail open by design: a license-guard crash must never brick a paying
|
|
157
|
+
// user (an unreadable or corrupt license file, a transient FS error). But
|
|
158
|
+
// it must not be silent: log to stderr so the failure is visible instead
|
|
159
|
+
// of silently bypassing enforcement.
|
|
160
|
+
process.stderr.write(`[chati] license-guard error (allowing operation): ${err && err.message ? err.message : err}\n`);
|
|
142
161
|
process.stdout.write(isPreToolUse
|
|
143
162
|
? JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } })
|
|
144
163
|
: '{}');
|
|
@@ -169,9 +188,26 @@ function buildMessage(status, reason) {
|
|
|
169
188
|
// YAML helpers (no external deps)
|
|
170
189
|
// ---------------------------------------------------------------------------
|
|
171
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Unquote a scalar. Single-quoted values un-double the '' escape; double-quoted
|
|
193
|
+
* values are taken verbatim; bare values are returned trimmed.
|
|
194
|
+
*/
|
|
195
|
+
function unquoteYaml(s) {
|
|
196
|
+
s = s.trim();
|
|
197
|
+
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
|
|
198
|
+
return s.slice(1, -1).replace(/''/g, "'");
|
|
199
|
+
}
|
|
200
|
+
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
|
201
|
+
return s.slice(1, -1);
|
|
202
|
+
}
|
|
203
|
+
return s;
|
|
204
|
+
}
|
|
205
|
+
|
|
172
206
|
function readYamlField(raw, field) {
|
|
173
|
-
const match = raw.match(new RegExp(`^${field}:\\s*(
|
|
174
|
-
|
|
207
|
+
const match = raw.match(new RegExp(`^${field}:\\s*(.*)$`, 'm'));
|
|
208
|
+
if (!match) return null;
|
|
209
|
+
const value = unquoteYaml(match[1]);
|
|
210
|
+
return value === '' ? null : value;
|
|
175
211
|
}
|
|
176
212
|
|
|
177
213
|
/** Minimal YAML object parser for simple key:value files */
|
|
@@ -179,15 +215,29 @@ function parseYaml(raw) {
|
|
|
179
215
|
const obj = {};
|
|
180
216
|
for (const line of raw.split('\n')) {
|
|
181
217
|
const m = line.match(/^(\w+):\s*(.*)$/);
|
|
182
|
-
if (m) obj[m[1]] = m[2]
|
|
218
|
+
if (m) obj[m[1]] = unquoteYaml(m[2]);
|
|
183
219
|
}
|
|
184
220
|
return obj;
|
|
185
221
|
}
|
|
186
222
|
|
|
187
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* Minimal YAML serializer for simple flat objects.
|
|
225
|
+
* Single-quotes any value that contains ':', a quote, '#' or surrounding
|
|
226
|
+
* whitespace, and doubles inner single-quotes ('' is the YAML single-quote
|
|
227
|
+
* escape). Newlines are collapsed to spaces so a multi-line reason can never
|
|
228
|
+
* split the record across lines and corrupt later fields. A naive serializer
|
|
229
|
+
* that wraps colon-bearing values in single quotes without escaping inner
|
|
230
|
+
* quotes can write a file the parser then cannot read back, which would brick
|
|
231
|
+
* the license cache until manually deleted.
|
|
232
|
+
*/
|
|
188
233
|
function dumpYaml(obj) {
|
|
189
234
|
return Object.entries(obj)
|
|
190
|
-
.map(([k, v]) =>
|
|
235
|
+
.map(([k, v]) => {
|
|
236
|
+
if (v == null) return `${k}: `;
|
|
237
|
+
const s = String(v).replace(/\r?\n/g, ' ').trim();
|
|
238
|
+
const needsQuote = s === '' || /[:'#]/.test(s) || /^\s|\s$/.test(s);
|
|
239
|
+
return needsQuote ? `${k}: '${s.replace(/'/g, "''")}'` : `${k}: ${s}`;
|
|
240
|
+
})
|
|
191
241
|
.join('\n') + '\n';
|
|
192
242
|
}
|
|
193
243
|
|