@hanzlaa/rcode 4.9.0 → 4.10.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/AGENTS.md +1 -1
- package/CONTRIBUTING.md +3 -0
- package/README.md +2 -2
- package/cli/doctor.js +4 -2
- package/cli/github-sync.js +26 -17
- package/cli/install.js +43 -21
- package/cli/lib/namespace-migrate.cjs +62 -4
- package/cli/migrate-namespace.js +4 -0
- package/dist/rcode.js +207 -207
- package/package.json +1 -1
- package/rcode/bin/lib/brain.cjs +16 -1
- package/rcode/bin/lib/gitignore.cjs +3 -5
- package/rcode/bin/rcode-hooks.cjs +22 -5
- package/rcode/bin/rcode-tools.cjs +60 -14
- package/rcode/data/intent-table.json +1 -1
- package/rcode/workflows/do.md +33 -1
- package/rcode/workflows/execute.md +58 -2
- package/rcode/workflows/init.md +18 -4
- package/rcode/workflows/scan.md +22 -1
- package/server/lib/html/client/components/Sidebar.js +8 -5
- package/server/lib/html/client/components/shared.js +29 -10
- package/server/lib/html/client/views/PhasesView.js +1 -1
- package/server/lib/html/css.js +4 -0
- package/server/lib/scanner.js +8 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzlaa/rcode",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.10.0",
|
|
4
4
|
"description": "rcode — the AI team that never forgets. Persistent memory, specialist agents, and slash commands for AI IDEs. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
|
|
5
5
|
"main": "cli/index.js",
|
|
6
6
|
"bin": {
|
package/rcode/bin/lib/brain.cjs
CHANGED
|
@@ -243,7 +243,22 @@ function cmdBrain(args, { PROJECT_ROOT, RCODE_DIR }) {
|
|
|
243
243
|
const os = require('os');
|
|
244
244
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rcode-brain-'));
|
|
245
245
|
const branch = s.branch || cfg.defaults?.branch || 'main';
|
|
246
|
-
|
|
246
|
+
// #1029 — `sparse-checkout set --no-cone` treats each path as a
|
|
247
|
+
// .gitignore-style pattern, not a literal path pin. A bare filename
|
|
248
|
+
// (no '/', no wildcard) matches that filename at any depth in the repo
|
|
249
|
+
// tree, over-fetching every same-named file repo-wide. Anchor bare
|
|
250
|
+
// filenames to the repo root with a leading '/' so they pin the
|
|
251
|
+
// root-level file only. Patterns that already start with '/', contain a
|
|
252
|
+
// '/', or use wildcards (already scoped or intentionally recursive) are
|
|
253
|
+
// left untouched.
|
|
254
|
+
function anchorBareFilename(p) {
|
|
255
|
+
const str = String(p || '');
|
|
256
|
+
if (str.startsWith('/')) return str;
|
|
257
|
+
if (/[*?[]/.test(str)) return str;
|
|
258
|
+
if (str.includes('/')) return str;
|
|
259
|
+
return `/${str}`;
|
|
260
|
+
}
|
|
261
|
+
const sparsePaths = (Array.isArray(s.paths) ? s.paths : []).map(anchorBareFilename);
|
|
247
262
|
|
|
248
263
|
// Cache key = sha1(repo + branch + sparsePaths joined). Changing any of
|
|
249
264
|
// those gets a fresh cache slot. Different projects pulling the same
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Gitignore — extracted from rcode-tools.cjs (issue #204).
|
|
3
|
-
* move, no behavior change (including the pre-existing `slice_end` typo in
|
|
4
|
-
* spliceBlock, left untouched — out of scope for this extraction).
|
|
2
|
+
* Gitignore — extracted from rcode-tools.cjs (issue #204).
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
5
|
const fs = require('fs');
|
|
@@ -102,8 +100,8 @@ function cmdGitignore(args, { PROJECT_ROOT, RCODE_DIR }) {
|
|
|
102
100
|
let sliceStart = start;
|
|
103
101
|
if (sliceStart > 0 && existing[sliceStart - 1] === '\n') sliceStart -= 1;
|
|
104
102
|
let sliceEnd = endIdx + END.length;
|
|
105
|
-
if (existing[
|
|
106
|
-
return existing.slice(0, sliceStart) + newBlock + existing.slice(
|
|
103
|
+
if (existing[sliceEnd] === '\n') sliceEnd += 1;
|
|
104
|
+
return existing.slice(0, sliceStart) + newBlock + existing.slice(sliceEnd);
|
|
107
105
|
}
|
|
108
106
|
|
|
109
107
|
if (!fs.existsSync(gitignorePath)) {
|
|
@@ -743,7 +743,10 @@ function parseSimpleYamlInline(text) {
|
|
|
743
743
|
/**
|
|
744
744
|
* Read prompt_nudge from .rcode/config.yaml.
|
|
745
745
|
* Returns 'every' | 'once-per-intent' | 'when-stale' | 'off'.
|
|
746
|
-
* Defaults to '
|
|
746
|
+
* Defaults to 'once-per-intent' when key is absent, file is missing, or value
|
|
747
|
+
* is unknown — #953: 'every' re-nudged on every matching prompt during an
|
|
748
|
+
* active session, which read as noise once the keyword matcher's false
|
|
749
|
+
* positives compounded it. 'every' is still available as an opt-in.
|
|
747
750
|
*/
|
|
748
751
|
function readPromptNudgeToggle(cwd) {
|
|
749
752
|
const VALID = new Set(['every', 'once-per-intent', 'when-stale', 'off']);
|
|
@@ -752,9 +755,9 @@ function readPromptNudgeToggle(cwd) {
|
|
|
752
755
|
const text = fs.readFileSync(cfgPath, 'utf8');
|
|
753
756
|
const parsed = parseSimpleYamlInline(text);
|
|
754
757
|
const val = (parsed.prompt_nudge || '').trim().toLowerCase();
|
|
755
|
-
return VALID.has(val) ? val : '
|
|
758
|
+
return VALID.has(val) ? val : 'once-per-intent';
|
|
756
759
|
} catch {
|
|
757
|
-
return '
|
|
760
|
+
return 'once-per-intent';
|
|
758
761
|
}
|
|
759
762
|
}
|
|
760
763
|
|
|
@@ -791,6 +794,20 @@ function isStateStaleFallbackTrue(cwd) {
|
|
|
791
794
|
}
|
|
792
795
|
}
|
|
793
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Word-boundary keyword match — #953: plain `lower.includes(kw)` matched
|
|
799
|
+
* generic keywords like "bug" and "crash" inside unrelated words ("debugger",
|
|
800
|
+
* "crashing"), over-firing the debug nudge. Boundaries are checked against
|
|
801
|
+
* Unicode letters/numbers (not just ASCII \w) so Arabic/Urdu keyword phrases
|
|
802
|
+
* still match correctly.
|
|
803
|
+
*/
|
|
804
|
+
function keywordMatches(lower, kw) {
|
|
805
|
+
const kwLower = kw.toLowerCase();
|
|
806
|
+
const escaped = kwLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
807
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'u');
|
|
808
|
+
return re.test(lower);
|
|
809
|
+
}
|
|
810
|
+
|
|
794
811
|
/**
|
|
795
812
|
* prompt-router: Nudge toward rcode commands for memory consistency (#892).
|
|
796
813
|
* Reads stdin synchronously (NOT async — rejects bad JSON). Keyword-matches INTENT_TABLE,
|
|
@@ -868,12 +885,12 @@ function promptRouter() {
|
|
|
868
885
|
process.exit(0);
|
|
869
886
|
}
|
|
870
887
|
|
|
871
|
-
// ── Keyword match (first-match-wins, case-insensitive)
|
|
888
|
+
// ── Keyword match (first-match-wins, case-insensitive, word-boundary) ─
|
|
872
889
|
const lower = prompt.toLowerCase();
|
|
873
890
|
let matched = null;
|
|
874
891
|
for (const entry of INTENT_TABLE) {
|
|
875
892
|
for (const kw of entry.keywords) {
|
|
876
|
-
if (lower
|
|
893
|
+
if (keywordMatches(lower, kw)) {
|
|
877
894
|
matched = entry;
|
|
878
895
|
break;
|
|
879
896
|
}
|
|
@@ -4547,9 +4547,11 @@ function cmdGenerateClaudeMd(rawArgs) {
|
|
|
4547
4547
|
const force = args.includes('--force');
|
|
4548
4548
|
const claudeMdPath = path.join(PROJECT_ROOT, 'CLAUDE.md');
|
|
4549
4549
|
const agentsMdPath = path.join(PROJECT_ROOT, 'AGENTS.md');
|
|
4550
|
+
const claudeExisted = fs.existsSync(claudeMdPath);
|
|
4551
|
+
const agentsExisted = fs.existsSync(agentsMdPath);
|
|
4550
4552
|
|
|
4551
|
-
if (
|
|
4552
|
-
throw new Error(`CLAUDE.md already
|
|
4553
|
+
if (claudeExisted && agentsExisted && !force) {
|
|
4554
|
+
throw new Error(`CLAUDE.md and AGENTS.md already exist at ${PROJECT_ROOT}. Use --force to overwrite.`);
|
|
4553
4555
|
}
|
|
4554
4556
|
|
|
4555
4557
|
// Resolve project name from package.json or directory.
|
|
@@ -4647,26 +4649,33 @@ Before handling planning, exploration, auditing, refactoring, or multi-step buil
|
|
|
4647
4649
|
**This file is part of the project. Treat it as load-bearing.**
|
|
4648
4650
|
`;
|
|
4649
4651
|
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
+
// Each file's own existence gates only its own write — a project with an
|
|
4653
|
+
// existing CLAUDE.md must still get a missing AGENTS.md backfilled, and
|
|
4654
|
+
// vice versa (#1025).
|
|
4655
|
+
const wroteClaude = !claudeExisted || force;
|
|
4656
|
+
if (wroteClaude) {
|
|
4657
|
+
fs.writeFileSync(claudeMdPath, content);
|
|
4658
|
+
}
|
|
4652
4659
|
|
|
4653
4660
|
// Mirror the same rules to AGENTS.md (the cross-tool standard Codex, Cursor,
|
|
4654
4661
|
// Windsurf, Antigravity, and Gemini read). Skip when it already exists without
|
|
4655
4662
|
// --force so an install-appended "## rcode Agents (installed)" roster survives.
|
|
4656
|
-
const agentsExisted = fs.existsSync(agentsMdPath);
|
|
4657
4663
|
const wroteAgents = !agentsExisted || force;
|
|
4658
4664
|
if (wroteAgents) {
|
|
4659
4665
|
fs.writeFileSync(agentsMdPath, content);
|
|
4660
4666
|
}
|
|
4661
4667
|
|
|
4668
|
+
const writtenPaths = [];
|
|
4669
|
+
if (wroteClaude) writtenPaths.push(path.relative(PROJECT_ROOT, claudeMdPath));
|
|
4670
|
+
if (wroteAgents) writtenPaths.push(path.relative(PROJECT_ROOT, agentsMdPath));
|
|
4671
|
+
|
|
4662
4672
|
return {
|
|
4663
4673
|
ok: true,
|
|
4664
4674
|
path: path.relative(PROJECT_ROOT, claudeMdPath),
|
|
4665
|
-
paths:
|
|
4666
|
-
? [path.relative(PROJECT_ROOT, claudeMdPath), path.relative(PROJECT_ROOT, agentsMdPath)]
|
|
4667
|
-
: [path.relative(PROJECT_ROOT, claudeMdPath)],
|
|
4675
|
+
paths: writtenPaths,
|
|
4668
4676
|
project_name: projectName,
|
|
4669
|
-
overwritten: force && claudeExisted,
|
|
4677
|
+
overwritten: force && (claudeExisted || agentsExisted),
|
|
4678
|
+
claude_md_skipped: !wroteClaude,
|
|
4670
4679
|
agents_md_skipped: !wroteAgents,
|
|
4671
4680
|
};
|
|
4672
4681
|
}
|
|
@@ -5283,18 +5292,55 @@ function cmdPhasePlanIndex(rawArgs) {
|
|
|
5283
5292
|
const stem = file.replace(/-SPRINT\.md$/i, '');
|
|
5284
5293
|
const text = fs.readFileSync(path.join(phaseDir, file), 'utf8');
|
|
5285
5294
|
const { frontmatter, body } = parseFrontmatter(text);
|
|
5295
|
+
let block = '';
|
|
5296
|
+
if (text.startsWith('---\n')) {
|
|
5297
|
+
const end = text.indexOf('\n---\n', 4);
|
|
5298
|
+
if (end !== -1) block = text.slice(4, end);
|
|
5299
|
+
}
|
|
5286
5300
|
const id = frontmatter.sprint || frontmatter.plan || stem;
|
|
5287
|
-
|
|
5288
|
-
|
|
5301
|
+
// `wave:` may be an explicit scalar key, or absent — in which case it's
|
|
5302
|
+
// derived from `depends_on` below (block-list or inline form, issue #951).
|
|
5303
|
+
const hasExplicitWave = /^wave\s*:\s*\d+/m.test(block);
|
|
5304
|
+
const wave = hasExplicitWave ? (parseInt(frontmatter.wave, 10) || 1) : null;
|
|
5305
|
+
const dependsOn = fmListField(block, 'depends_on');
|
|
5306
|
+
const hasAutonomousKey = /^autonomous\s*:/m.test(block);
|
|
5307
|
+
const autonomous = hasAutonomousKey
|
|
5308
|
+
? String(frontmatter.autonomous || '').toLowerCase() === 'true'
|
|
5309
|
+
: /<automated>/i.test(body);
|
|
5289
5310
|
const gapClosure = String(frontmatter.gap_closure || frontmatter.type || '').toLowerCase() === 'gap_closure';
|
|
5290
5311
|
const objMatch = body.match(/^##\s+(?:Objective|Goal)\s*\n+([^\n]+)/mi);
|
|
5291
5312
|
const objective = objMatch ? objMatch[1].trim() : (frontmatter.goal || '').replace(/^["']|["']$/g, '');
|
|
5292
|
-
const
|
|
5293
|
-
const
|
|
5313
|
+
const checkboxCount = (body.match(/^[-*]\s+\[[ xX]\]/gm) || []).length;
|
|
5314
|
+
const storyHeaderCount = (body.match(/^###\s+Story\s+\S+/gm) || []).length;
|
|
5315
|
+
const taskCount = checkboxCount > 0 ? checkboxCount : storyHeaderCount;
|
|
5316
|
+
const filesModifiedList = fmListField(block, 'files_modified');
|
|
5317
|
+
const filesModified = filesModifiedList.length > 0
|
|
5318
|
+
? filesModifiedList.length
|
|
5319
|
+
: (body.match(/^\s*-\s*path:\s*["']?([^"'\n]+)/gm) || []).length;
|
|
5294
5320
|
const hasSummary = summarySet.has(stem);
|
|
5295
5321
|
if (/checkpoint/i.test(body)) hasCheckpoints = true;
|
|
5296
|
-
return { id, wave, autonomous, gap_closure: gapClosure, objective, task_count: taskCount, files_modified: filesModified, has_summary: hasSummary, file: path.relative(PROJECT_ROOT, path.join(phaseDir, file)) };
|
|
5322
|
+
return { id, wave, dependsOn, autonomous, gap_closure: gapClosure, objective, task_count: taskCount, files_modified: filesModified, has_summary: hasSummary, file: path.relative(PROJECT_ROOT, path.join(phaseDir, file)) };
|
|
5297
5323
|
});
|
|
5324
|
+
|
|
5325
|
+
// Resolve waves left undetermined (no explicit `wave:` key) from depends_on:
|
|
5326
|
+
// wave(p) = 1 + max(wave of each same-phase dependency), or 1 if none.
|
|
5327
|
+
const idToPlan = new Map(plans.map((p) => [p.id, p]));
|
|
5328
|
+
function resolveWave(p, seen) {
|
|
5329
|
+
if (p.wave !== null) return p.wave;
|
|
5330
|
+
if (seen.has(p.id)) { p.wave = 1; return 1; }
|
|
5331
|
+
seen.add(p.id);
|
|
5332
|
+
let maxDepWave = 0;
|
|
5333
|
+
for (const depId of p.dependsOn) {
|
|
5334
|
+
const dep = idToPlan.get(depId);
|
|
5335
|
+
if (!dep) continue;
|
|
5336
|
+
maxDepWave = Math.max(maxDepWave, resolveWave(dep, seen));
|
|
5337
|
+
}
|
|
5338
|
+
p.wave = maxDepWave > 0 ? maxDepWave + 1 : 1;
|
|
5339
|
+
return p.wave;
|
|
5340
|
+
}
|
|
5341
|
+
for (const p of plans) resolveWave(p, new Set());
|
|
5342
|
+
for (const p of plans) delete p.dependsOn;
|
|
5343
|
+
|
|
5298
5344
|
const waves = {};
|
|
5299
5345
|
for (const p of plans) {
|
|
5300
5346
|
const k = String(p.wave);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"intent": "debug",
|
|
14
|
-
"keywords": ["
|
|
14
|
+
"keywords": ["getting an error", "throwing an error", "error in the", "fix the error", "debug this", "crash", "not working", "exception", "traceback", "kharab", "masla", "ye kaam nahi kar raha", "yeh kaam nahi kar raha", "error a raha hai", "theek karo", "khud se crash", "خطأ", "مشكلة", "لا يعمل", "أصلح الخطأ", "تعطل البرنامج"],
|
|
15
15
|
"command": "/rcode-debug"
|
|
16
16
|
},
|
|
17
17
|
{
|
package/rcode/workflows/do.md
CHANGED
|
@@ -387,6 +387,38 @@ Scope: {one-line scope summary}
|
|
|
387
387
|
Routing to: {chosen command}
|
|
388
388
|
Reason: {one-line why}
|
|
389
389
|
```
|
|
390
|
+
|
|
391
|
+
**herdr hint (cached read only — no live check from here).** When the chosen command is
|
|
392
|
+
`/rcode-execute` or `/rcode-add-phase` (routes that fan out substantial multi-file
|
|
393
|
+
execution work), read the cached availability flag left by `/rcode-execute`'s own
|
|
394
|
+
availability check:
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
HERDR_AVAILABLE=$(node .rcode/bin/rcode-tools.cjs config-get workflow._herdr_available 2>/dev/null || echo "false")
|
|
398
|
+
HERDR_AVAILABLE=${HERDR_AVAILABLE:-false}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
This is a plain `config-get` read — the same sanctioned category as `config-get mode`
|
|
402
|
+
used elsewhere in this workflow (see `<guardrails>`). It reuses the exact cache
|
|
403
|
+
`/rcode-execute` writes to (`workflow._herdr_checked` / `workflow._herdr_available`) —
|
|
404
|
+
do NOT duplicate the `command -v herdr` probe or call `config-set` here. `/rcode-do`
|
|
405
|
+
has no sanctioned way to run that shell probe or persist a value itself, so if the
|
|
406
|
+
cache hasn't been populated yet (`_herdr_checked` still false), say nothing — the
|
|
407
|
+
first `/rcode-execute` run downstream will perform and cache the check itself.
|
|
408
|
+
|
|
409
|
+
If `HERDR_AVAILABLE == "true"`, append one extra line to the banner above:
|
|
410
|
+
|
|
411
|
+
```
|
|
412
|
+
Note: herdr is available on this machine — {chosen command} will offer a
|
|
413
|
+
multi-agent orchestration option if the work fans out into independent
|
|
414
|
+
plans; you'll be asked to confirm before anything runs via herdr.
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Do NOT turn this into an `AskUserQuestion` prompt and do NOT ask the user to
|
|
418
|
+
decide here — `/rcode-do` only routes, it never presents execution-mode choices.
|
|
419
|
+
The actual offer (with the full tradeoff explanation and required confirmation)
|
|
420
|
+
is `/rcode-execute`'s `three_options` step, which fires after dispatch regardless
|
|
421
|
+
of whether this hint line was shown.
|
|
390
422
|
</step>
|
|
391
423
|
|
|
392
424
|
<step name="dispatch">
|
|
@@ -426,7 +458,7 @@ If the chosen command expects a phase number and one wasn't provided in the text
|
|
|
426
458
|
<guardrails>
|
|
427
459
|
**Hard prohibitions during /rcode-do execution (issue #458, refined by #1007):**
|
|
428
460
|
|
|
429
|
-
The steps above (`parse_args`, `check_project`, `auto_init_check`, `greenfield_guard`, `explicit_intent_check`, `persona_shortcut`) legitimately call Bash for structured state/config lookups (`rcode-tools.cjs state load`, `progress init`, `config-get mode`, `classify-question`, milestone/PRD/epic detection via `ls`/`grep`) and Read for the specific persona/capability-table lookup in `persona_shortcut` step 2. That is routing plumbing, not investigation, and is allowed. What's prohibited is using those same tools to figure out the route by inspecting application code, or to do the routed work itself:
|
|
461
|
+
The steps above (`parse_args`, `check_project`, `auto_init_check`, `greenfield_guard`, `explicit_intent_check`, `persona_shortcut`, `display`) legitimately call Bash for structured state/config lookups (`rcode-tools.cjs state load`, `progress init`, `config-get mode`, `config-get workflow._herdr_available`, `classify-question`, milestone/PRD/epic detection via `ls`/`grep`) and Read for the specific persona/capability-table lookup in `persona_shortcut` step 2. That is routing plumbing, not investigation, and is allowed. `config-get workflow._herdr_available` is a read of a cache another workflow (`/rcode-execute`) already populated — `/rcode-do` MUST NOT run `command -v herdr` itself or call `config-set` to populate that cache; it only reads whatever is already there. What's prohibited is using those same tools to figure out the route by inspecting application code, or to do the routed work itself:
|
|
430
462
|
|
|
431
463
|
- MUST NOT use Bash/Read/Grep/Glob to explore or read application source code to guess what a vague request means. The state/config lookups named above are the only sanctioned uses — anything beyond them (grepping `src/`, reading a feature file to understand behavior, etc.) means the dispatcher contract has failed — STOP and use the no-route exit instead.
|
|
432
464
|
- MUST NOT call Write or Edit. The dispatcher never modifies files.
|
|
@@ -98,7 +98,41 @@ CONFIG_MODE=$(node .rcode/bin/rcode-tools.cjs config-get mode 2>/dev/null || ech
|
|
|
98
98
|
|
|
99
99
|
**If `CONFIG_MODE == "yolo"` or `$ARGUMENTS` contains `--auto`:** Skip the menu. Auto-select **A) Autonomous run** and print one line: `▶ Auto-selecting Autonomous run (yolo mode). /rcode-settings set mode guided to change.`
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
**herdr availability check (cached).** Before offering options, determine whether a 4th
|
|
102
|
+
option (**D) herdr multi-agent orchestration**) should be shown. This uses the same
|
|
103
|
+
cached-boolean idiom as `workflow._auto_chain_active` above — read with a safe default,
|
|
104
|
+
write back once resolved, and skip the live check on subsequent runs:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
HERDR_NAMED_EXPLICITLY=$([[ "$ARGUMENTS" =~ herdr ]] && echo true || echo false)
|
|
108
|
+
|
|
109
|
+
HERDR_CHECKED=$(node .rcode/bin/rcode-tools.cjs config-get workflow._herdr_checked 2>/dev/null || echo "false")
|
|
110
|
+
HERDR_CHECKED=${HERDR_CHECKED:-false} # config-get exits 0 with empty output when key absent
|
|
111
|
+
|
|
112
|
+
if [[ "$HERDR_CHECKED" != "true" || "$HERDR_NAMED_EXPLICITLY" == "true" ]]; then
|
|
113
|
+
# Live re-check: first time ever, OR user named herdr explicitly this run
|
|
114
|
+
# (herdr may have been installed since the last negative cache hit).
|
|
115
|
+
if command -v herdr >/dev/null 2>&1; then
|
|
116
|
+
HERDR_AVAILABLE=true
|
|
117
|
+
else
|
|
118
|
+
HERDR_AVAILABLE=false
|
|
119
|
+
fi
|
|
120
|
+
node .rcode/bin/rcode-tools.cjs config-set workflow._herdr_available "$HERDR_AVAILABLE" 2>/dev/null
|
|
121
|
+
node .rcode/bin/rcode-tools.cjs config-set workflow._herdr_checked true 2>/dev/null
|
|
122
|
+
else
|
|
123
|
+
# Cached negative (or positive) result from a prior run — skip the check entirely.
|
|
124
|
+
HERDR_AVAILABLE=$(node .rcode/bin/rcode-tools.cjs config-get workflow._herdr_available 2>/dev/null || echo "false")
|
|
125
|
+
HERDR_AVAILABLE=${HERDR_AVAILABLE:-false}
|
|
126
|
+
fi
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Do **not** re-run `command -v herdr` on every invocation once `_herdr_checked` is `true` —
|
|
130
|
+
that defeats the point of caching. The only exception is `HERDR_NAMED_EXPLICITLY`: if the
|
|
131
|
+
user's request names herdr by name (e.g. "use herdr for this", "orchestrate via herdr"),
|
|
132
|
+
always re-check live regardless of the cached value, since herdr may have been installed
|
|
133
|
+
since the negative result was cached.
|
|
134
|
+
|
|
135
|
+
Otherwise, offer modes via AskUserQuestion. Each option names the tradeoff explicitly:
|
|
102
136
|
|
|
103
137
|
**A) Autonomous run** — Spawn subagent per plan in sequence/parallel per
|
|
104
138
|
wave rules. Checkpoints still pause for user. Fastest wall-clock.
|
|
@@ -112,9 +146,31 @@ Otherwise, offer three modes via AskUserQuestion. Each option names the tradeoff
|
|
|
112
146
|
later waves in a separate session. Good for staged rollout / review
|
|
113
147
|
gates.
|
|
114
148
|
|
|
149
|
+
**D) herdr multi-agent orchestration** (only shown if `HERDR_AVAILABLE == "true"`) —
|
|
150
|
+
Fan this phase's plans out to parallel `herdr` panes/tabs, each running its own
|
|
151
|
+
Claude agent in an isolated git worktree, then merge their work back. Be specific
|
|
152
|
+
about the tradeoff, not a one-liner: separate terminal panes you can watch
|
|
153
|
+
independently, genuinely parallel wall-clock (not wave-sequenced), noticeably
|
|
154
|
+
higher token cost than A/B/C since each pane runs a full agent session, and a
|
|
155
|
+
merge step at the end. Best fit is plans that are truly independent (no shared
|
|
156
|
+
`files_modified`, no cross-plan sequencing) — for plans with overlaps or a single
|
|
157
|
+
linear wave, herdr adds coordination overhead for no benefit; prefer A or C instead.
|
|
158
|
+
Selecting this option invokes the `rcode-herdr-orchestration` skill — it does not
|
|
159
|
+
replace this workflow's execution, it's a different way to run the same plans.
|
|
160
|
+
|
|
161
|
+
**Never auto-select D, even in yolo/`--auto` mode.** Orchestrating via herdr requires
|
|
162
|
+
explicit user confirmation on this turn — if `CONFIG_MODE == "yolo"` skipped the menu
|
|
163
|
+
above, herdr is simply not offered this run; the autonomous-run auto-selection must
|
|
164
|
+
never silently switch into herdr mode.
|
|
165
|
+
|
|
115
166
|
Include a recommendation line: "My recommendation: {letter} because {reason
|
|
116
167
|
in one clause}." Then ask which option to proceed with — do NOT silently
|
|
117
|
-
pick one.
|
|
168
|
+
pick one. If the user selects D, confirm once more in plain language what
|
|
169
|
+
will happen (parallel panes, worktrees, higher cost) before invoking
|
|
170
|
+
`Skill(skill="rcode-herdr-orchestration", ...)` — do not invoke it on the same
|
|
171
|
+
turn as the AskUserQuestion answer without that confirmation being part of
|
|
172
|
+
the answer itself (i.e. selecting the option IS the confirmation only if its
|
|
173
|
+
label made the tradeoff explicit, which it does above).
|
|
118
174
|
</three_options>
|
|
119
175
|
|
|
120
176
|
<output_format>
|
package/rcode/workflows/init.md
CHANGED
|
@@ -42,6 +42,12 @@ Run detection in parallel:
|
|
|
42
42
|
test -f .rcode/config.yaml && echo "rcode-configured: yes" || echo "rcode-configured: no"
|
|
43
43
|
test -f .rcode/state.json && echo "state-present: yes" || echo "state-present: no"
|
|
44
44
|
test -f .rcode/JOURNEY.md && echo "rihla-present: yes" || echo "rihla-present: no"
|
|
45
|
+
# #1028: config.yaml is written by the installer itself, so it exists on the
|
|
46
|
+
# very first /rcode-init run after a fresh install — not just on a genuine
|
|
47
|
+
# "returning" session. state.json's installer-seeded _seeded_stub marker
|
|
48
|
+
# (cleared once a real init/new-project run completes) is what actually
|
|
49
|
+
# distinguishes "normal first brownfield run" from "prior init was interrupted".
|
|
50
|
+
grep -q '"_seeded_stub"[[:space:]]*:[[:space:]]*true' .rcode/state.json 2>/dev/null && echo "seeded-stub: yes" || echo "seeded-stub: no"
|
|
45
51
|
|
|
46
52
|
# Project presence
|
|
47
53
|
# Use git rev-parse instead of test -d .git — in git worktrees .git is a FILE not a dir
|
|
@@ -83,12 +89,20 @@ If `state === "returning"` and `--reset` not passed:
|
|
|
83
89
|
Or run with --reset to reconfigure.
|
|
84
90
|
```
|
|
85
91
|
|
|
86
|
-
- If `rihla-present: no` —
|
|
92
|
+
- If `rihla-present: no` and `seeded-stub: yes` — this is the normal, expected first `/rcode-init` run on a brownfield project: `config.yaml` was seeded by the installer, but no real init has happened yet. Nothing was broken; do NOT use "recovery" language. Print a plain first-run notice and continue to Steps 4 and 4b to write the baseline:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
✓ rcode is configured. Writing the JOURNEY.md baseline now...
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Skip Steps 2 and 3 (config already exists). Jump directly to Step 4.
|
|
99
|
+
|
|
100
|
+
- If `rihla-present: no` and `seeded-stub: no` — a genuine prior init/new-project run completed (the stub marker was cleared) but JOURNEY.md is missing, meaning that prior run was interrupted before writing it. This is an actual recovery case. Print a recovery notice and continue to Steps 4 and 4b to write the missing baseline:
|
|
87
101
|
|
|
88
102
|
```
|
|
89
103
|
✓ rcode is already configured here.
|
|
90
104
|
|
|
91
|
-
JOURNEY.md baseline is missing — completing
|
|
105
|
+
JOURNEY.md baseline is missing from a prior interrupted init — completing it now...
|
|
92
106
|
```
|
|
93
107
|
|
|
94
108
|
Skip Steps 2 and 3 (config already exists). Jump directly to Step 4.
|
|
@@ -290,12 +304,12 @@ test -f .rcode/sources.yaml && node .rcode/bin/rcode-tools.cjs context refresh >
|
|
|
290
304
|
`generate-claude-md` (the command routing rule + project rules block every agent needs at session start) previously only ran via the `/rcode-new-project` roadmap flow — a project set up with `/rcode-init` alone (the common "add rcode to an existing codebase" path) never got it, so agents had no ambient instruction to check `do.md` before acting ad-hoc. Close that gap here, unconditionally (not just on `fresh`):
|
|
291
305
|
|
|
292
306
|
```bash
|
|
293
|
-
if [ ! -f CLAUDE.md ]; then
|
|
307
|
+
if [ ! -f CLAUDE.md ] || [ ! -f AGENTS.md ]; then
|
|
294
308
|
node .rcode/bin/rcode-tools.cjs generate-claude-md
|
|
295
309
|
fi
|
|
296
310
|
```
|
|
297
311
|
|
|
298
|
-
Never pass `--force` here — an existing `CLAUDE.md` is the user's own file (or was already generated by a prior init/new-project run) and must not be overwritten. Silent no-op when
|
|
312
|
+
Never pass `--force` here — an existing `CLAUDE.md` or `AGENTS.md` is the user's own file (or was already generated by a prior init/new-project run) and must not be overwritten. `cmdGenerateClaudeMd` gates each file's write on that file's own existence, so this backfills whichever one is missing (#1025). Silent no-op when both files already exist.
|
|
299
313
|
|
|
300
314
|
## Step 5 — Suggest the next step
|
|
301
315
|
|
package/rcode/workflows/scan.md
CHANGED
|
@@ -14,10 +14,12 @@ Valid rcode subagent types (use exact names — do not fall back to 'general-pur
|
|
|
14
14
|
|
|
15
15
|
## Step 0 — Usage check
|
|
16
16
|
|
|
17
|
-
If `$ARGUMENTS`
|
|
17
|
+
If `$ARGUMENTS` contains `--help` or `-h`:
|
|
18
18
|
- Print the usage block below
|
|
19
19
|
- STOP — do not proceed
|
|
20
20
|
|
|
21
|
+
If `$ARGUMENTS` is empty, do NOT stop — proceed to Step 1, which defaults focus to `tech+arch`.
|
|
22
|
+
|
|
21
23
|
**Usage:**
|
|
22
24
|
```
|
|
23
25
|
/rcode-scan [--focus tech|arch|quality|concerns|tech+arch] [--refresh] [--reset]
|
|
@@ -339,6 +341,25 @@ This file tracks structural changes between scans. Each entry is auto-written by
|
|
|
339
341
|
|
|
340
342
|
This file is **read by future `/rcode-scan --refresh` runs** as additional anchor context — the memory bank is self-improving across scans.
|
|
341
343
|
|
|
344
|
+
## Step 6.6: Propagate scan findings into PROJECT.md / STATE.md stubs
|
|
345
|
+
|
|
346
|
+
After the RETURNED banner, check whether `.planning/PROJECT.md` and `.planning/STATE.md` are still install stubs (contain the `<!-- INSTALL STUB` banner). If real, non-stub content already exists, skip this step entirely — never overwrite user-authored planning docs.
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
grep -q '<!-- INSTALL STUB' .planning/PROJECT.md 2>/dev/null && echo "PROJECT_IS_STUB"
|
|
350
|
+
grep -q '<!-- INSTALL STUB' .planning/STATE.md 2>/dev/null && echo "STATE_IS_STUB"
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
If `PROJECT.md` is a stub: replace its `**One-line:**` sentence with a brief, grounded one-line summary derived from the scan output just written (e.g. from STACK.md/ARCHITECTURE.md's opening lines — language, framework, primary purpose). Keep the rest of the stub structure (Vision/Stack sections) intact; do not attempt a full rewrite. Use Read then Edit — never blind-overwrite.
|
|
354
|
+
|
|
355
|
+
If `STATE.md` is a stub: update `**Current phase:**` line to read:
|
|
356
|
+
```
|
|
357
|
+
**Current phase:** none — existing project scanned, see .planning/codebase/ — no phases planned yet
|
|
358
|
+
```
|
|
359
|
+
and add one line under `## Next Action` noting the scan already ran: `Codebase scanned via /rcode-scan — run /rcode-new-project or /rcode-add-phase to plan phases.`
|
|
360
|
+
|
|
361
|
+
This is a minimal, targeted edit — not a full PROJECT.md content-generation system. The goal is closing the "user opens STATE.md and sees zero acknowledgment a scan already ran" gap.
|
|
362
|
+
|
|
342
363
|
## Step 7: Final cue (orchestrator-level, after RETURNED banner)
|
|
343
364
|
|
|
344
365
|
The RETURNED banner above is Dalil's voice. After it, the orchestrator may add ONE neutral cue line if the user might want a deeper scan:
|
|
@@ -63,11 +63,14 @@ export function Sidebar({ activeView, projectName }) {
|
|
|
63
63
|
const name = project.name || projectName || 'No project';
|
|
64
64
|
const user = (project.user && project.user.name) ? project.user : null;
|
|
65
65
|
|
|
66
|
-
// Full store subscription for live health badge counts.
|
|
66
|
+
// Full store subscription for live health badge counts. Derived from
|
|
67
|
+
// activeSessions (the /api/sessions poll) — same source of truth as the
|
|
68
|
+
// OrchPanel footer and NotifyCenter — not the static store.blockers list
|
|
69
|
+
// (PROJECT.md-backed, unrelated to live orchestrator state; see #965).
|
|
67
70
|
// Re-renders on every setState (sessions poll every 4 s, state refresh every 30 s).
|
|
68
|
-
const { activeSessions
|
|
69
|
-
const sessionCount = (activeSessions || []).filter(s => s.status === 'running').length;
|
|
70
|
-
const blockerCount = (
|
|
71
|
+
const { activeSessions } = useStore();
|
|
72
|
+
const sessionCount = (activeSessions || []).filter(s => s.status === 'running' && !s.waiting).length;
|
|
73
|
+
const blockerCount = (activeSessions || []).filter(s => s.status === 'blocked' || s.waiting).length;
|
|
71
74
|
|
|
72
75
|
return html`
|
|
73
76
|
<aside class="sidebar" id="sidebar">
|
|
@@ -93,7 +96,7 @@ export function Sidebar({ activeView, projectName }) {
|
|
|
93
96
|
</span>
|
|
94
97
|
<span
|
|
95
98
|
class=${'health-badge' + (blockerCount > 0 ? ' health-badge--alert' : ' health-badge--zero')}
|
|
96
|
-
title=${blockerCount + '
|
|
99
|
+
title=${blockerCount + ' blocked orchestration session' + (blockerCount === 1 ? '' : 's')}
|
|
97
100
|
>
|
|
98
101
|
<${Icon} name="alert-triangle" size=${12} />
|
|
99
102
|
${blockerCount} blocked
|
|
@@ -332,18 +332,37 @@ export function TaskCard({ task: t }) {
|
|
|
332
332
|
const done = t.status === 'done' || t.status === 'completed';
|
|
333
333
|
const running = isSessionRunning(t.id);
|
|
334
334
|
|
|
335
|
-
// Build cmd hints for this task
|
|
335
|
+
// Build cmd hints for this task. rcode has two separate tracking pipelines
|
|
336
|
+
// that both use "sprint" terminology: phase -> SPRINT.md plan -> task
|
|
337
|
+
// (t.phaseId set; this is the real, execution-backed pipeline) vs.
|
|
338
|
+
// epic -> story (no t.phaseId; dev-story/create-story/verify-work). Using
|
|
339
|
+
// dev-story/create-story commands against a phase-pipeline task id (e.g.
|
|
340
|
+
// "1.1.2") doesn't parse — those commands only accept 2-part epic.story
|
|
341
|
+
// ids. Branch on t.phaseId so the hints (and the RunBtn below) actually
|
|
342
|
+
// resolve to a real command for whichever pipeline this task is from.
|
|
343
|
+
const isPhaseTask = !!t.phaseId;
|
|
344
|
+
const runCmd = isPhaseTask ? '/rcode-execute ' + t.phaseId : '/rcode-dev-story ' + t.id;
|
|
336
345
|
const taskCmds = [];
|
|
337
346
|
if (t.id) {
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
347
|
+
if (isPhaseTask) {
|
|
348
|
+
if (!done) {
|
|
349
|
+
taskCmds.push(['/rcode-execute ' + t.phaseId, 'Execute phase ' + t.phaseId]);
|
|
350
|
+
} else {
|
|
351
|
+
taskCmds.push(['/rcode-verify-phase ' + t.phaseId, 'Verify phase ' + t.phaseId]);
|
|
352
|
+
taskCmds.push(['/rcode-review --phase ' + t.phaseId + ' --all', 'Review code for this phase']);
|
|
353
|
+
}
|
|
354
|
+
taskCmds.push(['/rcode-progress', 'Overall project status']);
|
|
341
355
|
} else {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
356
|
+
if (!done) {
|
|
357
|
+
taskCmds.push(['/rcode-dev-story ' + t.id, 'Implement this story']);
|
|
358
|
+
taskCmds.push(['/rcode-create-story ' + (t.sprintId || ''), 'Add related story']);
|
|
359
|
+
} else {
|
|
360
|
+
taskCmds.push(['/rcode-verify-work ' + t.id, 'Verify this story']);
|
|
361
|
+
taskCmds.push(['/rcode-review ' + t.id, 'Review code for this story']);
|
|
362
|
+
}
|
|
363
|
+
if (t.sprintId) {
|
|
364
|
+
taskCmds.push(['/rcode-sprint-status ' + t.sprintId, 'Sprint ' + t.sprintId + ' status']);
|
|
365
|
+
}
|
|
347
366
|
}
|
|
348
367
|
}
|
|
349
368
|
|
|
@@ -357,7 +376,7 @@ export function TaskCard({ task: t }) {
|
|
|
357
376
|
aria-expanded=${expanded}
|
|
358
377
|
...${pressable(() => setExpanded(e => !e))}>
|
|
359
378
|
<div class="item-title" style=${done ? 'text-decoration:line-through' : ''}>
|
|
360
|
-
${t.id && !done ? html`<${RunBtn} storyId=${t.id} cmd=${'
|
|
379
|
+
${t.id && !done ? html`<${RunBtn} storyId=${t.id} cmd=${runCmd} label=${isPhaseTask ? 'Phase ' + t.phaseId : 'Story ' + t.id}/>` : null}
|
|
361
380
|
${t.file ? html`
|
|
362
381
|
<button class="card-file-btn" title=${'View ' + t.file} onClick=${handleViewFile}>
|
|
363
382
|
<${Icon} name="file-text" size=${11}/> File
|
|
@@ -91,7 +91,7 @@ function PhaseDetail({ phase: p, S }) {
|
|
|
91
91
|
<div class="attr-grid">
|
|
92
92
|
<${AttrItem} label="Status" value=${html`<${Chip} status=${p.status}/>`}/>
|
|
93
93
|
<${AttrItem} label="Sprints" value=${sps.length}/>
|
|
94
|
-
<${AttrItem} label="Tasks Done" value=${done + '/' + stories.length}/>
|
|
94
|
+
<${AttrItem} label="Tasks Done" value=${stories.length ? (done + '/' + stories.length) : 'no tasks tracked'}/>
|
|
95
95
|
<${AttrItem} label="Progress" value=${pct(done, stories.length)}/>
|
|
96
96
|
${p.completed_at ? html`<${AttrItem} label="Completed" value=${humanDate(p.completed_at)}/>` : null}
|
|
97
97
|
</div>
|
package/server/lib/html/css.js
CHANGED
|
@@ -2651,6 +2651,8 @@ footer {
|
|
|
2651
2651
|
overflow: hidden;
|
|
2652
2652
|
text-overflow: ellipsis;
|
|
2653
2653
|
white-space: nowrap;
|
|
2654
|
+
min-width: 0;
|
|
2655
|
+
flex-shrink: 1;
|
|
2654
2656
|
}
|
|
2655
2657
|
.orch-term-dock-live {
|
|
2656
2658
|
display: inline-flex;
|
|
@@ -2661,6 +2663,7 @@ footer {
|
|
|
2661
2663
|
color: var(--green);
|
|
2662
2664
|
font-family: var(--font-mono);
|
|
2663
2665
|
white-space: nowrap;
|
|
2666
|
+
flex-shrink: 0;
|
|
2664
2667
|
}
|
|
2665
2668
|
.orch-term-dock-live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
|
|
2666
2669
|
.orch-term-dock-stop {
|
|
@@ -2675,6 +2678,7 @@ footer {
|
|
|
2675
2678
|
cursor: pointer;
|
|
2676
2679
|
font-family: var(--font-sans);
|
|
2677
2680
|
margin-left: var(--space-2);
|
|
2681
|
+
flex-shrink: 0;
|
|
2678
2682
|
}
|
|
2679
2683
|
.orch-term-dock-stop:hover { opacity: 0.85; }
|
|
2680
2684
|
.orch-term-dock-body {
|
package/server/lib/scanner.js
CHANGED
|
@@ -195,7 +195,7 @@ function buildPhaseTree(projectDir, rawPhases, listCached, overrides) {
|
|
|
195
195
|
// Status: a *-SUMMARY.md sibling means the sprint shipped.
|
|
196
196
|
const hasSummary = files.includes(f.replace(/-SPRINT\.md$/i, '-SUMMARY.md'));
|
|
197
197
|
const status = hasSummary ? 'complete'
|
|
198
|
-
: (p.status === 'active'
|
|
198
|
+
: toState(p.status) === 'active' ? 'in_progress'
|
|
199
199
|
: 'planned';
|
|
200
200
|
|
|
201
201
|
// Project-relative path to this sprint's SPRINT.md — same shape as the
|
|
@@ -269,6 +269,9 @@ function buildDashboard(state) {
|
|
|
269
269
|
: (Array.isArray(raw.phases) ? raw.phases : []);
|
|
270
270
|
|
|
271
271
|
// ---- phases (superset: rich phaseTree + contract range/state) ----
|
|
272
|
+
// Sorted numerically by phase number — state.json array order reflects
|
|
273
|
+
// insertion order (when a phase entry was appended), not phase sequence,
|
|
274
|
+
// so an inserted-late phase like 13 would otherwise render after 38/39/42/43.
|
|
272
275
|
const phases = tree.map(p => {
|
|
273
276
|
const started = p.started || p.created || null;
|
|
274
277
|
const completed = p.completed || p.completed_at || null;
|
|
@@ -276,7 +279,7 @@ function buildDashboard(state) {
|
|
|
276
279
|
? [fmtShort(started), fmtShort(completed)].filter(Boolean).join(' – ')
|
|
277
280
|
: '';
|
|
278
281
|
return { ...p, name: p.name || p.slug || String(p.id || ''), range, state: toState(p.status) };
|
|
279
|
-
});
|
|
282
|
+
}).sort((a, b) => (parseFloat(a.id ?? a.number) || 0) - (parseFloat(b.id ?? b.number) || 0));
|
|
280
283
|
|
|
281
284
|
// ---- progress (prefer story counts; fall back to phase-level counts) ----
|
|
282
285
|
let completed = 0, total = 0, inProg = 0;
|
|
@@ -380,7 +383,7 @@ function buildDashboard(state) {
|
|
|
380
383
|
date: fmtISODate(p.completed || s.completed_at || p.created),
|
|
381
384
|
file: s.file || null,
|
|
382
385
|
});
|
|
383
|
-
} else if (p.
|
|
386
|
+
} else if (toState(p.status) === 'active') {
|
|
384
387
|
// No per-task progress tracking exists — pct stays null and the UI
|
|
385
388
|
// omits the percent pill rather than inventing a number.
|
|
386
389
|
inProgressTasks.push({ title: st.title || st.id, pct: null, file: s.file || null });
|
|
@@ -390,10 +393,10 @@ function buildDashboard(state) {
|
|
|
390
393
|
}
|
|
391
394
|
// Fallbacks so the cards are never empty when stories are unregistered in state.json.
|
|
392
395
|
if (!completedTasks.length) {
|
|
393
|
-
phases.filter(p => p.
|
|
396
|
+
phases.filter(p => toState(p.status) === 'done').slice(-6).forEach(p =>
|
|
394
397
|
completedTasks.push({ title: p.name, date: fmtISODate(p.completed || p.created), file: null }));
|
|
395
398
|
}
|
|
396
|
-
if (!inProgressTasks.length && activePhase && activePhase.
|
|
399
|
+
if (!inProgressTasks.length && activePhase && toState(activePhase.status) === 'active') {
|
|
397
400
|
inProgressTasks.push({ title: activePhase.name, pct: null, file: null });
|
|
398
401
|
}
|
|
399
402
|
const tasks = {
|