dotmd-cli 0.64.3 → 0.65.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/bin/dotmd.mjs +26 -3
- package/package.json +1 -1
- package/src/new.mjs +3 -3
- package/src/query.mjs +30 -2
- package/src/render.mjs +7 -7
- package/src/runlist.mjs +452 -5
package/bin/dotmd.mjs
CHANGED
|
@@ -43,7 +43,7 @@ const FLAG_SPECS = {
|
|
|
43
43
|
update: { flags: new Set(['--check', '--cli-only', '--plugin-only']), values: new Set() },
|
|
44
44
|
check: { flags: new Set(['--fix', '--errors-only', '--no-collapse', '--json', '--verbose']), values: new Set() },
|
|
45
45
|
doctor: { flags: new Set(['--apply', '--yes', '--dry-run', '-n', '--statuses', '--migrate-template', '--migrate-prompts', '--frontmatter-fix', '--project', '--json', '--include-archived']), values: new Set() },
|
|
46
|
-
runlist: { flags: new Set(['--json', '--full', '--no-index', '--show-files']), values: new Set(), subcommands: new Set(['next']) },
|
|
46
|
+
runlist: { flags: new Set(['--json', '--full', '--no-index', '--show-files', '--clear-parent', '--before', '--after']), values: new Set(['--before', '--after']), subcommands: new Set(['next', 'add', 'remove', 'reorder']) },
|
|
47
47
|
runlists: { flags: new Set(['--json', '--limit', '--sort']), values: new Set(['--limit', '--sort']) },
|
|
48
48
|
prompts: {
|
|
49
49
|
flags: new Set(['--json', '--status', '--include-archived', '--sort', '--limit', '--all', '--no-index', '--show-files', '--body', '--message', '--title']),
|
|
@@ -235,7 +235,7 @@ Validate & Fix:
|
|
|
235
235
|
Lifecycle:
|
|
236
236
|
use <file> Open a plan (mark in-session + print it) or consume a prompt
|
|
237
237
|
set <status> <file> Change a document's status (frontmatter write; archive also moves the file)
|
|
238
|
-
runlist <hub> [next]
|
|
238
|
+
runlist <hub> [next|add|remove|reorder] Show, walk, or mutate an ordered group of plans (see \`dotmd help runlist\`)
|
|
239
239
|
runlists List coordination-hub runlists (the Runlists dashboard)
|
|
240
240
|
status <file> <status> Transition document status (deprecated; prefer \`set\`)
|
|
241
241
|
archive <file> Archive (status + move + update refs)
|
|
@@ -1212,7 +1212,7 @@ directory, updates references, and regenerates the index.
|
|
|
1212
1212
|
|
|
1213
1213
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
1214
1214
|
|
|
1215
|
-
runlist: `dotmd runlist <hub> [next] — work with an ordered group of plans
|
|
1215
|
+
runlist: `dotmd runlist <hub> [next|add|remove|reorder] — work with an ordered group of plans
|
|
1216
1216
|
|
|
1217
1217
|
A "runlist" is just a plan with a \`runlist:\` array of child plan paths in its
|
|
1218
1218
|
frontmatter — there is no separate doc type. The hub plan can have any status;
|
|
@@ -1225,6 +1225,29 @@ Usage:
|
|
|
1225
1225
|
in-session + prints it). Stops if it's not in a
|
|
1226
1226
|
workable status (active / planned / in-session)
|
|
1227
1227
|
so you resolve the blocker first.
|
|
1228
|
+
dotmd runlist add <hub> <child...>
|
|
1229
|
+
Append children to the hub's \`runlist:\` array
|
|
1230
|
+
(no more hand-editing the YAML). Each child can be:
|
|
1231
|
+
• a bare slug (\`cleanup\`) → scaffolds a
|
|
1232
|
+
\`planned\` stub \`<hub>-NN-<slug>.md\` next to
|
|
1233
|
+
the hub (mirrors \`new plan --runlist\`), or
|
|
1234
|
+
• a path/slug of an existing plan → wired in by a
|
|
1235
|
+
hub-relative ref, with its \`parent_plan:\` set
|
|
1236
|
+
back at the hub.
|
|
1237
|
+
A plain plan gains a \`runlist:\` (becomes a hub).
|
|
1238
|
+
Coordination hubs (body-order) aren't handled here.
|
|
1239
|
+
dotmd runlist remove <hub> <child...>
|
|
1240
|
+
Drop children from the \`runlist:\` array. Children
|
|
1241
|
+
match by full path or short slug (\`cleanup\` finds
|
|
1242
|
+
\`<hub>-03-cleanup.md\`). \`--clear-parent\` also blanks
|
|
1243
|
+
each removed child's \`parent_plan:\` back-ref.
|
|
1244
|
+
dotmd runlist reorder <hub> <child> --before|--after <other>
|
|
1245
|
+
dotmd runlist reorder <hub> <c1> <c2> <c3...>
|
|
1246
|
+
Move one child relative to another, or pass every
|
|
1247
|
+
child to set a full new order.
|
|
1248
|
+
All three mutators take \`--dry-run\` / \`--json\` and
|
|
1249
|
+
keep any body \`## Order of operations\` link list in
|
|
1250
|
+
sync (preserving per-item ⬜/✅ markers).
|
|
1228
1251
|
|
|
1229
1252
|
Flags (only meaningful with \`next\`):
|
|
1230
1253
|
--full Print full plan body instead of the card.
|
package/package.json
CHANGED
package/src/new.mjs
CHANGED
|
@@ -269,10 +269,10 @@ export function readBodyInput(source) {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
// Slug/title helpers shared by name resolution and runlist child generation.
|
|
272
|
-
function slugify(s) {
|
|
272
|
+
export function slugify(s) {
|
|
273
273
|
return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
274
274
|
}
|
|
275
|
-
function titleize(s) {
|
|
275
|
+
export function titleize(s) {
|
|
276
276
|
return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
277
277
|
}
|
|
278
278
|
|
|
@@ -420,7 +420,7 @@ ${bodyInput?.trim() ?? ''}
|
|
|
420
420
|
// Minimal child plan stub for a scaffolded runlist child. parent_plan points
|
|
421
421
|
// back at the hub (same dir) so \`dotmd doctor\` is satisfied and the reverse
|
|
422
422
|
// link/graph work; status starts `planned` (queued behind the hub).
|
|
423
|
-
function runlistChildContent(childTitle, hubSlug, hubTitle, childStatus, today) {
|
|
423
|
+
export function runlistChildContent(childTitle, hubSlug, hubTitle, childStatus, today) {
|
|
424
424
|
return `---
|
|
425
425
|
type: plan
|
|
426
426
|
status: ${childStatus}
|
package/src/query.mjs
CHANGED
|
@@ -524,7 +524,11 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
|
|
|
524
524
|
const headerParts = [];
|
|
525
525
|
if (hubCount) headerParts.push(`${hubCount} runlist${hubCount === 1 ? '' : 's'}`);
|
|
526
526
|
headerParts.push(...counts);
|
|
527
|
-
|
|
527
|
+
// Hubs are held OUT of the headline plan count — they read as a separate
|
|
528
|
+
// `N runlist` sibling, never as actionable plans. So "N plans" counts leaves
|
|
529
|
+
// only and the status segments sum to it (the runlist sibling sits apart).
|
|
530
|
+
const headlineTotal = totalAll - hubCount;
|
|
531
|
+
const header = `${headlineTotal} ${noun}${headerParts.length ? ' · ' + headerParts.join(' · ') : ''}`;
|
|
528
532
|
process.stdout.write(dim(header) + '\n');
|
|
529
533
|
|
|
530
534
|
// Active filter note
|
|
@@ -599,6 +603,23 @@ function renderPlansOutput(docs, filters, config, opts = {}) {
|
|
|
599
603
|
}
|
|
600
604
|
}
|
|
601
605
|
|
|
606
|
+
// Runlist nav stays discoverable under a narrowing filter. The Runlists
|
|
607
|
+
// section respects the active filter (a coordination hub shows only when its
|
|
608
|
+
// own status matches), so `--status blocked` legitimately hides an `active`
|
|
609
|
+
// hub. Rather than silently dropping the map, point at `dotmd runlists` when
|
|
610
|
+
// a filter hid live hubs — honest count, filter respected, map never lost.
|
|
611
|
+
if (coordination?.size && activeFilters.length) {
|
|
612
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
613
|
+
const shownHubs = new Set(coordAll.map(d => d.path));
|
|
614
|
+
const filterHidden = [...coordination.values()].filter(v =>
|
|
615
|
+
!shownHubs.has(v.doc.path)
|
|
616
|
+
&& !archiveStatuses.has(v.doc.status)
|
|
617
|
+
&& !isArchivedPath(v.doc.path, config)).length;
|
|
618
|
+
if (filterHidden > 0) {
|
|
619
|
+
process.stdout.write(dim(` ${filterHidden} runlist${filterHidden === 1 ? '' : 's'} hidden by filter · dotmd runlists\n`));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
602
623
|
process.stdout.write('\n');
|
|
603
624
|
return;
|
|
604
625
|
}
|
|
@@ -760,8 +781,15 @@ function renderHubBlock(hub, info, children, maxWidth, topMaxSlug) {
|
|
|
760
781
|
process.stdout.write(header + '\n');
|
|
761
782
|
|
|
762
783
|
if (children.length === 0) return;
|
|
784
|
+
// Render rows in runlist order (info.children), not the docs view's recency
|
|
785
|
+
// sort — a runlist reads in sequence, and a just-added child shouldn't jump to
|
|
786
|
+
// the top because its mtime is newest. Only children present in the current
|
|
787
|
+
// view are shown; any not tracked by the runlist index trail at the end.
|
|
788
|
+
const byPath = new Map(children.map(c => [c.path, c]));
|
|
789
|
+
const ordered = info.children.map(ic => byPath.get(ic.path)).filter(Boolean);
|
|
790
|
+
for (const c of children) if (!ordered.includes(c)) ordered.push(c);
|
|
763
791
|
const childMaxSlug = Math.min(28, Math.max(...children.map(c => stripHubPrefix(toSlug(c), hubSlug).length)));
|
|
764
|
-
for (const c of
|
|
792
|
+
for (const c of ordered) {
|
|
765
793
|
const isNext = c.path === info.nextChildPath;
|
|
766
794
|
const indent = isNext ? ` ${green('→')} ` : ' ';
|
|
767
795
|
process.stdout.write(formatPlanRow(c, maxWidth, {
|
package/src/render.mjs
CHANGED
|
@@ -342,17 +342,17 @@ export function renderBriefing(index, config) {
|
|
|
342
342
|
]);
|
|
343
343
|
const live = plans.filter(p => !closed.has(p.status) && !isArchivedPath(p.path, config));
|
|
344
344
|
const liveHubs = live.filter(isHub);
|
|
345
|
+
const liveLeaves = live.length - liveHubs.length;
|
|
345
346
|
const bySt = {};
|
|
346
347
|
for (const p of live) { if (isHub(p)) continue; bySt[p.status] = (bySt[p.status] ?? 0) + 1; }
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const counts = countParts.join(', ');
|
|
348
|
+
// Coordination hubs are navigation maps, not units of work — held OUT of the
|
|
349
|
+
// headline plan count entirely (they get their own `N runlists · dotmd
|
|
350
|
+
// runlists` pointer line below). The breakdown is leaf statuses only and sums
|
|
351
|
+
// back to the leaf count, so "N live plans" means N things to actually work on.
|
|
352
|
+
const counts = Object.entries(bySt).map(([s, n]) => `${n} ${s}`).join(', ');
|
|
353
353
|
const closedCount = plans.length - live.length;
|
|
354
354
|
const closedPart = closedCount ? ` (${closedCount} archived)` : '';
|
|
355
|
-
lines.push(
|
|
355
|
+
lines.push(liveLeaves ? `${liveLeaves} live plans${closedPart}: ${counts}` : `0 live plans${closedPart}`);
|
|
356
356
|
const show = plans.filter(p => (p.status === 'in-session' || p.status === 'active') && !isHub(p));
|
|
357
357
|
for (const p of show) {
|
|
358
358
|
const next = p.nextStep ? `next: ${p.nextStep}` : '(no next step)';
|
package/src/runlist.mjs
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { extractFirstHeading } from './extractors.mjs';
|
|
4
5
|
import {
|
|
5
6
|
asString,
|
|
6
7
|
die,
|
|
8
|
+
escapeRegex,
|
|
7
9
|
isArchivedPath,
|
|
8
10
|
normalizeStringList,
|
|
11
|
+
nowIso,
|
|
9
12
|
resolveRefPath,
|
|
10
13
|
toRepoPath,
|
|
11
14
|
toSlug,
|
|
15
|
+
warn,
|
|
12
16
|
} from './util.mjs';
|
|
13
17
|
import { resolveDocArg } from './index.mjs';
|
|
18
|
+
import { runlistChildContent, slugify, titleize } from './new.mjs';
|
|
14
19
|
import { bold, cyan, dim, green, red, yellow } from './color.mjs';
|
|
15
20
|
|
|
16
21
|
const PICKUPABLE_STATUSES = new Set(['active', 'planned', 'in-session']);
|
|
@@ -164,13 +169,16 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
|
|
|
164
169
|
const repoPath = toRepoPath(abs, config.repoRoot);
|
|
165
170
|
try {
|
|
166
171
|
const childRaw = readFileSync(abs, 'utf8');
|
|
167
|
-
const { frontmatter: childFmRaw } = extractFrontmatter(childRaw);
|
|
172
|
+
const { frontmatter: childFmRaw, body: childBody } = extractFrontmatter(childRaw);
|
|
168
173
|
const childFm = parseSimpleFrontmatter(childFmRaw);
|
|
169
174
|
out.push({
|
|
170
175
|
ref,
|
|
171
176
|
path: repoPath,
|
|
172
177
|
status: asString(childFm.status) ?? null,
|
|
173
|
-
|
|
178
|
+
// Fall back to the body H1 (like the main index) before the bare
|
|
179
|
+
// filename — runlist child stubs carry their title as an H1, not a
|
|
180
|
+
// `title:` field, so this keeps the synced body order list readable.
|
|
181
|
+
title: asString(childFm.title) ?? extractFirstHeading(childBody) ?? path.basename(abs, '.md'),
|
|
174
182
|
parentPlan: childFm.parent_plan ?? null,
|
|
175
183
|
missing: false,
|
|
176
184
|
});
|
|
@@ -335,11 +343,450 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
|
|
|
335
343
|
return lines.join('\n') + '\n';
|
|
336
344
|
}
|
|
337
345
|
|
|
346
|
+
// --- `runlist add` mutation (Phase 1) -------------------------------------
|
|
347
|
+
|
|
348
|
+
// Replace a top-level frontmatter field (its `key:` line + any indented
|
|
349
|
+
// continuation block) with `serialized`, or append it when absent. The regex
|
|
350
|
+
// mirrors what `mergeBodyFrontmatter` uses so the rewritten field keeps the
|
|
351
|
+
// scaffold's shape. Shared by the block-array (`runlist:`) and scalar
|
|
352
|
+
// (`parent_plan:`, `updated:`) writers below.
|
|
353
|
+
function upsertFrontmatterField(fm, key, serialized) {
|
|
354
|
+
const re = new RegExp(`^${escapeRegex(key)}:.*(\\n[ \\t]+.*)*`, 'm');
|
|
355
|
+
if (re.test(fm)) return fm.replace(re, serialized);
|
|
356
|
+
return fm.replace(/\s*$/, '') + '\n' + serialized;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function serializeBlockArray(key, items) {
|
|
360
|
+
if (items.length === 0) return `${key}:`;
|
|
361
|
+
return `${key}:\n${items.map(v => ` - ${v}`).join('\n')}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Hub-relative ref for a child path: a bare basename when the child sits in the
|
|
365
|
+
// hub's directory (the common case, matching how `--runlist` writes refs), else
|
|
366
|
+
// a relative path (forward slashes) so the ref resolves from the hub. This is
|
|
367
|
+
// the hub-relative resolution that lets an existing plan elsewhere be wired in
|
|
368
|
+
// (the thrice-parked "point a hub at an existing plan" carryover).
|
|
369
|
+
function hubRelativeRef(childAbs, hubDir) {
|
|
370
|
+
const rel = path.relative(hubDir, childAbs).split(path.sep).join('/');
|
|
371
|
+
return rel;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Resolve one `runlist add` child token against an existing hub. Returns one of:
|
|
375
|
+
// { kind: 'existing', abs, repoPath, ref } — token names a plan that exists
|
|
376
|
+
// { kind: 'scaffold', abs, repoPath, ref, slug, title } — bare slug to create
|
|
377
|
+
// Dies on an unresolvable path (a `/`-bearing token that points nowhere — we
|
|
378
|
+
// scaffold from bare slugs only, never invent a nested path).
|
|
379
|
+
function classifyChildToken(token, hubDir, hubSlug, pos, config) {
|
|
380
|
+
// Existing plan? Try hub-relative first (Phase 3 resolution), then the shared
|
|
381
|
+
// resolver (repo-relative / by-basename across the index).
|
|
382
|
+
const hubRel = resolveRefPath(token, hubDir, config.repoRoot)
|
|
383
|
+
|| (token.endsWith('.md') ? null : resolveRefPath(`${token}.md`, hubDir, config.repoRoot));
|
|
384
|
+
const abs = hubRel || resolveDocArg(token, config, { dieOnMiss: false });
|
|
385
|
+
if (abs) {
|
|
386
|
+
return {
|
|
387
|
+
kind: 'existing',
|
|
388
|
+
abs,
|
|
389
|
+
repoPath: toRepoPath(abs, config.repoRoot),
|
|
390
|
+
ref: hubRelativeRef(abs, hubDir),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Not found. Scaffold a stub — but only from a bare slug. A `/`-bearing token
|
|
395
|
+
// is a path to a file that doesn't exist; refuse rather than guess.
|
|
396
|
+
if (token.includes('/') || token.includes(path.sep)) {
|
|
397
|
+
die(`No plan found at "${token}". Pass a bare slug (e.g. \`cleanup\`) to scaffold a new child, or a path to an existing plan.`);
|
|
398
|
+
}
|
|
399
|
+
const childSlug = slugify(token.replace(/\.md$/, ''));
|
|
400
|
+
if (!childSlug) die(`Child token resolves to an empty slug: "${token}"`);
|
|
401
|
+
const nn = String(pos).padStart(2, '0');
|
|
402
|
+
const file = `${hubSlug}-${nn}-${childSlug}.md`;
|
|
403
|
+
const childAbs = path.join(hubDir, file);
|
|
404
|
+
return {
|
|
405
|
+
kind: 'scaffold',
|
|
406
|
+
abs: childAbs,
|
|
407
|
+
repoPath: toRepoPath(childAbs, config.repoRoot),
|
|
408
|
+
ref: file,
|
|
409
|
+
slug: childSlug,
|
|
410
|
+
title: titleize(token.replace(/\.md$/, '')),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Set `parent_plan:` on an existing child to point back at the hub, unless it
|
|
415
|
+
// already resolves to the hub. Never clobbers a parent_plan that points
|
|
416
|
+
// elsewhere (warns instead — the child may belong to another hub). Returns
|
|
417
|
+
// true when it wrote, false when it left the file alone.
|
|
418
|
+
function setChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
|
|
419
|
+
const raw = readFileSync(childAbs, 'utf8');
|
|
420
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
421
|
+
if (fmRaw == null) return false;
|
|
422
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
423
|
+
const childDir = path.dirname(childAbs);
|
|
424
|
+
const existing = asString(fm.parent_plan);
|
|
425
|
+
if (existing) {
|
|
426
|
+
const resolved = resolveRefPath(existing, childDir, config.repoRoot);
|
|
427
|
+
if (resolved === hubAbs) return false; // already points at this hub
|
|
428
|
+
warn(`${toRepoPath(childAbs, config.repoRoot)} already has parent_plan: ${existing} — left as-is (not pointing it at the hub).`);
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
|
|
432
|
+
if (dryRun) return true;
|
|
433
|
+
let newFm = upsertFrontmatterField(fmRaw, 'parent_plan', `parent_plan: ${ref}`);
|
|
434
|
+
newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${nowIso()}`);
|
|
435
|
+
writeFileSync(childAbs, replaceFrontmatter(raw, newFm), 'utf8');
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// `dotmd runlist add <hub> <child...>` — append children to a hub's `runlist:`
|
|
440
|
+
// array, scaffolding a `planned` stub for any bare-slug child that doesn't yet
|
|
441
|
+
// exist (mirroring `dotmd new plan --runlist`) and wiring each child's
|
|
442
|
+
// `parent_plan:` back-ref. Coordination hubs (body-order, no `runlist:` array)
|
|
443
|
+
// are out of this path — guarded with an actionable message.
|
|
444
|
+
async function runRunlistAdd(positional, config, { dryRun, json }) {
|
|
445
|
+
const hubInput = positional[0];
|
|
446
|
+
const childTokens = positional.slice(1);
|
|
447
|
+
if (!hubInput || childTokens.length === 0) {
|
|
448
|
+
die('Usage: dotmd runlist add <hub-plan> <child...> (one or more child slugs or plan paths)');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const hubAbs = resolveHubInput(hubInput, config);
|
|
452
|
+
if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
|
|
453
|
+
const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
|
|
454
|
+
const hubDir = path.dirname(hubAbs);
|
|
455
|
+
const hubSlug = path.basename(hubAbs, '.md');
|
|
456
|
+
|
|
457
|
+
const hubRaw = readFileSync(hubAbs, 'utf8');
|
|
458
|
+
const { frontmatter: hubFmRaw } = extractFrontmatter(hubRaw);
|
|
459
|
+
if (hubFmRaw == null) die(`Hub ${hubRepoPath} has no frontmatter.`);
|
|
460
|
+
const hubFm = parseSimpleFrontmatter(hubFmRaw);
|
|
461
|
+
const existingRefs = normalizeStringList(hubFm.runlist);
|
|
462
|
+
const isCoord = hubFm.execution_mode === 'coordination';
|
|
463
|
+
|
|
464
|
+
// Coordination hubs keep their order in the body (`## Ranked queue` /
|
|
465
|
+
// `## Order of operations`), not a `runlist:` array. Mutating that prose-first
|
|
466
|
+
// order is a separate path; for now point the user at it rather than writing a
|
|
467
|
+
// `runlist:` array onto a hub that deliberately doesn't use one.
|
|
468
|
+
if (isCoord && existingRefs.length === 0) {
|
|
469
|
+
die(
|
|
470
|
+
`${hubRepoPath} is a coordination hub (execution_mode: coordination) — it keeps its order in the body\n` +
|
|
471
|
+
`(\`## Ranked queue\` table or \`## Order of operations\` list), not a \`runlist:\` array.\n` +
|
|
472
|
+
`Add the plan as a ranked row/link there. \`runlist add\` manages sprint \`runlist:\` arrays.`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Resolve every token up front (so a bad token aborts before any write), then
|
|
477
|
+
// dedupe against refs already in the runlist (by resolved abs path).
|
|
478
|
+
const resolvedExisting = new Set();
|
|
479
|
+
for (const ref of existingRefs) {
|
|
480
|
+
const abs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
481
|
+
if (abs) resolvedExisting.add(abs);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const toAdd = [];
|
|
485
|
+
let pos = existingRefs.length;
|
|
486
|
+
for (const token of childTokens) {
|
|
487
|
+
pos += 1;
|
|
488
|
+
const c = classifyChildToken(token, hubDir, hubSlug, pos, config);
|
|
489
|
+
if (c.abs === hubAbs) { warn(`Skipping "${token}" — a hub can't list itself.`); pos -= 1; continue; }
|
|
490
|
+
if (resolvedExisting.has(c.abs)) { warn(`Skipping "${token}" — already in the runlist (${c.repoPath}).`); pos -= 1; continue; }
|
|
491
|
+
resolvedExisting.add(c.abs);
|
|
492
|
+
toAdd.push(c);
|
|
493
|
+
}
|
|
494
|
+
if (toAdd.length === 0) die('Nothing to add — all children were already in the runlist or skipped.');
|
|
495
|
+
|
|
496
|
+
const hubTitle = asString(hubFm.title) ?? titleize(hubSlug);
|
|
497
|
+
const today = nowIso();
|
|
498
|
+
const newRefs = [...existingRefs, ...toAdd.map(c => c.ref)];
|
|
499
|
+
|
|
500
|
+
if (json) {
|
|
501
|
+
process.stdout.write(JSON.stringify({
|
|
502
|
+
hub: hubRepoPath,
|
|
503
|
+
added: toAdd.map(c => ({ ref: c.ref, path: c.repoPath, scaffolded: c.kind === 'scaffold' })),
|
|
504
|
+
runlist: newRefs,
|
|
505
|
+
dryRun: !!dryRun,
|
|
506
|
+
}, null, 2) + '\n');
|
|
507
|
+
if (dryRun) return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const prefix = dryRun ? `${dim('[dry-run]')} ` : '';
|
|
511
|
+
if (!json) process.stdout.write(bold(`${prefix}runlist add → ${hubRepoPath}`) + '\n');
|
|
512
|
+
|
|
513
|
+
for (const c of toAdd) {
|
|
514
|
+
if (c.kind === 'scaffold') {
|
|
515
|
+
if (!dryRun) {
|
|
516
|
+
if (existsSync(c.abs)) {
|
|
517
|
+
warn(`Child already exists, left as-is: ${c.repoPath}`);
|
|
518
|
+
} else {
|
|
519
|
+
writeFileSync(c.abs, runlistChildContent(c.title, hubSlug, hubTitle, 'planned', today), 'utf8');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim('(scaffolded · planned)')}\n`);
|
|
523
|
+
} else {
|
|
524
|
+
const wrote = setChildParentPlan(c.abs, hubAbs, config, { dryRun });
|
|
525
|
+
const note = wrote ? 'existing · parent_plan set' : 'existing';
|
|
526
|
+
if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim(`(${note})`)}\n`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Write the hub's `runlist:` array (+ bump `updated:`, + sync the body order
|
|
531
|
+
// list). The child stubs are already on disk, so title resolution works.
|
|
532
|
+
if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
|
|
533
|
+
|
|
534
|
+
if (!json) {
|
|
535
|
+
process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
|
|
536
|
+
if (!dryRun) {
|
|
537
|
+
process.stdout.write(dim(` Show: dotmd runlist ${hubRepoPath} · pick up next: dotmd runlist next ${hubRepoPath}`) + '\n');
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// --- `runlist remove` / `runlist reorder` mutation (Phase 2) ---------------
|
|
543
|
+
|
|
544
|
+
// Heading alternatives that carry a `## Order of operations`-style link list
|
|
545
|
+
// mirroring the `runlist:` array (the `new plan --runlist` scaffold writes one).
|
|
546
|
+
const ORDER_SECTION_RE = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/im;
|
|
547
|
+
|
|
548
|
+
// Keep a hub's body `## Order of operations` link list in sync with the
|
|
549
|
+
// authoritative `runlist:` array. Regenerates the numbered link block from
|
|
550
|
+
// `orderedRefs`, preserving each item's display title and trailing status
|
|
551
|
+
// marker (⬜/✅/…) for children that remain — so a hand-checked-off item keeps
|
|
552
|
+
// its mark across an add/remove/reorder. No such section → body untouched (we
|
|
553
|
+
// never invent one). Only the contiguous run of list-item lines is rewritten;
|
|
554
|
+
// surrounding prose (e.g. the "pick up the next child" note) is preserved.
|
|
555
|
+
function syncOrderList(body, orderedRefs, titleFor) {
|
|
556
|
+
const m = body.match(ORDER_SECTION_RE);
|
|
557
|
+
if (!m || m.index === undefined) return body;
|
|
558
|
+
const headingEnd = m.index + m[0].length;
|
|
559
|
+
const rest = body.slice(headingEnd);
|
|
560
|
+
const nextRel = rest.search(/^##\s+/m);
|
|
561
|
+
const sectionEnd = nextRel >= 0 ? headingEnd + nextRel : body.length;
|
|
562
|
+
|
|
563
|
+
const lines = body.slice(headingEnd, sectionEnd).split('\n');
|
|
564
|
+
const isItem = (l) => /^\s*(?:\d+\.|[-*])\s+/.test(l) && /\.md(?:[#)\s]|$)/.test(l);
|
|
565
|
+
let start = -1, end = -1;
|
|
566
|
+
for (let i = 0; i < lines.length; i++) {
|
|
567
|
+
if (isItem(lines[i])) { if (start === -1) start = i; end = i; }
|
|
568
|
+
else if (start !== -1) break; // first non-item after the block ends it
|
|
569
|
+
}
|
|
570
|
+
if (start === -1) return body; // section present but no list to sync
|
|
571
|
+
|
|
572
|
+
const linkRe = /\[([^\]]+)\]\(([^)]+\.md)(?:#[^)]*)?\)/;
|
|
573
|
+
const prior = new Map(); // basename → { title, marker }
|
|
574
|
+
for (let i = start; i <= end; i++) {
|
|
575
|
+
const lk = linkRe.exec(lines[i]);
|
|
576
|
+
if (lk) {
|
|
577
|
+
prior.set(lk[2].split('/').pop(), { title: lk[1], marker: lines[i].slice(lk.index + lk[0].length).trim() });
|
|
578
|
+
} else {
|
|
579
|
+
const bare = /(\S+\.md)/.exec(lines[i]);
|
|
580
|
+
if (bare) prior.set(bare[1].split('/').pop(), { title: null, marker: lines[i].slice(bare.index + bare[0].length).trim() });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const rebuilt = orderedRefs.map((ref, i) => {
|
|
585
|
+
const prev = prior.get(ref.split('/').pop());
|
|
586
|
+
const title = prev?.title ?? titleFor(ref);
|
|
587
|
+
const marker = prev?.marker ? ` ${prev.marker}` : ' ⬜';
|
|
588
|
+
return `${i + 1}. [${title}](${ref})${marker}`;
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const newLines = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end + 1)];
|
|
592
|
+
return body.slice(0, headingEnd) + newLines.join('\n') + body.slice(sectionEnd);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Authoritative hub write: set `runlist:` to `newRefs`, bump `updated:`, and
|
|
596
|
+
// resync the body order list. Shared by add/remove/reorder so all three keep
|
|
597
|
+
// the frontmatter array and the body link list consistent.
|
|
598
|
+
function writeHubRunlist(hubAbs, newRefs, config, today) {
|
|
599
|
+
const raw = readFileSync(hubAbs, 'utf8');
|
|
600
|
+
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
|
601
|
+
let newFm = upsertFrontmatterField(fmRaw, 'runlist', serializeBlockArray('runlist', newRefs));
|
|
602
|
+
newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${today}`);
|
|
603
|
+
|
|
604
|
+
const titles = new Map();
|
|
605
|
+
for (const r of resolveRunlistRefs(newRefs, hubAbs, config)) {
|
|
606
|
+
if (r.title) titles.set(r.ref.split('/').pop(), r.title);
|
|
607
|
+
}
|
|
608
|
+
const titleFor = (ref) => titles.get(ref.split('/').pop()) ?? titleize(path.basename(ref, '.md'));
|
|
609
|
+
const newBody = syncOrderList(body, newRefs, titleFor);
|
|
610
|
+
|
|
611
|
+
writeFileSync(hubAbs, `---\n${newFm}\n---\n${newBody}`, 'utf8');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Match a child token to one of the hub's existing runlist refs. Resolves
|
|
615
|
+
// hub-relative (then by slug/basename across the index), then compares against
|
|
616
|
+
// each ref's resolved abs path, with a basename fallback for refs that don't
|
|
617
|
+
// resolve to a file. Returns the matched ref string, or null.
|
|
618
|
+
function findRefForToken(token, existingRefs, hubDir, config) {
|
|
619
|
+
const tokenAbs = resolveRefPath(token, hubDir, config.repoRoot)
|
|
620
|
+
|| (token.endsWith('.md') ? null : resolveRefPath(`${token}.md`, hubDir, config.repoRoot))
|
|
621
|
+
|| resolveDocArg(token, config, { dieOnMiss: false });
|
|
622
|
+
const tokBase = token.endsWith('.md') ? token.split('/').pop() : `${token.split('/').pop()}.md`;
|
|
623
|
+
// Exact: resolved-path match or basename match.
|
|
624
|
+
for (const ref of existingRefs) {
|
|
625
|
+
const refAbs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
626
|
+
if (tokenAbs && refAbs && refAbs === tokenAbs) return ref;
|
|
627
|
+
if (ref.split('/').pop() === tokBase) return ref;
|
|
628
|
+
}
|
|
629
|
+
// Convenience: the short slug a sprint child was scaffolded from — e.g.
|
|
630
|
+
// `cleanup` matches `auth-revamp-03-cleanup.md`. Unique-or-bust so an
|
|
631
|
+
// ambiguous slug never silently picks the wrong child.
|
|
632
|
+
const slug = tokBase.replace(/\.md$/, '');
|
|
633
|
+
const suffixMatches = existingRefs.filter(ref => {
|
|
634
|
+
const base = ref.split('/').pop().replace(/\.md$/, '');
|
|
635
|
+
return base === slug || base.endsWith(`-${slug}`);
|
|
636
|
+
});
|
|
637
|
+
if (suffixMatches.length === 1) return suffixMatches[0];
|
|
638
|
+
if (suffixMatches.length > 1) {
|
|
639
|
+
die(`"${token}" matches multiple children: ${suffixMatches.join(', ')}. Use the full filename.`);
|
|
640
|
+
}
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Clear a removed child's `parent_plan:` when it points back at this hub (so the
|
|
645
|
+
// reverse link doesn't dangle). Leaves a parent_plan pointing elsewhere alone.
|
|
646
|
+
function clearChildParentPlan(childAbs, hubAbs, config, { dryRun }) {
|
|
647
|
+
let raw;
|
|
648
|
+
try { raw = readFileSync(childAbs, 'utf8'); } catch { return false; }
|
|
649
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
650
|
+
if (fmRaw == null) return false;
|
|
651
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
652
|
+
const existing = asString(fm.parent_plan);
|
|
653
|
+
if (!existing) return false;
|
|
654
|
+
if (resolveRefPath(existing, path.dirname(childAbs), config.repoRoot) !== hubAbs) return false;
|
|
655
|
+
if (dryRun) return true;
|
|
656
|
+
let newFm = upsertFrontmatterField(fmRaw, 'parent_plan', 'parent_plan:');
|
|
657
|
+
newFm = upsertFrontmatterField(newFm, 'updated', `updated: ${nowIso()}`);
|
|
658
|
+
writeFileSync(childAbs, replaceFrontmatter(raw, newFm), 'utf8');
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Shared front half of remove/reorder: resolve the hub, read its `runlist:`,
|
|
663
|
+
// die if it isn't a sprint hub with an array to mutate.
|
|
664
|
+
function loadSprintHub(hubInput, verb, config) {
|
|
665
|
+
if (!hubInput) die(`Usage: dotmd runlist ${verb} <hub-plan> <child...>`);
|
|
666
|
+
const hubAbs = resolveHubInput(hubInput, config);
|
|
667
|
+
if (!hubAbs) die(`Hub plan not found: ${hubInput}`);
|
|
668
|
+
const hubRepoPath = toRepoPath(hubAbs, config.repoRoot);
|
|
669
|
+
const hubDir = path.dirname(hubAbs);
|
|
670
|
+
const raw = readFileSync(hubAbs, 'utf8');
|
|
671
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
672
|
+
const fm = fmRaw == null ? {} : parseSimpleFrontmatter(fmRaw);
|
|
673
|
+
const existingRefs = normalizeStringList(fm.runlist);
|
|
674
|
+
if (existingRefs.length === 0) {
|
|
675
|
+
die(`${hubRepoPath} has no \`runlist:\` array to ${verb} from.` +
|
|
676
|
+
(fm.execution_mode === 'coordination' ? ' (It is a coordination hub — order lives in the body.)' : ''));
|
|
677
|
+
}
|
|
678
|
+
return { hubAbs, hubRepoPath, hubDir, existingRefs };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function runRunlistRemove(positional, config, { dryRun, json, clearParent }) {
|
|
682
|
+
const { hubAbs, hubRepoPath, hubDir, existingRefs } = loadSprintHub(positional[0], 'remove', config);
|
|
683
|
+
const childTokens = positional.slice(1);
|
|
684
|
+
if (childTokens.length === 0) die('Usage: dotmd runlist remove <hub-plan> <child...>');
|
|
685
|
+
|
|
686
|
+
const removeRefs = [];
|
|
687
|
+
for (const token of childTokens) {
|
|
688
|
+
const ref = findRefForToken(token, existingRefs, hubDir, config);
|
|
689
|
+
if (!ref) die(`"${token}" is not in the runlist of ${hubRepoPath}.`);
|
|
690
|
+
if (!removeRefs.includes(ref)) removeRefs.push(ref);
|
|
691
|
+
}
|
|
692
|
+
const newRefs = existingRefs.filter(r => !removeRefs.includes(r));
|
|
693
|
+
const today = nowIso();
|
|
694
|
+
|
|
695
|
+
if (json) {
|
|
696
|
+
process.stdout.write(JSON.stringify({ hub: hubRepoPath, removed: removeRefs, runlist: newRefs, clearedParent: !!clearParent, dryRun: !!dryRun }, null, 2) + '\n');
|
|
697
|
+
} else {
|
|
698
|
+
process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist remove → ${hubRepoPath}`) + '\n');
|
|
699
|
+
for (const ref of removeRefs) process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${red('-')} ${ref}\n`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
|
|
703
|
+
if (clearParent) {
|
|
704
|
+
for (const ref of removeRefs) {
|
|
705
|
+
const abs = resolveRefPath(ref, hubDir, config.repoRoot);
|
|
706
|
+
if (abs && clearChildParentPlan(abs, hubAbs, config, { dryRun }) && !json) {
|
|
707
|
+
process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${dim(`cleared parent_plan on ${toRepoPath(abs, config.repoRoot)}`)}\n`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (!json) process.stdout.write(dim(` runlist now has ${newRefs.length} ${newRefs.length === 1 ? 'child' : 'children'}.`) + '\n');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Parse `reorder` argv: skip the subcommand + flags, capture `--before`/`--after`
|
|
715
|
+
// values (their operands would otherwise leak into the child list).
|
|
716
|
+
function parseReorderArgs(argv) {
|
|
717
|
+
const pos = [];
|
|
718
|
+
let before = null, after = null;
|
|
719
|
+
for (let i = 0; i < argv.length; i++) {
|
|
720
|
+
const a = argv[i];
|
|
721
|
+
if (a === 'reorder') continue;
|
|
722
|
+
if (a === '--before') { before = argv[++i] ?? null; continue; }
|
|
723
|
+
if (a === '--after') { after = argv[++i] ?? null; continue; }
|
|
724
|
+
if (a.startsWith('-')) continue;
|
|
725
|
+
pos.push(a);
|
|
726
|
+
}
|
|
727
|
+
return { hubInput: pos[0], children: pos.slice(1), before, after };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function runRunlistReorder(argv, config, { dryRun, json }) {
|
|
731
|
+
const { hubInput, children, before, after } = parseReorderArgs(argv);
|
|
732
|
+
if (!hubInput || children.length === 0) {
|
|
733
|
+
die('Usage: dotmd runlist reorder <hub> <child> --before|--after <other>\n or: dotmd runlist reorder <hub> <child1> <child2> ... (full new order)');
|
|
734
|
+
}
|
|
735
|
+
const { hubAbs, hubRepoPath, hubDir, existingRefs } = loadSprintHub(hubInput, 'reorder', config);
|
|
736
|
+
|
|
737
|
+
let newRefs;
|
|
738
|
+
if (before || after) {
|
|
739
|
+
if (children.length !== 1) die('--before/--after move exactly one child. Pass a single child, or list every child for a full reorder.');
|
|
740
|
+
const moving = findRefForToken(children[0], existingRefs, hubDir, config);
|
|
741
|
+
if (!moving) die(`"${children[0]}" is not in the runlist of ${hubRepoPath}.`);
|
|
742
|
+
const anchorTok = before || after;
|
|
743
|
+
const anchor = findRefForToken(anchorTok, existingRefs, hubDir, config);
|
|
744
|
+
if (!anchor) die(`"${anchorTok}" is not in the runlist of ${hubRepoPath}.`);
|
|
745
|
+
if (moving === anchor) die('A child cannot be moved relative to itself.');
|
|
746
|
+
const without = existingRefs.filter(r => r !== moving);
|
|
747
|
+
const idx = without.indexOf(anchor);
|
|
748
|
+
const insertAt = before ? idx : idx + 1;
|
|
749
|
+
newRefs = [...without.slice(0, insertAt), moving, ...without.slice(insertAt)];
|
|
750
|
+
} else {
|
|
751
|
+
if (children.length !== existingRefs.length) {
|
|
752
|
+
die(`Full reorder needs all ${existingRefs.length} children in the new order; got ${children.length}. Use --before/--after to move just one.`);
|
|
753
|
+
}
|
|
754
|
+
newRefs = children.map(tok => {
|
|
755
|
+
const ref = findRefForToken(tok, existingRefs, hubDir, config);
|
|
756
|
+
if (!ref) die(`"${tok}" is not in the runlist of ${hubRepoPath}.`);
|
|
757
|
+
return ref;
|
|
758
|
+
});
|
|
759
|
+
if (new Set(newRefs).size !== newRefs.length) die('The new order repeats a child — list each exactly once.');
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (newRefs.join('\n') === existingRefs.join('\n')) die('New order matches the current order — nothing to do.');
|
|
763
|
+
const today = nowIso();
|
|
764
|
+
|
|
765
|
+
if (json) {
|
|
766
|
+
process.stdout.write(JSON.stringify({ hub: hubRepoPath, runlist: newRefs, dryRun: !!dryRun }, null, 2) + '\n');
|
|
767
|
+
} else {
|
|
768
|
+
process.stdout.write(bold(`${dryRun ? dim('[dry-run]') + ' ' : ''}runlist reorder → ${hubRepoPath}`) + '\n');
|
|
769
|
+
newRefs.forEach((ref, i) => process.stdout.write(`${dryRun ? dim('[dry-run]') + ' ' : ''} ${String(i + 1).padStart(2)}. ${ref}\n`));
|
|
770
|
+
}
|
|
771
|
+
if (!dryRun) writeHubRunlist(hubAbs, newRefs, config, today);
|
|
772
|
+
}
|
|
773
|
+
|
|
338
774
|
export async function runRunlist(argv, config, opts = {}) {
|
|
339
775
|
const json = argv.includes('--json');
|
|
340
776
|
const positional = argv.filter(a => !a.startsWith('-'));
|
|
341
777
|
|
|
342
|
-
// Subcommand dispatch:
|
|
778
|
+
// Subcommand dispatch: mutators (`add`/`remove`/`reorder`) vs `next` (pickup)
|
|
779
|
+
// vs `show` (default).
|
|
780
|
+
if (positional[0] === 'add') {
|
|
781
|
+
return runRunlistAdd(positional.slice(1), config, { dryRun: opts.dryRun, json });
|
|
782
|
+
}
|
|
783
|
+
if (positional[0] === 'remove') {
|
|
784
|
+
return runRunlistRemove(positional.slice(1), config, { dryRun: opts.dryRun, json, clearParent: argv.includes('--clear-parent') });
|
|
785
|
+
}
|
|
786
|
+
if (positional[0] === 'reorder') {
|
|
787
|
+
return runRunlistReorder(argv, config, { dryRun: opts.dryRun, json });
|
|
788
|
+
}
|
|
789
|
+
|
|
343
790
|
const sub = positional[0] === 'next' ? 'next' : 'show';
|
|
344
791
|
const hubInput = sub === 'next' ? positional[1] : positional[0];
|
|
345
792
|
|