@hanzlaa/rcode 3.4.32 → 3.5.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 (86) hide show
  1. package/AGENTS.md +6 -6
  2. package/CONTRIBUTING.md +2 -0
  3. package/LICENSE +21 -0
  4. package/README.md +66 -403
  5. package/cli/agent.js +3 -2
  6. package/cli/doctor.js +87 -1
  7. package/cli/install.js +122 -31
  8. package/cli/lib/schemas.cjs +318 -0
  9. package/cli/postinstall.js +19 -3
  10. package/dist/rcode.js +318 -24
  11. package/package.json +8 -4
  12. package/rihal/agents/rihal-cross-platform-auditor.md +15 -0
  13. package/rihal/agents/rihal-dep-auditor.md +15 -0
  14. package/rihal/agents/rihal-docs-auditor.md +3 -145
  15. package/rihal/agents/rihal-i18n-auditor.md +16 -0
  16. package/rihal/agents/rihal-nyquist-auditor.md +4 -156
  17. package/rihal/agents/rihal-observability-auditor.md +16 -0
  18. package/rihal/agents/rihal-phase-researcher.md +1 -1
  19. package/rihal/agents/rihal-planner.md +1 -1
  20. package/rihal/bin/rihal-hooks.cjs +394 -4
  21. package/rihal/bin/rihal-tools.cjs +891 -24
  22. package/rihal/commands/create-prd.md +18 -0
  23. package/rihal/commands/execute-milestone.md +18 -0
  24. package/rihal/commands/plan-milestone.md +18 -0
  25. package/rihal/commands/scaffold-milestone.md +18 -0
  26. package/rihal/commands/scaffold-skill.md +18 -0
  27. package/rihal/references/REFERENCES_INDEX.md +49 -7
  28. package/rihal/references/agent-contracts.md +10 -0
  29. package/rihal/references/design-tokens.md +98 -0
  30. package/rihal/references/docs-auditor-playbook.md +148 -0
  31. package/rihal/references/git-preflight.md +117 -0
  32. package/rihal/references/iterative-retrieval.md +85 -0
  33. package/rihal/references/nyquist-auditor-playbook.md +157 -0
  34. package/rihal/references/workstream-flag.md +2 -2
  35. package/rihal/skills/actions/1-analysis/rihal-prfaq/SKILL.md +9 -0
  36. package/rihal/skills/actions/4-implementation/rihal-checkpoint-preview/SKILL.md +9 -0
  37. package/rihal/skills/actions/4-implementation/rihal-ci/SKILL.md +4 -0
  38. package/rihal/skills/actions/4-implementation/rihal-code-review/steps/step-02-review.md +7 -3
  39. package/rihal/skills/actions/4-implementation/rihal-harden/SKILL.md +4 -0
  40. package/rihal/skills/actions/4-implementation/rihal-migrate/SKILL.md +4 -0
  41. package/rihal/skills/agents/haitham-frontend/SKILL.md +2 -0
  42. package/rihal/skills/agents/majlis-council/SKILL.md +1 -1
  43. package/rihal/team.yaml +32 -0
  44. package/rihal/templates/settings-hooks.json +39 -0
  45. package/rihal/workflows/check-todos.md +4 -0
  46. package/rihal/workflows/code-review-fix.md +4 -3
  47. package/rihal/workflows/code-review.md +1 -1
  48. package/rihal/workflows/debug.md +1 -1
  49. package/rihal/workflows/dev-story.md +4 -0
  50. package/rihal/workflows/diff.md +2 -2
  51. package/rihal/workflows/do.md +16 -8
  52. package/rihal/workflows/docs-update.md +2 -2
  53. package/rihal/workflows/enable-hooks.md +6 -1
  54. package/rihal/workflows/execute-milestone.md +139 -0
  55. package/rihal/workflows/execute-regression-gates.md +1 -1
  56. package/rihal/workflows/execute-sprint.md +54 -2
  57. package/rihal/workflows/execute-verify-phase-goal.md +31 -4
  58. package/rihal/workflows/execute-waves.md +33 -5
  59. package/rihal/workflows/execute.md +40 -6
  60. package/rihal/workflows/help.md +1 -1
  61. package/rihal/workflows/import.md +1 -1
  62. package/rihal/workflows/lens-audit.md +39 -23
  63. package/rihal/workflows/list-workspaces.md +1 -1
  64. package/rihal/workflows/map-codebase.md +4 -4
  65. package/rihal/workflows/new-milestone.md +18 -1
  66. package/rihal/workflows/new-project-research.md +53 -1
  67. package/rihal/workflows/new-workspace.md +1 -1
  68. package/rihal/workflows/plan-milestone.md +105 -0
  69. package/rihal/workflows/plan-research-validation.md +1 -1
  70. package/rihal/workflows/plan-spawn-planner.md +1 -1
  71. package/rihal/workflows/plan.md +31 -3
  72. package/rihal/workflows/plant-seed.md +6 -0
  73. package/rihal/workflows/quick.md +11 -5
  74. package/rihal/workflows/research-phase.md +24 -0
  75. package/rihal/workflows/scaffold-milestone.md +60 -0
  76. package/rihal/workflows/scaffold-skill.md +137 -0
  77. package/rihal/workflows/scan.md +1 -1
  78. package/rihal/workflows/session-report.md +43 -3
  79. package/rihal/workflows/verify-work.md +3 -3
  80. package/server/dashboard.js +53 -6
  81. package/server/lib/api.js +7 -0
  82. package/server/lib/html/client.js +725 -13
  83. package/server/lib/html/css.js +2046 -466
  84. package/server/lib/html/shell.js +227 -134
  85. package/server/lib/scanner.js +33 -0
  86. package/server/orchestrator.js +438 -0
@@ -6,6 +6,11 @@
6
6
  * pre-edit — verify file was Read before Edit/Write (exit 2 if not)
7
7
  * pre-workflow — soft warning for rihal-* commands with suspicious args
8
8
  * post-commit — verify commit format and no forbidden patterns
9
+ * bash-guard — block dangerous Bash commands before they run (exit 2)
10
+ * pre-compact — refresh HANDOFF.json before context compaction (#743)
11
+ * stop-verify — syntax-check files changed during the response (#744)
12
+ * cost-track — append per-response token usage to cost.jsonl (#745)
13
+ * compact-nudge — advise /rihal-trim or /clear after N Edit/Write calls (#749)
9
14
  *
10
15
  * All subcommands read stdin JSON from the hook execution context.
11
16
  * Pure Node stdlib. No external dependencies.
@@ -111,6 +116,8 @@ async function preWorkflow() {
111
116
  */
112
117
  async function postCommit() {
113
118
  try {
119
+ const path = require('path');
120
+ const os = require('os');
114
121
  const input = await readInputJson();
115
122
  const command = input.tool_input?.command || input.command || '';
116
123
  const output = input.tool_input?.output || input.output || '';
@@ -130,11 +137,27 @@ async function postCommit() {
130
137
 
131
138
  let commitMsg = output;
132
139
 
133
- // If -F flag used, try to read the message file
140
+ // If -F flag used, try to read the message file — but only if it resolves
141
+ // inside the repo working tree. An attacker-controlled commit command could
142
+ // otherwise point -F at e.g. ~/.ssh/id_rsa. Mirror the resolve + realpathSync
143
+ // + startsWith guard from server/lib/api.js:131-141 (#754).
134
144
  const fMatch = command.match(/-F\s+(\S+)/);
135
- if (fMatch && fs.existsSync(fMatch[1])) {
145
+ if (fMatch) {
136
146
  try {
137
- commitMsg += '\n' + fs.readFileSync(fMatch[1], 'utf8');
147
+ const repoRoot = process.cwd();
148
+ const resolved = path.resolve(repoRoot, fMatch[1]);
149
+ // Dereference symlinks so a symlink outside the repo cannot bypass the guard.
150
+ const realPath = fs.realpathSync(resolved);
151
+ const insideRepo = realPath.startsWith(repoRoot + path.sep);
152
+ // Exception: rihal-tools.cjs writes its commit-message tmp file to
153
+ // os.tmpdir() (outside the repo) — see rihal-tools.cjs:3668. That path
154
+ // is rihal-controlled (not attacker input), so allow it explicitly.
155
+ const isRihalCommitMsgTmp =
156
+ realPath.startsWith(fs.realpathSync(os.tmpdir()) + path.sep) &&
157
+ /^rihal-commit-msg-\d+\.txt$/.test(path.basename(realPath));
158
+ if (insideRepo || isRihalCommitMsgTmp) {
159
+ commitMsg += '\n' + fs.readFileSync(resolved, 'utf8');
160
+ }
138
161
  } catch {}
139
162
  }
140
163
 
@@ -175,6 +198,358 @@ async function postCommit() {
175
198
  }
176
199
  }
177
200
 
201
+ // rm -rf is permitted only against these relative build/cache paths.
202
+ const RM_SAFE_TARGET = /^(?:\.\/)?(?:node_modules|dist|build|coverage|\.next|out|temp|tmp|\.rihal\/cache)(?:\/.*)?$/;
203
+
204
+ /**
205
+ * bash-guard: Block dangerous Bash commands before they execute.
206
+ * Exit 2 blocks the tool call; exit 0 allows it.
207
+ *
208
+ * Enforces the repo's non-negotiable rules (AGENTS.md): no unapproved
209
+ * `git push`, never `--force`, no `--no-verify`, no unscoped destructive
210
+ * git/rm. An authorized push must be prefixed with `RIHAL_PUSH_OK=1`.
211
+ *
212
+ * This guard is best-effort, NOT a security boundary: a determined caller
213
+ * can still craft a bypass (e.g. obscure git aliases). It enforces AGENTS.md
214
+ * conventions, not a sandbox.
215
+ */
216
+ async function bashGuard() {
217
+ try {
218
+ const input = await readInputJson();
219
+ const command = (input.tool_input?.command || input.command || '').trim();
220
+
221
+ if (!command) {
222
+ process.exit(0);
223
+ }
224
+
225
+ const block = (reason, guidance) => {
226
+ console.error(`⛔ BLOCKED by rihal bash-guard: ${reason}`);
227
+ if (guidance) console.error(` ${guidance}`);
228
+ process.exit(2);
229
+ };
230
+
231
+ const isPush = /\bgit\s+push\b/.test(command);
232
+
233
+ // A `+`-prefixed refspec (`git push origin +main`) is a force-push that
234
+ // matches neither `--force` nor `-f`. Detect it by scanning the tokens
235
+ // after `push` for a non-flag token starting with `+` (`+` is not a glob
236
+ // or option char, so a leading-`+` token is unambiguously a refspec).
237
+ const isPlusRefspecForce =
238
+ isPush &&
239
+ (() => {
240
+ const tokens = command.split(/\s+/);
241
+ const pushIdx = tokens.findIndex((t) => t === 'push');
242
+ if (pushIdx === -1) return false;
243
+ return tokens
244
+ .slice(pushIdx + 1)
245
+ .some((t) => t.startsWith('+'));
246
+ })();
247
+
248
+ // Force-push is never permitted through an agent.
249
+ if (
250
+ isPush &&
251
+ (/(--force\b|--force-with-lease\b|(?:^|\s)-f\b)/.test(command) ||
252
+ isPlusRefspecForce)
253
+ ) {
254
+ block(
255
+ 'git push --force is never permitted.',
256
+ 'A human must run a force-push manually. See AGENTS.md.'
257
+ );
258
+ }
259
+
260
+ // Plain git push requires an explicit per-push authorization token.
261
+ // Token must be a real leading env-var assignment — substring match is
262
+ // bypassable via 'echo RIHAL_PUSH_OK; git push'.
263
+ if (isPush && !/^\s*RIHAL_PUSH_OK=1(\s|$)/.test(command)) {
264
+ block(
265
+ 'git push requires explicit human approval.',
266
+ 'If the user authorized THIS push, prefix the command with RIHAL_PUSH_OK=1. See AGENTS.md.'
267
+ );
268
+ }
269
+
270
+ // Bypassing git hooks is banned.
271
+ if (/--no-verify\b/.test(command)) {
272
+ block(
273
+ '--no-verify bypasses git hooks.',
274
+ 'Fix the underlying hook failure instead of skipping it.'
275
+ );
276
+ }
277
+
278
+ // Destructive git operations.
279
+ if (/\bgit\s+reset\s+--hard\b/.test(command)) {
280
+ block(
281
+ 'git reset --hard discards uncommitted work.',
282
+ 'Confirm with the user; they should run it manually if intended.'
283
+ );
284
+ }
285
+ if (/\bgit\s+clean\s+-[a-zA-Z]*f/.test(command)) {
286
+ block(
287
+ 'git clean -f permanently deletes untracked files.',
288
+ 'Confirm with the user; they should run it manually if intended.'
289
+ );
290
+ }
291
+
292
+ // rm -rf outside the safe build/cache allowlist.
293
+ for (const segment of command.split(/(?:&&|\|\||;|\|)/)) {
294
+ const m = segment.trim().match(/^(?:\S+=\S+\s+)*rm\s+(.+)$/);
295
+ if (!m) continue;
296
+ const tokens = m[1].split(/\s+/).filter(Boolean);
297
+ const flags = tokens.filter((t) => /^-[a-zA-Z]+$/.test(t)).join('');
298
+ if (!(/r/.test(flags) && /f/.test(flags))) continue;
299
+ const targets = tokens.filter((t) => !t.startsWith('-'));
300
+ const unsafe =
301
+ targets.length === 0 ||
302
+ targets.some((t) => {
303
+ if (t.startsWith('/tmp/')) return false;
304
+ if (t.includes('..') || t.includes('*')) return true;
305
+ if (t.startsWith('/') || t.startsWith('~') || t === '.') return true;
306
+ return !RM_SAFE_TARGET.test(t);
307
+ });
308
+ if (unsafe) {
309
+ block(
310
+ `rm -rf targets a path outside the safe allowlist: ${targets.join(', ') || '(none)'}`,
311
+ 'Safe targets: node_modules, dist, build, temp, /tmp/*. Confirm anything else with the user.'
312
+ );
313
+ }
314
+ }
315
+
316
+ process.exit(0);
317
+ } catch (err) {
318
+ console.error(`Hook error: ${err.message}`);
319
+ process.exit(1);
320
+ }
321
+ }
322
+
323
+ /**
324
+ * pre-compact: Refresh HANDOFF.json before context compaction (#743).
325
+ *
326
+ * Triggered by the PreCompact hook. Reads .rihal/state.json from the current
327
+ * working directory and, if a phase is active, writes a HANDOFF.json pointer
328
+ * so a post-compaction agent can resume cleanly. No-op when no phase is
329
+ * active. Never blocks compaction.
330
+ */
331
+ async function preCompact() {
332
+ try {
333
+ const path = require('path');
334
+ await readInputJson(); // drain the PreCompact event payload
335
+
336
+ const cwd = process.cwd();
337
+ const statePath = path.join(cwd, '.rihal', 'state.json');
338
+ if (!fs.existsSync(statePath)) {
339
+ process.exit(0);
340
+ }
341
+
342
+ let state;
343
+ try {
344
+ state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
345
+ } catch {
346
+ process.exit(0);
347
+ }
348
+
349
+ const phases = Array.isArray(state.phases) ? state.phases : [];
350
+ const hasActivePhase =
351
+ !!state.current_phase &&
352
+ phases.length > 0 &&
353
+ phases.some(
354
+ (p) =>
355
+ p &&
356
+ (p.status === 'executing' ||
357
+ p.name === state.current_phase ||
358
+ p.number === state.current_phase)
359
+ );
360
+
361
+ if (!hasActivePhase) {
362
+ process.exit(0);
363
+ }
364
+
365
+ const executing = phases.find((p) => p && p.status === 'executing');
366
+ const matched = phases.find(
367
+ (p) => p && (p.name === state.current_phase || p.number === state.current_phase)
368
+ );
369
+ const activePhase = executing || matched;
370
+ const phaseLabel = activePhase
371
+ ? activePhase.number || activePhase.name || state.current_phase
372
+ : state.current_phase;
373
+
374
+ const handoff = {
375
+ generated_at: new Date().toISOString(),
376
+ reason: 'pre-compact',
377
+ phase: phaseLabel,
378
+ current_plan: state.current_plan ?? null,
379
+ current_sprint: state.current_sprint ?? null,
380
+ };
381
+
382
+ const handoffPath = path.join(cwd, 'HANDOFF.json');
383
+ const tmpPath = handoffPath + '.tmp';
384
+ fs.writeFileSync(tmpPath, JSON.stringify(handoff, null, 2) + '\n');
385
+ fs.renameSync(tmpPath, handoffPath);
386
+
387
+ process.exit(0);
388
+ } catch (err) {
389
+ console.error(`Hook error: ${err.message}`);
390
+ process.exit(1);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * stop-verify: Syntax-check files changed during the response (#744).
396
+ *
397
+ * Triggered by the Stop hook. Collects the files changed during the response
398
+ * (from the payload, falling back to `git diff --name-only`) and syntax-checks
399
+ * each .js/.cjs (node --check) and .json (JSON.parse). Surfaces failures to
400
+ * stderr with a non-zero exit. Advisory only — never auto-fixes, never blocks.
401
+ */
402
+ async function stopVerify() {
403
+ try {
404
+ const path = require('path');
405
+ const { spawnSync } = require('child_process');
406
+ const input = await readInputJson();
407
+
408
+ let changed =
409
+ input.changed_files ||
410
+ input.tool_input?.changed_files ||
411
+ input.files_changed ||
412
+ null;
413
+
414
+ if (!Array.isArray(changed)) {
415
+ const diff = spawnSync('git', ['diff', '--name-only'], {
416
+ encoding: 'utf8',
417
+ cwd: process.cwd(),
418
+ });
419
+ changed =
420
+ diff.status === 0
421
+ ? diff.stdout.split('\n').map((l) => l.trim()).filter(Boolean)
422
+ : [];
423
+ }
424
+
425
+ if (changed.length === 0) {
426
+ process.exit(0);
427
+ }
428
+
429
+ const failures = [];
430
+ for (const file of changed) {
431
+ const abs = path.isAbsolute(file)
432
+ ? file
433
+ : path.resolve(process.cwd(), file);
434
+ if (!fs.existsSync(abs)) continue;
435
+ const ext = path.extname(abs).toLowerCase();
436
+ if (ext === '.js' || ext === '.cjs' || ext === '.mjs') {
437
+ const check = spawnSync(process.execPath, ['--check', abs], {
438
+ encoding: 'utf8',
439
+ });
440
+ if (check.status !== 0) {
441
+ failures.push(`${file}: ${(check.stderr || '').trim().split('\n')[0]}`);
442
+ }
443
+ } else if (ext === '.json') {
444
+ try {
445
+ JSON.parse(fs.readFileSync(abs, 'utf8'));
446
+ } catch (e) {
447
+ failures.push(`${file}: ${e.message}`);
448
+ }
449
+ }
450
+ }
451
+
452
+ if (failures.length > 0) {
453
+ console.error('⚠ stop-verify: changed files failed syntax check:');
454
+ failures.forEach((f) => console.error(` • ${f}`));
455
+ process.exit(1);
456
+ }
457
+
458
+ process.exit(0);
459
+ } catch (err) {
460
+ console.error(`Hook error: ${err.message}`);
461
+ process.exit(1);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * cost-track: Append per-response token usage to cost.jsonl (#745).
467
+ *
468
+ * Triggered by the Stop hook. Extracts the token usage block from the Stop
469
+ * event payload and appends one JSON line to .rihal/telemetry/cost.jsonl so
470
+ * session-report can report measured totals. No-op when no usage block is
471
+ * present. Never blocks.
472
+ */
473
+ async function costTrack() {
474
+ try {
475
+ const path = require('path');
476
+ const input = await readInputJson();
477
+
478
+ const usage = input.usage || input.tool_input?.usage || null;
479
+ if (!usage || typeof usage !== 'object') {
480
+ process.exit(0);
481
+ }
482
+
483
+ const record = {
484
+ ts: new Date().toISOString(),
485
+ input_tokens: usage.input_tokens ?? 0,
486
+ output_tokens: usage.output_tokens ?? 0,
487
+ };
488
+ if (usage.cache_creation_input_tokens != null) {
489
+ record.cache_creation_input_tokens = usage.cache_creation_input_tokens;
490
+ }
491
+ if (usage.cache_read_input_tokens != null) {
492
+ record.cache_read_input_tokens = usage.cache_read_input_tokens;
493
+ }
494
+
495
+ const telemetryDir = path.join(process.cwd(), '.rihal', 'telemetry');
496
+ fs.mkdirSync(telemetryDir, { recursive: true });
497
+ fs.appendFileSync(
498
+ path.join(telemetryDir, 'cost.jsonl'),
499
+ JSON.stringify(record) + '\n'
500
+ );
501
+
502
+ process.exit(0);
503
+ } catch (err) {
504
+ console.error(`Hook error: ${err.message}`);
505
+ process.exit(1);
506
+ }
507
+ }
508
+
509
+ /**
510
+ * compact-nudge: Advise /rihal-trim or /clear after N Edit/Write calls (#749).
511
+ *
512
+ * Triggered by the PreToolUse:Edit|Write hook. Maintains a per-session call
513
+ * counter in a temp file and, once the count crosses RIHAL_NUDGE_THRESHOLD
514
+ * (default 50), prints an advisory to reclaim context budget. Purely
515
+ * advisory — always exits 0, never blocks a tool call.
516
+ */
517
+ async function compactNudge() {
518
+ try {
519
+ const path = require('path');
520
+ const os = require('os');
521
+ const input = await readInputJson();
522
+
523
+ const sessionId =
524
+ input.session_id || input.tool_input?.session_id || 'default';
525
+ const counterPath = path.join(
526
+ os.tmpdir(),
527
+ 'rihal-nudge-' + sessionId + '.count'
528
+ );
529
+
530
+ let count = 0;
531
+ try {
532
+ count = parseInt(fs.readFileSync(counterPath, 'utf8').trim(), 10) || 0;
533
+ } catch {}
534
+ count += 1;
535
+ try {
536
+ fs.writeFileSync(counterPath, String(count));
537
+ } catch {}
538
+
539
+ const threshold = parseInt(process.env.RIHAL_NUDGE_THRESHOLD, 10) || 50;
540
+ if (count >= threshold) {
541
+ console.error(
542
+ `⚠ rihal compact-nudge: ${count} edits this session. Consider /rihal-trim or /clear to reclaim context budget.`
543
+ );
544
+ }
545
+
546
+ process.exit(0);
547
+ } catch {
548
+ // Advisory hook must never break the session.
549
+ process.exit(0);
550
+ }
551
+ }
552
+
178
553
  /**
179
554
  * Main entry point.
180
555
  */
@@ -191,9 +566,24 @@ async function main() {
191
566
  case 'post-commit':
192
567
  await postCommit();
193
568
  break;
569
+ case 'bash-guard':
570
+ await bashGuard();
571
+ break;
572
+ case 'pre-compact':
573
+ await preCompact();
574
+ break;
575
+ case 'stop-verify':
576
+ await stopVerify();
577
+ break;
578
+ case 'cost-track':
579
+ await costTrack();
580
+ break;
581
+ case 'compact-nudge':
582
+ await compactNudge();
583
+ break;
194
584
  default:
195
585
  console.error(`Unknown subcommand: ${subcommand}`);
196
- console.error('Usage: rihal-hooks.cjs pre-edit|pre-workflow|post-commit');
586
+ console.error('Usage: rihal-hooks.cjs pre-edit|pre-workflow|post-commit|bash-guard|pre-compact|stop-verify|cost-track|compact-nudge');
197
587
  process.exit(1);
198
588
  }
199
589
  }