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,39 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ /**
6
+ * Resolve the built SvelteKit adapter-node entry. The server package sits next to the cli package
7
+ * in the monorepo; its build output is packages/server/build/index.js. Overridable via opts for
8
+ * tests / non-standard layouts. The CLI does NOT import the server — it only spawns it (strict
9
+ * dep direction, index §1).
10
+ */
11
+ export function resolveServerEntry(explicit) {
12
+ if (explicit)
13
+ return resolve(explicit);
14
+ // From packages/cli/dist/commands/serve.js: 3 levels up (commands→dist→cli→packages) lands at
15
+ // packages/, then server/build/index.js → packages/server/build/index.js.
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ return resolve(here, '../../../server/build/index.js');
18
+ }
19
+ /** Spawn the built server with PORT + THREADIT_DB_PATH in its env. Returns the child process. */
20
+ export function startServer(opts = {}) {
21
+ const port = opts.port ?? 4317;
22
+ const dbPath = resolve(opts.dbPath ?? join('.threadit', 'server.sqlite'));
23
+ mkdirSync(dirname(dbPath), { recursive: true });
24
+ const entry = resolveServerEntry(opts.serverEntry);
25
+ if (!existsSync(entry)) {
26
+ throw new Error(`serve: built server not found at ${entry}. Run \`npm run build --workspace @threadit/server\` first.`);
27
+ }
28
+ return spawn(process.execPath, [entry], {
29
+ stdio: 'inherit',
30
+ env: { ...process.env, PORT: String(port), THREADIT_DB_PATH: dbPath },
31
+ });
32
+ }
33
+ /** CLI entry: start the server and keep the process alive until the child exits. */
34
+ export function runServe(opts = {}) {
35
+ const child = startServer(opts);
36
+ child.on('error', (err) => { process.stderr.write(`serve: failed to start server: ${err.message}\n`); process.exit(1); });
37
+ child.on('exit', (code) => process.exit(code ?? 0));
38
+ return child;
39
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Flip a node to shipped. Courtesy check (warn, never block): set shipped in-memory,
3
+ * build CommitFacts from git, run the engine; if it raises `unbacked-shipped` for this
4
+ * node, warn — then save anyway (the two-tier model: ship is a convenience, not a gate;
5
+ * the server is the real backstop). Returns an optional warning string for the caller to print.
6
+ */
7
+ export declare function runShip(repoRoot: string, id: string): {
8
+ warning?: string;
9
+ };
@@ -0,0 +1,28 @@
1
+ import { engine } from '@threadit/core';
2
+ import { resolveThreaditPath } from '../paths.js';
3
+ import { loadThreadit, saveThreadit } from '../yaml/io.js';
4
+ import { findNode } from '../mutate.js';
5
+ import { buildCommitFacts } from '../git/commitFacts.js';
6
+ /**
7
+ * Flip a node to shipped. Courtesy check (warn, never block): set shipped in-memory,
8
+ * build CommitFacts from git, run the engine; if it raises `unbacked-shipped` for this
9
+ * node, warn — then save anyway (the two-tier model: ship is a convenience, not a gate;
10
+ * the server is the real backstop). Returns an optional warning string for the caller to print.
11
+ */
12
+ export function runShip(repoRoot, id) {
13
+ const path = resolveThreaditPath(repoRoot);
14
+ const file = loadThreadit(path);
15
+ const node = findNode(file, id);
16
+ node.status = 'shipped';
17
+ const facts = buildCommitFacts(repoRoot, file);
18
+ const result = engine({ file, commitFacts: facts });
19
+ const unbacked = result.findings.find((f) => f.kind === 'unbacked-shipped' && f.target === id);
20
+ saveThreadit(path, file);
21
+ if (unbacked) {
22
+ return {
23
+ warning: `ship: "${id}" has no qualifying backing commit yet — flipped to shipped anyway. ` +
24
+ `It will surface as an unbacked-shipped finding until a Threadit:${id}-trailered code commit lands.`,
25
+ };
26
+ }
27
+ return {};
28
+ }
@@ -0,0 +1,2 @@
1
+ import type { ValidatedNode } from '@threadit/core';
2
+ export declare function runShow(repoRoot: string, id: string): ValidatedNode;
@@ -0,0 +1,16 @@
1
+ import { engine } from '@threadit/core';
2
+ import { resolveThreaditPath } from '../paths.js';
3
+ import { loadThreadit } from '../yaml/io.js';
4
+ import { buildCommitFacts } from '../git/commitFacts.js';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ export function runShow(repoRoot, id) {
8
+ const file = loadThreadit(resolveThreaditPath(repoRoot));
9
+ // CommitFacts only if this is a git repo; otherwise derive without them.
10
+ const facts = existsSync(join(repoRoot, '.git')) ? buildCommitFacts(repoRoot, file) : undefined;
11
+ const { graph } = engine({ file, ...(facts ? { commitFacts: facts } : {}) });
12
+ const node = graph.nodes.find((n) => n.id === id);
13
+ if (!node)
14
+ throw new Error(`No node with id "${id}" in threadit.yml`);
15
+ return node;
16
+ }
@@ -0,0 +1,5 @@
1
+ import type { ParsedInboxFile, UntilPredicate } from '@threadit/core';
2
+ /** Pure core: snooze an inbox item with a wake trigger. */
3
+ export declare function applySnooze(inbox: ParsedInboxFile, itemId: string, until: UntilPredicate): void;
4
+ /** Snooze MUST carry a wake trigger (no park-forever). */
5
+ export declare function runSnooze(repoRoot: string, itemId: string, until: UntilPredicate): void;
@@ -0,0 +1,16 @@
1
+ import { resolveInboxPath } from '../paths.js';
2
+ import { mutateInbox } from '../mutate.js';
3
+ /** Pure core: snooze an inbox item with a wake trigger. */
4
+ export function applySnooze(inbox, itemId, until) {
5
+ if (!until || Object.keys(until).length === 0)
6
+ throw new Error('snooze requires an --until wake trigger');
7
+ const item = inbox.items.find((i) => i.id === itemId);
8
+ if (!item)
9
+ throw new Error(`No inbox item "${itemId}"`);
10
+ item.status = 'snoozed';
11
+ item.snooze_until = until;
12
+ }
13
+ /** Snooze MUST carry a wake trigger (no park-forever). */
14
+ export function runSnooze(repoRoot, itemId, until) {
15
+ mutateInbox(resolveInboxPath(repoRoot), (inbox) => applySnooze(inbox, itemId, until));
16
+ }
@@ -0,0 +1,14 @@
1
+ import type { Finding, NodeId, ReadingListEntry } from '@threadit/core';
2
+ export interface StatusReport {
3
+ head: string | null;
4
+ lastSha: string | undefined;
5
+ unsynced: boolean;
6
+ currentSession: number;
7
+ wrappedThrough: number | undefined;
8
+ priorSessionUnwrapped: boolean;
9
+ workingSlice: NodeId[];
10
+ findings: Finding[];
11
+ readingList: ReadingListEntry[];
12
+ }
13
+ export declare function runStatus(repoRoot: string): StatusReport;
14
+ export declare function formatReadingList(entries: ReadingListEntry[]): string[];
@@ -0,0 +1,47 @@
1
+ import { engine, computeWorkingSlice, deriveReadingList } from '@threadit/core';
2
+ import { resolveThreaditPath, resolveInboxPath } from '../paths.js';
3
+ import { loadThreadit, loadInbox } from '../yaml/io.js';
4
+ import { buildCommitFacts } from '../git/commitFacts.js';
5
+ import { readConfig } from '../config.js';
6
+ import { firstParentChain } from '../git/log.js';
7
+ import { existsSync } from 'node:fs';
8
+ export function runStatus(repoRoot) {
9
+ const file = loadThreadit(resolveThreaditPath(repoRoot));
10
+ const inboxPath = resolveInboxPath(repoRoot);
11
+ const inbox = existsSync(inboxPath) ? loadInbox(inboxPath) : undefined;
12
+ const facts = buildCommitFacts(repoRoot, file);
13
+ const result = engine({ file, commitFacts: facts, ...(inbox ? { inbox } : {}) });
14
+ const slice = computeWorkingSlice(result.graph);
15
+ const chain = firstParentChain(repoRoot);
16
+ const head = chain[0]?.sha ?? null;
17
+ const cfg = readConfig(repoRoot);
18
+ const unsynced = head !== null && head !== cfg.lastSha;
19
+ const currentSession = cfg.session ?? 0;
20
+ const wrappedThrough = file.wrapup?.session;
21
+ // `> 0` guards the degenerate case: `readSession` returns 0 pre-bump, so a `wrapup` stamped before
22
+ // any SessionStart records session 0 — "set but not meaningfully wrapped" → don't warn.
23
+ const priorSessionUnwrapped = wrappedThrough !== undefined && wrappedThrough > 0 && wrappedThrough < currentSession - 1;
24
+ return {
25
+ head,
26
+ lastSha: cfg.lastSha,
27
+ unsynced,
28
+ currentSession,
29
+ wrappedThrough,
30
+ priorSessionUnwrapped,
31
+ workingSlice: [...slice],
32
+ findings: result.findings,
33
+ readingList: deriveReadingList(result.graph),
34
+ };
35
+ }
36
+ export function formatReadingList(entries) {
37
+ if (entries.length === 0)
38
+ return ['READ NEXT: none'];
39
+ const lines = ['READ NEXT:'];
40
+ for (const e of entries) {
41
+ lines.push('');
42
+ lines.push(`${e.nodeId} [${e.status}${e.relevance === 'context' ? ' · context' : ''}]`);
43
+ for (const d of e.docs)
44
+ lines.push(` ${d.role.padEnd(9)} ${d.ref}`);
45
+ }
46
+ return lines;
47
+ }
@@ -0,0 +1 @@
1
+ export declare function runSupersede(repoRoot: string, id: string, by: string): void;
@@ -0,0 +1,9 @@
1
+ import { resolveThreaditPath } from '../paths.js';
2
+ import { mutate, findNode } from '../mutate.js';
3
+ export function runSupersede(repoRoot, id, by) {
4
+ mutate(resolveThreaditPath(repoRoot), (file) => {
5
+ const node = findNode(file, id);
6
+ node.status = 'superseded';
7
+ node.superseded_by = by;
8
+ });
9
+ }
@@ -0,0 +1,16 @@
1
+ import { type SyncDeps } from '../transport/sync.js';
2
+ export interface SyncOpts {
3
+ draft?: boolean;
4
+ sessionKey?: string;
5
+ }
6
+ export interface DriftAdvisory {
7
+ kind: 'client-older' | 'server-older';
8
+ clientV: string;
9
+ serverV: string;
10
+ }
11
+ export declare function driftAdvisory(clientV: string, serverV: string): DriftAdvisory | null;
12
+ /** Push committed history (or the uncommitted draft) to the server. Transport is injected for tests. */
13
+ export declare function runSync(repoRoot: string, opts: SyncOpts, deps?: SyncDeps): Promise<void>;
14
+ /** Best-effort post of the current on-disk inbox (current-state, not per-commit). Exported so the
15
+ * postAction inbox-push and runSync share one definition. */
16
+ export declare function pushInbox(repoRoot: string, projectId: string, server: string, token: string, deps: SyncDeps): Promise<void>;
@@ -0,0 +1,66 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolveThreaditPath, resolveInboxPath, THREADIT_FILE } from '../paths.js';
3
+ import { readConfig, updateConfig } from '../config.js';
4
+ import { firstParentChain, fileBlobAt, trailersFor, touchedPaths, isAncestor } from '../git/log.js';
5
+ import { buildRevisionPayloads, postIngest, postDraft, postInbox, postManifest } from '../transport/sync.js';
6
+ import { PACK_VERSION, isOlder, isDevVersion } from '../version.js';
7
+ export function driftAdvisory(clientV, serverV) {
8
+ if (isDevVersion(clientV) || isDevVersion(serverV) || clientV === serverV)
9
+ return null;
10
+ return isOlder(clientV, serverV) ? { kind: 'client-older', clientV, serverV } : { kind: 'server-older', clientV, serverV };
11
+ }
12
+ /** Push committed history (or the uncommitted draft) to the server. Transport is injected for tests. */
13
+ export async function runSync(repoRoot, opts, deps = { fetch: globalThis.fetch }) {
14
+ const cfg = readConfig(repoRoot);
15
+ if (!cfg.server || !cfg.ingestToken)
16
+ throw new Error('sync: config missing server/ingestToken (run threadit init + set them)');
17
+ if (opts.draft) {
18
+ const fileBlob = readFileSync(resolveThreaditPath(repoRoot), 'utf8');
19
+ await postDraft(cfg.server, cfg.ingestToken, {
20
+ projectId: cfg.projectId,
21
+ sessionKey: opts.sessionKey ?? 'local',
22
+ fileBlob,
23
+ }, deps);
24
+ return; // draft never advances the cursor
25
+ }
26
+ // Detect a history REWRITE: the server's last-synced sha is no longer an ancestor of HEAD
27
+ // (rebase / amend / force-push moved or dropped it). On a rewrite we purge orphans server-side
28
+ // (via the manifest) and re-backfill the WHOLE chain; idempotent ingest makes surviving shas no-ops.
29
+ const rewrite = cfg.lastSha !== undefined && !isAncestor(repoRoot, cfg.lastSha, 'HEAD');
30
+ // On a rewrite, walk the full chain (sinceSha = undefined). Otherwise, incremental since lastSha.
31
+ const sinceSha = rewrite ? undefined : cfg.lastSha;
32
+ const chain = firstParentChain(repoRoot, sinceSha);
33
+ if (rewrite) {
34
+ // FIRST tell the server the full current sha set so it purges anything no longer reachable,
35
+ // THEN re-ingest. Order matters: purge-before-reingest, so an orphaned sha can't survive.
36
+ const fullChain = firstParentChain(repoRoot, undefined);
37
+ await postManifest(cfg.server, cfg.ingestToken, { projectId: cfg.projectId, shas: fullChain.map((c) => c.sha) }, deps);
38
+ }
39
+ if (chain.length === 0) {
40
+ // No new commits — still sync the inbox if present (its content may have changed).
41
+ await pushInbox(repoRoot, cfg.projectId, cfg.server, cfg.ingestToken, deps);
42
+ return;
43
+ }
44
+ const payloads = buildRevisionPayloads(cfg.projectId, chain, (sha) => fileBlobAt(repoRoot, sha, THREADIT_FILE), (sha) => trailersFor(repoRoot, sha), (sha) => touchedPaths(repoRoot, sha));
45
+ let lastInfo;
46
+ for (const p of payloads)
47
+ lastInfo = await postIngest(cfg.server, cfg.ingestToken, p, deps);
48
+ await pushInbox(repoRoot, cfg.projectId, cfg.server, cfg.ingestToken, deps);
49
+ const newest = chain[0].sha;
50
+ updateConfig(repoRoot, { lastSha: newest });
51
+ if (lastInfo) {
52
+ const adv = driftAdvisory(PACK_VERSION, lastInfo.serverVersion);
53
+ if (adv)
54
+ process.stderr.write(`threadit: ${adv.kind === 'client-older'
55
+ ? `server is newer (${adv.serverV} > ${adv.clientV}) — update the client`
56
+ : `server is older (${adv.serverV} < ${adv.clientV}) — consider updating the server`}\n`);
57
+ }
58
+ }
59
+ /** Best-effort post of the current on-disk inbox (current-state, not per-commit). Exported so the
60
+ * postAction inbox-push and runSync share one definition. */
61
+ export async function pushInbox(repoRoot, projectId, server, token, deps) {
62
+ const inboxPath = resolveInboxPath(repoRoot);
63
+ if (!existsSync(inboxPath))
64
+ return;
65
+ await postInbox(server, token, { projectId, fileBlob: readFileSync(inboxPath, 'utf8') }, deps);
66
+ }
@@ -0,0 +1,13 @@
1
+ import type { ParsedThreaditFile, Status, Flow } from '@threadit/core';
2
+ export interface UpdateOpts {
3
+ status?: Status;
4
+ title?: string;
5
+ summary?: string;
6
+ summaryAt?: string;
7
+ detail?: string;
8
+ flow?: Flow;
9
+ milestone?: boolean;
10
+ }
11
+ /** Pure core: mutate a parsed threadit file in-place. */
12
+ export declare function applyUpdate(file: ParsedThreaditFile, id: string, opts: UpdateOpts): void;
13
+ export declare function runUpdate(repoRoot: string, id: string, opts: UpdateOpts): void;
@@ -0,0 +1,23 @@
1
+ import { resolveThreaditPath } from '../paths.js';
2
+ import { mutate, findNode } from '../mutate.js';
3
+ /** Pure core: mutate a parsed threadit file in-place. */
4
+ export function applyUpdate(file, id, opts) {
5
+ const node = findNode(file, id);
6
+ if (opts.status !== undefined)
7
+ node.status = opts.status;
8
+ if (opts.title !== undefined)
9
+ node.title = opts.title;
10
+ if (opts.summary !== undefined)
11
+ node.summary = opts.summary;
12
+ if (opts.summaryAt !== undefined)
13
+ node.summary_at = opts.summaryAt;
14
+ if (opts.detail !== undefined)
15
+ node.detail = opts.detail;
16
+ if (opts.flow !== undefined)
17
+ node.flow = opts.flow;
18
+ if (opts.milestone !== undefined)
19
+ node.milestone = opts.milestone;
20
+ }
21
+ export function runUpdate(repoRoot, id, opts) {
22
+ mutate(resolveThreaditPath(repoRoot), (file) => applyUpdate(file, id, opts));
23
+ }
@@ -0,0 +1,18 @@
1
+ export interface ValidateResult {
2
+ code: number;
3
+ output: string;
4
+ }
5
+ /**
6
+ * Run structural validation on the file at `filePath`.
7
+ * Returns { code, output } without printing — bin.ts prints + exits.
8
+ *
9
+ * Exit codes:
10
+ * 0 — file is structurally valid
11
+ * 1 — structural errors found (dangling edge, cycle, etc.) OR parse failure
12
+ * 2 — IO error (file cannot be read) or usage error (wrong args)
13
+ */
14
+ export declare function runValidate(filePath: string): Promise<ValidateResult>;
15
+ /** Command wrapper: resolves the threadit.yml path from global opts, then runs validation. */
16
+ export declare function runValidateCommand(globalOpts: {
17
+ repo?: string;
18
+ }, explicitPath?: string): Promise<ValidateResult>;
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { engine, parseThreaditFile, ThreaditParseError } from '@threadit/core';
3
+ import { resolveThreaditPath } from '../paths.js';
4
+ /**
5
+ * Run structural validation on the file at `filePath`.
6
+ * Returns { code, output } without printing — bin.ts prints + exits.
7
+ *
8
+ * Exit codes:
9
+ * 0 — file is structurally valid
10
+ * 1 — structural errors found (dangling edge, cycle, etc.) OR parse failure
11
+ * 2 — IO error (file cannot be read) or usage error (wrong args)
12
+ */
13
+ export async function runValidate(filePath) {
14
+ // --- 1. Read file from disk ---
15
+ let text;
16
+ try {
17
+ text = readFileSync(filePath, 'utf8');
18
+ }
19
+ catch (err) {
20
+ const msg = err instanceof Error ? err.message : String(err);
21
+ return { code: 2, output: `Cannot read file "${filePath}": ${msg}` };
22
+ }
23
+ // --- 2. Parse YAML + schema ---
24
+ let file;
25
+ try {
26
+ file = parseThreaditFile(text);
27
+ }
28
+ catch (err) {
29
+ if (err instanceof ThreaditParseError) {
30
+ return { code: 1, output: `Parse error in "${filePath}": ${err.message}` };
31
+ }
32
+ // unexpected error — treat as IO/usage
33
+ const msg = err instanceof Error ? err.message : String(err);
34
+ return { code: 2, output: `Unexpected error parsing "${filePath}": ${msg}` };
35
+ }
36
+ // --- 3. Run engine (structural validation only; no commitFacts in Phase 1) ---
37
+ const result = engine({ file });
38
+ if (result.structuralErrors.length === 0) {
39
+ return {
40
+ code: 0,
41
+ output: `OK: "${filePath}" is structurally valid (${result.graph.nodes.length} nodes)`,
42
+ };
43
+ }
44
+ // Format one line per structural error: "<kind> at <at>: <message>"
45
+ const lines = result.structuralErrors.map((e) => `${e.kind} at ${e.at}: ${e.message}`);
46
+ return { code: 1, output: lines.join('\n') };
47
+ }
48
+ /** Command wrapper: resolves the threadit.yml path from global opts, then runs validation. */
49
+ export async function runValidateCommand(globalOpts, explicitPath) {
50
+ const path = explicitPath ?? resolveThreaditPath(globalOpts.repo);
51
+ return runValidate(path);
52
+ }
@@ -0,0 +1,3 @@
1
+ /** Stamp the wrap-up watermark = the current session counter. Purely mechanical
2
+ * (the first stub of Threadit subsuming the wrap-up skill). Returns the stamped session. */
3
+ export declare function runWrapup(repoRoot: string): number;
@@ -0,0 +1,10 @@
1
+ import { resolveThreaditPath } from '../paths.js';
2
+ import { mutate } from '../mutate.js';
3
+ import { readSession } from '../session.js';
4
+ /** Stamp the wrap-up watermark = the current session counter. Purely mechanical
5
+ * (the first stub of Threadit subsuming the wrap-up skill). Returns the stamped session. */
6
+ export function runWrapup(repoRoot) {
7
+ const session = readSession(repoRoot);
8
+ mutate(resolveThreaditPath(repoRoot), (file) => { file.wrapup = { session }; });
9
+ return session;
10
+ }
@@ -0,0 +1,11 @@
1
+ export interface ThreaditConfig {
2
+ projectId: string;
3
+ server?: string;
4
+ ingestToken?: string;
5
+ lastSha?: string;
6
+ session?: number;
7
+ packVersion?: string;
8
+ }
9
+ export declare function readConfig(repoRoot: string): ThreaditConfig;
10
+ export declare function writeConfig(repoRoot: string, cfg: ThreaditConfig): void;
11
+ export declare function updateConfig(repoRoot: string, patch: Partial<ThreaditConfig>): ThreaditConfig;
package/dist/config.js ADDED
@@ -0,0 +1,20 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ function configPath(repoRoot) {
4
+ return join(repoRoot, '.threadit', 'config.json');
5
+ }
6
+ export function readConfig(repoRoot) {
7
+ const p = configPath(repoRoot);
8
+ if (!existsSync(p))
9
+ return { projectId: '' };
10
+ return JSON.parse(readFileSync(p, 'utf8'));
11
+ }
12
+ export function writeConfig(repoRoot, cfg) {
13
+ mkdirSync(join(repoRoot, '.threadit'), { recursive: true });
14
+ writeFileSync(configPath(repoRoot), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
15
+ }
16
+ export function updateConfig(repoRoot, patch) {
17
+ const merged = { ...readConfig(repoRoot), ...patch };
18
+ writeConfig(repoRoot, merged);
19
+ return merged;
20
+ }
@@ -0,0 +1,29 @@
1
+ import { type SyncOpts } from './commands/sync.js';
2
+ /** The working file is an in-flight draft worth streaming iff it exists AND differs from committed HEAD. */
3
+ export declare function draftDiverged(workingBlob: string | null, headBlob: string | null): boolean;
4
+ /** Which commands get a post-action draft push. `sync` already pushes; `serve` is long-running.
5
+ * Everything else relies on the divergence gate (read commands simply never diverge). */
6
+ export declare function shouldPushAfter(commandName: string): boolean;
7
+ export interface DraftPushDeps {
8
+ readWorking: () => string | null;
9
+ readHead: () => string | null;
10
+ readSession: () => number;
11
+ serverConfigured: () => boolean;
12
+ sync: (repoRoot: string, opts: SyncOpts) => Promise<void>;
13
+ }
14
+ export declare function defaultDraftPushDeps(repoRoot: string): DraftPushDeps;
15
+ /** Best-effort: push the working file as a session-keyed draft overlay iff it diverged from HEAD
16
+ * and a server is configured. Returns whether it pushed. Caller owns error swallowing. */
17
+ export declare function maybePushDraft(repoRoot: string, deps: DraftPushDeps): Promise<{
18
+ pushed: boolean;
19
+ }>;
20
+ export declare function shouldPushInboxAfter(commandName: string): boolean;
21
+ export interface InboxPushDeps {
22
+ serverConfigured: () => boolean;
23
+ push: (repoRoot: string) => Promise<void>;
24
+ }
25
+ export declare function defaultInboxPushDeps(repoRoot: string): InboxPushDeps;
26
+ /** Best-effort: push the current inbox iff a server is configured. Caller owns error swallowing. */
27
+ export declare function maybePushInbox(repoRoot: string, deps: InboxPushDeps): Promise<{
28
+ pushed: boolean;
29
+ }>;
package/dist/draft.js ADDED
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolveThreaditPath, THREADIT_FILE } from './paths.js';
3
+ import { fileBlobAt } from './git/log.js';
4
+ import { readConfig } from './config.js';
5
+ import { readSession } from './session.js';
6
+ import { runSync, pushInbox } from './commands/sync.js';
7
+ /** The working file is an in-flight draft worth streaming iff it exists AND differs from committed HEAD. */
8
+ export function draftDiverged(workingBlob, headBlob) {
9
+ return workingBlob !== null && workingBlob !== headBlob;
10
+ }
11
+ /** Which commands get a post-action draft push. `sync` already pushes; `serve` is long-running.
12
+ * Everything else relies on the divergence gate (read commands simply never diverge). */
13
+ export function shouldPushAfter(commandName) {
14
+ return commandName !== 'sync' && commandName !== 'serve';
15
+ }
16
+ export function defaultDraftPushDeps(repoRoot) {
17
+ return {
18
+ readWorking: () => { try {
19
+ return readFileSync(resolveThreaditPath(repoRoot), 'utf8');
20
+ }
21
+ catch {
22
+ return null;
23
+ } },
24
+ readHead: () => fileBlobAt(repoRoot, 'HEAD', THREADIT_FILE),
25
+ readSession: () => readSession(repoRoot),
26
+ serverConfigured: () => { const c = readConfig(repoRoot); return !!c.server && !!c.ingestToken; },
27
+ sync: runSync,
28
+ };
29
+ }
30
+ /** Best-effort: push the working file as a session-keyed draft overlay iff it diverged from HEAD
31
+ * and a server is configured. Returns whether it pushed. Caller owns error swallowing. */
32
+ export async function maybePushDraft(repoRoot, deps) {
33
+ if (!deps.serverConfigured())
34
+ return { pushed: false };
35
+ if (!draftDiverged(deps.readWorking(), deps.readHead()))
36
+ return { pushed: false };
37
+ await deps.sync(repoRoot, { draft: true, sessionKey: `s${deps.readSession()}` });
38
+ return { pushed: true };
39
+ }
40
+ /** Which commands mutate the inbox file and so warrant a post-action inbox push.
41
+ * These five are the inbox-mutating verbs; everything else leaves the inbox untouched. */
42
+ const INBOX_VERBS = new Set(['capture', 'promote', 'merge', 'drop', 'snooze', 'reconcile']);
43
+ export function shouldPushInboxAfter(commandName) { return INBOX_VERBS.has(commandName); }
44
+ export function defaultInboxPushDeps(repoRoot) {
45
+ return {
46
+ serverConfigured: () => { const c = readConfig(repoRoot); return !!c.server && !!c.ingestToken; },
47
+ push: async (root) => {
48
+ const c = readConfig(root);
49
+ await pushInbox(root, c.projectId, c.server, c.ingestToken, { fetch: globalThis.fetch });
50
+ },
51
+ };
52
+ }
53
+ /** Best-effort: push the current inbox iff a server is configured. Caller owns error swallowing. */
54
+ export async function maybePushInbox(repoRoot, deps) {
55
+ if (!deps.serverConfigured())
56
+ return { pushed: false };
57
+ await deps.push(repoRoot);
58
+ return { pushed: true };
59
+ }
@@ -0,0 +1,6 @@
1
+ import type { ParsedThreaditFile, CommitFacts } from '@threadit/core';
2
+ /**
3
+ * Build the CommitFacts struct from live git. Same shape the Phase-3 server
4
+ * builds from stored commit rows — the engine computes identical findings either way.
5
+ */
6
+ export declare function buildCommitFacts(repoDir: string, file: ParsedThreaditFile, sinceSha?: string): CommitFacts;
@@ -0,0 +1,56 @@
1
+ import picomatch from 'picomatch';
2
+ import { firstParentChain, trailerCommits, touchedPaths } from './log.js';
3
+ import { isOwnedPath } from '../paths.js';
4
+ /**
5
+ * Build the CommitFacts struct from live git. Same shape the Phase-3 server
6
+ * builds from stored commit rows — the engine computes identical findings either way.
7
+ */
8
+ export function buildCommitFacts(repoDir, file, sinceSha) {
9
+ const chain = firstParentChain(repoDir, sinceSha);
10
+ const nodeById = new Map(file.nodes.map((n) => [n.id, n]));
11
+ const trailerCommitsByNode = {};
12
+ // Single pass over trailered commits — reused for both trailerCommitsByNode and trailersBySha below.
13
+ const allTrailerCommits = trailerCommits(repoDir, sinceSha);
14
+ const trailersBySha = new Map(allTrailerCommits.map((tc) => [tc.sha, tc.trailers]));
15
+ for (const tc of allTrailerCommits) {
16
+ const paths = touchedPaths(repoDir, tc.sha);
17
+ const touchesCodePath = paths.some((p) => !isOwnedPath(p, file));
18
+ for (const nodeId of tc.trailers) {
19
+ const node = nodeById.get(nodeId);
20
+ const globs = node?.paths;
21
+ const touchesNodePaths = globs !== undefined && globs.length > 0
22
+ ? paths.some((p) => globs.some((g) => picomatch.isMatch(p, g.startsWith('./') ? g.slice(2) : g)))
23
+ : false;
24
+ (trailerCommitsByNode[nodeId] ??= []).push({
25
+ sha: tc.sha,
26
+ authorDate: tc.authorDate,
27
+ touchesCodePath,
28
+ touchesNodePaths,
29
+ });
30
+ }
31
+ }
32
+ // nodeLatestWorkSha: newest first-parent commit that carries the node's trailer OR touches its paths.
33
+ // Iterate chain newest-first; first match per node wins. Mirrors server's buildServerCommitFacts exactly.
34
+ const nodeLatestWorkSha = {};
35
+ for (const commit of chain) {
36
+ const commitTrailers = trailersBySha.get(commit.sha) ?? [];
37
+ // Lazily compute touched paths only when a node with globs still needs evaluation.
38
+ let paths;
39
+ const getPaths = () => { paths ??= touchedPaths(repoDir, commit.sha); return paths; };
40
+ for (const node of file.nodes) {
41
+ if (nodeLatestWorkSha[node.id] !== undefined)
42
+ continue;
43
+ const byTrailer = commitTrailers.includes(node.id);
44
+ const globs = node.paths ?? [];
45
+ const byPath = globs.length > 0 && getPaths().some((p) => globs.some((g) => picomatch.isMatch(p, g.startsWith('./') ? g.slice(2) : g)));
46
+ if (byTrailer || byPath)
47
+ nodeLatestWorkSha[node.id] = commit.sha;
48
+ }
49
+ }
50
+ return {
51
+ trailerCommits: trailerCommitsByNode,
52
+ firstParentChain: chain.map((c) => ({ sha: c.sha, authorDate: c.authorDate, parent: c.parent })),
53
+ knownShas: chain.map((c) => c.sha),
54
+ ...(Object.keys(nodeLatestWorkSha).length > 0 ? { nodeLatestWorkSha } : {}),
55
+ };
56
+ }
@@ -0,0 +1,41 @@
1
+ export interface ChainEntry {
2
+ sha: string;
3
+ authorDate: string;
4
+ parent: string | null;
5
+ }
6
+ /** main's first-parent chain, newest-first. `sinceSha` (exclusive) bounds the bottom. */
7
+ export declare function firstParentChain(repoDir: string, sinceSha?: string): ChainEntry[];
8
+ /** File content at a revision, or null if the file did not exist there. */
9
+ export declare function fileBlobAt(repoDir: string, sha: string, relPath: string): string | null;
10
+ export interface TrailerCommit {
11
+ sha: string;
12
+ authorDate: string;
13
+ trailers: string[];
14
+ }
15
+ /**
16
+ * All commits on main's first-parent chain that carry ≥1 `Threadit: <id>` trailer.
17
+ * Uses git's trailer formatting (exact values, no regex fragility).
18
+ */
19
+ export declare function trailerCommits(repoDir: string, sinceSha?: string): TrailerCommit[];
20
+ /**
21
+ * The `Threadit: <id>` trailer values on ONE commit (by sha). Mirrors trailerCommits' git-trailer
22
+ * formatter (no regex). Returns [] when the commit carries none.
23
+ */
24
+ export declare function trailersFor(repoDir: string, sha: string): string[];
25
+ /**
26
+ * True iff `maybeAncestor` is an ancestor of `descendant` on the current history. Uses
27
+ * `git merge-base --is-ancestor` (exit 0 = ancestor, exit 1 = NOT ancestor). `descendant` is always
28
+ * a valid ref here ('HEAD'), so a 128 means `maybeAncestor` itself is unresolvable — the stored sha
29
+ * was rewritten away AND pruned/gc'd (or this is a fresh clone). That is itself a divergence signal,
30
+ * so we treat 1 AND 128 as "not an ancestor" rather than crashing sync (the resulting full re-sync is
31
+ * idempotent — safe to over-trigger). A spawn failure (no numeric status) or any other code rethrows,
32
+ * mirroring the gitProcessExited discipline (distinguish a clean answer from a real error).
33
+ *
34
+ * Used by `sync` to detect a history REWRITE: if the server's last-synced sha is NO LONGER an
35
+ * ancestor of HEAD, the chain was rebased/amended/force-pushed and the server must purge + re-ingest.
36
+ */
37
+ export declare function isAncestor(repoDir: string, maybeAncestor: string, descendant: string): boolean;
38
+ /** sha → subject line for main's first-parent chain. `sinceSha` (exclusive) bounds the bottom. */
39
+ export declare function firstParentSubjects(repoDir: string, sinceSha?: string): Map<string, string>;
40
+ /** Paths a single commit touched (root commit handled via --root; -z = NUL-terminated, quoting-safe). */
41
+ export declare function touchedPaths(repoDir: string, sha: string): string[];