@gitdocket/core 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,194 @@
1
+ // @gitdocket/core — the engine. Every surface (CLI, web, MCP, App) is a thin
2
+ // client over this library; there is exactly one write path.
3
+
4
+ export {
5
+ type Bundle,
6
+ findRepoRoot,
7
+ loadBundle,
8
+ loadRepo,
9
+ readyWorkItems,
10
+ } from "./bundle";
11
+ export {
12
+ CONFIG_FILENAME,
13
+ DEFAULT_BUNDLE,
14
+ type DocketConfig,
15
+ parseConfig,
16
+ } from "./config";
17
+ export {
18
+ ENGINE_SEMANTICS,
19
+ READY_QUEUE_DESCRIPTION,
20
+ } from "./engine-semantics";
21
+ export { type FileStore, InMemoryFileStore, LocalFileStore } from "./filestore";
22
+ export { applyIndex, INDEX_MARKER, renderIndex } from "./indexmd";
23
+ export {
24
+ ALLOW_RULES,
25
+ composeFreshnessBaseline,
26
+ composeHook,
27
+ defaultConfigYaml,
28
+ deriveProjectKey,
29
+ ensureGitignore,
30
+ type InitAction,
31
+ type InitResult,
32
+ mergeClaudeSettings,
33
+ mergeCodexConfig,
34
+ mergeMcpJson,
35
+ needsFrontmatter,
36
+ proposeType,
37
+ scaffoldFiles,
38
+ upgradeCodexConfig,
39
+ upgradeHookBlock,
40
+ } from "./init";
41
+ export {
42
+ AGENT_INTENT_DISAMBIGUATION,
43
+ AGENT_INTENT_IDS,
44
+ AGENT_INTENTS,
45
+ type AgentIntentContract,
46
+ type AgentIntentId,
47
+ agentIntent,
48
+ DIRECT_WORK_INTENT,
49
+ DIRECT_WORK_INTENT_ID,
50
+ DOCKET_INTENT_DISAMBIGUATION,
51
+ DOCKET_INTENT_IDS,
52
+ DOCKET_INTENTS,
53
+ type DocketIntentContract,
54
+ type DocketIntentId,
55
+ docketIntent,
56
+ type IntentEntrypoint,
57
+ type IntentMode,
58
+ PICKUP_AUTHORITY_EVIDENCE,
59
+ } from "./intents";
60
+ export {
61
+ type FreshnessWatermark,
62
+ findFreshnessWatermark,
63
+ type LintOptions,
64
+ lintBundle,
65
+ resolveLink,
66
+ } from "./lint";
67
+ export {
68
+ appendLog,
69
+ type CreateInput,
70
+ createWorkItem,
71
+ nextId,
72
+ setEpic,
73
+ setPriority,
74
+ setRank,
75
+ setStatus,
76
+ slugify,
77
+ } from "./ops";
78
+ export {
79
+ buildContextPacket,
80
+ type CommitRef,
81
+ type ContextPacket,
82
+ type PacketDep,
83
+ type PacketLink,
84
+ } from "./packet";
85
+ export {
86
+ type Concept,
87
+ type Decision,
88
+ type Diagnostic,
89
+ type GenericConcept,
90
+ isReserved,
91
+ type Link,
92
+ parseConcept,
93
+ type WorkItem,
94
+ } from "./parse";
95
+ export {
96
+ type IntentDiscoveryDiagnostic,
97
+ type IntentDiscoveryDiagnosticCode,
98
+ PROMPT_ROUTING_FIXTURES,
99
+ type PromptRoutingDiagnostic,
100
+ type PromptRoutingDiagnosticCode,
101
+ type PromptRoutingFixture,
102
+ type PromptRoutingTrait,
103
+ validateIntentDiscoveryDescriptions,
104
+ validatePromptRoutingFixtures,
105
+ } from "./prompt-routing";
106
+ export {
107
+ buildSchemas,
108
+ type DecisionFrontmatter,
109
+ type GenericFrontmatter,
110
+ type Schemas,
111
+ type WorkItemFrontmatter,
112
+ } from "./schema";
113
+ export { type SearchHit, searchBundle } from "./search";
114
+ export {
115
+ formatOrigin,
116
+ type Origin,
117
+ parseOrigin,
118
+ recoverOrigin,
119
+ type ShippedHistory,
120
+ shippedHistory,
121
+ shippedWorkflow,
122
+ } from "./shipped";
123
+ export {
124
+ type LegacyStateOfPlayNote,
125
+ parseStateOfPlay,
126
+ presentStateOfPlay,
127
+ REENTRY_CONTEXT_FORMAT,
128
+ REENTRY_CONTEXT_V1_FORMAT,
129
+ type ReentryAssessment,
130
+ type ReentryContextNote,
131
+ type ReentryV1ContextNote,
132
+ STATE_OF_PLAY_PATH,
133
+ STATE_OF_PLAY_REVIEW_MAX_DAYS,
134
+ STATE_OF_PLAY_STALE_COMMITS,
135
+ type StateOfPlayNote,
136
+ type StateOfPlayParseResult,
137
+ type StateOfPlayPresentationOptions,
138
+ type StateOfPlayReview,
139
+ type StateOfPlayReviewReason,
140
+ type StateOfPlayView,
141
+ } from "./state-of-play";
142
+ export {
143
+ byManualOrder,
144
+ canTransition,
145
+ DECISION_STATES,
146
+ type DecisionStatus,
147
+ isPriority,
148
+ isReady,
149
+ isStatus,
150
+ isTerminalStatus,
151
+ PRIORITIES,
152
+ type Priority,
153
+ STATES,
154
+ type Status,
155
+ TERMINAL_STATES,
156
+ TRANSITIONS,
157
+ WORK_ITEM_TYPES,
158
+ type WorkItemType,
159
+ } from "./states";
160
+ export {
161
+ type Merge3,
162
+ markerVersion,
163
+ type UpgradeAction,
164
+ type UpgradeResult,
165
+ upgradeAdapter,
166
+ upgradeWorkflowFile,
167
+ } from "./upgrade";
168
+ export {
169
+ resolveVerifyMarkers,
170
+ scanVerifyMarkers,
171
+ VERIFY_TOKEN,
172
+ type VerifyMarker,
173
+ type VerifySource,
174
+ type VerifyStatusRow,
175
+ verifyStatus,
176
+ } from "./verify";
177
+ export { DOCKET_VERSION } from "./version";
178
+ export {
179
+ ADAPTER_MARKER,
180
+ composeManagedSection,
181
+ DOCKET_WORKFLOWS,
182
+ hasAdapterMarker,
183
+ hasDocketSection,
184
+ renderAgentSkillStub,
185
+ renderClaudeSkillStub,
186
+ renderDocketSection,
187
+ renderWorkflow,
188
+ validateWorkflowSemantics,
189
+ WORKFLOWS_DIR,
190
+ type WorkflowDef,
191
+ type WorkflowSemantic,
192
+ type WorkflowSemanticDiagnostic,
193
+ workflowPath,
194
+ } from "./workflows";
package/src/indexmd.ts ADDED
@@ -0,0 +1,146 @@
1
+ // Generated index.md: derived content is never hand-maintained.
2
+ // Everything above the marker is human preamble and survives regeneration;
3
+ // everything below is machine-owned and rendered deterministically from the
4
+ // bundle (lockfile pattern — committed, and CI fails on drift).
5
+
6
+ import type { Bundle } from "./bundle";
7
+ import type { GenericConcept, WorkItem } from "./parse";
8
+ import { isTerminalStatus } from "./states";
9
+
10
+ export const INDEX_MARKER = "<!-- docket:generated -->";
11
+
12
+ const idNum = (id: string): number => Number(id.match(/(\d+)$/)?.[1] ?? 0);
13
+ const byId = (a: { fm: { id: string } }, b: { fm: { id: string } }): number =>
14
+ idNum(a.fm.id) - idNum(b.fm.id);
15
+
16
+ const link = (text: string, path: string): string => `[${text}](/${path})`;
17
+
18
+ const taskLine = (t: WorkItem, ready: ReadonlySet<string>): string => {
19
+ const text = link(`${t.fm.id} — ${t.fm.title ?? t.fm.id}`, t.path);
20
+ if (t.fm.status === "done") return `- ✅ ${text}`;
21
+ if (t.fm.status === "closed") return `- ⏹️ ${text} *(closed)*`;
22
+ if (t.fm.status === "in-progress" || t.fm.status === "in-review")
23
+ return `- 🔄 ${text}`;
24
+ if (t.fm.status === "blocked") return `- 🚫 ${text}`;
25
+ return ready.has(t.fm.id) ? `- ${text} *(ready)*` : `- ${text}`;
26
+ };
27
+
28
+ const tsOf = (t: WorkItem): string =>
29
+ typeof t.fm.timestamp === "string" ? t.fm.timestamp : "";
30
+
31
+ /**
32
+ * Liveness order: what's moving, then what could move, then the
33
+ * queue, then terminal history newest-first (timestamp is the transition time; ISO
34
+ * strings compare chronologically). File-derived and deterministic: no
35
+ * wall-clock input, so regeneration without a bundle change never diffs.
36
+ */
37
+ export function byLiveness(
38
+ ready: ReadonlySet<string>,
39
+ ): (a: WorkItem, b: WorkItem) => number {
40
+ const rank = (t: WorkItem): number => {
41
+ if (t.fm.status === "in-progress" || t.fm.status === "in-review") return 0;
42
+ if (ready.has(t.fm.id)) return 1;
43
+ if (isTerminalStatus(t.fm.status)) return 3;
44
+ return 2; // todo (unready) + blocked
45
+ };
46
+ return (a, b) => {
47
+ const byRank = rank(a) - rank(b);
48
+ if (byRank !== 0) return byRank;
49
+ if (rank(a) === 3) {
50
+ const byClose = tsOf(b).localeCompare(tsOf(a));
51
+ if (byClose !== 0) return byClose;
52
+ }
53
+ return byId(a, b);
54
+ };
55
+ }
56
+
57
+ /** The machine-owned body: generic concepts by directory, decisions, work by epic. */
58
+ export function renderIndex(bundle: Bundle): string {
59
+ const sections: string[] = [];
60
+
61
+ const groups = new Map<string, GenericConcept[]>();
62
+ for (const c of bundle.concepts) {
63
+ if (c.kind !== "generic") continue;
64
+ const dir = c.path.includes("/") ? (c.path.split("/")[0] ?? "") : "";
65
+ const group = groups.get(dir) ?? [];
66
+ group.push(c);
67
+ groups.set(dir, group);
68
+ }
69
+ for (const [dir, items] of [...groups.entries()].sort()) {
70
+ const name = dir ? dir[0]?.toUpperCase() + dir.slice(1) : "Concepts";
71
+ const lines = items
72
+ .sort((a, z) => a.path.localeCompare(z.path))
73
+ .map(
74
+ (c) =>
75
+ `- ${link(c.fm.title ?? c.path, c.path)}${c.fm.description ? ` — ${c.fm.description}` : ""}`,
76
+ );
77
+ sections.push(`## ${name}\n\n${lines.join("\n")}`);
78
+ }
79
+
80
+ if (bundle.decisions.length > 0) {
81
+ const lines = [...bundle.decisions].sort(byId).map((d) => {
82
+ const text = link(`${d.fm.id} — ${d.fm.title ?? d.fm.id}`, d.path);
83
+ return d.fm.status === "superseded"
84
+ ? `- ~~${text}~~ *(superseded)*`
85
+ : `- ${text}`;
86
+ });
87
+ sections.push(`## Decisions\n\n${lines.join("\n")}`);
88
+ }
89
+
90
+ const ready = new Set(bundle.readyIds());
91
+ const epics = bundle.workItems.filter((w) => w.fm.type === "Epic").sort(byId);
92
+ const tasks = bundle.workItems.filter((w) => w.fm.type === "Task").sort(byId);
93
+ if (epics.length > 0 || tasks.length > 0) {
94
+ const work: string[] = ["## Work"];
95
+ const claimed = new Set<string>();
96
+ const order = byLiveness(ready);
97
+ for (const epic of epics) {
98
+ const own = tasks.filter((t) => t.fm.epic?.includes(`/${epic.fm.id}-`));
99
+ for (const t of own) claimed.add(t.fm.id);
100
+ // Fully-done epics collapse to their rollup: the history lives
101
+ // in git and on the epic page; the index is orientation, not archive.
102
+ const finished =
103
+ epic.fm.status === "done" &&
104
+ own.length > 0 &&
105
+ own.every((t) => t.fm.status === "done");
106
+ const head = `### ${link(epic.fm.title ?? epic.fm.id, epic.path)} *(${epic.fm.status})*`;
107
+ if (finished) {
108
+ work.push(`${head}\n\n✅ all ${own.length} tasks done`);
109
+ continue;
110
+ }
111
+ work.push(head);
112
+ if (own.length > 0)
113
+ work.push(
114
+ own
115
+ .sort(order)
116
+ .map((t) => taskLine(t, ready))
117
+ .join("\n"),
118
+ );
119
+ }
120
+ const orphans = tasks.filter((t) => !claimed.has(t.fm.id));
121
+ if (orphans.length > 0) {
122
+ work.push("### No epic");
123
+ work.push(
124
+ orphans
125
+ .sort(order)
126
+ .map((t) => taskLine(t, ready))
127
+ .join("\n"),
128
+ );
129
+ }
130
+ sections.push(work.join("\n\n"));
131
+ }
132
+
133
+ return sections.join("\n\n");
134
+ }
135
+
136
+ /** Splice the generated body below the marker, preserving the human preamble above it. */
137
+ export function applyIndex(current: string, body: string): string {
138
+ const at = current.indexOf(INDEX_MARKER);
139
+ const preamble =
140
+ at >= 0
141
+ ? current.slice(0, at)
142
+ : current.trim()
143
+ ? `${current.trimEnd()}\n\n`
144
+ : "# Index\n\n";
145
+ return `${preamble}${INDEX_MARKER}\n\n${body}\n`;
146
+ }
package/src/init.ts ADDED
@@ -0,0 +1,338 @@
1
+ // docket init — pure text transforms for adopt-in-place. Adoption is
2
+ // additive, never a migration: every function here takes what already
3
+ // exists and returns a composed result, so callers can guarantee "never
4
+ // clobbers". Filesystem and git orchestration live in the CLI; this module
5
+ // stays runtime-portable.
6
+
7
+ import { INDEX_MARKER } from "./indexmd";
8
+ import { findFreshnessWatermark } from "./lint";
9
+ import { DOCKET_VERSION } from "./version";
10
+
11
+ export type InitAction = "create" | "update" | "skip";
12
+
13
+ export interface InitResult {
14
+ action: InitAction;
15
+ content: string;
16
+ /** Present when action is "skip" for a reason worth surfacing. */
17
+ reason?: string;
18
+ }
19
+
20
+ /** docket.yaml template — mirrors this repo's reference copy. */
21
+ export function defaultConfigYaml(project: string, bundle: string): string {
22
+ return `# Docket configuration — consumed by the docket CLI and by agents.
23
+
24
+ bundle: ${bundle}
25
+
26
+ project: ${project} # ID key for work items: ${project}-1, ${project}-2, … (epics + tasks share one sequence)
27
+
28
+ ids:
29
+ scheme: sequential # next = max existing + 1
30
+ decision_prefix: DEC # decisions number independently: DEC-1, DEC-2, …
31
+
32
+ workflow:
33
+ states: [todo, in-progress, blocked, in-review, done, closed]
34
+ # \`ready\` is never written to a file — it is derived:
35
+ # status == todo AND every task in depends_on has status == done.
36
+
37
+ git:
38
+ trailer: "Task" # commit trailer key linking commits to tasks
39
+ branch_prefix: "task/" # branch naming: task/${project}-12-short-slug
40
+ `;
41
+ }
42
+
43
+ /** Project key from a directory name: letters/digits, uppercased, max 3. */
44
+ export function deriveProjectKey(dirname: string): string {
45
+ const key = dirname
46
+ .replace(/[^a-zA-Z0-9]/g, "")
47
+ .toUpperCase()
48
+ .slice(0, 3);
49
+ return key || "DKT";
50
+ }
51
+
52
+ /** Bundle files scaffolded when missing. Paths are bundle-relative. */
53
+ export function scaffoldFiles(
54
+ project: string,
55
+ today: string,
56
+ ): { path: string; content: string }[] {
57
+ return [
58
+ {
59
+ path: "index.md",
60
+ content: `# ${project} — docs & work\n\n<!-- Replace the heading above with the project's full name and add one concise sentence explaining its purpose. Docket preserves this preamble when regenerating the index. -->\n\n${INDEX_MARKER}\n`,
61
+ },
62
+ {
63
+ path: "log.md",
64
+ content: `# Log\n\n## ${today}\n\n- **Create** — Adopted Docket (\`docket init\`).\n`,
65
+ },
66
+ ];
67
+ }
68
+
69
+ /**
70
+ * Stamp the freshness baseline watermark into log.md. Adoption is
71
+ * the baseline: a fresh bundle has nothing to retrospect, so init seeds the
72
+ * watermark the docket-freshness workflow advances from — instead of lint
73
+ * nagging for a ritual with nothing to sweep. The entry lands under today's
74
+ * section (log.md is newest-first). "create" refers to the entry — the file
75
+ * itself always pre-exists (scaffolded or brownfield).
76
+ */
77
+ export function composeFreshnessBaseline(
78
+ log: string,
79
+ sha: string | undefined,
80
+ today: string,
81
+ ): InitResult {
82
+ if (findFreshnessWatermark(log))
83
+ return { action: "skip", content: log, reason: "already stamped" };
84
+ if (!sha) return { action: "skip", content: log, reason: "no commits yet" };
85
+ const entry = `- **Freshness** — baseline at adoption; reviewed through \`${sha}\` (nothing to review before Docket).`;
86
+ const heading = `## ${today}`;
87
+ const lines = log.split("\n");
88
+ const at = lines.indexOf(heading);
89
+ if (at >= 0) {
90
+ lines.splice(at + 1, 0, "", entry);
91
+ return { action: "create", content: lines.join("\n") };
92
+ }
93
+ // No section for today — open one. The log is newest-first, so it goes
94
+ // right after the title (or at the very top of a title-less log).
95
+ const title = lines.findIndex((line) => line.startsWith("# "));
96
+ lines.splice(title + 1, 0, "", heading, "", entry);
97
+ return { action: "create", content: lines.join("\n") };
98
+ }
99
+
100
+ const HOOK_BEGIN = `# >>> docket prepare-commit-msg@${DOCKET_VERSION} >>>`;
101
+ const HOOK_END = "# <<< docket prepare-commit-msg <<<";
102
+ // Matches the begin marker at any version, and the legacy unversioned form.
103
+ const HOOK_MARKER_PREFIX = "# >>> docket prepare-commit-msg";
104
+
105
+ // Self-contained sh block; variable names are prefixed so appending into an
106
+ // existing hook can't collide, and every failure path exits 0 so a broken
107
+ // docket state never blocks a commit.
108
+ const HOOK_BLOCK = `${HOOK_BEGIN}
109
+ # Inject the active task's trailer into the commit message. Active task is
110
+ # per-checkout state in .docket/active-task (untracked), so git worktrees
111
+ # each carry their own active task.
112
+ docket_msg_file="$1"
113
+ docket_top="$(git rev-parse --show-toplevel 2>/dev/null)" || docket_top=""
114
+ if [ -n "$docket_top" ] && [ -f "$docket_top/.docket/active-task" ]; then
115
+ docket_task_id="$(head -n1 "$docket_top/.docket/active-task" | tr -d '[:space:]')"
116
+ if [ -n "$docket_task_id" ] && ! grep -qi "^Task:" "$docket_msg_file"; then
117
+ git interpret-trailers --in-place --trailer "Task: $docket_task_id" "$docket_msg_file"
118
+ fi
119
+ fi
120
+ ${HOOK_END}
121
+ `;
122
+
123
+ /**
124
+ * Compose the trailer-injecting block into a prepare-commit-msg hook.
125
+ * Missing hook → create; existing without our block → append; already
126
+ * installed → skip. Never rewrites what's there.
127
+ */
128
+ export function composeHook(existing: string | undefined): InitResult {
129
+ if (existing === undefined) {
130
+ return { action: "create", content: `#!/bin/sh\n${HOOK_BLOCK}` };
131
+ }
132
+ // Our marker (any version), or any hand-rolled hook already reading the
133
+ // active-task file (this repo's Phase 0 hook predates the marker).
134
+ if (
135
+ existing.includes(HOOK_MARKER_PREFIX) ||
136
+ existing.includes(".docket/active-task")
137
+ ) {
138
+ return { action: "skip", content: existing, reason: "already installed" };
139
+ }
140
+ const base = existing.endsWith("\n") ? existing : `${existing}\n`;
141
+ return { action: "update", content: `${base}\n${HOOK_BLOCK}` };
142
+ }
143
+
144
+ // Full block span, any marker version — for upgrade's regenerate-in-place.
145
+ const HOOK_BLOCK_RE =
146
+ /# >>> docket prepare-commit-msg(@\S+)? >>>[\s\S]*?# <<< docket prepare-commit-msg <<<\n?/;
147
+
148
+ /**
149
+ * Upgrade the hook block in place: the marked span (any version) is replaced
150
+ * with the current block. Hand-rolled hooks — active-task readers without our
151
+ * marker — are never touched (same rule as composeHook, inverted: compose
152
+ * skips what upgrade regenerates).
153
+ */
154
+ export function upgradeHookBlock(existing: string): InitResult {
155
+ if (!HOOK_BLOCK_RE.test(existing)) {
156
+ return {
157
+ action: "skip",
158
+ content: existing,
159
+ reason: existing.includes(".docket/active-task")
160
+ ? "hand-rolled hook — never touched"
161
+ : "no docket block",
162
+ };
163
+ }
164
+ // Replacer fn: the block contains a literal `$1`, which a string
165
+ // replacement would eat as a capture-group reference.
166
+ const content = existing.replace(HOOK_BLOCK_RE, () => HOOK_BLOCK);
167
+ return content === existing
168
+ ? { action: "skip", content: existing, reason: "up to date" }
169
+ : { action: "update", content };
170
+ }
171
+
172
+ const MCP_SERVER = { command: "docket-mcp" };
173
+
174
+ const CODEX_MCP_BEGIN = `# >>> docket mcp@${DOCKET_VERSION} >>>`;
175
+ const CODEX_MCP_END = "# <<< docket mcp <<<";
176
+ const CODEX_MCP_BEGIN_RE = /# >>> docket mcp(@\S+)? >>>/;
177
+ const CODEX_MCP_BLOCK = `${CODEX_MCP_BEGIN}
178
+ [mcp_servers.docket]
179
+ command = "docket-mcp"
180
+ ${CODEX_MCP_END}
181
+ `;
182
+ const CODEX_MCP_BLOCK_RE =
183
+ /# >>> docket mcp(@\S+)? >>>[\s\S]*?# <<< docket mcp <<<\n?/;
184
+
185
+ /** Register the docket MCP server in .mcp.json without touching other entries. */
186
+ export function mergeMcpJson(existing: string | undefined): InitResult {
187
+ if (existing === undefined) {
188
+ return {
189
+ action: "create",
190
+ content: `${JSON.stringify({ mcpServers: { docket: MCP_SERVER } }, null, 2)}\n`,
191
+ };
192
+ }
193
+ let parsed: unknown;
194
+ try {
195
+ parsed = JSON.parse(existing);
196
+ } catch {
197
+ return { action: "skip", content: existing, reason: "not valid JSON" };
198
+ }
199
+ if (typeof parsed !== "object" || parsed === null) {
200
+ return { action: "skip", content: existing, reason: "not a JSON object" };
201
+ }
202
+ const root = parsed as Record<string, unknown>;
203
+ const servers =
204
+ typeof root.mcpServers === "object" && root.mcpServers !== null
205
+ ? (root.mcpServers as Record<string, unknown>)
206
+ : {};
207
+ if (servers.docket) {
208
+ return { action: "skip", content: existing, reason: "already registered" };
209
+ }
210
+ root.mcpServers = { ...servers, docket: MCP_SERVER };
211
+ return { action: "update", content: `${JSON.stringify(root, null, 2)}\n` };
212
+ }
213
+
214
+ /**
215
+ * Add Docket's marker-managed MCP table to a valid Codex project config.
216
+ * TOML validation belongs to the runtime caller; this pure transform only
217
+ * composes text and refuses to replace an existing docket registration.
218
+ */
219
+ export function mergeCodexConfig(existing: string | undefined): InitResult {
220
+ if (existing === undefined)
221
+ return { action: "create", content: CODEX_MCP_BLOCK };
222
+ if (
223
+ CODEX_MCP_BEGIN_RE.test(existing) ||
224
+ /^\s*\[\s*mcp_servers(?:\.|\s*\.\s*)["']?docket["']?\s*\]\s*$/m.test(
225
+ existing,
226
+ )
227
+ ) {
228
+ return { action: "skip", content: existing, reason: "already registered" };
229
+ }
230
+ const base = existing.endsWith("\n") ? existing : `${existing}\n`;
231
+ return { action: "update", content: `${base}\n${CODEX_MCP_BLOCK}` };
232
+ }
233
+
234
+ /** Regenerate only Docket's marked Codex MCP block; never add one. */
235
+ export function upgradeCodexConfig(existing: string): InitResult {
236
+ if (!CODEX_MCP_BLOCK_RE.test(existing)) {
237
+ return {
238
+ action: "skip",
239
+ content: existing,
240
+ reason: "no docket block",
241
+ };
242
+ }
243
+ const content = existing.replace(CODEX_MCP_BLOCK_RE, () => CODEX_MCP_BLOCK);
244
+ return content === existing
245
+ ? { action: "skip", content: existing, reason: "up to date" }
246
+ : { action: "update", content };
247
+ }
248
+
249
+ export const ALLOW_RULES = ["mcp__docket", "Bash(docket:*)"] as const;
250
+
251
+ /**
252
+ * Ensure .claude/settings.json pre-approves the docket surface:
253
+ * enableAllProjectMcpServers plus the allow rules. Preserves everything else.
254
+ */
255
+ export function mergeClaudeSettings(existing: string | undefined): InitResult {
256
+ let root: Record<string, unknown> = {};
257
+ if (existing !== undefined) {
258
+ let parsed: unknown;
259
+ try {
260
+ parsed = JSON.parse(existing);
261
+ } catch {
262
+ return { action: "skip", content: existing, reason: "not valid JSON" };
263
+ }
264
+ if (typeof parsed !== "object" || parsed === null) {
265
+ return { action: "skip", content: existing, reason: "not a JSON object" };
266
+ }
267
+ root = parsed as Record<string, unknown>;
268
+ }
269
+
270
+ const permissions =
271
+ typeof root.permissions === "object" && root.permissions !== null
272
+ ? (root.permissions as Record<string, unknown>)
273
+ : {};
274
+ const allow = Array.isArray(permissions.allow)
275
+ ? permissions.allow.filter((r): r is string => typeof r === "string")
276
+ : [];
277
+ const missing = ALLOW_RULES.filter((rule) => !allow.includes(rule));
278
+
279
+ if (
280
+ existing !== undefined &&
281
+ missing.length === 0 &&
282
+ root.enableAllProjectMcpServers === true
283
+ ) {
284
+ return { action: "skip", content: existing, reason: "already configured" };
285
+ }
286
+
287
+ root.enableAllProjectMcpServers = true;
288
+ root.permissions = { ...permissions, allow: [...allow, ...missing] };
289
+ return {
290
+ action: existing === undefined ? "create" : "update",
291
+ content: `${JSON.stringify(root, null, 2)}\n`,
292
+ };
293
+ }
294
+
295
+ const GITIGNORE_BLOCK = `# docket per-checkout state (active task, cache)
296
+ .docket/
297
+ `;
298
+
299
+ /**
300
+ * Ensure `.docket/` is ignored. Additive: appends a commented rule unless some
301
+ * line already covers the directory; never reorders or rewrites existing rules.
302
+ */
303
+ export function ensureGitignore(existing: string | undefined): InitResult {
304
+ if (existing === undefined)
305
+ return { action: "create", content: GITIGNORE_BLOCK };
306
+ const covered = existing
307
+ .split("\n")
308
+ .map((line) => line.trim())
309
+ .some(
310
+ (line) =>
311
+ line === ".docket" ||
312
+ line === ".docket/" ||
313
+ line === "/.docket" ||
314
+ line === "/.docket/",
315
+ );
316
+ if (covered)
317
+ return { action: "skip", content: existing, reason: "already ignored" };
318
+ const base = existing.endsWith("\n") ? existing : `${existing}\n`;
319
+ return { action: "update", content: `${base}\n${GITIGNORE_BLOCK}` };
320
+ }
321
+
322
+ /** True when a markdown source lacks a frontmatter block with a `type` key. */
323
+ export function needsFrontmatter(source: string): boolean {
324
+ const match = source.match(/^---\n([\s\S]*?)\n---/);
325
+ if (!match) return true;
326
+ return !/^type:/m.test(match[1] ?? "");
327
+ }
328
+
329
+ /** Mechanical type proposal from a bundle-relative path — the agent refines it. */
330
+ export function proposeType(path: string): string {
331
+ if (path.startsWith("work/epics/")) return "Epic";
332
+ if (path.startsWith("work/tasks/")) return "Task";
333
+ if (path.startsWith("decisions/")) return "Decision";
334
+ if (path.startsWith("specs/")) return "Spec";
335
+ if (path.startsWith("reference/")) return "Reference";
336
+ if (path.startsWith("workflows/")) return "Workflow";
337
+ return "Doc";
338
+ }