threadit-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/assets/install.sh +16 -0
  2. package/assets/pack/capture-directive.md +1 -0
  3. package/assets/skills/threadit-orient/SKILL.md +15 -0
  4. package/assets/skills/threadit-reconcile/SKILL.md +15 -0
  5. package/dist/assets.d.ts +3 -0
  6. package/dist/assets.js +16 -0
  7. package/dist/bin.d.ts +11 -0
  8. package/dist/bin.js +472 -0
  9. package/dist/commands/accept.d.ts +12 -0
  10. package/dist/commands/accept.js +21 -0
  11. package/dist/commands/add.d.ts +11 -0
  12. package/dist/commands/add.js +19 -0
  13. package/dist/commands/capture.d.ts +24 -0
  14. package/dist/commands/capture.js +45 -0
  15. package/dist/commands/doc.d.ts +7 -0
  16. package/dist/commands/doc.js +56 -0
  17. package/dist/commands/drop.d.ts +4 -0
  18. package/dist/commands/drop.js +12 -0
  19. package/dist/commands/fold.d.ts +1 -0
  20. package/dist/commands/fold.js +9 -0
  21. package/dist/commands/hook.d.ts +24 -0
  22. package/dist/commands/hook.js +52 -0
  23. package/dist/commands/init.d.ts +18 -0
  24. package/dist/commands/init.js +55 -0
  25. package/dist/commands/ls.d.ts +8 -0
  26. package/dist/commands/ls.js +27 -0
  27. package/dist/commands/merge.d.ts +4 -0
  28. package/dist/commands/merge.js +18 -0
  29. package/dist/commands/move.d.ts +11 -0
  30. package/dist/commands/move.js +45 -0
  31. package/dist/commands/pack.d.ts +5 -0
  32. package/dist/commands/pack.js +12 -0
  33. package/dist/commands/park.d.ts +2 -0
  34. package/dist/commands/park.js +10 -0
  35. package/dist/commands/promote.d.ts +11 -0
  36. package/dist/commands/promote.js +33 -0
  37. package/dist/commands/reconcile.d.ts +12 -0
  38. package/dist/commands/reconcile.js +97 -0
  39. package/dist/commands/serve.d.ts +17 -0
  40. package/dist/commands/serve.js +39 -0
  41. package/dist/commands/ship.d.ts +9 -0
  42. package/dist/commands/ship.js +28 -0
  43. package/dist/commands/show.d.ts +2 -0
  44. package/dist/commands/show.js +16 -0
  45. package/dist/commands/snooze.d.ts +5 -0
  46. package/dist/commands/snooze.js +16 -0
  47. package/dist/commands/status.d.ts +14 -0
  48. package/dist/commands/status.js +47 -0
  49. package/dist/commands/supersede.d.ts +1 -0
  50. package/dist/commands/supersede.js +9 -0
  51. package/dist/commands/sync.d.ts +16 -0
  52. package/dist/commands/sync.js +66 -0
  53. package/dist/commands/update.d.ts +13 -0
  54. package/dist/commands/update.js +23 -0
  55. package/dist/commands/validate.d.ts +18 -0
  56. package/dist/commands/validate.js +52 -0
  57. package/dist/commands/wrapup.d.ts +3 -0
  58. package/dist/commands/wrapup.js +10 -0
  59. package/dist/config.d.ts +11 -0
  60. package/dist/config.js +20 -0
  61. package/dist/draft.d.ts +29 -0
  62. package/dist/draft.js +59 -0
  63. package/dist/git/commitFacts.d.ts +6 -0
  64. package/dist/git/commitFacts.js +56 -0
  65. package/dist/git/log.d.ts +41 -0
  66. package/dist/git/log.js +143 -0
  67. package/dist/git/reconcileCommits.d.ts +3 -0
  68. package/dist/git/reconcileCommits.js +18 -0
  69. package/dist/git/run.d.ts +2 -0
  70. package/dist/git/run.js +8 -0
  71. package/dist/ids.d.ts +4 -0
  72. package/dist/ids.js +25 -0
  73. package/dist/inbox/recurrence.d.ts +7 -0
  74. package/dist/inbox/recurrence.js +51 -0
  75. package/dist/install/atomicWrite.d.ts +2 -0
  76. package/dist/install/atomicWrite.js +9 -0
  77. package/dist/install/gitHooks.d.ts +6 -0
  78. package/dist/install/gitHooks.js +53 -0
  79. package/dist/install/gitignore.d.ts +4 -0
  80. package/dist/install/gitignore.js +29 -0
  81. package/dist/install/settings.d.ts +7 -0
  82. package/dist/install/settings.js +40 -0
  83. package/dist/install/skills.d.ts +11 -0
  84. package/dist/install/skills.js +38 -0
  85. package/dist/install/tty.d.ts +6 -0
  86. package/dist/install/tty.js +39 -0
  87. package/dist/mutate.d.ts +6 -0
  88. package/dist/mutate.js +21 -0
  89. package/dist/paths.d.ts +13 -0
  90. package/dist/paths.js +46 -0
  91. package/dist/reconcileFormat.d.ts +3 -0
  92. package/dist/reconcileFormat.js +30 -0
  93. package/dist/session.d.ts +4 -0
  94. package/dist/session.js +11 -0
  95. package/dist/transport/sync.d.ts +38 -0
  96. package/dist/transport/sync.js +64 -0
  97. package/dist/version.d.ts +5 -0
  98. package/dist/version.js +34 -0
  99. package/dist/yaml/emit.d.ts +3 -0
  100. package/dist/yaml/emit.js +50 -0
  101. package/dist/yaml/io.d.ts +11 -0
  102. package/dist/yaml/io.js +33 -0
  103. package/package.json +32 -0
@@ -0,0 +1,45 @@
1
+ import { resolveInboxPath } from '../paths.js';
2
+ import { mutateInbox } from '../mutate.js';
3
+ import { uniqueInboxId } from '../ids.js';
4
+ import { matchOpenItem } from '../inbox/recurrence.js';
5
+ /**
6
+ * Pure core: capture an inbox item into a parsed inbox object. If it fuzzy-matches an existing
7
+ * OPEN item, record a recurrence (seen_count++ / append seen_at) instead of duplicating.
8
+ * The `at` default (new Date().toISOString()) lives here so Task 9 can call applyCapture
9
+ * with an empty opts and still get a timestamp. Returns the affected item id + recurrence flag.
10
+ */
11
+ export function applyCapture(inbox, text, opts) {
12
+ const at = opts.at ?? new Date().toISOString();
13
+ const existing = matchOpenItem(inbox.items, text);
14
+ if (existing) {
15
+ existing.seen_count = (existing.seen_count ?? 1) + 1;
16
+ existing.seen_at = [...(existing.seen_at ?? [existing.created]), at];
17
+ return { id: existing.id, recurrence: true };
18
+ }
19
+ const item = {
20
+ id: uniqueInboxId(inbox),
21
+ text,
22
+ created: at,
23
+ source: 'session',
24
+ status: 'open',
25
+ seen_count: 1,
26
+ seen_at: [at],
27
+ };
28
+ if (opts.tags?.length)
29
+ item.tags = opts.tags;
30
+ if (opts.riskNode !== undefined)
31
+ item.risk_node = opts.riskNode;
32
+ inbox.items.push(item);
33
+ return { id: item.id, recurrence: false };
34
+ }
35
+ /**
36
+ * Capture an inbox item. If it fuzzy-matches an existing OPEN item, record a recurrence
37
+ * (seen_count++ / append seen_at) instead of duplicating. Returns the affected item id.
38
+ */
39
+ export function runCapture(repoRoot, text, opts) {
40
+ let result = { id: '', recurrence: false };
41
+ mutateInbox(resolveInboxPath(repoRoot), (inbox) => {
42
+ result = applyCapture(inbox, text, opts);
43
+ });
44
+ return result;
45
+ }
@@ -0,0 +1,7 @@
1
+ import type { DocLink, DocRole } from '@threadit/core';
2
+ export declare const DOC_ROLES: readonly DocRole[];
3
+ export declare function runDocAdd(repoRoot: string, id: string, role: string, ref: string): {
4
+ warning?: string;
5
+ };
6
+ export declare function runDocRm(repoRoot: string, id: string, ref: string, role?: string): void;
7
+ export declare function runDocList(repoRoot: string, id: string): DocLink[];
@@ -0,0 +1,56 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { resolveThreaditPath } from '../paths.js';
4
+ import { mutate, findNode } from '../mutate.js';
5
+ import { loadThreadit } from '../yaml/io.js';
6
+ export const DOC_ROLES = ['handoff', 'memory', 'spec', 'plan', 'playbook'];
7
+ function assertRole(role) {
8
+ if (!DOC_ROLES.includes(role)) {
9
+ throw new Error(`Unknown doc role "${role}". Expected one of: ${DOC_ROLES.join(', ')}`);
10
+ }
11
+ }
12
+ /** Heuristic: a ref that looks like a repo path (not a URL scheme, not a bare slug). */
13
+ function isPathLike(ref) {
14
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(ref))
15
+ return false; // URL
16
+ return ref.includes('/') || /\.[a-z0-9]+$/i.test(ref);
17
+ }
18
+ export function runDocAdd(repoRoot, id, role, ref) {
19
+ assertRole(role);
20
+ const r = role; // explicit narrowing so the closure push stays DocRole
21
+ mutate(resolveThreaditPath(repoRoot), (file) => {
22
+ const node = findNode(file, id);
23
+ if (!node.docs)
24
+ node.docs = [];
25
+ if (!node.docs.some((d) => d.role === r && d.ref === ref))
26
+ node.docs.push({ role: r, ref });
27
+ });
28
+ if (isPathLike(ref)) {
29
+ const abs = isAbsolute(ref) ? ref : join(repoRoot, ref);
30
+ if (!existsSync(abs)) {
31
+ return { warning: `warning: doc ref "${ref}" does not resolve on disk (forward reference?)` };
32
+ }
33
+ }
34
+ return {};
35
+ }
36
+ export function runDocRm(repoRoot, id, ref, role) {
37
+ if (role !== undefined)
38
+ assertRole(role);
39
+ const matches = (d) => d.ref === ref && (role === undefined || d.role === role);
40
+ const path = resolveThreaditPath(repoRoot);
41
+ // Validate BEFORE mutating so a no-match never triggers a (no-op) canonical re-emit / draft-push.
42
+ const existing = findNode(loadThreadit(path), id).docs ?? [];
43
+ if (!existing.some(matches)) {
44
+ throw new Error(`No doc-link with ref "${ref}"${role ? ` and role "${role}"` : ''} on node "${id}"`);
45
+ }
46
+ mutate(path, (file) => {
47
+ const node = findNode(file, id);
48
+ node.docs = (node.docs ?? []).filter((d) => !matches(d));
49
+ if (node.docs.length === 0)
50
+ delete node.docs;
51
+ });
52
+ }
53
+ export function runDocList(repoRoot, id) {
54
+ const file = loadThreadit(resolveThreaditPath(repoRoot));
55
+ return findNode(file, id).docs ?? [];
56
+ }
@@ -0,0 +1,4 @@
1
+ import type { ParsedInboxFile } from '@threadit/core';
2
+ /** Pure core: flip an inbox item's status to dropped. */
3
+ export declare function applyDrop(inbox: ParsedInboxFile, itemId: string): void;
4
+ export declare function runDrop(repoRoot: string, itemId: string): void;
@@ -0,0 +1,12 @@
1
+ import { resolveInboxPath } from '../paths.js';
2
+ import { mutateInbox } from '../mutate.js';
3
+ /** Pure core: flip an inbox item's status to dropped. */
4
+ export function applyDrop(inbox, itemId) {
5
+ const item = inbox.items.find((i) => i.id === itemId);
6
+ if (!item)
7
+ throw new Error(`No inbox item "${itemId}"`);
8
+ item.status = 'dropped';
9
+ }
10
+ export function runDrop(repoRoot, itemId) {
11
+ mutateInbox(resolveInboxPath(repoRoot), (inbox) => applyDrop(inbox, itemId));
12
+ }
@@ -0,0 +1 @@
1
+ export declare function runFold(repoRoot: string, id: string, into: string): void;
@@ -0,0 +1,9 @@
1
+ import { resolveThreaditPath } from '../paths.js';
2
+ import { mutate, findNode } from '../mutate.js';
3
+ export function runFold(repoRoot, id, into) {
4
+ mutate(resolveThreaditPath(repoRoot), (file) => {
5
+ const node = findNode(file, id);
6
+ node.status = 'superseded'; // closed-lineage; folded_into satisfies the successor requirement (Task 1)
7
+ node.folded_into = into;
8
+ });
9
+ }
@@ -0,0 +1,24 @@
1
+ export interface CaptureDirectiveOpts {
2
+ skillsDirOverride?: string;
3
+ currentVersion?: string;
4
+ }
5
+ /**
6
+ * Emit the SessionStart hook output: the capture directive (pack-owned prose) as
7
+ * additionalContext, plus a stale-pack nudge appended when the installed pack is older
8
+ * than the current pack version. The CLI is a courier for the pack asset — it does not author the prose.
9
+ * `opts` exists for hermetic tests (override the install dir + current version).
10
+ */
11
+ export declare function runHookCaptureDirective(repoRoot: string, opts?: CaptureDirectiveOpts): string;
12
+ export interface HookValidateResult {
13
+ code: number;
14
+ stderr: string;
15
+ }
16
+ /**
17
+ * PostToolUse validate. Reads the tool payload; acts ONLY when the edited file is the repo's
18
+ * threadit.yml (exact match — naturally excludes threadit-inbox.yml and everything else).
19
+ * Warn-only (Claude hook, not a git hook):
20
+ * exit 0 — no-op (non-matching / unparseable stdin), OR valid, OR an IO/parse-IO error
21
+ * (runValidate code 2 — don't masquerade an internal error as a structural finding)
22
+ * exit 2 — structural-invalid (runValidate code 1; PostToolUse exit-2 feeds stderr to the model)
23
+ */
24
+ export declare function runHookValidate(repoRoot: string, stdin: string): Promise<HookValidateResult>;
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { relative, resolve, join as pjoin } from 'node:path';
3
+ import { captureDirectivePath } from '../assets.js';
4
+ import { readInstalledPackVersion } from '../install/skills.js';
5
+ import { THREADIT_FILE } from '../paths.js';
6
+ import { PACK_VERSION, isOlder, isDevVersion } from '../version.js';
7
+ import { runValidate } from './validate.js';
8
+ /**
9
+ * Emit the SessionStart hook output: the capture directive (pack-owned prose) as
10
+ * additionalContext, plus a stale-pack nudge appended when the installed pack is older
11
+ * than the current pack version. The CLI is a courier for the pack asset — it does not author the prose.
12
+ * `opts` exists for hermetic tests (override the install dir + current version).
13
+ */
14
+ export function runHookCaptureDirective(repoRoot, opts = {}) {
15
+ const current = opts.currentVersion ?? PACK_VERSION;
16
+ let prose = readFileSync(captureDirectivePath(), 'utf8').trim();
17
+ const installed = readInstalledPackVersion(repoRoot, opts.skillsDirOverride);
18
+ if (installed && !isDevVersion(current) && isOlder(installed, current)) {
19
+ prose += `\n\nThreadit pack is stale (installed ${installed}, available ${current}) — run \`threadit pack sync\`.`;
20
+ }
21
+ return JSON.stringify({
22
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: prose },
23
+ });
24
+ }
25
+ /**
26
+ * PostToolUse validate. Reads the tool payload; acts ONLY when the edited file is the repo's
27
+ * threadit.yml (exact match — naturally excludes threadit-inbox.yml and everything else).
28
+ * Warn-only (Claude hook, not a git hook):
29
+ * exit 0 — no-op (non-matching / unparseable stdin), OR valid, OR an IO/parse-IO error
30
+ * (runValidate code 2 — don't masquerade an internal error as a structural finding)
31
+ * exit 2 — structural-invalid (runValidate code 1; PostToolUse exit-2 feeds stderr to the model)
32
+ */
33
+ export async function runHookValidate(repoRoot, stdin) {
34
+ let filePath;
35
+ try {
36
+ const payload = JSON.parse(stdin);
37
+ filePath = payload.tool_input?.file_path ?? payload.file_path;
38
+ }
39
+ catch {
40
+ return { code: 0, stderr: '' };
41
+ }
42
+ if (!filePath)
43
+ return { code: 0, stderr: '' };
44
+ if (relative(repoRoot, resolve(repoRoot, filePath)) !== THREADIT_FILE)
45
+ return { code: 0, stderr: '' };
46
+ const { code, output } = await runValidate(pjoin(repoRoot, THREADIT_FILE));
47
+ if (code === 0)
48
+ return { code: 0, stderr: '' };
49
+ if (code === 2)
50
+ return { code: 0, stderr: `threadit hook validate: internal error — ${output}` }; // IO → don't masquerade as structural
51
+ return { code: 2, stderr: `threadit validate failed after a threadit.yml edit — fix the structure:\n${output}` };
52
+ }
@@ -0,0 +1,18 @@
1
+ export interface InitOptions {
2
+ name?: string;
3
+ server?: string;
4
+ token?: string;
5
+ skills?: 'global' | 'repo';
6
+ yes?: boolean;
7
+ packVersion?: string;
8
+ }
9
+ /**
10
+ * Idempotent, resumable installer. Seeds files if absent; always (re)writes pointers/hooks/skills.
11
+ * If `.claude/settings.json` is malformed, `mergeSettings` throws AFTER yml/inbox/.gitignore/config
12
+ * are written — but `.threadit/` is already gitignored (step 2 precedes config), so the token is safe,
13
+ * and re-running after fixing the JSON converges.
14
+ */
15
+ export declare function runInit(repoRoot: string, projectId: string, opts?: InitOptions): {
16
+ skillsLocation: 'global' | 'repo';
17
+ warnings: string[];
18
+ };
@@ -0,0 +1,55 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolveThreaditPath, resolveInboxPath } from '../paths.js';
3
+ import { saveThreadit, saveInbox } from '../yaml/io.js';
4
+ import { readConfig, writeConfig } from '../config.js';
5
+ import { ensureGitignore, verifyIgnored } from '../install/gitignore.js';
6
+ import { mergeSettings } from '../install/settings.js';
7
+ import { installGitHooks } from '../install/gitHooks.js';
8
+ import { installSkills } from '../install/skills.js';
9
+ import { promptChoice } from '../install/tty.js';
10
+ import { PACK_VERSION } from '../version.js';
11
+ /**
12
+ * Idempotent, resumable installer. Seeds files if absent; always (re)writes pointers/hooks/skills.
13
+ * If `.claude/settings.json` is malformed, `mergeSettings` throws AFTER yml/inbox/.gitignore/config
14
+ * are written — but `.threadit/` is already gitignored (step 2 precedes config), so the token is safe,
15
+ * and re-running after fixing the JSON converges.
16
+ */
17
+ export function runInit(repoRoot, projectId, opts = {}) {
18
+ const warnings = [];
19
+ const version = opts.packVersion ?? PACK_VERSION;
20
+ // 1. Seed yml/inbox ONLY if absent (never clobber an existing graph).
21
+ const ymlPath = resolveThreaditPath(repoRoot);
22
+ if (!existsSync(ymlPath)) {
23
+ const file = { threadit: 1, project: { id: projectId, name: opts.name ?? projectId }, nodes: [] };
24
+ saveThreadit(ymlPath, file);
25
+ }
26
+ const inboxPath = resolveInboxPath(repoRoot);
27
+ if (!existsSync(inboxPath)) {
28
+ const inbox = { threadit_inbox: 1, project: projectId, items: [] };
29
+ saveInbox(inboxPath, inbox);
30
+ }
31
+ // 2. .gitignore BEFORE config (close the token-leak window), then verify.
32
+ ensureGitignore(repoRoot);
33
+ if (!verifyIgnored(repoRoot, '.threadit/config.json')) {
34
+ warnings.push('⚠ .threadit/config.json is not git-ignored — the ingest token could be committed. Check .gitignore for a negation pattern.');
35
+ }
36
+ // 3. Config (preserve existing fields; fill the new ones).
37
+ const prev = readConfig(repoRoot);
38
+ writeConfig(repoRoot, {
39
+ ...prev,
40
+ projectId,
41
+ session: prev.session ?? 1,
42
+ packVersion: version,
43
+ ...(opts.server ? { server: opts.server } : {}),
44
+ ...(opts.token ? { ingestToken: opts.token } : {}),
45
+ });
46
+ // 4. Claude hooks (merge) + git hooks (chain).
47
+ mergeSettings(repoRoot);
48
+ const { skipped } = installGitHooks(repoRoot);
49
+ for (const h of skipped)
50
+ warnings.push(`⚠ git hook ${h} is a non-shell script — left untouched; sync won't auto-fire from it (SessionStart sync still covers you).`);
51
+ // 5. Skills — location: flag → /dev/tty prompt → default global.
52
+ const skillsLocation = opts.skills ?? (opts.yes ? 'global' : promptChoice('Install Threadit skills globally or in this repo?', ['global', 'repo'], 'global'));
53
+ installSkills(repoRoot, skillsLocation, version);
54
+ return { skillsLocation, warnings };
55
+ }
@@ -0,0 +1,8 @@
1
+ import type { Status } from '@threadit/core';
2
+ export interface LsRow {
3
+ id: string;
4
+ title: string;
5
+ status: Status;
6
+ depth: number;
7
+ }
8
+ export declare function runLs(repoRoot: string): LsRow[];
@@ -0,0 +1,27 @@
1
+ import { resolveThreaditPath } from '../paths.js';
2
+ import { loadThreadit } from '../yaml/io.js';
3
+ export function runLs(repoRoot) {
4
+ const file = loadThreadit(resolveThreaditPath(repoRoot));
5
+ const byId = new Map(file.nodes.map((n) => [n.id, n]));
6
+ const depthOf = (id) => {
7
+ let depth = 0;
8
+ let cur = byId.get(id)?.parent ?? null;
9
+ const guard = new Set();
10
+ while (cur && !guard.has(cur)) {
11
+ guard.add(cur);
12
+ depth++;
13
+ cur = byId.get(cur)?.parent ?? null;
14
+ }
15
+ return depth;
16
+ };
17
+ const childrenOf = (parent) => file.nodes.filter((n) => (n.parent ?? null) === parent).sort((a, b) => a.order - b.order);
18
+ const rows = [];
19
+ const walk = (parent) => {
20
+ for (const n of childrenOf(parent)) {
21
+ rows.push({ id: n.id, title: n.title, status: n.status, depth: depthOf(n.id) });
22
+ walk(n.id);
23
+ }
24
+ };
25
+ walk(null);
26
+ return rows;
27
+ }
@@ -0,0 +1,4 @@
1
+ import type { ParsedInboxFile } from '@threadit/core';
2
+ /** Pure core: merge `itemId` into sibling `intoId` (recurrence rolls up; merged item dropped + linked). */
3
+ export declare function applyMerge(inbox: ParsedInboxFile, itemId: string, intoId: string): void;
4
+ export declare function runMerge(repoRoot: string, itemId: string, intoId: string): void;
@@ -0,0 +1,18 @@
1
+ import { resolveInboxPath } from '../paths.js';
2
+ import { mutateInbox } from '../mutate.js';
3
+ /** Pure core: merge `itemId` into sibling `intoId` (recurrence rolls up; merged item dropped + linked). */
4
+ export function applyMerge(inbox, itemId, intoId) {
5
+ const item = inbox.items.find((i) => i.id === itemId);
6
+ const into = inbox.items.find((i) => i.id === intoId);
7
+ if (!item)
8
+ throw new Error(`No inbox item "${itemId}"`);
9
+ if (!into)
10
+ throw new Error(`No inbox item "${intoId}"`);
11
+ into.seen_count = (into.seen_count ?? 1) + (item.seen_count ?? 1);
12
+ into.seen_at = [...(into.seen_at ?? []), ...(item.seen_at ?? [])];
13
+ item.status = 'dropped';
14
+ item.related = [...(item.related ?? []), intoId];
15
+ }
16
+ export function runMerge(repoRoot, itemId, intoId) {
17
+ mutateInbox(resolveInboxPath(repoRoot), (inbox) => applyMerge(inbox, itemId, intoId));
18
+ }
@@ -0,0 +1,11 @@
1
+ export interface MoveOpts {
2
+ parent?: string | null;
3
+ order?: number;
4
+ }
5
+ /**
6
+ * Move a node: change parent and/or order. If the requested order would collide with
7
+ * a sibling and there is no integer gap to slot into, rebalance the whole sibling set
8
+ * to gaps of 10 (the one sanctioned multi-line structural diff), preserving the intended
9
+ * relative position of the moved node.
10
+ */
11
+ export declare function runMove(repoRoot: string, id: string, opts: MoveOpts): void;
@@ -0,0 +1,45 @@
1
+ import { resolveThreaditPath } from '../paths.js';
2
+ import { mutate, findNode } from '../mutate.js';
3
+ /**
4
+ * Move a node: change parent and/or order. If the requested order would collide with
5
+ * a sibling and there is no integer gap to slot into, rebalance the whole sibling set
6
+ * to gaps of 10 (the one sanctioned multi-line structural diff), preserving the intended
7
+ * relative position of the moved node.
8
+ */
9
+ export function runMove(repoRoot, id, opts) {
10
+ mutate(resolveThreaditPath(repoRoot), (file) => {
11
+ const node = findNode(file, id);
12
+ const newParent = opts.parent !== undefined ? opts.parent : (node.parent ?? null);
13
+ node.parent = newParent;
14
+ if (opts.order !== undefined)
15
+ node.order = opts.order;
16
+ const siblings = file.nodes.filter((n) => (n.parent ?? null) === (newParent ?? null));
17
+ if (hasOrderCollision(siblings))
18
+ rebalance(siblings, node);
19
+ });
20
+ }
21
+ function hasOrderCollision(siblings) {
22
+ const seen = new Set();
23
+ for (const s of siblings) {
24
+ if (seen.has(s.order))
25
+ return true;
26
+ seen.add(s.order);
27
+ }
28
+ return false;
29
+ }
30
+ /**
31
+ * Reassign orders to 10,20,30,... over the sibling set sorted by (order, then the moved
32
+ * node first on a tie so it keeps the slot it asked for), then by id for stability.
33
+ */
34
+ function rebalance(siblings, moved) {
35
+ const sorted = [...siblings].sort((a, b) => {
36
+ if (a.order !== b.order)
37
+ return a.order - b.order;
38
+ if (a.id === moved.id)
39
+ return -1;
40
+ if (b.id === moved.id)
41
+ return 1;
42
+ return a.id < b.id ? -1 : 1;
43
+ });
44
+ sorted.forEach((n, i) => { n.order = (i + 1) * 10; });
45
+ }
@@ -0,0 +1,5 @@
1
+ /** Re-copy the skills + directive to wherever they're installed (repo if a repo marker is present,
2
+ * else global) and re-stamp the version. `globalOverride` redirects the global dest (tests). */
3
+ export declare function runPackSync(repoRoot: string, version?: string, globalOverride?: string): {
4
+ location: 'global' | 'repo';
5
+ };
@@ -0,0 +1,12 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { installSkills } from '../install/skills.js';
4
+ import { PACK_VERSION } from '../version.js';
5
+ /** Re-copy the skills + directive to wherever they're installed (repo if a repo marker is present,
6
+ * else global) and re-stamp the version. `globalOverride` redirects the global dest (tests). */
7
+ export function runPackSync(repoRoot, version = PACK_VERSION, globalOverride) {
8
+ const repoMarker = join(repoRoot, '.claude', 'skills', '.threadit-pack-version');
9
+ const location = existsSync(repoMarker) ? 'repo' : 'global';
10
+ installSkills(repoRoot, location, version, globalOverride);
11
+ return { location };
12
+ }
@@ -0,0 +1,2 @@
1
+ import type { UntilPredicate } from '@threadit/core';
2
+ export declare function runPark(repoRoot: string, id: string, until?: UntilPredicate): void;
@@ -0,0 +1,10 @@
1
+ import { resolveThreaditPath } from '../paths.js';
2
+ import { mutate, findNode } from '../mutate.js';
3
+ export function runPark(repoRoot, id, until) {
4
+ mutate(resolveThreaditPath(repoRoot), (file) => {
5
+ const node = findNode(file, id);
6
+ node.status = 'parked';
7
+ if (until !== undefined)
8
+ node.until = until;
9
+ });
10
+ }
@@ -0,0 +1,11 @@
1
+ import type { ParsedInboxFile, Status } from '@threadit/core';
2
+ export interface PromoteOpts {
3
+ under: string;
4
+ id?: string;
5
+ status?: Status;
6
+ title?: string;
7
+ }
8
+ /** Pure core: mark an inbox item as promoted, linking to the given nodeId. */
9
+ export declare function applyPromoteMark(inbox: ParsedInboxFile, itemId: string, nodeId: string): void;
10
+ /** Promote an inbox item to a node under `--under`; flip the item to promoted + link. */
11
+ export declare function runPromote(repoRoot: string, itemId: string, opts: PromoteOpts): string;
@@ -0,0 +1,33 @@
1
+ import { resolveThreaditPath, resolveInboxPath } from '../paths.js';
2
+ import { loadInbox } from '../yaml/io.js';
3
+ import { mutate, mutateInbox } from '../mutate.js';
4
+ import { applyAddNode } from './add.js';
5
+ /** Pure core: mark an inbox item as promoted, linking to the given nodeId. */
6
+ export function applyPromoteMark(inbox, itemId, nodeId) {
7
+ const item = inbox.items.find((i) => i.id === itemId);
8
+ if (!item)
9
+ throw new Error(`No inbox item "${itemId}"`);
10
+ if (item.status !== 'open')
11
+ throw new Error(`Item "${itemId}" is ${item.status}, not open`);
12
+ item.status = 'promoted';
13
+ item.promoted_to = nodeId;
14
+ }
15
+ /** Promote an inbox item to a node under `--under`; flip the item to promoted + link. */
16
+ export function runPromote(repoRoot, itemId, opts) {
17
+ const inbox = loadInbox(resolveInboxPath(repoRoot));
18
+ const item = inbox.items.find((i) => i.id === itemId);
19
+ if (!item)
20
+ throw new Error(`No inbox item "${itemId}"`);
21
+ if (item.status !== 'open')
22
+ throw new Error(`Item "${itemId}" is ${item.status}, not open`);
23
+ const title = opts.title ?? item.text;
24
+ let createdId = '';
25
+ mutate(resolveThreaditPath(repoRoot), (file) => {
26
+ createdId = applyAddNode(file, title, {
27
+ ...(opts.id !== undefined ? { id: opts.id } : {}),
28
+ parent: opts.under, status: opts.status ?? 'planned',
29
+ });
30
+ });
31
+ mutateInbox(resolveInboxPath(repoRoot), (ibx) => applyPromoteMark(ibx, itemId, createdId));
32
+ return createdId;
33
+ }
@@ -0,0 +1,12 @@
1
+ import type { Disposition, Proposal } from '@threadit/core';
2
+ /**
3
+ * Working-set = OPEN items (Phase 2; "this-session items + now-due snoozes" needs session
4
+ * tracking + clock = Phase-5 hook context). Without `apply`: return a suggested disposition
5
+ * per open item. With `apply`: every open item MUST have a disposition (forced drain — no ignore);
6
+ * execute them.
7
+ */
8
+ export declare function runReconcile(repoRoot: string, opts: {
9
+ apply?: Record<string, Disposition>;
10
+ }): {
11
+ proposals: Proposal[];
12
+ };
@@ -0,0 +1,97 @@
1
+ import { engine, deriveReconcile } from '@threadit/core';
2
+ import { resolveInboxPath, resolveThreaditPath } from '../paths.js';
3
+ import { loadInbox, loadThreadit } from '../yaml/io.js';
4
+ import { buildCommitFacts } from '../git/commitFacts.js';
5
+ import { gatherReconcileCommits } from '../git/reconcileCommits.js';
6
+ import { matchOpenItem } from '../inbox/recurrence.js';
7
+ import { firstParentChain } from '../git/log.js';
8
+ import { mutate, mutateInbox } from '../mutate.js';
9
+ import { applyAddNode } from './add.js';
10
+ import { applyUpdate } from './update.js';
11
+ import { applyAccept } from './accept.js';
12
+ import { applyPromoteMark } from './promote.js';
13
+ import { applyMerge } from './merge.js';
14
+ import { applyDrop } from './drop.js';
15
+ import { applySnooze } from './snooze.js';
16
+ import { applyCapture } from './capture.js';
17
+ /**
18
+ * Working-set = OPEN items (Phase 2; "this-session items + now-due snoozes" needs session
19
+ * tracking + clock = Phase-5 hook context). Without `apply`: return a suggested disposition
20
+ * per open item. With `apply`: every open item MUST have a disposition (forced drain — no ignore);
21
+ * execute them.
22
+ */
23
+ export function runReconcile(repoRoot, opts) {
24
+ const inboxFile = loadInbox(resolveInboxPath(repoRoot));
25
+ const open = inboxFile.items.filter((i) => i.status === 'open');
26
+ // Inbox proposals (existing heuristic).
27
+ const proposals = open.map((item) => ({
28
+ class: 'inbox',
29
+ itemId: item.id,
30
+ text: item.text,
31
+ seenCount: item.seen_count ?? 1,
32
+ suggested: suggest(item, open),
33
+ }));
34
+ // Status + loop proposals (engine findings + un-captured-loop scan).
35
+ const file = loadThreadit(resolveThreaditPath(repoRoot));
36
+ const facts = buildCommitFacts(repoRoot, file);
37
+ const commits = gatherReconcileCommits(repoRoot, file, file.reconcile?.sha);
38
+ const { findings } = engine({ file, commitFacts: facts, inbox: inboxFile });
39
+ const { statusProposals, loopProposals } = deriveReconcile({ findings, commits });
40
+ if (!opts.apply) {
41
+ return { proposals: [...proposals, ...statusProposals, ...loopProposals] };
42
+ }
43
+ // opts.apply is set below this point.
44
+ const apply = opts.apply;
45
+ // Forced-drain check (BEFORE any write): open inbox items + loops + forced (stale-summary) status.
46
+ const forcedLoopShas = loopProposals.map((l) => l.sha);
47
+ const forcedStatusIds = statusProposals.filter((s) => s.forced).map((s) => s.nodeId);
48
+ const required = [...open.map((i) => i.id), ...forcedLoopShas, ...forcedStatusIds];
49
+ const missing = required.filter((key) => !(key in apply));
50
+ if (missing.length)
51
+ throw new Error(`forced disposition: ${missing.length} item(s) have no disposition: ${missing.join(', ')}`);
52
+ // Resolve promote default titles from the inbox BEFORE Pass 1 (Pass 1 writes threadit.yml before Pass 2 reads/writes the inbox).
53
+ const itemTextById = new Map(inboxFile.items.map((i) => [i.id, i.text]));
54
+ const head = firstParentChain(repoRoot)[0]?.sha;
55
+ // Pass 1: threadit.yml — node mutations + promote node-creation + watermark — ONE atomic mutate.
56
+ const createdIds = {};
57
+ mutate(resolveThreaditPath(repoRoot), (file) => {
58
+ for (const [key, d] of Object.entries(apply)) {
59
+ if (d.kind === 'downgrade')
60
+ applyUpdate(file, d.nodeId, { status: d.to });
61
+ else if (d.kind === 'resummarize')
62
+ applyUpdate(file, d.nodeId, { summary: d.summary, summaryAt: d.summaryAt });
63
+ else if (d.kind === 'accept')
64
+ applyAccept(file, d.nodeId, { kind: d.finding, target: d.target, until: d.until, text: d.text, ...(d.id ? { id: d.id } : {}), ...(d.at !== undefined ? { at: d.at } : {}) });
65
+ else if (d.kind === 'promote')
66
+ createdIds[key] = applyAddNode(file, d.title ?? itemTextById.get(key) ?? key, { ...(d.id ? { id: d.id } : {}), parent: d.under, status: d.status ?? 'planned' });
67
+ }
68
+ if (head !== undefined)
69
+ file.reconcile = { sha: head };
70
+ });
71
+ // Pass 2: threadit-inbox.yml — promote marks + merge/drop/snooze/capture — ONE atomic mutate.
72
+ mutateInbox(resolveInboxPath(repoRoot), (inbox) => {
73
+ for (const [key, d] of Object.entries(apply)) {
74
+ if (d.kind === 'promote')
75
+ applyPromoteMark(inbox, key, createdIds[key]);
76
+ else if (d.kind === 'merge')
77
+ applyMerge(inbox, key, d.into);
78
+ else if (d.kind === 'drop')
79
+ applyDrop(inbox, key);
80
+ else if (d.kind === 'snooze')
81
+ applySnooze(inbox, key, d.until);
82
+ else if (d.kind === 'capture')
83
+ applyCapture(inbox, d.text, {});
84
+ // 'dismiss': no-op — watermark advance (Pass 1) means it won't re-surface.
85
+ }
86
+ });
87
+ return { proposals: [] };
88
+ }
89
+ /** Heuristic suggestion: recurring (≥3) → promote; fuzzy sibling exists → merge; else drop. */
90
+ function suggest(item, open) {
91
+ if ((item.seen_count ?? 1) >= 3)
92
+ return 'promote';
93
+ const others = open.filter((o) => o.id !== item.id);
94
+ if (matchOpenItem(others, item.text))
95
+ return 'merge';
96
+ return 'drop';
97
+ }
@@ -0,0 +1,17 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export interface ServeOpts {
3
+ port?: number;
4
+ dbPath?: string;
5
+ serverEntry?: string;
6
+ }
7
+ /**
8
+ * Resolve the built SvelteKit adapter-node entry. The server package sits next to the cli package
9
+ * in the monorepo; its build output is packages/server/build/index.js. Overridable via opts for
10
+ * tests / non-standard layouts. The CLI does NOT import the server — it only spawns it (strict
11
+ * dep direction, index §1).
12
+ */
13
+ export declare function resolveServerEntry(explicit?: string): string;
14
+ /** Spawn the built server with PORT + THREADIT_DB_PATH in its env. Returns the child process. */
15
+ export declare function startServer(opts?: ServeOpts): ChildProcess;
16
+ /** CLI entry: start the server and keep the process alive until the child exits. */
17
+ export declare function runServe(opts?: ServeOpts): ChildProcess;