runlist 0.0.0 → 0.94.1
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/LICENSE +21 -0
- package/README.md +1114 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +292 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +794 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
rmdirSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { processOwnerLiveness } from './atomic-mutation.mjs';
|
|
14
|
+
import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
|
|
15
|
+
|
|
16
|
+
export { stateDir } from './naming.mjs';
|
|
17
|
+
|
|
18
|
+
const MIGRATION_MARKER = 'migrated-from-dotmd.json';
|
|
19
|
+
const DROP_ENTRIES = new Set(['handoffs']);
|
|
20
|
+
const TERMINAL_TRANSACTION_STATUSES = new Set(['committed', 'rolled-back', 'failed-manual']);
|
|
21
|
+
|
|
22
|
+
function heldLocks(lockRoot) {
|
|
23
|
+
if (!existsSync(lockRoot)) return [];
|
|
24
|
+
let entries;
|
|
25
|
+
try { entries = readdirSync(lockRoot); }
|
|
26
|
+
catch { return [{ lock: path.basename(lockRoot), status: 'unreadable-directory' }]; }
|
|
27
|
+
const held = [];
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const ownerPath = path.join(lockRoot, entry, 'owner.json');
|
|
30
|
+
if (!existsSync(ownerPath)) {
|
|
31
|
+
held.push({ lock: entry, status: 'owner-missing' });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
let owner;
|
|
35
|
+
try { owner = JSON.parse(readFileSync(ownerPath, 'utf8')); }
|
|
36
|
+
catch {
|
|
37
|
+
held.push({ lock: entry, status: 'owner-unreadable' });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (processOwnerLiveness(owner) !== 'dead') {
|
|
41
|
+
held.push({ lock: entry, status: 'held-or-unverifiable', pid: owner.pid, hostname: owner.hostname });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return held;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unresolvedTransactions(transactionRoot) {
|
|
48
|
+
if (!existsSync(transactionRoot)) return [];
|
|
49
|
+
let entries;
|
|
50
|
+
try { entries = readdirSync(transactionRoot); }
|
|
51
|
+
catch { return [{ id: path.basename(transactionRoot), status: 'unreadable-directory' }]; }
|
|
52
|
+
const open = [];
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const manifestPath = path.join(transactionRoot, entry, 'manifest.json');
|
|
55
|
+
if (!existsSync(manifestPath)) {
|
|
56
|
+
open.push({ id: entry, status: 'manifest-missing' });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let manifest;
|
|
60
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); }
|
|
61
|
+
catch { open.push({ id: entry, status: 'unreadable' }); continue; }
|
|
62
|
+
if (!TERMINAL_TRANSACTION_STATUSES.has(manifest.status)) {
|
|
63
|
+
open.push({ id: entry, status: manifest.status ?? 'unknown' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return open;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function moveEntry(from, to) {
|
|
70
|
+
if (existsSync(to)) {
|
|
71
|
+
if (statSync(to).isDirectory() && statSync(from).isDirectory()) {
|
|
72
|
+
for (const child of readdirSync(from)) moveEntry(path.join(from, child), path.join(to, child));
|
|
73
|
+
rmSync(from, { recursive: true, force: true });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Current state wins an exact-name collision. The legacy copy is stale by
|
|
77
|
+
// definition and retaining both would make the next migration ambiguous.
|
|
78
|
+
rmSync(from, { recursive: true, force: true });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
renameSync(from, to);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function migrateStateDirectory(repoRoot, options = {}) {
|
|
85
|
+
const root = path.resolve(repoRoot);
|
|
86
|
+
const from = path.join(root, LEGACY_STATE_DIR);
|
|
87
|
+
const to = path.join(root, STATE_DIR);
|
|
88
|
+
if (!existsSync(from)) return { status: 'noop', reason: 'nothing-to-migrate', from, to };
|
|
89
|
+
|
|
90
|
+
const held = heldLocks(path.join(from, 'locks'));
|
|
91
|
+
if (held.length) {
|
|
92
|
+
return {
|
|
93
|
+
status: 'refused', reason: 'locks-held', detail: held, from, to,
|
|
94
|
+
message: `Legacy state has ${held.length} lock${held.length === 1 ? '' : 's'} that ${held.length === 1 ? 'is' : 'are'} not provably abandoned. State migration refused; retry after active work finishes or inspect with \`runlist doctor --transactions\`.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const open = unresolvedTransactions(path.join(from, 'transactions'));
|
|
99
|
+
if (open.length) {
|
|
100
|
+
return {
|
|
101
|
+
status: 'refused', reason: 'transactions-unresolved', detail: open, from, to,
|
|
102
|
+
message: `${open.length} transaction${open.length === 1 ? ' is' : 's are'} not terminal (${open.map(item => `${item.id}: ${item.status}`).join(', ')}). State migration refused. Resolve with \`runlist doctor --transactions --apply\`, then retry.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (options.dryRun) return { status: 'noop', reason: 'dry-run', from, to };
|
|
107
|
+
|
|
108
|
+
mkdirSync(to, { recursive: true });
|
|
109
|
+
for (const entry of readdirSync(from)) {
|
|
110
|
+
if (DROP_ENTRIES.has(entry)) {
|
|
111
|
+
rmSync(path.join(from, entry), { recursive: true, force: true });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
moveEntry(path.join(from, entry), path.join(to, entry));
|
|
115
|
+
}
|
|
116
|
+
try { if (readdirSync(from).length === 0) rmdirSync(from); } catch {}
|
|
117
|
+
|
|
118
|
+
writeFileSync(
|
|
119
|
+
path.join(to, MIGRATION_MARKER),
|
|
120
|
+
JSON.stringify({ from: LEGACY_STATE_DIR, to: STATE_DIR, migratedAt: new Date().toISOString() }, null, 2) + '\n',
|
|
121
|
+
);
|
|
122
|
+
return { status: 'migrated', from, to };
|
|
123
|
+
}
|
package/src/stats.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { capitalize, toSlug, warn } from './util.mjs';
|
|
2
|
+
import { bold, dim } from './color.mjs';
|
|
3
|
+
|
|
4
|
+
function pct(n, total) {
|
|
5
|
+
if (!total) return 0;
|
|
6
|
+
return Math.round((n / total) * 100);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildStats(index, config) {
|
|
10
|
+
const docs = index.docs;
|
|
11
|
+
const scope = config.statusOrder.filter(s => !config.lifecycle.terminalStatuses.has(s));
|
|
12
|
+
for (const typeSet of (config.typeStatuses?.values() ?? [])) {
|
|
13
|
+
for (const s of typeSet) {
|
|
14
|
+
if (!config.lifecycle.terminalStatuses.has(s) && !scope.includes(s)) scope.push(s);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const scoped = docs.filter(d => scope.includes(d.status));
|
|
18
|
+
const nonArchived = docs.filter(d => !config.lifecycle.skipsWarnings(d.status, d.type));
|
|
19
|
+
|
|
20
|
+
// Health
|
|
21
|
+
const staleCount = nonArchived.filter(d => d.isStale).length;
|
|
22
|
+
const brokenRefCount = index.errors.filter(e => e.message.includes('does not resolve to an existing file')).length;
|
|
23
|
+
const brokenLinkCount = index.warnings.filter(w => w.message.startsWith('body link')).length;
|
|
24
|
+
|
|
25
|
+
// Freshness
|
|
26
|
+
const withDays = nonArchived.filter(d => d.daysSinceUpdate != null);
|
|
27
|
+
const sorted = [...withDays].sort((a, b) => (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0));
|
|
28
|
+
const oldest = sorted[0] ?? null;
|
|
29
|
+
|
|
30
|
+
// Checklists
|
|
31
|
+
const withChecklists = docs.filter(d => d.checklist?.total > 0);
|
|
32
|
+
const avgCompletion = withChecklists.length > 0
|
|
33
|
+
? Math.round(withChecklists.reduce((sum, d) => sum + (d.checklistCompletionRate ?? 0), 0) / withChecklists.length * 100)
|
|
34
|
+
: 0;
|
|
35
|
+
const fullyComplete = withChecklists.filter(d => d.checklistCompletionRate === 1).length;
|
|
36
|
+
const withOpenItems = withChecklists.filter(d => d.checklist.open > 0).length;
|
|
37
|
+
|
|
38
|
+
// Audit
|
|
39
|
+
const auditCounts = { pass1: 0, pass2: 0, deep: 0 };
|
|
40
|
+
for (const d of scoped) {
|
|
41
|
+
if (auditCounts[d.auditLevel] !== undefined) auditCounts[d.auditLevel]++;
|
|
42
|
+
}
|
|
43
|
+
const auditedCount = auditCounts.pass1 + auditCounts.pass2 + auditCounts.deep;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
generatedAt: new Date().toISOString(),
|
|
47
|
+
totalDocs: docs.length,
|
|
48
|
+
countsByStatus: index.countsByStatus,
|
|
49
|
+
countsByType: index.countsByType ?? {},
|
|
50
|
+
health: {
|
|
51
|
+
staleCount,
|
|
52
|
+
stalePct: pct(staleCount, nonArchived.length),
|
|
53
|
+
nonArchivedCount: nonArchived.length,
|
|
54
|
+
errorCount: index.errors.length,
|
|
55
|
+
warningCount: index.warnings.length,
|
|
56
|
+
brokenRefCount,
|
|
57
|
+
brokenLinkCount,
|
|
58
|
+
},
|
|
59
|
+
freshness: {
|
|
60
|
+
today: docs.filter(d => d.daysSinceUpdate === 0).length,
|
|
61
|
+
thisWeek: docs.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= 7).length,
|
|
62
|
+
thisMonth: docs.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= 30).length,
|
|
63
|
+
oldest: oldest ? { path: oldest.path, slug: toSlug(oldest), daysSinceUpdate: oldest.daysSinceUpdate } : null,
|
|
64
|
+
},
|
|
65
|
+
completeness: {
|
|
66
|
+
scoped: scoped.length,
|
|
67
|
+
hasOwner: scoped.filter(d => d.owner).length,
|
|
68
|
+
hasSurface: scoped.filter(d => d.surfaces?.length).length,
|
|
69
|
+
hasModule: scoped.filter(d => d.modules?.length).length,
|
|
70
|
+
hasNextStep: scoped.filter(d => d.hasNextStep).length,
|
|
71
|
+
},
|
|
72
|
+
checklists: {
|
|
73
|
+
docsWithChecklists: withChecklists.length,
|
|
74
|
+
avgCompletion,
|
|
75
|
+
fullyComplete,
|
|
76
|
+
withOpenItems,
|
|
77
|
+
},
|
|
78
|
+
audit: {
|
|
79
|
+
audited: auditedCount,
|
|
80
|
+
...auditCounts,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Text renderer ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export function renderStats(stats, config) {
|
|
88
|
+
const defaultRenderer = (s) => _renderStats(s, config);
|
|
89
|
+
if (!config._execution?.suppressSideEffects && config.hooks.renderStats) {
|
|
90
|
+
try { return config.hooks.renderStats(stats, defaultRenderer); }
|
|
91
|
+
catch (err) { warn(`Hook 'renderStats' threw: ${err.message}`); }
|
|
92
|
+
}
|
|
93
|
+
return defaultRenderer(stats);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _renderStats(stats, config) {
|
|
97
|
+
const lines = [];
|
|
98
|
+
lines.push(bold(`Stats`) + dim(` — ${stats.totalDocs} docs`));
|
|
99
|
+
lines.push('');
|
|
100
|
+
|
|
101
|
+
// Status. With F6, render one line per type when 2+ types have docs so
|
|
102
|
+
// `plan/partial` and `doc/partial` (semantically distinct under per-type
|
|
103
|
+
// taxonomies) don't collapse into one number. Single-type corpora keep the
|
|
104
|
+
// existing flat line — no needless `Plans:` header on a plans-only repo.
|
|
105
|
+
lines.push(bold('Status'));
|
|
106
|
+
const typesWithDocs = Object.entries(stats.countsByType ?? {})
|
|
107
|
+
.filter(([, statusMap]) => Object.values(statusMap).some(n => n > 0));
|
|
108
|
+
if (typesWithDocs.length > 1) {
|
|
109
|
+
// Type order: respect config.validTypes declaration order; unknown last.
|
|
110
|
+
const typeOrder = [...(config.validTypes ?? [])];
|
|
111
|
+
const sortedTypes = typesWithDocs.sort(([a], [b]) => {
|
|
112
|
+
const ai = typeOrder.indexOf(a);
|
|
113
|
+
const bi = typeOrder.indexOf(b);
|
|
114
|
+
if (ai === -1 && bi === -1) return a.localeCompare(b);
|
|
115
|
+
if (ai === -1) return 1;
|
|
116
|
+
if (bi === -1) return -1;
|
|
117
|
+
return ai - bi;
|
|
118
|
+
});
|
|
119
|
+
const labelFor = (t) => t === 'unknown' ? 'Untyped' : (t.charAt(0).toUpperCase() + t.slice(1) + 's');
|
|
120
|
+
for (const [type, statusMap] of sortedTypes) {
|
|
121
|
+
const statusesForType = [
|
|
122
|
+
...config.statusOrder.filter(s => statusMap[s]),
|
|
123
|
+
...Object.keys(statusMap).filter(s => !config.statusOrder.includes(s)).sort(),
|
|
124
|
+
];
|
|
125
|
+
const parts = statusesForType
|
|
126
|
+
.filter(s => statusMap[s])
|
|
127
|
+
.map(s => `${s}: ${statusMap[s]}`);
|
|
128
|
+
lines.push(` ${dim(labelFor(type) + ':')} ${parts.join(' ')}`);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
const allStatuses = [
|
|
132
|
+
...config.statusOrder.filter(s => stats.countsByStatus[s]),
|
|
133
|
+
...Object.keys(stats.countsByStatus).filter(s => !config.statusOrder.includes(s)).sort(),
|
|
134
|
+
];
|
|
135
|
+
const statusParts = allStatuses
|
|
136
|
+
.filter(s => stats.countsByStatus[s])
|
|
137
|
+
.map(s => `${s}: ${stats.countsByStatus[s]}`);
|
|
138
|
+
lines.push(' ' + statusParts.join(' '));
|
|
139
|
+
}
|
|
140
|
+
lines.push('');
|
|
141
|
+
|
|
142
|
+
// Health
|
|
143
|
+
const h = stats.health;
|
|
144
|
+
lines.push(bold('Health'));
|
|
145
|
+
lines.push(` stale: ${h.staleCount}/${h.nonArchivedCount} (${h.stalePct}%)`);
|
|
146
|
+
lines.push(` errors: ${h.errorCount}`);
|
|
147
|
+
lines.push(` warnings: ${h.warningCount}`);
|
|
148
|
+
if (h.brokenRefCount) lines.push(` broken refs: ${h.brokenRefCount}`);
|
|
149
|
+
if (h.brokenLinkCount) lines.push(` broken links: ${h.brokenLinkCount}`);
|
|
150
|
+
lines.push('');
|
|
151
|
+
|
|
152
|
+
// Freshness
|
|
153
|
+
const f = stats.freshness;
|
|
154
|
+
lines.push(bold('Freshness'));
|
|
155
|
+
lines.push(` updated today: ${f.today} this week: ${f.thisWeek} this month: ${f.thisMonth}`);
|
|
156
|
+
if (f.oldest) {
|
|
157
|
+
lines.push(` oldest: ${f.oldest.slug} (${f.oldest.daysSinceUpdate}d)`);
|
|
158
|
+
}
|
|
159
|
+
lines.push('');
|
|
160
|
+
|
|
161
|
+
// Completeness
|
|
162
|
+
const c = stats.completeness;
|
|
163
|
+
if (c.scoped > 0) {
|
|
164
|
+
lines.push(bold('Completeness') + dim(' (active/ready/planned/blocked)'));
|
|
165
|
+
lines.push(` has owner: ${c.hasOwner}/${c.scoped} (${pct(c.hasOwner, c.scoped)}%)`);
|
|
166
|
+
lines.push(` has surface: ${c.hasSurface}/${c.scoped} (${pct(c.hasSurface, c.scoped)}%)`);
|
|
167
|
+
lines.push(` has module: ${c.hasModule}/${c.scoped} (${pct(c.hasModule, c.scoped)}%)`);
|
|
168
|
+
lines.push(` has next_step: ${c.hasNextStep}/${c.scoped} (${pct(c.hasNextStep, c.scoped)}%)`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Checklists
|
|
173
|
+
const cl = stats.checklists;
|
|
174
|
+
if (cl.docsWithChecklists > 0) {
|
|
175
|
+
lines.push(bold('Checklists'));
|
|
176
|
+
lines.push(` docs with checklists: ${cl.docsWithChecklists}`);
|
|
177
|
+
lines.push(` avg completion: ${cl.avgCompletion}%`);
|
|
178
|
+
lines.push(` fully complete: ${cl.fullyComplete} open items: ${cl.withOpenItems}`);
|
|
179
|
+
lines.push('');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Audit
|
|
183
|
+
const a = stats.audit;
|
|
184
|
+
if (c.scoped > 0) {
|
|
185
|
+
lines.push(bold('Audit'));
|
|
186
|
+
lines.push(` audited: ${a.audited}/${c.scoped} (${pct(a.audited, c.scoped)}%)`);
|
|
187
|
+
if (a.audited > 0) {
|
|
188
|
+
lines.push(` pass1: ${a.pass1} pass2: ${a.pass2} deep: ${a.deep}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── JSON renderer ──────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
export function renderStatsJson(stats) {
|
|
199
|
+
return JSON.stringify(stats, null, 2) + '\n';
|
|
200
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Ordered, type-aware view of the effective status configuration. Config loading
|
|
2
|
+
// still resolves compatibility overrides; consumers use this module instead of
|
|
3
|
+
// reconstructing behavior from several global/type-specific fields.
|
|
4
|
+
const CACHE = new WeakMap();
|
|
5
|
+
|
|
6
|
+
export function resolveStatusMetadata(config) {
|
|
7
|
+
const cached = CACHE.get(config);
|
|
8
|
+
if (cached) return cached;
|
|
9
|
+
const byType = {};
|
|
10
|
+
const typeOrder = [...(config.validTypes ?? [])];
|
|
11
|
+
|
|
12
|
+
for (const type of typeOrder) {
|
|
13
|
+
const statuses = [...(config.typeStatuses?.get(type) ?? [])];
|
|
14
|
+
const typeDef = config.raw?.types?.[type] ?? {};
|
|
15
|
+
const context = config.typeContextConfig?.get(type) ?? typeDef.context ?? {};
|
|
16
|
+
const contextByStatus = new Map();
|
|
17
|
+
for (const bucket of ['expanded', 'listed', 'counted']) {
|
|
18
|
+
for (const status of context[bucket] ?? []) {
|
|
19
|
+
if (!contextByStatus.has(status)) contextByStatus.set(status, bucket);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
byType[type] = statuses.map((name, rank) => {
|
|
24
|
+
const skipStale = config.lifecycle.skipStaleFor.has(name);
|
|
25
|
+
const skipWarnings = config.lifecycle.skipsWarnings(name, type);
|
|
26
|
+
const hasTypeStaleDays = Object.prototype.hasOwnProperty.call(typeDef.staleDays ?? {}, name);
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
rank,
|
|
30
|
+
context: contextByStatus.get(name) ?? 'counted',
|
|
31
|
+
staleDays: hasTypeStaleDays ? typeDef.staleDays[name] : (config.staleDaysByStatus?.[name] ?? null),
|
|
32
|
+
startable: config.lifecycle.startableStatuses.has(name),
|
|
33
|
+
terminal: config.lifecycle.isTerminal?.(name, type) ?? config.lifecycle.terminalStatuses.has(name),
|
|
34
|
+
archive: config.lifecycle.archiveStatuses.has(name),
|
|
35
|
+
filed: config.lifecycle.filedStatuses.get(name) ?? null,
|
|
36
|
+
skipStale,
|
|
37
|
+
skipWarnings,
|
|
38
|
+
quiet: skipStale && skipWarnings,
|
|
39
|
+
requiresModule: config.moduleRequiredStatuses.has(name),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const resolved = { typeOrder, byType };
|
|
45
|
+
CACHE.set(config, resolved);
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function statusMetadataFor(config, type, status) {
|
|
50
|
+
if (!type || !status) return null;
|
|
51
|
+
return resolveStatusMetadata(config).byType[type]?.find(item => item.name === status) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function statusesForContext(config, type, bucket) {
|
|
55
|
+
return (resolveStatusMetadata(config).byType[type] ?? [])
|
|
56
|
+
.filter(item => item.context === bucket)
|
|
57
|
+
.map(item => item.name);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function statusHasContext(config, type, status, bucket) {
|
|
61
|
+
return statusMetadataFor(config, type, status)?.context === bucket;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function actionablePromptStatuses(config) {
|
|
65
|
+
const expanded = statusesForContext(config, 'prompt', 'expanded');
|
|
66
|
+
return new Set(expanded.length > 0 ? expanded : ['pending']);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function compareStrings(a, b) {
|
|
70
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Oldest actionable prompt first. Missing dates sort last; path is the stable
|
|
74
|
+
// final tie-breaker shared by HUD, agent-context, and no-arg `runlist use`.
|
|
75
|
+
export function comparePromptDocs(a, b) {
|
|
76
|
+
const aCreated = a.created ?? '';
|
|
77
|
+
const bCreated = b.created ?? '';
|
|
78
|
+
if (aCreated && bCreated && aCreated !== bCreated) return compareStrings(aCreated, bCreated);
|
|
79
|
+
if (aCreated && !bCreated) return -1;
|
|
80
|
+
if (!aCreated && bCreated) return 1;
|
|
81
|
+
const aUpdated = a.updated ?? '';
|
|
82
|
+
const bUpdated = b.updated ?? '';
|
|
83
|
+
if (aUpdated && bUpdated && aUpdated !== bUpdated) return compareStrings(aUpdated, bUpdated);
|
|
84
|
+
if (aUpdated && !bUpdated) return -1;
|
|
85
|
+
if (!aUpdated && bUpdated) return 1;
|
|
86
|
+
return compareStrings(a.path ?? '', b.path ?? '');
|
|
87
|
+
}
|