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.
Files changed (156) hide show
  1. package/README.md +18 -13
  2. package/bin/chati.js +32 -4
  3. package/framework/agents/build/dev.md +9 -5
  4. package/framework/agents/discover/brief.md +2 -0
  5. package/framework/agents/discover/brownfield-wu.md +2 -0
  6. package/framework/agents/discover/greenfield-wu.md +18 -1
  7. package/framework/agents/plan/detail.md +2 -0
  8. package/framework/agents/plan/tasks.md +31 -0
  9. package/framework/agents/plan/ux-brand-architect.md +21 -2
  10. package/framework/agents/plan/ux-component-engineer.md +10 -0
  11. package/framework/agents/quality/qa-implementation.md +1 -1
  12. package/framework/agents/quality/qa-planning.md +1 -1
  13. package/framework/agents/quality/qa-visual.md +33 -21
  14. package/framework/config.yaml +5 -4
  15. package/framework/constitution.md +58 -11
  16. package/framework/context/governance.md +12 -10
  17. package/framework/context/root.md +2 -2
  18. package/framework/data/entity-registry.yaml +12 -4
  19. package/framework/data/qa-rubrics.yaml +335 -0
  20. package/framework/domains/constitution.yaml +3 -3
  21. package/framework/executors/json-validate.js +80 -0
  22. package/framework/executors/npm-script.js +72 -0
  23. package/framework/executors/path-check.js +67 -0
  24. package/framework/executors/registry.yaml +36 -0
  25. package/framework/hooks/advance-trigger.js +47 -3
  26. package/framework/hooks/constitution-guard.js +9 -4
  27. package/framework/hooks/git-push-authority.js +113 -0
  28. package/framework/hooks/license-guard.js +59 -9
  29. package/framework/hooks/model-governance.js +40 -17
  30. package/framework/hooks/prism-engine.js +78 -11
  31. package/framework/hooks/session-digest.js +12 -3
  32. package/framework/hooks/settings.json +7 -3
  33. package/framework/hooks/style-guard.js +52 -3
  34. package/framework/hooks/team-quality-gate.js +43 -5
  35. package/framework/i18n/en.yaml +3 -3
  36. package/framework/i18n/es.yaml +3 -3
  37. package/framework/i18n/fr.yaml +3 -3
  38. package/framework/i18n/pt.yaml +3 -3
  39. package/framework/intelligence/confidence.yaml +85 -6
  40. package/framework/intelligence/context-engine.md +9 -5
  41. package/framework/intelligence/decision-engine.md +1 -1
  42. package/framework/orchestrator/chati-router.js +59 -5
  43. package/framework/orchestrator/chati-update.md +19 -3
  44. package/framework/orchestrator/chati.md +20 -1
  45. package/framework/quality-gates/planning-gate.md +2 -0
  46. package/framework/rules/human-writing-style.md +47 -0
  47. package/framework/scaffold/motion-premium/README.md +12 -2
  48. package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
  49. package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
  50. package/framework/scaffold/motion-premium-3d/README.md +10 -0
  51. package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
  52. package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
  53. package/framework/scaffold/saas-dashboard/README.md +58 -0
  54. package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
  55. package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
  56. package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
  57. package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
  58. package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
  59. package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
  60. package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
  61. package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
  62. package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
  63. package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
  64. package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
  65. package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
  66. package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
  67. package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
  68. package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
  69. package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
  70. package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
  71. package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
  72. package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
  73. package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
  74. package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
  75. package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
  76. package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
  77. package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
  78. package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
  79. package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
  80. package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
  81. package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
  82. package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
  83. package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
  84. package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
  85. package/framework/schemas/session.schema.json +5 -0
  86. package/framework/schemas/task.schema.json +9 -0
  87. package/framework/scripts/visual-qa.js +101 -3
  88. package/framework/tasks/orchestrator-health.md +4 -4
  89. package/framework/tasks/qa-impl-verdict.md +11 -5
  90. package/framework/templates/brandbook-html-tmpl.md +1 -1
  91. package/framework/templates/qa-gate-tmpl.yaml +24 -11
  92. package/package.json +4 -3
  93. package/src/config/context-file-generator.js +0 -6
  94. package/src/dashboard/renderer.js +0 -36
  95. package/src/executors/runner.js +204 -0
  96. package/src/installer/core.js +13 -3
  97. package/src/installer/templates.js +5 -6
  98. package/src/intelligence/registry-manager.js +1 -1
  99. package/src/license/client.js +9 -3
  100. package/src/license/commands.js +12 -2
  101. package/src/license/machine-id.js +42 -1
  102. package/src/memory/gotchas.js +58 -40
  103. package/src/memory/magic-docs.js +1 -1
  104. package/src/memory/session-digest.js +9 -4
  105. package/src/orchestrator/cli.js +186 -15
  106. package/src/orchestrator/doctor.js +98 -16
  107. package/src/orchestrator/pipeline-manager.js +44 -22
  108. package/src/orchestrator/session-manager.js +64 -8
  109. package/src/telemetry/sender.js +7 -19
  110. package/src/terminal/run-team.js +3 -3
  111. package/src/upgrade/tracked-files-detector.js +34 -16
  112. package/src/utils/feature-flags.js +1 -1
  113. package/src/utils/flatten-entities.js +4 -40
  114. package/src/utils/schema-validator.js +0 -14
  115. package/src/wizard/i18n.js +3 -3
  116. package/src/api/index.js +0 -120
  117. package/src/autonomy/autonomous-gate.js +0 -294
  118. package/src/autonomy/build-loop.js +0 -281
  119. package/src/autonomy/build-state.js +0 -286
  120. package/src/autonomy/cause-analyzer.js +0 -177
  121. package/src/autonomy/escalation.js +0 -214
  122. package/src/autonomy/index.js +0 -51
  123. package/src/autonomy/mode-manager.js +0 -225
  124. package/src/autonomy/mode-suggester.js +0 -283
  125. package/src/autonomy/progress-reporter.js +0 -275
  126. package/src/autonomy/safety-net.js +0 -370
  127. package/src/config/agent-customizer.js +0 -231
  128. package/src/decision/analyzer.js +0 -291
  129. package/src/decision/engine.js +0 -250
  130. package/src/decision/index.js +0 -38
  131. package/src/decision/registry-healer.js +0 -468
  132. package/src/decision/registry-updater.js +0 -339
  133. package/src/extensions/loader.js +0 -145
  134. package/src/extensions/registry.js +0 -134
  135. package/src/gates/circuit-breaker.js +0 -151
  136. package/src/gates/g1-planning-complete.js +0 -154
  137. package/src/gates/g2-qa-planning.js +0 -156
  138. package/src/gates/g3-implementation.js +0 -215
  139. package/src/gates/g4-qa-implementation.js +0 -240
  140. package/src/gates/g5-deploy-ready.js +0 -181
  141. package/src/gates/gate-base.js +0 -185
  142. package/src/gates/index.js +0 -46
  143. package/src/health/auto-fix.js +0 -216
  144. package/src/health/engine.js +0 -246
  145. package/src/merger/semantic-merger.js +0 -292
  146. package/src/preview/detector.js +0 -238
  147. package/src/preview/index.js +0 -20
  148. package/src/preview/launcher.js +0 -235
  149. package/src/preview/log-buffer.js +0 -103
  150. package/src/quality/metrics-collector.js +0 -281
  151. package/src/quality/test-runner.js +0 -366
  152. package/src/tasks/executor.js +0 -195
  153. package/src/tasks/index.js +0 -4
  154. package/src/tasks/loader.js +0 -210
  155. package/src/tasks/router.js +0 -182
  156. 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 = { 'qa-planning': 95, 'qa-implementation': 95, 'qa-visual': 90 };
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 { /* non-fatal */ }
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
- process.stderr.write(`[chati-hook-error] constitution-guard: ${err?.message || 'unknown'}\n`);
141
- // Fail-open on hook errors: a broken hook should not block legitimate work.
142
- // Real security checks above are explicit and run before this catch.
143
- process.stdout.write(JSON.stringify(allowOutput()));
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?key=${encodeURIComponent(licenseKey)}&machine_id=${encodeURIComponent(machineId)}`,
75
- { signal: AbortSignal.timeout(5000) }
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 { /* fail open */
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*(.+)$`, 'm'));
174
- return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
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].trim().replace(/^["']|["']$/g, '');
218
+ if (m) obj[m[1]] = unquoteYaml(m[2]);
183
219
  }
184
220
  return obj;
185
221
  }
186
222
 
187
- /** Minimal YAML serializer for simple flat objects */
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]) => `${k}: ${v == null ? '' : String(v).includes(':') ? `'${v}'` : 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